From 5c4071bab12deeeee8e4aa4b80104fcd6a0c6083 Mon Sep 17 00:00:00 2001 From: Leonardo Brondani Schenkel Date: Thu, 1 Aug 2019 19:53:15 +0200 Subject: [PATCH] First commit, version 0.1.0 --- .gitignore | 4 + Dockerfile | 16 +++ LICENSE.txt | 21 ++++ README.md | 177 ++++++++++++++++++++++++++++++++ broadlink-bridge.code-workspace | 10 ++ broadlink_bridge/__init__.py | 167 ++++++++++++++++++++++++++++++ broadlink_bridge/cli.py | 76 ++++++++++++++ broadlink_bridge/discovery.py | 93 +++++++++++++++++ broadlink_bridge/http.py | 63 ++++++++++++ broadlink_bridge/lirc.py | 103 +++++++++++++++++++ broadlink_bridge/mqtt.py | 74 +++++++++++++ broadlink_bridge/util.py | 120 ++++++++++++++++++++++ config.example.ini | 49 +++++++++ setup.py | 17 +++ 14 files changed, 990 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 broadlink-bridge.code-workspace create mode 100644 broadlink_bridge/__init__.py create mode 100644 broadlink_bridge/cli.py create mode 100644 broadlink_bridge/discovery.py create mode 100644 broadlink_bridge/http.py create mode 100644 broadlink_bridge/lirc.py create mode 100644 broadlink_bridge/mqtt.py create mode 100644 broadlink_bridge/util.py create mode 100644 config.example.ini create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59519db --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.* +__*__ +*.egg-info +venv diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58bff8b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM alpine + +ENV LANG C.UTF-8 + +RUN apk add py3-cryptography py3-paho-mqtt +COPY setup.py /tmp/build/ +COPY broadlink_bridge/ /tmp/build/broadlink_bridge/ +RUN pip3 install /tmp/build \ + && rm -Rf /tmp/build + +RUN mkdir -p /config && touch /config/config.ini +VOLUME [ "/config" ] + +USER nobody +EXPOSE 8765 8780 +CMD [ "broadlink-bridge", "/config/config.ini" ] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..fa91632 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Leonardo Brondani Schenkel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c83ece7 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# broadlink-bridge + +A HTTP/MQTT/LIRC bridge to Broadlink IR/RF devices, written in Python +and powered by [python-broadlink](https://github.com/mjg59/python-broadlink). + +🛑 🚧 EXPERIMENTAL / WORK IN PROGRESS 🚧 🛑 + +## Features + +- supports [HTTP](#http) protocol +- supports [MQTT](#mqtt) protocol +- supports [LIRC](#lirc) protocol +- supports multiple Broadlink devices +- supports Broadlink IR/RF codes +- supports Pronto hex codes +- supports defining named commands +- supports specifying repeats +- can act as sending hardware for [IrScrutinizer](http://www.harctoolbox.org/IrScrutinizer.html) via the [LIRC output](http://www.harctoolbox.org/IrScrutinizer.html#The+%22Lirc%22+pane) + +## Installing/running + +To install: `pip install [--user] git+https://github.com/lbschenkel/broadlink-bridge.git` + +To run: `broadlink-bridge [config-file]` + +### Docker + +(to be written) + +### Usage in Home Assistant + +This bridge is available as an [unnoficial add-on](https://github.com/lbschenkel/hass-addon-broadlink-bridge/tree/master/broadlink-bridge). + +As a [RESTful switch](https://www.home-assistant.io/components/switch.rest): + +```yaml +switch: +- platform: rest + resource: http://BRIDGE_HOST:PORT/device/DEVICE + body_on: CODE + body_off: CODE +``` + +As a [MQTT switch](https://www.home-assistant.io/components/switch.mqtt): + +```yaml +switch: +- platform: mqtt + command_topic: PREFIX/DEVICE/transmit + body_on: CODE + body_off: CODE +``` + +Where: + +- `BRIDGE_HOST` is the hostname/IP address of this bridge +- `PORT` is the HTTP port (default `8780`) +- `PREFIX` is the MQTT prefix (default `broadlink`) +- `DEVICE` identifies the target [device](#device) +- `CODE` is the [code](#code) (or command) to transmit, + prefixed by any [repeats](#repeats) + +## Configuration + +A configuration file can be optionally specified as a command-line argument. An [example file](config.example.ini) is provided. + +### MQTT client + +To enable the MQTT client, it is necessary to specify the URL of the MQTT +broker as the `broker_url` value inside the `[mqtt]` section. + +The prefix for the MQTT topics is configurable via `topic_prefix`. + +### Manually declared devices + +Devices can be manually declared in the `[devices]` section. When a device is declared in this way it is given an *alias* which can act as an additional [identifier](#device) for the device in the bridge. Auto-discovered devices do not have aliases. + +### Commands + +Commands can be defined in the `[commands]` section. Commands associate a *name* with a *code*. The name can then be used anywhere where a code can appear. + +The code for the command can be in [any supported format](#code) and contain +[repeats](#repeats), with the exception that it cannot be another command. + +## Protocols + +### Definitions + +#### Device + +A *device* represents a Broadlink device which can be addressed on any of the protocols. +It can be identified by any of the following: + +- by its alias (as specified in the configuration file) +- by its host (as specified in the configuration file or found via discovery) +- by its MAC address +- by one of its IP addresses + +When the device is not found via one of the mechanisms above, a final attempt +is made by interpreting *device* as a host: in case a device is discovered at +that address, then it is added to the list of known devices (this can be +considered a lazy discovery mechanism). + +#### Default device + +The *default device* is the [device](#device) with an alias of `default`. If there is no device with such an alias, it is the first declared or discovered device. + +#### Code + +The *code* is the data to transmit. It can have one of the following forms: + +- IR/RF data in Broadlink format (encoded as base64): + `JgAcAB0dHB44HhweGx4cHR06HB0cHhwdHB8bHhwADQUAAAAAAAAAAAAAAAA=` +- IR data in Pronto hex format: + + ``` + 0000 006C 0022 0002 015B 00AD 0016 0016 0016 0016 0016 0041 0016 0016 0016 + 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0041 0016 0016 0016 0041 + 0016 0041 0016 0041 0016 0041 0016 0041 0016 0016 0016 0016 0016 0041 0016 + 0016 0016 0016 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0016 + 0016 0041 0016 0041 0016 0041 0016 0016 0016 0016 0016 05F7 015B 0057 0016 + 0E6C + ``` + +- a *command* defined via the configuration file: in that case the code for + the command is used (which can have any of the above forms) + +Note that codes can contain repeats (see next section). + +#### Repeats + +*Repeats* are the number of times a *code* will be retransmitted. +Repeats can be specified in multiple ways: + +- as part of the code — if the code starts with `N*` (number plus asterisk) + then the code will be sent `N` times (`N-1` repeats), for example: + - `3 * JgAcAB...` (Broadlink format, sent 3 times) + - `2 * 0000 006C ...` (Pronto hex format, sent 2 times) + - `5 * tv/on` (command defined via configuration file, sent 5 times) +- inside Broadlink data packet (second data byte is the number of repeats) +- via the protocol command (in case of LIRC) + +When repeats are specified in more than one way, their effects multiply. +For example, LIRC command `SEND_ONCE default 3*tv/on 1` (1 being the repeat +and `tv/on` being a command defined in the configuration file as `2*JgAcAB...`) +will result in the code being sent 2x3x2 = 12 times (11 repeats). + +### HTTP + +verb | path | description +--|--|-- +POST | /devices/*device* | transmits the submitted [code](#code) via [device](#device) + +Status codes: + +- `404` when the device is unknown +- `400` when the code is invalid or not recognized + +### MQTT + +The topic *prefix* defaults to `broadlink` and can be changed via the configuration file. + +topic | description +--|-- +*prefix*/devices/*device*/transmit | transmits the submitted [code](#code) via [device](#device) + +### LIRC + +A subset of the [LIRC command interface](http://www.lirc.org/html/lircd.html) and the unofficial [CCF extension](http://www.harctoolbox.org/lirc_ccf.html) is supported. + +command | description +--|-- +SEND_ONCE *device* *code* [*repeat*] | transmits the given [code](#code) (no spaces) via [device](#device), optionally repeating it *repeat* times +SEND_CCF_ONCE *repeat* *code* | transmits the given [code](#code) (spaces allowed) via the [default device](#default-device), repeating it *repeat* times +LIST | replies with all known devices +LIST *device* | replies with all defined commands (commands are not device-specific) +VERSION | replies with bridge version diff --git a/broadlink-bridge.code-workspace b/broadlink-bridge.code-workspace new file mode 100644 index 0000000..459ea65 --- /dev/null +++ b/broadlink-bridge.code-workspace @@ -0,0 +1,10 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "python.pythonPath": ".venv/bin/python3.6" + } +} \ No newline at end of file diff --git a/broadlink_bridge/__init__.py b/broadlink_bridge/__init__.py new file mode 100644 index 0000000..4b54c43 --- /dev/null +++ b/broadlink_bridge/__init__.py @@ -0,0 +1,167 @@ +import broadlink +import logging +import pkg_resources +from .discovery import discover +from .util import * + +NAME = 'broadlink-bridge' +VERSION = pkg_resources.get_distribution(NAME).version +SERVER = NAME + '/' + VERSION +LOGGER = logging.getLogger(__name__) + +class Registry: + def __init__(self): + self._devices = [] + self._devices_by_alias = {} + self._commands = {} + + def add_manual_device(self, host, alias=None): + return self._add_device(host, alias) + + def discover(self, timeout=None): + if not timeout or timeout <= 0: + LOGGER.info('Discovery disabled') + return False + + LOGGER.info("Discovery: searching for devices for %s seconds...", timeout) + devices = broadlink.discover(timeout=timeout) + if not isinstance(devices, list): + devices = [devices] + for dev in devices: + dev = self._add_device(dev) + return True if self._devices else False + + def get_devices(self): + return self._devices + + def find_device(self, id): + LOGGER.debug('Finding device: %s...', id) + device = self._devices_by_alias.get(id) + if device: + LOGGER.debug('Found device by alias: %s', device) + return device + elif id == 'default': + if self._devices: + device = self._devices[0] + LOGGER.debug('Using first device: %s', device) + return device + else: + mac = mac_format(id) + for device in self._devices: + if mac == device.mac or id == device.host or id in device.addresses: + LOGGER.debug('Found device by address: %s', device) + return device + LOGGER.debug('Checking if device exists at address: %s', id) + device = Device(id) + if device.connect(): + LOGGER.debug('Found device %s, registering', device) + self._add_device(device) + return device + LOGGER.debug('Device not found.') + return None + + def set_command(self, command, data): + if ' ' in command: + raise ValueError('Commands cannot contain spaces: ' + command) + LOGGER.info('Registering command: %s', command) + self._commands[command] = ir_decode(data)[0] + + def get_commands(self): + return self._commands.keys() + + def get_command(self, command): + return self._commands.get(command) + + def _add_device(self, dev, alias=None): + if not isinstance(dev, Device): + dev = Device(dev) + if not alias: + alias = dev.host + + if alias not in self._devices_by_alias: + LOGGER.info('Device: %s has alias %s', dev, alias) + self._devices.append(dev) + self._devices_by_alias[alias] = dev + return dev + else: + LOGGER.info('Device: %s skipped, alias %s already exists', dev, alias) + return None + +class Device: + def __init__(self, host=None, dev=None): + assert host or dev + if host is None: + host = dev.host[0] + + self._host = host + self._dev = dev + self._mac = None + self._addresses = None + self.connect() + + def connect(self): + if self._dev: + return True + else: + connected = False + try: + self._dev = discover(self.host) + if self._dev: + connected = self._dev.auth() + except (socket.gaierror, socket.timeout): + pass + + if connected: + self._mac = mac_format(self._dev.mac, reverse=True) + self._addresses = get_ip_addresses(self._dev.host[0]) + return True + else: + self._dev = None + self._mac = None + self._addresses = None + return False + + @property + def host(self): + return self._host + + @property + def mac(self): + if self.connect(): + return self._mac + else: + return '??-??-??-??-??-??' + + @property + def addresses(self): + if self.connect(): + return self._addresses + else: + return [] + + def transmit(self, code, repeat=None): + if not isinstance(code, str): + code = code.decode('US-ASCII') + + (code, repeat) = ir_decode_multiply(code, repeat) + command_code = REGISTRY.get_command(code) + if command_code: + code = command_code + return self._transmit(code, repeat=repeat) + + def _transmit(self, code, repeat=None): + (code, repeat) = ir_decode(code, repeat=repeat) + LOGGER.debug('Transmitting to: %s (repeat: %s)', self, repeat) + if self.connect(): + self._dev.send_data(code) + return True + else: + return False + + def __str__(self): + return self.mac + '@' + self.host + + def __repr__(self): + return self.__str__() + +REGISTRY = Registry() \ No newline at end of file diff --git a/broadlink_bridge/cli.py b/broadlink_bridge/cli.py new file mode 100644 index 0000000..93d3ec8 --- /dev/null +++ b/broadlink_bridge/cli.py @@ -0,0 +1,76 @@ +import argparse +import configparser +import logging +import pathlib +import signal +import sys +import threading +from . import LOGGER, NAME, REGISTRY, SERVER +from .http import httpd_start +from .lirc import lircd_start +from .mqtt import mqtt_connect + +DEFAULTS = { + 'commands': { + }, + 'devices': { + }, + 'discovery': { + 'timeout': 5, + }, + 'http': { + 'port': '8780', + }, + 'lirc': { + 'port': '8765', + }, + 'mqtt': { + 'broker_url': '', + } +} + +def main(): + parser = argparse.ArgumentParser( + description='Bridge to Broadlink devices', + ) + parser.add_argument('config', metavar='CONFIG-FILE', nargs='?', help='path to configuration file') + parser.add_argument('-d', '--debug', metavar='DEBUG', action='store_const', const=True, help='enable debug logging') + args = parser.parse_args() + + console = logging.StreamHandler() + console.setLevel(logging.DEBUG) + console.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(message)s')) + logger = logging.getLogger('') + logger.addHandler(console) + logger.setLevel(logging.INFO if not args.debug else logging.DEBUG) + + LOGGER.info('Starting %s...', SERVER) + + config = configparser.SafeConfigParser() + config.read_dict(DEFAULTS) + if args.config: + LOGGER.info('Reading config file: %s', args.config) + with open(args.config) as f: + config.read_file(f) + + for item in config.items('commands'): + command = item[0] + payload = item[1] + REGISTRY.set_command(command, payload) + for item in config.items('devices'): + alias = item[0] + url = item[1] + REGISTRY.add_manual_device(url, alias) + REGISTRY.discover(config.getint('discovery', 'timeout')) + + httpd_start(config.getint('http', 'port')) + lircd_start(config.getint('lirc', 'port')) + mqtt_connect(config.get('mqtt', 'broker_url')) + + quit = threading.Event() + def quit_handler(signo, stack_frame): + quit.set() + signal.signal(signal.SIGINT, quit_handler) + signal.signal(signal.SIGTERM, quit_handler) + quit.wait() + LOGGER.info('Exiting...') \ No newline at end of file diff --git a/broadlink_bridge/discovery.py b/broadlink_bridge/discovery.py new file mode 100644 index 0000000..77d6324 --- /dev/null +++ b/broadlink_bridge/discovery.py @@ -0,0 +1,93 @@ +# The MIT License (MIT) +# +# Copyright (c) 2014 Mike Ryan +# Copyright (c) 2016 Matthew Garrett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# --- +# This file contains code copied (and modified) from: +# https://github.com/mjg59/python-broadlink/blob/9d9b49c3db96a8e92c3b267aa07f764eff659e2b/broadlink/__init__.py +# The intention is to eventually submit these changes upstream. +# +import broadlink +import socket +import time +from datetime import datetime + +def discover(host='255.255.255.255', timeout=2): + local_ip_address = socket.gethostbyname(socket.gethostname()) + if local_ip_address.startswith('127.'): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 53)) # connecting to a UDP address doesn't send packets + local_ip_address = s.getsockname()[0] + address = local_ip_address.split('.') + cs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + cs.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + cs.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + cs.bind((local_ip_address, 0)) + port = cs.getsockname()[1] + + timezone = int(time.timezone / -3600) + packet = bytearray(0x30) + + year = datetime.now().year + + if timezone < 0: + packet[0x08] = 0xff + timezone - 1 + packet[0x09] = 0xff + packet[0x0a] = 0xff + packet[0x0b] = 0xff + else: + packet[0x08] = timezone + packet[0x09] = 0 + packet[0x0a] = 0 + packet[0x0b] = 0 + packet[0x0c] = year & 0xff + packet[0x0d] = year >> 8 + packet[0x0e] = datetime.now().minute + packet[0x0f] = datetime.now().hour + subyear = str(year)[2:] + packet[0x10] = int(subyear) + packet[0x11] = datetime.now().isoweekday() + packet[0x12] = datetime.now().day + packet[0x13] = datetime.now().month + packet[0x18] = int(address[0]) + packet[0x19] = int(address[1]) + packet[0x1a] = int(address[2]) + packet[0x1b] = int(address[3]) + packet[0x1c] = port & 0xff + packet[0x1d] = port >> 8 + packet[0x26] = 6 + checksum = 0xbeaf + + for i in range(len(packet)): + checksum += packet[i] + checksum = checksum & 0xffff + packet[0x20] = checksum & 0xff + packet[0x21] = checksum >> 8 + + cs.sendto(packet, (host, 80)) + cs.settimeout(timeout) + response = cs.recvfrom(1024) + responsepacket = bytearray(response[0]) + host = response[1] + mac = responsepacket[0x3a:0x40] + devtype = responsepacket[0x34] | responsepacket[0x35] << 8 + + return broadlink.gendevice(devtype, host, mac) diff --git a/broadlink_bridge/http.py b/broadlink_bridge/http.py new file mode 100644 index 0000000..35a0d1a --- /dev/null +++ b/broadlink_bridge/http.py @@ -0,0 +1,63 @@ +import http.server +import threading +from . import LOGGER, REGISTRY, SERVER + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = SERVER + + def log_request(self, code='-', size='-'): + LOGGER.debug('HTTP: %s code %s', self.requestline, code) + + def log_error(self, format, *args): + LOGGER.warn('HTTP: %s %s', self.requestline, format%args) + + def send_error(self, code, message=None, explain=None): + self.log_error("code %d, message %s", code, message) + self.send_response(code, message) + self.end_headers() + + def do_POST(self): + self.protocol_version = 'HTTP/1.1' + self.send_header('Content-Length', 0) + + path = self.path + if not path.startswith('/device/'): + return self.send_error(404) + path = path[8:] + + path = path.split('/', 1) + if len(path) != 1: + return self.send_error(404) + + device_id = path[0] + device = REGISTRY.find_device(device_id) + if not device: + return self.send_error(404, 'Device not found: ' + device_id) + + size = int(self.headers.get('Content-Length', 0)) + payload = self.rfile.read(size) + if not payload: + return self.send_error(400, 'No payload') + try: + if device.transmit(payload): + self.send_response(204, 'OK') + self.end_headers() + return + except ValueError: + pass + self.send_error(400, 'Bad payload') + +def httpd_start(port): + if not port or port <= 0: + LOGGER.info('HTTP server disabled') + return False + + httpd = http.server.HTTPServer(('', port), Handler) + httpd.request_queue_size = 10 + + httpd_thread = threading.Thread(target=httpd.serve_forever) + httpd_thread.daemon = True + httpd_thread.start() + + LOGGER.info('HTTP server started on port %s', port) + return True \ No newline at end of file diff --git a/broadlink_bridge/lirc.py b/broadlink_bridge/lirc.py new file mode 100644 index 0000000..f424982 --- /dev/null +++ b/broadlink_bridge/lirc.py @@ -0,0 +1,103 @@ +import socketserver +import threading +from . import LOGGER, REGISTRY, SERVER + +class Handler(socketserver.StreamRequestHandler): + def handle(self): + while(True): + self.line = self.readline() + self.line = self.line.decode('UTF-8') + if not self.line: + break + parsed = self.line.split(' ', 1) + command = parsed[0] + args = None + if len(parsed) > 1: + args = parsed[1] + self.handle_command(command, args) + + def readline(self): + line = self.rfile.readline() + line = line.strip() + return line + + def reply(self, success=True, data=None): + self.writeline('BEGIN') + self.writeline(self.line) + self.writeline('SUCCESS' if success else 'ERROR') + if data: + self.writeline('DATA') + self.writeline(str(len(data))) + for line in data: + self.writeline(line) + self.writeline('END') + + def write(self, data): + if isinstance(data, str): + data = data.encode('UTF-8') + self.wfile.write(data) + return self + + def writeline(self, line): + self.write(line).write(b'\n').wfile.flush() + + def handle_command(self, command, args): + if command == 'VERSION': + self.reply(True, [SERVER]) + return + elif command == 'LIST': + if not args: + self.reply(True, [str(dev.host) for dev in REGISTRY.get_devices()]) + else: + self.reply(True, REGISTRY.get_commands()) + return + elif command == 'SEND_ONCE': + if args: + payload = args.split(' ') + device_id = payload[0] + command = payload[1] + repeat = int(payload[2]) if len(payload) > 2 else None + + device = REGISTRY.find_device(device_id) + if device and command: + try: + device.transmit(command, repeat) + self.reply() + return + except ValueError: + pass + elif command == 'SEND_CCF_ONCE': + if args: + payload = args.split(' ', 1) + repeat = int(payload[0]) + payload = payload[1] + try: + device = REGISTRY.find_device('default') + if device: + device.transmit(payload, repeat=repeat) + self.reply() + return + except ValueError: + pass + self.reply(False) + +class Server(socketserver.TCPServer): + def handle_error(self, request, client_address): + super().handle_error(request, client_address) + +def lircd_start(port): + if not port or port <= 0: + LOGGER.info('LIRC server disabled') + return False + + lircd = Server(('', port), Handler, bind_and_activate=False) + lircd.allow_reuse_address = True + lircd.server_bind() + lircd.server_activate() + + lircd_thread = threading.Thread(target=lircd.serve_forever) + lircd_thread.daemon = True + lircd_thread.start() + + LOGGER.info('LIRC server started on port %s', port) + return True \ No newline at end of file diff --git a/broadlink_bridge/mqtt.py b/broadlink_bridge/mqtt.py new file mode 100644 index 0000000..c3aa2f2 --- /dev/null +++ b/broadlink_bridge/mqtt.py @@ -0,0 +1,74 @@ +import paho.mqtt.client as mqtt_client +import ssl +import urllib.parse +from . import LOGGER, REGISTRY, Device + +def mqtt_on_connect(client, userdata, flags, rc): + LOGGER.info('MQTT client connected to broker: %s', client._host) + +def mqtt_on_disconnect(client, userdata, rc): + LOGGER.info('MQTT client disconnected from broker: %s', client._host) + +def mqtt_transmit(client, userdata, msg): + LOGGER.debug('MQTT %s: received message', msg.topic) + topic = msg.topic[len(userdata['prefix']):] + topic = topic.split('/', 1) + device_id = topic[0] + if not device_id: + LOGGER.warning('MQTT %s: Device mising', msg.topic) + return + device = REGISTRY.find_device(device_id) + if not device: + LOGGER.warning('MQTT %s: Device not found: %s', msg.topic, device_id) + return + code = msg.payload + if not code: + LOGGER.warning('MQTT %s: No payload', msg.topic) + return + try: + if device.transmit(code): + return + except: + pass + LOGGER.warning('MQTT %s: invalid payload', msg.topic) + +def mqtt_connect(url, prefix='broadlink'): + if not url: + LOGGER.info('MQTT client disabled') + return False + if not prefix.endswith('/'): + prefix = prefix + '/' + LOGGER.debug('MQTT using prefix %s', prefix) + + url = urllib.parse.urlparse(url, scheme='mqtt', allow_fragments=False) + query = urllib.parse.parse_qs(url.query) + port = url.port + if not port: + if url.scheme == 'mqtt': + port = 1883 + elif url.scheme == 'mqtts': + port = 8883 + else: + raise ValueError('Invalid protocol: %s', url.scheme) + + mqtt = mqtt_client.Client() + if url.username or url.password: + mqtt.username_pw_set(url.username, url.password) + if url.scheme == 'mqtts': + ctx = ssl.create_default_context() + if query.get('insecure', False): + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + mqtt.tls_set_context(ctx) + + mqtt.user_data_set({ + 'prefix': prefix, + }) + mqtt.enable_logger = True + mqtt.on_connect = mqtt_on_connect + mqtt.on_disconnect = mqtt_on_disconnect + mqtt.connect(url.hostname, port) + mqtt.message_callback_add(prefix + '+/transmit', mqtt_transmit) + mqtt.subscribe(prefix + '#') + mqtt.loop_start() + return True \ No newline at end of file diff --git a/broadlink_bridge/util.py b/broadlink_bridge/util.py new file mode 100644 index 0000000..f3e2e36 --- /dev/null +++ b/broadlink_bridge/util.py @@ -0,0 +1,120 @@ +import base64 +import binascii +import copy +import re +import socket +import struct +import urllib.parse + +def get_ip_addresses(address): + addrs = set() + try: + for info in socket.getaddrinfo(address, None): + addr = info[4][0] + if addr: + addrs.add(addr) + except: + pass + return addrs + +def mac_format(mac, reverse=False): + mac = mac_parse(mac, reverse) + if not mac: + return None + mac = binascii.hexlify(mac) + assert len(mac) == 12 + mac = str(mac, 'US-ASCII').upper() + mac = "-".join(["%s" % (mac[i:i+2]) for i in range(0, 12, 2)]) + return mac + +def mac_parse(mac, reverse=False): + if mac is None: + return None + if isinstance(mac, str): + mac = mac.replace(' ', '') + mac = mac.replace(':', '') + mac = mac.replace('-', '') + mac = ''.join(mac.split()) + if len(mac) != 12 or not mac.isalnum: + return None + try: + mac = binascii.unhexlify(mac) + except: + return None + assert len(mac) == 6 + if reverse: + mac = list(mac) + mac.reverse() + mac = bytearray(mac) + return mac + +def ir_decode(code, repeat=None): + if not code: + raise ValueError('Empty code') + + if code[0] not in [0x26, 0xb2, 0xd7]: + code = code.replace(' ', '') + (code, repeat) = ir_decode_multiply(code, repeat) + if len(code) < 5: + raise ValueError('Code too short') + if code.startswith('0000'): # Pronto hex + code = ir_decode_pronto(code) + else: # Broadlink base64 + code = base64.b64decode(code) + if code[0] not in [0x26, 0xb2, 0xd7]: + raise ValueError('Not a valid Broadlink code') + + if len(code) < 6: + raise ValueError('Code too short') + + if repeat is not None: + code = copy.copy(code) + code[1] = min((code[1] + 1) * (repeat + 1) - 1, 255) + return (code, code[1]) + +MULTIPLY_PATTERN = re.compile('(?:([0-9]+)[*])(.*)') + +def ir_decode_multiply(code, repeat=None): + code = code.replace(' ', '') + m = MULTIPLY_PATTERN.fullmatch(code) + if m: + number = int(m.group(1)) + code = m.group(2) + if repeat is None: + repeat = 0 + repeat = (repeat + 1) * number - 1 + repeat = min(255, repeat) + return (code, repeat) + +def ir_decode_pronto(pronto) -> bytearray: + # Shameless stolen from: + # https://gist.githubusercontent.com/appden/42d5272bf128125b019c45bc2ed3311f/raw/bdede927b231933df0c1d6d47dcd140d466d9484/pronto2broadlink.py + codes = [int(pronto[i:i + 4], 16) for i in range(0, len(pronto), 4)] + if codes[0]: + raise ValueError('Pronto code should start with 0000') + if len(codes) < 4: + raise ValueError('Code is too short') + if len(codes) != 4 + 2 * (codes[2] + codes[3]): + raise ValueError('Number of pulse widths does not match preamble') + if codes[1] == 0: + raise ValueError('Invalid frequency') + frequency = 1 / (codes[1] * 0.241246) + pulses = [int(round(code / frequency)) for code in codes[4:]] + array = bytearray() + for pulse in pulses: + pulse = pulse * 269 // 8192 # 32.84ms units + if pulse < 256: + array += bytearray(struct.pack('>B', pulse)) # 1-byte (BE) + else: + array += bytearray([0x00]) # next number is 2-bytes + array += bytearray(struct.pack('>H', pulse)) # 2-bytes (BE) + packet = bytearray([0x26, 0x00]) # 0x26 = IR, 0x00 = repeats + packet += bytearray(struct.pack('://[@@][:] +# where: +# - protocol: 'mqtt' or 'mqtts' (TLS) +# - user (optional) +# - password (optional) +# - host: hostname/IP address of the MQTT server +# - port (optional) +# When empty or missing, the MQTT client is disabled. +broker_url = mqtt://mqtt.example.org +# The prefix to use. Default is "broadlink" +topic_prefix = 'broadlink' \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..fc14fc6 --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup + +setup( + name='broadlink-bridge', + version='0.1.0', + packages=['broadlink_bridge'], + entry_points={ + 'console_scripts': [ + 'broadlink-bridge=broadlink_bridge.cli:main' + ], + }, + install_requires=[ + 'broadlink==0.11.1', + 'cryptography>=2.6', + 'paho-mqtt>=1.4.0', + ], +) \ No newline at end of file