def _are_valid_credentials(self, username, password): """Checks whether credentials are valid""" def _on_connect(client, userdata, flags, rc): """A callback that matches the MQTT client signature, that triggers when connection was established""" userdata.set_return_code(rc) client.disconnect() client.loop_stop() client.should_stop = True con_result = ConnectionResult() client = Client(userdata=con_result) client.username_pw_set(username, password) client.on_connect = _on_connect start_time = time.time() client.should_stop = False client.connect_async(self.host, self.port) client.loop_start() while not client.should_stop and time.time( ) - start_time < self.timeout: time.sleep(0.001) return con_result.did_succeed
class MqttClient(object): """Mqtt通讯封装""" def __init__(self, address): if not isinstance(address, tuple) or len(address) != 2: raise ValueError("Invalid address.") def on_connect(client, userdata, flags, rc): self.handleConnected() def on_message(client, userdata, msg): self.handleMessage(msg.topic, msg.payload) self.client = Mqtt() self.address = address self.client.on_connect = on_connect self.client.on_message = on_message def handleConnected(self): pass def handleMessage(self, topic, payload): pass def publish(self, topic, payload=None, qos=0, retain=False): self.client.publish(topic, payload, qos, retain) def subscribe(self, topic, qos=0): self.client.subscribe(topic, qos) def start(self): self.client.connect_async(self.address[0], self.address[1]) self.client.loop_start() def stop(self): self.client.loop_stop() def username_pw_set(self, username, password=None): self.client.username_pw_set(username, password) def will_set(self, topic, payload=None, qos=0, retain=False): self.client.will_set(topic, payload, qos, retain)
def __init__(self, client_id, config, wait=True): """ initialize mqtt client :param client_id: client id :param config: keeper configuration :param wait: whether to wait for connection """ self.logger = Logger() user = config.get("mqtt.user") pwd = config.get("mqtt.pass") client = Client(client_id=client_id) client.on_connect = self._on_connect client.on_disconnect = self._on_disconnect client.on_message = self._on_message client.enable_logger(self.logger) if user and pwd: client.username_pw_set(user, pwd) client.connect_async(config["mqtt.broker"], config["mqtt.port"], 30) self.client = client self.connected = False self.manager = None self.wait = wait
class Gateway3(Thread): pair_model = None pair_payload = None def __init__(self, host: str, token: str, config: dict, zha: bool = False): super().__init__(daemon=True) self.host = host self.zha = zha self.miio = Device(host, token) self.mqtt = Client() self.mqtt.on_connect = self.on_connect self.mqtt.on_disconnect = self.on_disconnect self.mqtt.on_message = self.on_message self.mqtt.connect_async(host) self.ble = GatewayBLE(self) self.debug = config['debug'] if 'debug' in config else '' self.devices = config['devices'] if 'devices' in config else {} self.updates = {} self.setups = {} @property def device(self): return self.devices['lumi.0'] def add_update(self, did: str, handler): """Add handler to device update event.""" self.updates.setdefault(did, []).append(handler) def add_setup(self, domain: str, handler): """Add hass device setup funcion.""" self.setups[domain] = handler def run(self): """Main loop""" while True: # if not telnet - enable it if not self._check_port(23) and not self._enable_telnet(): time.sleep(30) continue devices = self._get_devices_v3() if devices: self.setup_devices(devices) break # start bluetooth read loop self.ble.start() while True: # if not telnet - enable it if not self._check_port(23) and not self._enable_telnet(): time.sleep(30) continue if not self.zha: # if not mqtt - enable it if not self._mqtt_connect() and not self._enable_mqtt(): time.sleep(60) continue self.mqtt.loop_forever() elif not self._check_port(8888) and not self._enable_zha(): time.sleep(60) continue else: # ZHA works fine, check every 60 seconds time.sleep(60) def _check_port(self, port: int): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: return s.connect_ex((self.host, port)) == 0 finally: s.close() def _mqtt_connect(self) -> bool: try: self.mqtt.reconnect() return True except: return False def _miio_connect(self) -> bool: try: self.miio.send_handshake() return True except: _LOGGER.debug(f"{self.host} | Can't send handshake") return False def _get_devices_v1(self) -> Optional[list]: """Load devices via miio protocol.""" _LOGGER.debug(f"{self.host} | Read devices") try: devices = {} # endless loop protection for _ in range(16): # load only 8 device per part part = self.miio.send('get_device_list', retry_count=10) if len(part) == 0: return [] for item in part: devices[item['num']] = { 'did': item['did'], 'mac': f"0x{item['did'][5:]}", 'model': item['model'], } if part[0]['total'] == len(devices): break devices = list(devices.values()) for device in devices: desc = utils.get_device(device['model']) # skip unknown model if desc is None: continue # get xiaomi param names params = [p[1] for p in desc['params'] if p[1] is not None] # skip if don't have retain params if not params: continue # load param values values = self.miio.send('get_device_prop', [device['did']] + params) # get hass param names params = [p[2] for p in desc['params'] if p[1] is not None] data = dict(zip(params, values)) # fix some param values for k, v in data.items(): if k in ('temperature', 'humidity'): data[k] = v / 100.0 elif v in ('on', 'open'): data[k] = 1 elif v in ('off', 'close'): data[k] = 0 device['init'] = data device = self.miio.info() devices.append({ 'did': 'lumi.0', 'mac': device.mac_address, # wifi mac!!! 'model': device.model }) return devices except Exception as e: _LOGGER.exception(f"{self.host} | Get devices: {e}") return None def _get_devices_v2(self) -> Optional[list]: """Load device list via Telnet. Device desc example: mac: '0x158d0002c81234' shortId: '0x0691' manuCode: '0x115f' model: 'lumi.sensor_ht' did: 'lumi.158d0002c81234' devType: 0 appVer: 2 hardVer: 0 devID: 770 status: 0 model_ver: 2 """ _LOGGER.debug(f"{self.host} | Read devices") try: telnet = Telnet(self.host) telnet.read_until(b"login: "******"admin\r\n") telnet.read_until(b'\r\n# ') # skip greeting telnet.write(b"cat /data/zigbee/coordinator.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') device = json.loads(raw[:-2]) device.update({ 'did': 'lumi.0', 'model': 'lumi.gateway.mgl03', 'host': self.host }) devices = [device] telnet.write(b"cat /data/zigbee/device.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') raw = json.loads(raw[:-2]) devices += raw['devInfo'] telnet.close() return devices except Exception as e: _LOGGER.exception(f"Can't read devices: {e}") return None def _get_devices_v3(self): """Load device list via Telnet.""" _LOGGER.debug(f"{self.host} | Read devices") try: telnet = Telnet(self.host, timeout=5) telnet.read_until(b"login: "******"admin\r\n") telnet.read_until(b'\r\n# ') # skip greeting # read coordinator info telnet.write(b"cat /data/zigbee/coordinator.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') device = json.loads(raw[:-2]) devices = [{ 'did': 'lumi.0', 'model': 'lumi.gateway.mgl03', 'mac': device['mac'], 'type': 'gateway' }] if self.zha: return devices # https://github.com/AlexxIT/XiaomiGateway3/issues/14 # fw 1.4.6_0012 and below have one zigbee_gw.db file # fw 1.4.6_0030 have many json files in this folder telnet.write(b"cat /data/zigbee_gw/* | base64\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') raw = base64.b64decode(raw) if raw.startswith(b'unqlite'): db = Unqlite(raw) data = db.read_all() else: raw = re.sub(br'}\s+{', b',', raw) data = json.loads(raw) # data = {} or data = {'dev_list': 'null'} dev_list = json.loads(data.get('dev_list', 'null')) or [] _LOGGER.debug(f"{self.host} | Load {len(dev_list)} zigbee devices") for did in dev_list: model = data[did + '.model'] desc = utils.get_device(model) # skip unknown model if desc is None: _LOGGER.debug(f"{did} has an unsupported modell: {model}") continue retain = json.loads(data[did + '.prop'])['props'] _LOGGER.debug(f"{self.host} | {did} {model} retain: {retain}") params = { p[2]: retain.get(p[1]) for p in desc['params'] if p[1] is not None } device = { 'did': did, 'mac': '0x' + data[did + '.mac'], 'model': data[did + '.model'], 'type': 'zigbee', 'zb_ver': data[did + '.version'], 'init': utils.fix_xiaomi_props(params) } devices.append(device) return devices except (ConnectionRefusedError, socket.timeout): return None except Exception as e: _LOGGER.debug(f"Can't read devices: {e}") return None def _enable_telnet(self): _LOGGER.debug(f"{self.host} | Try enable telnet") try: resp = self.miio.send("enable_telnet_service") return resp[0] == 'ok' except Exception as e: _LOGGER.exception(f"Can't enable telnet: {e}") return False def _enable_mqtt(self): _LOGGER.debug(f"{self.host} | Try run public MQTT") try: telnet = Telnet(self.host) telnet.read_until(b"login: "******"admin\r\n") telnet.read_until(b"\r\n# ") # skip greeting # enable public mqtt telnet.write(b"killall mosquitto\r\n") telnet.read_until(b"\r\n") # skip command time.sleep(.5) # it's important to wait telnet.write(b"mosquitto -d\r\n") telnet.read_until(b"\r\n") # skip command time.sleep(.5) # it's important to wait # fix CPU 90% full time bug telnet.write(b"killall zigbee_gw\r\n") telnet.read_until(b"\r\n") # skip command time.sleep(.5) # it's important to wait telnet.close() return True except Exception as e: _LOGGER.debug(f"Can't run MQTT: {e}") return False def _enable_zha(self): _LOGGER.debug(f"{self.host} | Try enable ZHA") try: check_socat = \ "(md5sum /data/socat | grep 92b77e1a93c4f4377b4b751a5390d979)" download_socat = \ "(curl -o /data/socat http://pkg.musl.cc/socat/" \ "mipsel-linux-musln32/bin/socat && chmod +x /data/socat)" run_socat = "/data/socat tcp-l:8888,reuseaddr,fork /dev/ttyS2" telnet = Telnet(self.host, timeout=5) telnet.read_until(b"login: "******"admin\r\n") telnet.read_until(b"\r\n# ") # skip greeting # download socat and check md5 telnet.write(f"{check_socat} || {download_socat}\r\n".encode()) raw = telnet.read_until(b"\r\n# ") if b"Received" in raw: _LOGGER.debug(f"{self.host} | Downloading socat") telnet.write(f"{check_socat} && {run_socat} &\r\n".encode()) telnet.read_until(b"\r\n# ") telnet.write( b"killall daemon_app.sh; killall Lumi_Z3GatewayHost_MQTT\r\n") telnet.read_until(b"\r\n# ") telnet.close() return True except Exception as e: _LOGGER.debug(f"Can't enable ZHA: {e}") return False def on_connect(self, client, userdata, flags, rc): _LOGGER.debug(f"{self.host} | MQTT connected") self.mqtt.subscribe('#') def on_disconnect(self, client, userdata, rc): _LOGGER.debug(f"{self.host} | MQTT disconnected") # force end mqtt.loop_forever() self.mqtt.disconnect() def on_message(self, client: Client, userdata, msg: MQTTMessage): if 'mqtt' in self.debug: _LOGGER.debug(f"[MQ] {msg.topic} {msg.payload.decode()}") if msg.topic == 'zigbee/send': payload = json.loads(msg.payload) self.process_message(payload) elif self.pair_model and msg.topic.endswith('/commands'): self.process_pair(msg.payload) def setup_devices(self, devices: list): """Add devices to hass.""" for device in devices: desc = utils.get_device(device['model']) if not desc: _LOGGER.debug(f"Unsupported model: {device}") continue _LOGGER.debug(f"{self.host} | Setup device {device['model']}") device.update(desc) # update params from config default_config = self.devices.get(device['mac']) if default_config: device.update(default_config) self.devices[device['did']] = device for param in device['params']: domain = param[3] if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) attr = param[2] self.setups[domain](self, device, attr) def process_message(self, data: dict): if data['cmd'] == 'heartbeat': # don't know if only one item assert len(data['params']) == 1, data data = data['params'][0] pkey = 'res_list' elif data['cmd'] == 'report': pkey = 'params' if 'params' in data else 'mi_spec' elif data['cmd'] == 'write_rsp': pkey = 'results' else: raise NotImplemented(f"Unsupported cmd: {data}") did = data['did'] # skip without callback if did not in self.updates: return device = self.devices[did] payload = {} # convert codes to names for param in data[pkey]: if param.get('error_code', 0) != 0: continue prop = param['res_name'] if 'res_name' in param else \ f"{param['siid']}.{param['piid']}" if prop in GLOBAL_PROP: prop = GLOBAL_PROP[prop] else: prop = next((p[2] for p in device['params'] if p[0] == prop), prop) if prop in ('temperature', 'humidity', 'pressure'): payload[prop] = param['value'] / 100.0 elif prop == 'battery' and param['value'] > 1000: # xiaomi light sensor payload[prop] = round((min(param['value'], 3200) - 2500) / 7) elif prop == 'angle': # xiaomi cube 100 points = 360 degrees payload[prop] = param['value'] * 4 elif prop == 'duration': # xiaomi cube payload[prop] = param['value'] / 1000.0 else: payload[prop] = param['value'] _LOGGER.debug(f"{self.host} | {device['did']} {device['model']} <= " f"{payload}") for handler in self.updates[did]: handler(payload) if 'added_device' in payload: # {'did': 'lumi.fff', 'mac': 'fff', 'model': 'lumi.sen_ill.mgl01', # 'version': '21', 'zb_ver': '3.0'} device = payload['added_device'] device['mac'] = '0x' + device['mac'] device['type'] = 'zigbee' device['init'] = payload self.setup_devices([device]) def process_pair(self, raw: bytes): # get shortID and eui64 of paired device if b'lumi send-nwk-key' in raw: # create model response payload = f"0x18010105000042{len(self.pair_model):02x}" \ f"{self.pair_model.encode().hex()}" m = RE_NWK_KEY.search(raw.decode()) self.pair_payload = json.dumps( { 'sourceAddress': m[1], 'eui64': '0x' + m[2], 'profileId': '0x0104', 'clusterId': '0x0000', 'sourceEndpoint': '0x01', 'destinationEndpoint': '0x01', 'APSCounter': '0x01', 'APSPlayload': payload }, separators=(',', ':')) # send model response "from device" elif b'zdo active ' in raw: mac = self.device['mac'][2:].upper() self.mqtt.publish(f"gw/{mac}/MessageReceived", self.pair_payload) def process_ble_event(self, raw: Union[bytes, str]): data = json.loads(raw[10:])['params'] \ if isinstance(raw, bytes) else json.loads(raw) _LOGGER.debug(f"{self.host} | Process BLE {data}") did = data['dev']['did'] if did not in self.devices: mac = data['dev']['mac'].replace(':', '').lower() \ if 'mac' in data['dev'] else \ 'ble_' + did.replace('blt.3.', '') self.devices[did] = device = { 'did': did, 'mac': mac, 'init': {}, 'device_name': "BLE", 'type': 'ble' } else: device = self.devices[did] if isinstance(data['evt'], list): # check if only one assert len(data['evt']) == 1, data payload = ble.parse_xiaomi_ble(data['evt'][0]) elif isinstance(data['evt'], dict): payload = ble.parse_xiaomi_ble(data['evt']) else: payload = None if payload is None: _LOGGER.debug(f"Unsupported BLE {data}") return # init entities if needed for k in payload.keys(): if k in device['init']: continue device['init'][k] = payload[k] domain = ble.get_ble_domain(k) if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) self.setups[domain](self, device, k) if did in self.updates: for handler in self.updates[did]: handler(payload) def send(self, device: dict, data: dict): # convert hass prop to lumi prop params = [{ 'res_name': next(p[0] for p in device['params'] if p[2] == k), 'value': v } for k, v in data.items()] payload = { 'cmd': 'write', 'did': device['did'], 'params': params, } _LOGGER.debug(f"{self.host} | {device['did']} {device['model']} => " f"{payload}") payload = json.dumps(payload, separators=(',', ':')).encode() self.mqtt.publish('zigbee/recv', payload) def get_device(self, mac: str) -> Optional[dict]: for device in self.devices.values(): if device.get('mac') == mac: return device return None
class Mqtt(): """Main Mqtt class. :param app: flask application object :param connect_async: if True then connect_aync will be used to connect to MQTT broker :param mqtt_logging: if True then messages from MQTT client will be logged """ def __init__(self, app=None, connect_async=False, mqtt_logging=False): # type: (Flask, bool, bool) -> None self.app = app self._connect_async = connect_async # type: bool self._connect_handler = None # type: Optional[Callable] self._disconnect_handler = None # type: Optional[Callable] self.topics = {} # type: Dict[str, TopicQos] self.connected = False self.client = Client() if mqtt_logging: self.client.enable_logger(logger) if app is not None: self.init_app(app) def init_app(self, app): # type: (Flask) -> None """Init the Flask-MQTT addon.""" self.client_id = app.config.get("MQTT_CLIENT_ID", "") self.clean_session = app.config.get("MQTT_CLEAN_SESSION", True) if isinstance(self.client_id, unicode): self.client._client_id = self.client_id.encode('utf-8') else: self.client._client_id = self.client_id self.client._clean_session = self.clean_session self.client._transport = app.config.get("MQTT_TRANSPORT", "tcp").lower() self.client._protocol = app.config.get("MQTT_PROTOCOL_VERSION", MQTTv311) self.client.on_connect = self._handle_connect self.client.on_disconnect = self._handle_disconnect self.username = app.config.get("MQTT_USERNAME") self.password = app.config.get("MQTT_PASSWORD") self.broker_url = app.config.get("MQTT_BROKER_URL", "localhost") self.broker_port = app.config.get("MQTT_BROKER_PORT", 1883) self.tls_enabled = app.config.get("MQTT_TLS_ENABLED", False) self.keepalive = app.config.get("MQTT_KEEPALIVE", 60) self.last_will_topic = app.config.get("MQTT_LAST_WILL_TOPIC") self.last_will_message = app.config.get("MQTT_LAST_WILL_MESSAGE") self.last_will_qos = app.config.get("MQTT_LAST_WILL_QOS", 0) self.last_will_retain = app.config.get("MQTT_LAST_WILL_RETAIN", False) if self.tls_enabled: self.tls_ca_certs = app.config["MQTT_TLS_CA_CERTS"] self.tls_certfile = app.config.get("MQTT_TLS_CERTFILE") self.tls_keyfile = app.config.get("MQTT_TLS_KEYFILE") self.tls_cert_reqs = app.config.get("MQTT_TLS_CERT_REQS", ssl.CERT_REQUIRED) self.tls_version = app.config.get("MQTT_TLS_VERSION", ssl.PROTOCOL_TLSv1) self.tls_ciphers = app.config.get("MQTT_TLS_CIPHERS") self.tls_insecure = app.config.get("MQTT_TLS_INSECURE", False) # set last will message if self.last_will_topic is not None: self.client.will_set( self.last_will_topic, self.last_will_message, self.last_will_qos, self.last_will_retain, ) self._connect() def _connect(self): # type: () -> None if self.username is not None: self.client.username_pw_set(self.username, self.password) # security if self.tls_enabled: self.client.tls_set( ca_certs=self.tls_ca_certs, certfile=self.tls_certfile, keyfile=self.tls_keyfile, cert_reqs=self.tls_cert_reqs, tls_version=self.tls_version, ciphers=self.tls_ciphers, ) if self.tls_insecure: self.client.tls_insecure_set(self.tls_insecure) if self._connect_async: # if connect_async is used self.client.connect_async(self.broker_url, self.broker_port, keepalive=self.keepalive) else: res = self.client.connect(self.broker_url, self.broker_port, keepalive=self.keepalive) if res == 0: logger.debug("Connected client '{0}' to broker {1}:{2}".format( self.client_id, self.broker_url, self.broker_port)) else: logger.error( "Could not connect to MQTT Broker, Error Code: {0}".format( res)) self.client.loop_start() def _disconnect(self): # type: () -> None self.client.loop_stop() self.client.disconnect() logger.debug('Disconnected from Broker') def _handle_connect(self, client, userdata, flags, rc): # type: (Client, Any, Dict, int) -> None if rc == MQTT_ERR_SUCCESS: self.connected = True for key, item in self.topics.items(): self.client.subscribe(topic=item.topic, qos=item.qos) if self._connect_handler is not None: self._connect_handler(client, userdata, flags, rc) def _handle_disconnect(self, client, userdata, rc): # type: (str, Any, int) -> None self.connected = False if self._disconnect_handler is not None: self._disconnect_handler() def on_topic(self, topic): # type: (str) -> Callable """Decorator. Decorator to add a callback function that is called when a certain topic has been published. The callback function is expected to have the following form: `handle_topic(client, userdata, message)` :parameter topic: a string specifying the subscription topic to subscribe to The topic still needs to be subscribed via mqtt.subscribe() before the callback function can be used to handle a certain topic. This way it is possible to subscribe and unsubscribe during runtime. **Example usage:**:: app = Flask(__name__) mqtt = Mqtt(app) mqtt.subscribe('home/mytopic') @mqtt.on_topic('home/mytopic') def handle_mytopic(client, userdata, message): print('Received message on topic {}: {}' .format(message.topic, message.payload.decode())) """ def decorator(handler): # type: (Callable[[str], None]) -> Callable[[str], None] self.client.message_callback_add(topic, handler) return handler return decorator def subscribe(self, topic, qos=0): # type: (str, int) -> Tuple[int, int] """ Subscribe to a certain topic. :param topic: a string specifying the subscription topic to subscribe to. :param qos: the desired quality of service level for the subscription. Defaults to 0. :rtype: (int, int) :result: (result, mid) A topic is a UTF-8 string, which is used by the broker to filter messages for each connected client. A topic consists of one or more topic levels. Each topic level is separated by a forward slash (topic level separator). The function returns a tuple (result, mid), where result is MQTT_ERR_SUCCESS to indicate success or (MQTT_ERR_NO_CONN, None) if the client is not currently connected. mid is the message ID for the subscribe request. The mid value can be used to track the subscribe request by checking against the mid argument in the on_subscribe() callback if it is defined. **Topic example:** `myhome/groundfloor/livingroom/temperature` """ # TODO: add support for list of topics # don't subscribe if already subscribed # try to subscribe result, mid = self.client.subscribe(topic=topic, qos=qos) # if successful add to topics if result == MQTT_ERR_SUCCESS: self.topics[topic] = TopicQos(topic=topic, qos=qos) logger.debug('Subscribed to topic: {0}, qos: {1}'.format( topic, qos)) else: logger.error('Error {0} subscribing to topic: {1}'.format( result, topic)) return (result, mid) def unsubscribe(self, topic): # type: (str) -> Optional[Tuple[int, int]] """ Unsubscribe from a single topic. :param topic: a single string that is the subscription topic to unsubscribe from :rtype: (int, int) :result: (result, mid) Returns a tuple (result, mid), where result is MQTT_ERR_SUCCESS to indicate success or (MQTT_ERR_NO_CONN, None) if the client is not currently connected. mid is the message ID for the unsubscribe request. The mid value can be used to track the unsubscribe request by checking against the mid argument in the on_unsubscribe() callback if it is defined. """ # don't unsubscribe if not in topics if topic in self.topics: result, mid = self.client.unsubscribe(topic) if result == MQTT_ERR_SUCCESS: self.topics.pop(topic) logger.debug('Unsubscribed from topic: {0}'.format(topic)) else: logger.debug('Error {0} unsubscribing from topic: {1}'.format( result, topic)) # if successful remove from topics return result, mid return None def unsubscribe_all(self): # type: () -> None """Unsubscribe from all topics.""" topics = list(self.topics.keys()) for topic in topics: self.unsubscribe(topic) def publish(self, topic, payload=None, qos=0, retain=False): # type: (str, bytes, int, bool) -> Tuple[int, int] """ Send a message to the broker. :param topic: the topic that the message should be published on :param payload: the actual message to send. If not given, or set to None a zero length message will be used. Passing an int or float will result in the payload being converted to a string representing that number. If you wish to send a true int/float, use struct.pack() to create the payload you require. :param qos: the quality of service level to use :param retain: if set to True, the message will be set as the "last known good"/retained message for the topic :returns: Returns a tuple (result, mid), where result is MQTT_ERR_SUCCESS to indicate success or MQTT_ERR_NO_CONN if the client is not currently connected. mid is the message ID for the publish request. """ if not self.connected: self.client.reconnect() result, mid = self.client.publish(topic, payload, qos, retain) if result == MQTT_ERR_SUCCESS: logger.debug('Published topic {0}: {1}'.format(topic, payload)) else: logger.error('Error {0} publishing topic {1}'.format( result, topic)) return (result, mid) def on_connect(self): # type: () -> Callable """Decorator. Decorator to handle the event when the broker responds to a connection request. Only the last decorated function will be called. """ def decorator(handler): # type: (Callable) -> Callable self._connect_handler = handler return handler return decorator def on_disconnect(self): # type: () -> Callable """Decorator. Decorator to handle the event when client disconnects from broker. Only the last decorated function will be called. """ def decorator(handler): # type: (Callable) -> Callable self._disconnect_handler = handler return handler return decorator def on_message(self): # type: () -> Callable """Decorator. Decorator to handle all messages that have been subscribed and that are not handled via the `on_message` decorator. **Note:** Unlike as written in the paho mqtt documentation this callback will not be called if there exists an topic-specific callback added by the `on_topic` decorator. **Example Usage:**:: @mqtt.on_message() def handle_messages(client, userdata, message): print('Received message on topic {}: {}' .format(message.topic, message.payload.decode())) """ def decorator(handler): # type: (Callable) -> Callable self.client.on_message = handler return handler return decorator def on_publish(self): # type: () -> Callable """Decorator. Decorator to handle all messages that have been published by the client. **Example Usage:**:: @mqtt.on_publish() def handle_publish(client, userdata, mid): print('Published message with mid {}.' .format(mid)) """ def decorator(handler): # type: (Callable) -> Callable self.client.on_publish = handler return handler return decorator def on_subscribe(self): # type: () -> Callable """Decorate a callback function to handle subscritions. **Usage:**:: @mqtt.on_subscribe() def handle_subscribe(client, userdata, mid, granted_qos): print('Subscription id {} granted with qos {}.' .format(mid, granted_qos)) """ def decorator(handler): # type: (Callable) -> Callable self.client.on_subscribe = handler return handler return decorator def on_unsubscribe(self): # type: () -> Callable """Decorate a callback funtion to handle unsubscribtions. **Usage:**:: @mqtt.unsubscribe() def handle_unsubscribe(client, userdata, mid) print('Unsubscribed from topic (id: {})' .format(mid)') """ def decorator(handler): # type: (Callable) -> Callable self.client.on_unsubscribe = handler return handler return decorator def on_log(self): # type: () -> Callable """Decorate a callback function to handle MQTT logging. **Example Usage:** :: @mqtt.on_log() def handle_logging(client, userdata, level, buf): print(client, userdata, level, buf) """ def decorator(handler): # type: (Callable) -> Callable self.client.on_log = handler return handler return decorator
class GatewayEntry(Thread, GatewayStats): """Main class for working with the gateway via Telnet (23), MQTT (1883) and miIO (54321) protocols. """ time_offset = 0 pair_model = None pair_payload = None pair_payload2 = None telnet_cmd = None def __init__(self, host: str, token: str, config: dict, **options): super().__init__(daemon=True, name=f"{host}_main") self.host = host self.options = options self.miio = SyncmiIO(host, token) self.mqtt = Client() self.mqtt.on_connect = self.on_connect self.mqtt.on_disconnect = self.on_disconnect self.mqtt.on_message = self.on_message self._ble = options.get('ble', True) # for fast access self._debug = options.get('debug', '') # for fast access self.parent_scan_interval = options.get('parent', -1) self.default_devices = config['devices'] if config else None self.telnet_cmd = options.get('telnet_cmd') or TELNET_CMD if 'true' in self._debug: self.miio.debug = True self.setups = {} self.stats = {} @property def device(self): return self.devices[self.did] def debug(self, message: str): # basic logs if 'true' in self._debug: _LOGGER.debug(f"{self.host} | {message}") def stop(self, *args): self.enabled = False self.mqtt.loop_stop() for device in self.devices.values(): if self in device['gateways']: device['gateways'].remove(self) def run(self): """Main thread loop.""" self.debug("Start main thread") self.mqtt.connect_async(self.host) self.enabled = True while self.enabled: # if not telnet - enable it if not self._check_port(23) and not self._enable_telnet(): time.sleep(30) continue if not self.did: devices = self._get_devices() if not devices: time.sleep(60) continue self.setup_devices(devices) self.update_time_offset() self.mesh_start() # if not mqtt - enable it (handle Mi Home and ZHA mode) if not self._prepare_gateway() or not self._mqtt_connect(): time.sleep(60) continue self.mqtt.loop_forever() self.debug("Stop main thread") def update_time_offset(self): gw_time = ntp_time(self.host) if gw_time: self.time_offset = gw_time - time.time() self.debug(f"Gateway time offset: {self.time_offset}") def _check_port(self, port: int): """Check if gateway port open.""" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: return s.connect_ex((self.host, port)) == 0 finally: s.close() def _enable_telnet(self): """Enable telnet with miio protocol.""" raw = json.loads(self.telnet_cmd) if self.miio.send(raw['method'], raw.get('params')) != ['ok']: self.debug(f"Can't enable telnet") return False return True def _prepare_gateway(self): """Launching the required utilities on the hub, if they are not already running. """ self.debug("Prepare Gateway") try: shell = TelnetShell(self.host) self.debug(f"Version: {shell.ver}") ps = shell.get_running_ps() if "mosquitto -d" not in ps: self.debug("Run public mosquitto") shell.run_public_mosquitto() if "ntpd" not in ps: # run NTPd for sync time shell.run_ntpd() bt_fix = shell.check_bt() if bt_fix is None: self.debug("Fixed BT don't supported") elif bt_fix is False: self.debug("Download fixed BT") shell.download_bt() # check after download if shell.check_bt(): self.debug("Run fixed BT") shell.run_bt() elif "-t log/ble" not in ps: self.debug("Run fixed BT") shell.run_bt() if "-t log/miio" not in ps: # all data or only necessary events pattern = ('\\{"' if 'miio' in self._debug else "ot_agent_recv_handler_one.+" "ble_event|properties_changed|heartbeat") self.debug(f"Redirect miio to MQTT") shell.redirect_miio2mqtt(pattern) if self.options.get('buzzer'): if "dummy:basic_gw" not in ps: self.debug("Disable buzzer") shell.stop_buzzer() else: if "dummy:basic_gw" in ps: self.debug("Enable buzzer") shell.run_buzzer() if self.options.get('zha'): if "Lumi_Z3GatewayHost_MQTT" in ps: self.debug("Stop Lumi Zigbee") shell.stop_lumi_zigbee() if "tcp-l:8888" not in ps: if "Received" in shell.check_or_download_socat(): self.debug("Download socat") self.debug("Run Zigbee TCP") shell.run_zigbee_tcp() else: if "tcp-l:8888" in ps: self.debug("Stop Zigbee TCP") shell.stop_zigbee_tcp() if (self.parent_scan_interval >= 0 and "Lumi_Z3GatewayHost_MQTT -n 1 -b 115200 -l" not in ps): self.debug("Run public Zigbee console") shell.run_public_zb_console() elif "Lumi_Z3GatewayHost_MQTT" not in ps: self.debug("Run Lumi Zigbee") shell.run_lumi_zigbee() shell.close() return True except (ConnectionRefusedError, socket.timeout): return False except Exception as e: self.debug(f"Can't prepare gateway: {e}") return False def _mqtt_connect(self) -> bool: try: self.mqtt.reconnect() return True except: return False def _get_devices(self): """Load devices info for Coordinator, Zigbee and Mesh.""" try: shell = TelnetShell(self.host) # 1. Read coordinator info raw = shell.read_file('/data/zigbee/coordinator.info') device = json.loads(raw) devices = [{ 'did': shell.get_did(), 'model': 'lumi.gateway.mgl03', 'mac': device['mac'], 'wlan_mac': shell.get_wlan_mac(), 'type': 'gateway', 'fw_ver': shell.ver, 'online': True, 'init': { 'firmware lock': shell.check_firmware_lock(), } }] # 2. Read zigbee devices if not self.options.get('zha'): # read Silicon devices DB nwks = {} try: raw = shell.read_file( '/data/silicon_zigbee_host/devices.txt') raw = raw.decode().split(' ') for i in range(0, len(raw) - 1, 32): ieee = reversed(raw[i + 3:i + 11]) ieee = ''.join(f"{i:>02s}" for i in ieee) nwks[ieee] = f"{raw[i]:>04s}" except: _LOGGER.exception("Can't read Silicon devices DB") # read Xiaomi devices DB raw = shell.read_file(shell.zigbee_db, as_base64=True) # self.debug(f"Devices RAW: {raw}") if raw.startswith(b'unqlite'): db = Unqlite(raw) data = db.read_all() else: raw = re.sub(br'}\s*{', b',', raw) data = json.loads(raw) # data = {} or data = {'dev_list': 'null'} dev_list = json.loads(data.get('dev_list', 'null')) or [] for did in dev_list: model = data.get(did + '.model') if not model: self.debug(f"{did} has not in devices DB") continue desc = zigbee.get_device(model) # skip unknown model if desc is None: self.debug(f"{did} has an unsupported modell: {model}") continue retain = json.loads(data[did + '.prop'])['props'] self.debug(f"{did} {model} retain: {retain}") params = { p[2]: retain.get(p[1]) for p in (desc['params'] or desc['mi_spec']) if p[1] is not None } ieee = f"{data[did + '.mac']:>016s}" device = { 'did': did, 'mac': '0x' + data[did + '.mac'], 'ieee': ieee, 'nwk': nwks.get(ieee), 'model': model, 'type': 'zigbee', 'fw_ver': retain.get('fw_ver'), 'init': zigbee.fix_xiaomi_props(model, params), 'online': retain.get('alive', 1) == 1 } devices.append(device) # 3. Read bluetooth devices if self._ble: raw = shell.read_file('/data/miio/mible_local.db', as_base64=True) db = SQLite(raw) # load BLE devices rows = db.read_table('gateway_authed_table') for row in rows: device = { 'did': row[4], 'mac': RE_REVERSE.sub(r'\6\5\4\3\2\1', row[1]), 'model': row[2], 'type': 'ble', 'online': True, 'init': {} } devices.append(device) # load Mesh groups try: mesh_groups = {} rows = db.read_table(shell.mesh_group_table) for row in rows: # don't know if 8 bytes enougth mac = int(row[0]).to_bytes(8, 'big').hex() device = { 'did': 'group.' + row[0], 'mac': mac, 'model': 0, 'childs': [], 'type': 'mesh', 'online': True } devices.append(device) group_addr = row[1] mesh_groups[group_addr] = device # load Mesh bulbs rows = db.read_table(shell.mesh_device_table) for row in rows: device = { 'did': row[0], 'mac': row[1].replace(':', ''), 'model': row[2], 'type': 'mesh', 'online': False } devices.append(device) group_addr = row[5] if group_addr in mesh_groups: # add bulb to group if exist mesh_groups[group_addr]['childs'].append(row[0]) except: _LOGGER.exception("Can't read mesh devices") # for testing purposes for k, v in self.default_devices.items(): if k[0] == '_': devices.append(v) return devices except (ConnectionRefusedError, socket.timeout): return None except Exception as e: _LOGGER.exception(f"{self.host} | Can't read devices: {e}") return None def lock_firmware(self, enable: bool): self.debug(f"Set firmware lock to {enable}") try: shell = TelnetShell(self.host) if "Received" in shell.check_or_download_busybox(): self.debug("Download busybox") shell.lock_firmware(enable) locked = shell.check_firmware_lock() shell.close() return enable == locked except Exception as e: self.debug(f"Can't set firmware lock: {e}") return False def update_entities_states(self): for device in list(self.devices.values()): if self in device['gateways']: for entity in device['entities'].values(): if entity: entity.schedule_update_ha_state() def on_connect(self, client, userdata, flags, rc): self.debug("MQTT connected") self.mqtt.subscribe('#') self.available = True self.process_gw_stats() self.update_entities_states() def on_disconnect(self, client, userdata, rc): self.debug("MQTT disconnected") # force end mqtt.loop_forever() self.mqtt.disconnect() self.available = False self.process_gw_stats() self.update_entities_states() def on_message(self, client: Client, userdata, msg: MQTTMessage): # for debug purpose enabled = self.enabled try: topic = msg.topic if 'mqtt' in self._debug: _LOGGER.debug(f"{self.host} | MQTT | {topic} {msg.payload}") if topic == 'zigbee/send': payload = json.loads(msg.payload) self.process_message(payload) elif topic == 'log/miio': # don't need to process another data if b'ot_agent_recv_handler_one' not in msg.payload: return for raw in utils.extract_jsons(msg.payload): if self._ble and b'_async.ble_event' in raw: data = json.loads(raw)['params'] self.process_ble_event(data) self.process_ble_stats(data) elif self._ble and b'properties_changed' in raw: data = json.loads(raw)['params'] self.debug(f"Process props {data}") self.process_mesh_data(data) elif b'event.gw.heartbeat' in raw: payload = json.loads(raw)['params'][0] self.process_gw_stats(payload) # time offset may changed right after gw.heartbeat self.update_time_offset() elif topic == 'log/ble': payload = json.loads(msg.payload) self.process_ble_event_fix(payload) self.process_ble_stats(payload) elif topic == 'log/z3': self.process_z3(msg.payload.decode()) elif topic.endswith('/heartbeat'): payload = json.loads(msg.payload) self.process_gw_stats(payload) elif topic.endswith(('/MessageReceived', '/devicestatechange')): payload = json.loads(msg.payload) self.process_zb_stats(payload) # read only retained ble elif topic.startswith('ble') and msg.retain: payload = json.loads(msg.payload) self.process_ble_retain(topic[4:], payload) elif self.pair_model and topic.endswith('/commands'): self.process_pair(msg.payload) except: _LOGGER.exception(f"Processing MQTT: {msg.topic} {msg.payload}") def setup_devices(self, devices: list): """Add devices to hass.""" for device in devices: did = device['did'] type_ = device['type'] if type_ == 'gateway': self.did = device['did'] self.gw_topic = f"gw/{device['mac'][2:].upper()}/" # if device already exists - take it from registry if did not in self.devices: if type_ in ('gateway', 'zigbee'): desc = zigbee.get_device(device['model']) elif type_ == 'mesh': desc = bluetooth.get_device(device['model'], 'Mesh') elif type_ == 'ble': desc = bluetooth.get_device(device['model'], 'BLE') else: raise NotImplemented device.update(desc) # update params from config default_config = (self.default_devices.get(device['mac']) or self.default_devices.get(device['did'])) if default_config: device.update(default_config) self.debug(f"Setup {type_} device {device}") device['entities'] = {} device['gateways'] = [] self.devices[did] = device else: device = self.devices[did] if type_ in ('gateway', 'zigbee', 'mesh'): for param in (device['params'] or device['mi_spec']): self.add_entity(param[3], device, param[2]) if self.options.get('stats') and type_ != 'mesh': self.add_entity('sensor', device, device['type']) def process_message(self, data: dict): if data['cmd'] == 'heartbeat': # don't know if only one item assert len(data['params']) == 1, data data = data['params'][0] pkey = 'res_list' elif data['cmd'] == 'report': pkey = 'params' if 'params' in data else 'mi_spec' elif data['cmd'] in ('write_rsp', 'read_rsp'): pkey = 'results' if 'results' in data else 'mi_spec' elif data['cmd'] == 'write_ack': return else: _LOGGER.warning(f"Unsupported cmd: {data}") return did = data['did'] if data['did'] != 'lumi.0' else self.did # skip without callback and without data if did not in self.devices or pkey not in data: return ts = time.time() device = self.devices[did] payload = {} # convert codes to names for param in data[pkey]: if param.get('error_code', 0) != 0: continue if 'res_name' in param: prop = param['res_name'] elif 'piid' in param: prop = f"{param['siid']}.{param['piid']}" elif 'eiid' in param: prop = f"{param['siid']}.{param['eiid']}" else: _LOGGER.warning(f"Unsupported param: {data}") return if prop in zigbee.GLOBAL_PROP: prop = zigbee.GLOBAL_PROP[prop] else: prop = next((p[2] for p in (device['params'] or device['mi_spec']) if p[0] == prop), prop) # https://github.com/Koenkk/zigbee2mqtt/issues/798 # https://www.maero.dk/aqara-temperature-humidity-pressure-sensor-teardown/ if (prop == 'temperature' and device['model'] != 'lumi.airmonitor.acn01'): if -4000 < param['value'] < 12500: payload[prop] = param['value'] / 100.0 elif (prop == 'humidity' and device['model'] != 'lumi.airmonitor.acn01'): if 0 <= param['value'] <= 10000: payload[prop] = param['value'] / 100.0 elif prop == 'pressure': payload[prop] = param['value'] / 100.0 elif prop in ('battery', 'voltage'): # sometimes voltage and battery came in one payload if prop == 'voltage' and 'battery' in payload: continue # I do not know if the formula is correct, so battery is more # important than voltage payload['battery'] = (param['value'] if param['value'] < 1000 else int( (min(param['value'], 3200) - 2600) / 6)) elif prop == 'alive' and param['value']['status'] == 'offline': device['online'] = False elif prop == 'angle': # xiaomi cube 100 points = 360 degrees payload[prop] = param['value'] * 4 elif prop == 'duration': # xiaomi cube payload[prop] = param['value'] / 1000.0 elif prop in ('consumption', 'power'): payload[prop] = round(param['value'], 2) elif 'value' in param: payload[prop] = param['value'] elif 'arguments' in param: if prop == 'motion': payload[prop] = 1 else: payload[prop] = param['arguments'] # no time in device add command ts = round(ts - data['time'] * 0.001 + self.time_offset, 2) \ if 'time' in data else '?' self.debug(f"{device['did']} {device['model']} <= {payload} [{ts}]") if payload: device['online'] = True for entity in device['entities'].values(): if entity: entity.update(payload) # TODO: move code earlier!!! if 'added_device' in payload: # {'did': 'lumi.fff', 'mac': 'fff', 'model': 'lumi.sen_ill.mgl01', # 'version': '21', 'zb_ver': '3.0'} device = payload['added_device'] device['mac'] = '0x' + device['mac'] device['type'] = 'zigbee' device['init'] = payload self.setup_devices([device]) # return for tests purpose return payload def process_ble_event(self, data: dict): self.debug(f"Process BLE {data}") pdid = data['dev'].get('pdid') did = data['dev']['did'] if did not in self.devices: mac = data['dev']['mac'].replace(':', '').lower() \ if 'mac' in data['dev'] else \ 'ble_' + did.replace('blt.3.', '') self.devices[did] = device = { 'did': did, 'mac': mac, 'init': {}, 'type': 'bluetooth' } desc = bluetooth.get_device(pdid, 'BLE') device.update(desc) # update params from config default_config = self.default_devices.get(did) if default_config: device.update(default_config) else: device = self.devices[did] if device.get('seq') == data['frmCnt']: return device['seq'] = data['frmCnt'] if isinstance(data['evt'], list): # check if only one assert len(data['evt']) == 1, data payload = bluetooth.parse_xiaomi_ble(data['evt'][0], pdid) elif isinstance(data['evt'], dict): payload = bluetooth.parse_xiaomi_ble(data['evt'], pdid) else: payload = None if payload: self._process_ble_event(device, payload) def process_ble_event_fix(self, data: dict): self.debug(f"Process BLE Fix {data}") did = data['did'] if did not in self.devices: self.debug(f"Unregistered BLE device {did}") return device = self.devices[did] if device.get('seq') == data['seq']: return device['seq'] = data['seq'] payload = bluetooth.parse_xiaomi_ble(data, data['pdid']) if payload: self._process_ble_event(device, payload) def _process_ble_event(self, device: dict, payload: dict): did = device['did'] # init entities if needed init = device['init'] for k in payload.keys(): if k in init: # update for retain init[k] = payload[k] continue init[k] = payload[k] domain = bluetooth.get_ble_domain(k) self.add_entity(domain, device, k) for entity in device['entities'].values(): if entity: entity.update(payload) raw = json.dumps(init, separators=(',', ':')) self.mqtt.publish(f"ble/{did}", raw, retain=True) def process_ble_retain(self, did: str, payload: dict): if did not in self.devices: self.debug(f"BLE device {did} is no longer on the gateway") return self.debug(f"{did} retain: {payload}") device = self.devices[did] # init entities if needed for k in payload.keys(): # don't retain action and motion if k in device['entities']: continue if k in ('action', 'motion'): device['init'][k] = '' else: device['init'][k] = payload[k] domain = bluetooth.get_ble_domain(k) self.add_entity(domain, device, k) for entity in device['entities'].values(): if entity: entity.update(payload) def process_pair(self, raw: bytes): _LOGGER.debug(f"!!! {raw}") # get shortID and eui64 of paired device if b'lumi send-nwk-key' in raw: # create model response payload = f"0x08020105000042{len(self.pair_model):02x}" \ f"{self.pair_model.encode().hex()}" m = RE_NWK_KEY.search(raw.decode()) self.pair_payload = json.dumps( { 'sourceAddress': m[1], 'eui64': '0x' + m[2], 'profileId': '0x0104', 'clusterId': '0x0000', 'sourceEndpoint': '0x01', 'destinationEndpoint': '0x01', 'APSCounter': '0x01', 'APSPlayload': payload }, separators=(',', ':')) self.pair_payload2 = json.dumps( { 'sourceAddress': m[1], 'eui64': '0x' + m[2], 'profileId': '0x0104', 'clusterId': '0x0000', 'sourceEndpoint': '0x01', 'destinationEndpoint': '0x01', 'APSCounter': '0x01', 'APSPlayload': '0x0801010100002001' }, separators=(',', ':')) # send model response "from device" elif b'zdo active ' in raw: self.mqtt.publish(self.gw_topic + 'MessageReceived', self.pair_payload2) self.mqtt.publish(self.gw_topic + 'MessageReceived', self.pair_payload) def send(self, device: dict, data: dict): did = device['did'] if device['did'] != self.did else 'lumi.0' payload = {'cmd': 'write', 'did': did} # convert hass prop to lumi prop if device['mi_spec']: params = [] for k, v in data.items(): if k == 'switch': v = bool(v) k = next(p[0] for p in device['mi_spec'] if p[2] == k) params.append({ 'siid': int(k[0]), 'piid': int(k[2]), 'value': v }) payload['mi_spec'] = params else: params = [{ 'res_name': next(p[0] for p in device['params'] if p[2] == k), 'value': v } for k, v in data.items()] payload = { 'cmd': 'write', 'did': device['did'], 'params': params, } self.debug(f"{device['did']} {device['model']} => {payload}") payload = json.dumps(payload, separators=(',', ':')).encode() self.mqtt.publish('zigbee/recv', payload) def send_telnet(self, *args: str): try: shell = TelnetShell(self.host) for command in args: if command == 'ftp': shell.check_or_download_busybox() shell.run_ftp() elif command == 'dump': raw = shell.tar_data() filename = Path().absolute() / f"{self.host}.tar.gz" with open(filename, 'wb') as f: f.write(raw) else: shell.exec(command) shell.close() except Exception as e: _LOGGER.exception(f"Telnet command error: {e}") def send_mqtt(self, cmd: str): if cmd == 'publishstate': self.mqtt.publish(self.gw_topic + 'publishstate') def get_device(self, mac: str) -> Optional[dict]: for device in self.devices.values(): if device.get('mac') == mac: return device return None
class Gateway3(Thread): def __init__(self, host: str, token: str, config: dict): super().__init__(daemon=True) self.host = host self.miio = Device(host, token) self.mqtt = Client() self.mqtt.on_connect = self.on_connect self.mqtt.on_disconnect = self.on_disconnect self.mqtt.on_message = self.on_message self.mqtt.connect_async(host) self.ble = GatewayBLE(self) self.debug = config['debug'] if 'debug' in config else '' self.devices = config['devices'] if 'devices' in config else {} self.updates = {} self.setups = {} @property def device(self): return self.devices['lumi.0'] def add_update(self, did: str, handler): """Add handler to device update event.""" self.updates.setdefault(did, []).append(handler) def add_setup(self, domain: str, handler): """Add hass device setup funcion.""" self.setups[domain] = handler def run(self): """Main loop""" while 'lumi.0' not in self.devices: if self._miio_connect(): devices = self._get_devices_v3() if devices: self.setup_devices(devices) else: self._enable_telnet() else: time.sleep(30) # start bluetooth read loop self.ble.start() while True: if self._mqtt_connect(): self.mqtt.loop_forever() elif self._miio_connect() and self._enable_telnet(): self._enable_mqtt() else: _LOGGER.debug("sleep 30") time.sleep(30) def _mqtt_connect(self) -> bool: try: self.mqtt.reconnect() return True except: return False def _miio_connect(self) -> bool: try: self.miio.send_handshake() return True except: _LOGGER.debug(f"{self.host} | Can't send handshake") return False def _get_devices_v1(self) -> Optional[list]: """Load devices via miio protocol.""" _LOGGER.debug(f"{self.host} | Read devices") try: devices = {} # endless loop protection for _ in range(16): # load only 8 device per part part = self.miio.send('get_device_list', retry_count=10) if len(part) == 0: return [] for item in part: devices[item['num']] = { 'did': item['did'], 'mac': f"0x{item['did'][5:]}", 'model': item['model'], } if part[0]['total'] == len(devices): break devices = list(devices.values()) for device in devices: desc = utils.get_device(device['model']) # skip unknown model if desc is None: continue # get xiaomi param names params = [p[1] for p in desc['params'] if p[1] is not None] # skip if don't have retain params if not params: continue # load param values values = self.miio.send('get_device_prop', [device['did']] + params) # get hass param names params = [p[2] for p in desc['params'] if p[1] is not None] data = dict(zip(params, values)) # fix some param values for k, v in data.items(): if k in ('temperature', 'humidity'): data[k] = v / 100.0 elif v in ('on', 'open'): data[k] = 1 elif v in ('off', 'close'): data[k] = 0 device['init'] = data device = self.miio.info() devices.append({ 'did': 'lumi.0', 'mac': device.mac_address, # wifi mac!!! 'model': device.model }) return devices except Exception as e: _LOGGER.exception(f"{self.host} | Get devices: {e}") return None def _get_devices_v2(self) -> Optional[list]: """Load device list via Telnet. Device desc example: mac: '0x158d0002c81234' shortId: '0x0691' manuCode: '0x115f' model: 'lumi.sensor_ht' did: 'lumi.158d0002c81234' devType: 0 appVer: 2 hardVer: 0 devID: 770 status: 0 model_ver: 2 """ _LOGGER.debug(f"{self.host} | Read devices") try: telnet = Telnet(self.host) telnet.read_until(b"login: "******"admin\r\n") telnet.read_until(b'\r\n# ') # skip greeting telnet.write(b"cat /data/zigbee/coordinator.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') device = json.loads(raw[:-2]) device.update({ 'did': 'lumi.0', 'model': 'lumi.gateway.mgl03', 'host': self.host }) devices = [device] telnet.write(b"cat /data/zigbee/device.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') raw = json.loads(raw[:-2]) devices += raw['devInfo'] telnet.close() return devices except Exception as e: _LOGGER.exception(f"Can't read devices: {e}") return None def _get_devices_v3(self): """Load device list via Telnet.""" _LOGGER.debug(f"{self.host} | Read devices") try: telnet = Telnet(self.host, timeout=5) telnet.read_until(b"login: "******"admin\r\n") telnet.read_until(b'\r\n# ') # skip greeting # https://github.com/AlexxIT/XiaomiGateway3/issues/14 # fw 1.4.6_0012 and below have one zigbee_gw.db file # fw 1.4.6_0030 have many json files in this folder telnet.write(b"cat /data/zigbee_gw/* | base64\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') raw = base64.b64decode(raw) if raw.startswith(b'unqlite'): db = Unqlite(raw) data = db.read_all() else: raw = re.sub(br'}\s+{', b',', raw) data = json.loads(raw) devices = [] # data = {} or data = {'dev_list': 'null'} dev_list = json.loads(data.get('dev_list', 'null')) or [] _LOGGER.debug(f"{self.host} | Load {len(dev_list)} zigbee devices") for did in dev_list: model = data[did + '.model'] desc = utils.get_device(model) # skip unknown model if desc is None: _LOGGER.debug(f"Unsupported model: {model}") continue retain = json.loads(data[did + '.prop'])['props'] _LOGGER.debug(f"{self.host} | {model} retain: {retain}") params = { p[2]: retain.get(p[1]) for p in desc['params'] if p[1] is not None } # fix some param values for k, v in params.items(): if k in ('temperature', 'humidity'): params[k] = v / 100.0 elif v in ('on', 'open'): params[k] = 1 elif v in ('off', 'close'): params[k] = 0 elif k == 'battery' and v and v > 1000: params[k] = round((min(v, 3200) - 2500) / 7) device = { 'did': did, 'mac': '0x' + data[did + '.mac'], 'model': data[did + '.model'], 'type': 'zigbee', 'zb_ver': data[did + '.version'], 'init': params } devices.append(device) telnet.write(b"cat /data/zigbee/coordinator.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') device = json.loads(raw[:-2]) devices.insert(0, { 'did': 'lumi.0', 'model': 'lumi.gateway.mgl03', 'mac': device['mac'], 'type': 'gateway' }) return devices except (ConnectionRefusedError, socket.timeout): return None except Exception as e: _LOGGER.debug(f"Can't read devices: {e}") return None def _enable_telnet(self): _LOGGER.debug(f"{self.host} | Try enable telnet") try: resp = self.miio.send("enable_telnet_service") return resp[0] == 'ok' except Exception as e: _LOGGER.exception(f"Can't enable telnet: {e}") return False def _enable_mqtt(self): _LOGGER.debug(f"{self.host} | Try run public MQTT") try: telnet = Telnet(self.host) telnet.read_until(b"login: "******"admin\r\n") telnet.read_very_eager() # skip response # enable public mqtt telnet.write(b"killall mosquitto\r\n") telnet.read_very_eager() # skip response time.sleep(.5) telnet.write(b"mosquitto -d\r\n") telnet.read_very_eager() # skip response time.sleep(1) telnet.close() return True except Exception as e: _LOGGER.debug(f"Can't run MQTT: {e}") return False def on_connect(self, client, userdata, flags, rc): _LOGGER.debug(f"{self.host} | MQTT connected") self.mqtt.subscribe('#') def on_disconnect(self, client, userdata, rc): _LOGGER.debug(f"{self.host} | MQTT disconnected") # force end mqtt.loop_forever() self.mqtt.disconnect() def on_message(self, client: Client, userdata, msg: MQTTMessage): if 'mqtt' in self.debug: _LOGGER.debug(f"[MQ] {msg.topic} {msg.payload.decode()}") if msg.topic == 'zigbee/send': payload = json.loads(msg.payload) self.process_message(payload) def setup_devices(self, devices: list): """Add devices to hass.""" for device in devices: desc = utils.get_device(device['model']) if not desc: _LOGGER.debug(f"Unsupported model: {device}") continue _LOGGER.debug(f"{self.host} | Setup device {device['model']}") device.update(desc) # update params from config default_config = self.devices.get(device['mac']) if default_config: device.update(default_config) self.devices[device['did']] = device for param in device['params']: domain = param[3] if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) attr = param[2] self.setups[domain](self, device, attr) def process_message(self, data: dict): if data['cmd'] == 'heartbeat': # don't know if only one item assert len(data['params']) == 1, data data = data['params'][0] pkey = 'res_list' elif data['cmd'] == 'report': pkey = 'params' if 'params' in data else 'mi_spec' elif data['cmd'] == 'write_rsp': pkey = 'results' else: raise NotImplemented(f"Unsupported cmd: {data}") did = data['did'] # skip without callback if did not in self.updates: return device = self.devices[did] payload = {} # convert codes to names for param in data[pkey]: if param.get('error_code', 0) != 0: continue prop = param['res_name'] if 'res_name' in param else \ f"{param['siid']}.{param['piid']}" if prop in GLOBAL_PROP: prop = GLOBAL_PROP[prop] else: prop = next((p[2] for p in device['params'] if p[0] == prop), prop) if prop in ('temperature', 'humidity'): payload[prop] = param['value'] / 100.0 elif prop == 'battery' and param['value'] > 1000: payload[prop] = round((min(param['value'], 3200) - 2500) / 7) else: payload[prop] = param['value'] _LOGGER.debug(f"{self.host} | {device['did']} {device['model']} <= " f"{payload}") for handler in self.updates[did]: handler(payload) if 'added_device' in payload: # {'did': 'lumi.fff', 'mac': 'fff', 'model': 'lumi.sen_ill.mgl01', # 'version': '21', 'zb_ver': '3.0'} device = payload['added_device'] device['mac'] = '0x' + device['mac'] self.setup_devices([device]) def process_ble_event(self, raw: Union[bytes, str]): data = json.loads(raw[10:])['params'] \ if isinstance(raw, bytes) else json.loads(raw) _LOGGER.debug(f"{self.host} | Process BLE {data}") did = data['dev']['did'] if did not in self.devices: mac = data['dev']['mac'].replace(':', '').lower() \ if 'mac' in data['dev'] else \ 'ble_' + did.replace('blt.3.', '') self.devices[did] = device = { 'did': did, 'mac': mac, 'init': {}, 'device_name': "BLE", 'type': 'ble'} else: device = self.devices[did] if isinstance(data['evt'], list): # check if only one assert len(data['evt']) == 1, data payload = ble.parse_xiaomi_ble(data['evt'][0]) elif isinstance(data['evt'], dict): payload = ble.parse_xiaomi_ble(data['evt']) else: payload = None if payload is None: _LOGGER.debug(f"Unsupported BLE {data}") return # init entities if needed for k in payload.keys(): if k in device['init']: continue device['init'][k] = payload[k] domain = ble.get_ble_domain(k) if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) self.setups[domain](self, device, k) if did in self.updates: for handler in self.updates[did]: handler(payload) def send(self, device: dict, data: dict): # convert hass prop to lumi prop params = [{ 'res_name': next(p[0] for p in device['params'] if p[2] == k), 'value': v } for k, v in data.items()] payload = { 'cmd': 'write', 'did': device['did'], 'params': params, } _LOGGER.debug(f"{self.host} | {device['did']} {device['model']} => " f"{payload}") payload = json.dumps(payload, separators=(',', ':')).encode() self.mqtt.publish('zigbee/recv', payload)
class MqttHandler: def __init__(self, client_id='DEFAULT_CLIENT_ID', topic='DEFAULT_TOPIC', broker_host='localhost', broker_port=MQTT_BROKER_PORT): self.subscribed = False self.client_id = client_id self.client = Client(client_id=self.client_id, protocol=MQTT_PROTOCOL_VERSION) self.client.on_message = self.on_message_callback self.client.on_publish = self.on_publish_callback self.client.on_connect = self.connect_callback self.client.on_disconnect = self.disconnect_callback self.topic = topic self.broker_host = broker_host self.broker_port = broker_port self.message_received = 0 userdata = { USER_DATA_MESSAGE_RECEIVED: 0, } self.client.user_data_set(userdata) def connect(self): self.client.connect(host=self.broker_host, port=self.broker_port) def connect_async(self): self.client.connect_async(host=self.broker_host, port=self.broker_port) def connect_callback(self, client, userdata, flags, rc): print('connect_callback: result code[' + str(rc) + ']') (result, _) = client.subscribe(topic=self.topic) self.subscribed = result def disconnect(self): self.client.disconnect() def disconnect_callback(self, client, userdata, rc): print('disconnect_callback') def is_valid(self, my_json: json): if app.config.get('DEBUG', False): print("json_validation") # try: # if my_json['id'] is None or my_json['byte_stream'] is None: # return False # except KeyError: # return False return True def on_message_callback(self, client, userdata, message): from core.socketio_runner import emit_command userdata[USER_DATA_MESSAGE_RECEIVED] += 1 topic = message.topic payload = json.loads(message.payload) if app.config.get('DEBUG', False): print('on_message_callback: topic[' + topic + ']') if self.is_valid(payload): emit_command(topic, payload) else: raise Exception('Message payload not valid') @staticmethod def publish_single_message(topic, payload=None, qos=0, retain=False, hostname="localhost", port=MQTT_BROKER_PORT, client_id="", keepalive=60, will=None, auth=None, tls=None): if app.config.get('DEBUG', False): print("publish_single_message") single(topic=topic, payload=payload, qos=qos, retain=retain, hostname=hostname, port=port, client_id=client_id, keepalive=keepalive, will=will, auth=auth, tls=tls) def on_publish_callback(self, client, userdata, mid): print('on_publish_callback') def loop_for_ever(self): self.client.loop_forever() def loop_start(self): self.client.loop_start() def loop_stop(self, force=False): self.client.loop_stop(force=force)
class MQTTConnection(): client: Client _instance = None @classmethod def get_instance(cls) -> "MQTTConnection": if cls._instance is None: cls._instance = MQTTConnection() return cls._instance def __init__(self): self.client = Client( "pai" + os.urandom(8).hex(), protocol=protocol_map.get(str(cfg.MQTT_PROTOCOL), MQTTv311), transport=cfg.MQTT_TRANSPORT, ) self._last_pai_status = "unknown" self.pai_status_topic = "{}/{}/{}".format(cfg.MQTT_BASE_TOPIC, cfg.MQTT_INTERFACE_TOPIC, "pai_status") self.availability_topic = "{}/{}/{}".format(cfg.MQTT_BASE_TOPIC, cfg.MQTT_INTERFACE_TOPIC, "availability") self.client.on_connect = self._on_connect_cb self.client.on_disconnect = self._on_disconnect_cb self.state = ConnectionState.NEW # self.client.enable_logger(logger) # self.client.on_subscribe = lambda client, userdata, mid, granted_qos: logger.debug("Subscribed: %s" %(mid)) # self.client.on_message = lambda client, userdata, message: logger.debug("Message received: %s" % str(message)) # self.client.on_publish = lambda client, userdata, mid: logger.debug("Message published: %s" % str(mid)) ps.subscribe(self.on_run_state_change, "run-state") self.registrars = [] if cfg.MQTT_USERNAME is not None and cfg.MQTT_PASSWORD is not None: self.client.username_pw_set(username=cfg.MQTT_USERNAME, password=cfg.MQTT_PASSWORD) if cfg.MQTT_TLS_CERT_PATH is not None: self.client.tls_set( ca_certs=cfg.MQTT_TLS_CERT_PATH, certfile=None, keyfile=None, cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLSv1_2, ciphers=None, ) self.client.tls_insecure_set(False) self.client.will_set(self.availability_topic, "offline", 0, retain=True) self.client.on_log = self.on_client_log def on_client_log(self, client, userdata, level, buf): level_std = LOGGING_LEVEL[level] exc_info = None type_, exc, trace = sys.exc_info() if exc: # Can be (socket.error, OSError, WebsocketConnectionError, ...) if hasattr(exc, "errno"): exc_msg = f"{os.strerror(exc.errno)}({exc.errno})" if exc.errno in [22, 49]: level_std = logging.ERROR buf = f"{buf}: Please check MQTT connection settings. Especially MQTT_BIND_ADDRESS and MQTT_BIND_PORT" else: exc_msg = str(exc) buf = f"{buf}: {exc_msg}" if "Connection failed" in buf: level_std = logging.WARNING if level_std > logging.DEBUG: logger.log(level_std, buf, exc_info=exc_info) def on_run_state_change(self, state: RunState): v = RUN_STATE_2_PAYLOAD.get(state, "unknown") self._report_pai_status(v) def start(self): if self.state == ConnectionState.NEW: self.client.loop_start() # TODO: Some initial connection retry mechanism required try: self.client.connect_async( host=cfg.MQTT_HOST, port=cfg.MQTT_PORT, keepalive=cfg.MQTT_KEEPALIVE, bind_address=cfg.MQTT_BIND_ADDRESS, bind_port=cfg.MQTT_BIND_PORT, ) self.state = ConnectionState.CONNECTING logger.info("MQTT loop started") except socket.gaierror: logger.exception("Failed to connect to MQTT (%s:%d)", cfg.MQTT_HOST, cfg.MQTT_PORT) def stop(self): if self.state in [ ConnectionState.CONNECTING, ConnectionState.CONNECTED ]: self.disconnect() self.client.loop_stop() logger.info("MQTT loop stopped") def publish(self, topic, payload=None, *args, **kwargs): logger.debug("MQTT: {}={}".format(topic, payload)) self.client.publish(topic, payload, *args, **kwargs) def _call_registars(self, method, *args, **kwargs): for r in self.registrars: try: if hasattr(r, method) and isinstance(getattr(r, method), typing.Callable): getattr(r, method)(*args, **kwargs) except: logger.exception('Failed to call "%s" on "%s"', method, r.__class__.__name__) def register(self, cls): self.registrars.append(cls) self.start() def unregister(self, cls): self.registrars.remove(cls) if len(self.registrars) == 0: self.stop() @property def connected(self): return self.state == ConnectionState.CONNECTED def _report_pai_status(self, status): self._last_pai_status = status self.publish(self.pai_status_topic, status, qos=cfg.MQTT_QOS, retain=True) self.publish( self.availability_topic, "online" if status in ["online", "paused"] else "offline", qos=cfg.MQTT_QOS, retain=True, ) def _on_connect_cb(self, client, userdata, flags, result, properties=None): # called on Thread-6 if result == MQTT_ERR_SUCCESS: logger.info("MQTT Broker Connected") self.state = ConnectionState.CONNECTED self._report_pai_status(self._last_pai_status) self._call_registars("on_connect", client, userdata, flags, result) else: logger.error( f"Failed to connect to MQTT: {connack_string(result)} ({result})" ) def _on_disconnect_cb(self, client, userdata, rc): # called on Thread-6 if rc == MQTT_ERR_SUCCESS: logger.info("MQTT Broker Disconnected") else: logger.error(f"MQTT Broker unexpectedly disconnected. Code: {rc}") self.state = ConnectionState.NEW self._call_registars("on_disconnect", client, userdata, rc) def disconnect(self, reasoncode=None, properties=None): self.state = ConnectionState.DISCONNECTING self._report_pai_status("offline") self.client.disconnect() def message_callback_add(self, *args, **kwargs): self.client.message_callback_add(*args, **kwargs) def subscribe(self, *args, **kwargs): self.client.subscribe(*args, **kwargs)
class MqttServer(Thread): """Server fuer die Uebertragung des Prozessabbilds per MQTT.""" def __init__(self, basetopic, sendinterval, broker_address, port=1883, tls_set=False, username="", password=None, client_id="", send_events=False, write_outputs=False, replace_ios=None): """Init MqttServer class. @param basetopic Basis-Topic fuer Datenaustausch @param sendinterval Prozessabbild alle n Sekunden senden / 0 = aus @param broker_address Adresse <class 'str'> des MQTT-Servers @param port Portnummer <class 'int'> des MQTT-Servers @param tls_set TLS fuer Verbindung zum MQTT-Server verwenden @param username Optional Benutzername fuer MQTT-Server @param password Optional Password fuer MQTT-Server @param client_id MQTT ClientID, wenn leer automatisch random erzeugung @param send_events Sendet Werte bei IO Wertaenderung @param write_outputs Per MQTT auch Outputs schreiben @param replace_ios Replace IOs of RevPiModIO """ if not isinstance(basetopic, str): raise ValueError("parameter topic must be <class 'str'>") if not (isinstance(sendinterval, int) and sendinterval >= 0): raise ValueError( "parameter sendinterval must be <class 'int'> and >= 0") if not (isinstance(broker_address, str) and broker_address != ""): raise ValueError( "parameter broker_address must be <class 'str'> and not empty") if not (isinstance(port, int) and 0 < port < 65535): raise ValueError( "parameter sendinterval must be <class 'int'> and 1 - 65535") if not isinstance(tls_set, bool): raise ValueError("parameter tls_set must be <class 'bool'>") if not isinstance(username, str): raise ValueError("parameter username must be <class 'str'>") if not (password is None or isinstance(password, str)): raise ValueError("parameter password must be <class 'str'>") if not isinstance(client_id, str): raise ValueError("parameter client_id must be <class 'str'>") if not isinstance(send_events, bool): raise ValueError("parameter send_events must be <class 'bool'>") if not isinstance(write_outputs, bool): raise ValueError("parameter write_outputs must be <class 'bool'>") if not (replace_ios is None or isinstance(replace_ios, str)): raise ValueError("parameter replace_ios must be <class 'str'>") super().__init__() # Klassenvariablen self.__exit = False self._evt_data = Event() self._exported_ios = [] self._broker_address = broker_address self._port = port self._reloadmodio = False self._replace_ios = replace_ios self._rpi = None self._send_events = send_events self._sendinterval = sendinterval self._write_outputs = write_outputs # RevPiModIO laden oder mit Exception aussteigen self._loadrevpimodio() # Topics konfigurieren self._mqtt_evt_io = join(basetopic, "event/{0}") self._mqtt_got_io = join(basetopic, "got/{0}") self._mqtt_io = join(basetopic, "io/{0}") self._mqtt_ioget = join(basetopic, "get/#") self._mqtt_ioset = join(basetopic, "set/#") self._mqtt_ioreset = join(basetopic, "reset/#") self._mqtt_pictory = join(basetopic, "pictory") self._mqtt_senddata = join(basetopic, "get") self._mqtt_sendpictory = join(basetopic, "needpictory") self._mq = Client(client_id) if username != "": self._mq.username_pw_set(username, password) if tls_set: self._mq.tls_set(cert_reqs=CERT_NONE) self._mq.tls_insecure_set(True) # Handler konfigurieren self._mq.on_connect = self._on_connect self._mq.on_message = self._on_message def _evt_io(self, name, value, requested=False): """Sendet Daten aus Events. @param name IO-Name @param value IO-Value @param requested Wenn True, wird 'got' Topic verwendet """ if requested: topic = self._mqtt_got_io.format(name) else: topic = self._mqtt_evt_io.format(name) if isinstance(value, bytes): value = int.from_bytes(value, "little") self._mq.publish(topic, int(value)) def _loadrevpimodio(self): """Instantiiert das RevPiModIO Modul. @return None or Exception""" self._reloadmodio = False self._exported_ios = [] # RevPiModIO-Modul Instantiieren if self._rpi is not None: self._rpi.cleanup() proginit.logger.debug("create revpimodio2 object for MQTT") try: # Vollzugriff und Eventüberwachung self._rpi = revpimodio2.RevPiModIO( autorefresh=self._send_events, monitoring=not self._write_outputs, configrsc=proginit.pargs.configrsc, procimg=proginit.pargs.procimg, replace_io_file=self._replace_ios, shared_procimg=True, ) self._rpi.debug = -1 if self._replace_ios: proginit.logger.info("loaded replace_ios to MQTT") except Exception as e: try: # Lesend und Eventüberwachung self._rpi = revpimodio2.RevPiModIO( autorefresh=self._send_events, monitoring=not self._write_outputs, configrsc=proginit.pargs.configrsc, procimg=proginit.pargs.procimg, shared_procimg=True, ) self._rpi.debug = -1 proginit.logger.warning( "replace_ios_file not loadable for MQTT - using " "defaults now | {0}".format(e)) except Exception as e: self._rpi = None proginit.logger.error( "piCtory configuration not loadable for MQTT | " "{0}".format(e)) raise e # Exportierte IOs laden for dev in self._rpi.device: for io in dev.get_allios(export=True): io.reg_event(self._evt_io) self._exported_ios.append(io) # CoreIOs prüfen und zu export hinzufügen lst_coreio = [] if self._rpi.core: if self._rpi.core.a1green.export: lst_coreio.append(self._rpi.core.a1green) if self._rpi.core.a1red.export: lst_coreio.append(self._rpi.core.a1red) if self._rpi.core.a2green.export: lst_coreio.append(self._rpi.core.a2green) if self._rpi.core.a2red.export: lst_coreio.append(self._rpi.core.a2red) if self._rpi.core.wd.export: lst_coreio.append(self._rpi.core.wd) # Connect-IOs anhängen if type(self._rpi.core) == revpimodio2.device.Connect: if self._rpi.core.a3green.export: lst_coreio.append(self._rpi.core.a3green) if self._rpi.core.a3red.export: lst_coreio.append(self._rpi.core.a3red) if self._rpi.core.x2in.export: lst_coreio.append(self._rpi.core.x2in) if self._rpi.core.x2out.export: lst_coreio.append(self._rpi.core.x2out) # IOs exportieren und Events anmelden for io in lst_coreio: io.reg_event(self._evt_io) self._exported_ios.append(io) proginit.logger.debug("created revpimodio2 object") def _on_connect(self, client, userdata, flags, rc): """Verbindung zu MQTT Broker.""" proginit.logger.debug("enter MqttServer._on_connect()") if rc > 0: proginit.logger.warning( "can not connect to mqtt broker '{0}' - error '{1}' - " "will retry".format(self._broker_address, connack_string(rc))) else: # Subscribe piCtory Anforderung client.subscribe(self._mqtt_ioget) client.subscribe(self._mqtt_senddata) client.subscribe(self._mqtt_sendpictory) if self._write_outputs: client.subscribe(self._mqtt_ioset) client.subscribe(self._mqtt_ioreset) proginit.logger.debug("leave MqttServer._on_connect()") def _on_disconnect(self, client, userdata, rc): """Wertet Verbindungsabbruch aus.""" proginit.logger.debug("enter MqttServer._on_disconnect()") if rc != 0: proginit.logger.warning( "unexpected disconnection from mqtt broker - " "will try to reconnect") proginit.logger.debug("leave MqttServer._on_disconnect()") def _on_message(self, client, userdata, msg): """Sendet piCtory Konfiguration.""" if msg.topic == self._mqtt_pictory: # piCtory Konfiguration senden self._send_pictory_conf() elif msg.topic == self._mqtt_senddata: # Alle zyklischen Daten senden self._evt_data.set() else: lst_topic = msg.topic.split("/") if len(lst_topic) < 2: proginit.logger.info( "wrong topic format - need ./get/ioname or ./set/ioname") return # Aktion und IO auswerten ioget = lst_topic[-2].lower() == "get" ioset = lst_topic[-2].lower() == "set" ioreset = lst_topic[-2].lower() == "reset" ioname = lst_topic[-1] coreio = ioname.find(".") != -1 try: # IO holen if coreio: coreio = ioname.split(".")[-1] io = getattr(self._rpi.core, coreio) if not isinstance(io, revpimodio2.io.IOBase): raise RuntimeError() else: io = self._rpi.io[ioname] io_needbytes = type(io.value) == bytes except Exception: proginit.logger.error( "can not find io '{0}' for MQTT".format(ioname)) return # Aktion verarbeiten if not io.export: proginit.logger.error( "io '{0}' is not marked as export in piCtory for MQTT use" "".format(ioname)) elif ioget: # Werte laden, wenn nicht autorefresh if not self._send_events: io._parentdevice.readprocimg() # Publish Wert von IO self._evt_io(io.name, io.value, requested=True) elif ioset and io.type != revpimodio2.OUT: proginit.logger.error("can not write to inputs with MQTT") elif ioset: # Convert MQTT Payload to valid Output-Value value = msg.payload.decode("utf8") if value.isdecimal(): value = int(value) # Muss eine Byteumwandlung vorgenommen werden? if io_needbytes: try: value = value.to_bytes(io.length, io.byteorder) except OverflowError: proginit.logger.error( "can not convert value '{0}' to fitting bytes" "".format(value)) return elif value == "false" and not io_needbytes: value = 0 elif value == "true" and not io_needbytes: value = 1 else: proginit.logger.error( "can not convert value '{0}' for output '{1}'" "".format(value, ioname)) return # Write Value to RevPi try: io.value = value except Exception: proginit.logger.error( "could not write '{0}' to Output '{1}'" "".format(value, ioname)) elif ioreset: # Counter zurücksetzen if not isinstance(io, revpimodio2.io.IntIOCounter): proginit.logger.warning("this io has no counter") else: io.reset() else: # Aktion nicht erkennbar proginit.logger.warning( "can not see get/set in topic '{0}'".format(msg.topic)) def _send_pictory_conf(self): """Sendet piCtory Konfiguration per MQTT.""" try: fh = open(proginit.pargs.configrsc, "rb") self._mq.publish(self._mqtt_pictory, fh.read()) fh.close() except Exception: proginit.logger.error( "can not read and publish piCtory config '{0}'" "".format(proginit.pargs.configrsc)) def newlogfile(self): """Konfiguriert die FileHandler auf neue Logdatei.""" pass def reload_revpimodio(self): """Fuehrt im naechsten Zyklus zum Reload.""" proginit.logger.debug("enter MqttServer.reload_revpimodio()") self._reloadmodio = True self._evt_data.set() proginit.logger.debug("leave MqttServer.reload_revpimodio()") def run(self): """Startet die Uebertragung per MQTT.""" proginit.logger.debug("enter MqttServer.run()") # MQTT verbinden proginit.logger.info("connecting to mqtt broker {0}".format( self._broker_address)) try: self._mq.connect(self._broker_address, self._port, keepalive=60) except Exception: self._on_connect(self._mq, None, None, 3) self._mq.connect_async(self._broker_address, self._port, keepalive=60) self._mq.loop_start() # Eventüberwachung starten if self._send_events: proginit.logger.debug("start non blocking mainloop of revpimodio") self._rpi.mainloop(blocking=False) # mainloop send_cycledata = self._sendinterval > 0 while not self.__exit: self._evt_data.clear() # RevPiModIO neu laden if self._reloadmodio: proginit.logger.info("reload revpimodio for mqtt") self._loadrevpimodio() # Eventüberwachung erneut starten if self._send_events: proginit.logger.debug( "start non blocking mainloop of revpimodio") self._rpi.mainloop(blocking=False) if send_cycledata: # Werte laden, wenn nicht autorefresh if not self._send_events: self._rpi.readprocimg() # Exportierte IOs übertragen for io in self._exported_ios: value = io.value if isinstance(value, bytes): value = int.from_bytes(value, "little") self._mq.publish(self._mqtt_io.format(io.name), int(value)) self._evt_data.wait( 10 if not send_cycledata else self._sendinterval) # MQTT trennen proginit.logger.info("disconnecting from mqtt broker {0}".format( self._broker_address)) # NOTE: dies gab dead-locks: self._mq.loop_stop() self._mq.disconnect() # RevPiModIO aufräumen self._rpi.cleanup() proginit.logger.debug("leave MqttServer.run()") def stop(self): """Stoppt die Uebertragung per MQTT.""" proginit.logger.debug("enter MqttServer.stop()") self.__exit = True self._evt_data.set() proginit.logger.debug("leave MqttServer.stop()")
def on_connect(client, userdata, flags, rc): print('MQTT connect: {}'.format(connack_string(rc))) client.publish(status_topic, status_connected, retain=True) def on_disconnect(client, userdata, rc): print('MQTT disconnect: {}'.format(error_string(rc))) def on_message(client, userdata, message): pass # not used client = Client() client.will_set(status_topic, status_error, retain=True) client.connect_async(mqtt_server) # client.connect(mqtt_server) client.on_connect = on_connect client.on_disconnect = on_disconnect client.on_message = on_message client.loop_start() @atexit def run_at_exit(): client.publish(status_topic, status_disconnected, retain=True) client.loop_stop() client.disconnect() def publish(topic, value):
class Translator(object): """Translates messages between the LifeSOS and MQTT interfaces.""" # Default interval to wait before resetting Trigger device state to Off AUTO_RESET_INTERVAL = 30 # Keys for Home Assistant MQTT discovery configuration HA_AVAILABILITY_TOPIC = 'availability_topic' HA_COMMAND_TOPIC = 'command_topic' HA_DEVICE_CLASS = 'device_class' HA_ICON = 'icon' HA_NAME = 'name' HA_PAYLOAD_ARM_AWAY = 'payload_arm_away' HA_PAYLOAD_ARM_HOME = 'payload_arm_home' HA_PAYLOAD_AVAILABLE = 'payload_available' HA_PAYLOAD_DISARM = 'payload_disarm' HA_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' HA_PAYLOAD_OFF = 'payload_off' HA_PAYLOAD_ON = 'payload_on' HA_STATE_TOPIC = 'state_topic' HA_UNIQUE_ID = 'unique_id' HA_UNIT_OF_MEASUREMENT = 'unit_of_measurement' # Device class to classify the sensor type in Home Assistant HA_DC_DOOR = 'door' HA_DC_GAS = 'gas' HA_DC_HUMIDITY = 'humidity' HA_DC_ILLUMINANCE = 'illuminance' HA_DC_MOISTURE = 'moisture' HA_DC_MOTION = 'motion' HA_DC_SAFETY = 'safety' HA_DC_SMOKE = 'smoke' HA_DC_TEMPERATURE = 'temperature' HA_DC_VIBRATION = 'vibration' HA_DC_WINDOW = 'window' HA_DC_BATTERY = 'battery' # Icons in Home Assistant HA_ICON_RSSI = 'mdi:wifi' # Platforms in Home Assistant to represent our devices HA_PLATFORM_ALARM_CONTROL_PANEL = 'alarm_control_panel' HA_PLATFORM_BINARY_SENSOR = 'binary_sensor' HA_PLATFORM_SENSOR = 'sensor' HA_PLATFORM_SWITCH = 'switch' # Alarm states in Home Assistant HA_STATE_ARMED_AWAY = 'armed_away' HA_STATE_ARMED_HOME = 'armed_home' HA_STATE_DISARMED = 'disarmed' HA_STATE_PENDING = 'pending' HA_STATE_TRIGGERED = 'triggered' # Unit of measurement for Home Assistant sensors HA_UOM_CURRENT = 'A' HA_UOM_HUMIDITY = '%' HA_UOM_ILLUMINANCE = 'Lux' HA_UOM_RSSI = 'dB' HA_UOM_TEMPERATURE = '°C' # Ping MQTT broker this many seconds apart to check we're connected KEEP_ALIVE = 30 # Attempt reconnection this many seconds apart # (starts at min, doubles on retry until max reached) RECONNECT_MAX_DELAY = 120 RECONNECT_MIN_DELAY = 15 # Sub-topic to clear the alarm/warning LEDs on base unit and stop siren TOPIC_CLEAR_STATUS = 'clear_status' # Sub-topic to access the remote date/time TOPIC_DATETIME = 'datetime' # Sub-topic to provide alarm state that is recognised by Home Assistant TOPIC_HASTATE = 'ha_state' # Sub-topic that will be subscribed to on topics that can be set TOPIC_SET = 'set' def __init__(self, config: Config): self._config = config self._loop = asyncio.get_event_loop() self._shutdown = False self._get_task = None self._auto_reset_handles = {} self._state = None self._ha_state = None # Create LifeSOS base unit instance and attach callbacks self._baseunit = BaseUnit(self._config.lifesos.host, self._config.lifesos.port) if self._config.lifesos.password: self._baseunit.password = self._config.lifesos.password self._baseunit.on_device_added = self._baseunit_device_added self._baseunit.on_device_deleted = self._baseunit_device_deleted self._baseunit.on_event = self._baseunit_event self._baseunit.on_properties_changed = self._baseunit_properties_changed self._baseunit.on_switch_state_changed = self._baseunit_switch_state_changed # Create MQTT client instance self._mqtt = MQTTClient(client_id=self._config.mqtt.client_id, clean_session=False) self._mqtt.enable_logger() self._mqtt.will_set( '{}/{}'.format(self._config.translator.baseunit.topic, BaseUnit.PROP_IS_CONNECTED), str(False).encode(), QOS_1, True) self._mqtt.reconnect_delay_set(Translator.RECONNECT_MIN_DELAY, Translator.RECONNECT_MAX_DELAY) if self._config.mqtt.uri.username: self._mqtt.username_pw_set(self._config.mqtt.uri.username, self._config.mqtt.uri.password) if self._config.mqtt.uri.scheme == SCHEME_MQTTS: self._mqtt.tls_set() self._mqtt.on_connect = self._mqtt_on_connect self._mqtt.on_disconnect = self._mqtt_on_disconnect self._mqtt.on_message = self._mqtt_on_message self._mqtt_was_connected = False self._mqtt_last_connection = None self._mqtt_last_disconnection = None # Generate a list of topics we'll need to subscribe to self._subscribetopics = [] self._subscribetopics.append( SubscribeTopic( '{}/{}'.format(self._config.translator.baseunit.topic, Translator.TOPIC_CLEAR_STATUS), self._on_message_clear_status)) self._subscribetopics.append( SubscribeTopic( '{}/{}/{}'.format(self._config.translator.baseunit.topic, Translator.TOPIC_DATETIME, Translator.TOPIC_SET), self._on_message_set_datetime)) names = [BaseUnit.PROP_OPERATION_MODE] for name in names: self._subscribetopics.append( SubscribeTopic('{}/{}/{}'.format( self._config.translator.baseunit.topic, name, Translator.TOPIC_SET), self._on_message_baseunit, args=name)) for switch_number in self._config.translator.switches.keys(): switch_config = self._config.translator.switches.get(switch_number) if switch_config and switch_config.topic: self._subscribetopics.append( SubscribeTopic('{}/{}'.format(switch_config.topic, Translator.TOPIC_SET), self._on_message_switch, args=switch_number)) if self._config.translator.ha_birth_topic: self._subscribetopics.append( SubscribeTopic(self._config.translator.ha_birth_topic, self._on_ha_message)) # Also create a lookup dict for the topics to subscribe to self._subscribetopics_lookup = \ {st.topic: st for st in self._subscribetopics} # Create queue to store pending messages from our subscribed topics self._pending_messages = Queue() # # METHODS - Public # async def async_start(self) -> None: """Starts up the LifeSOS interface and connects to MQTT broker.""" self._shutdown = False # Start up the LifeSOS interface self._baseunit.start() # Connect to the MQTT broker self._mqtt_was_connected = False if self._config.mqtt.uri.port: self._mqtt.connect_async(self._config.mqtt.uri.hostname, self._config.mqtt.uri.port, keepalive=Translator.KEEP_ALIVE) else: self._mqtt.connect_async(self._config.mqtt.uri.hostname, keepalive=Translator.KEEP_ALIVE) # Start processing MQTT messages self._mqtt.loop_start() async def async_loop(self) -> None: """Loop indefinitely to process messages from our subscriptions.""" # Trap SIGINT and SIGTERM so that we can shutdown gracefully signal.signal(signal.SIGINT, self.signal_shutdown) signal.signal(signal.SIGTERM, self.signal_shutdown) try: while not self._shutdown: # Wait for next message self._get_task = self._loop.create_task( self._pending_messages.async_q.get()) try: message = await self._get_task except asyncio.CancelledError: _LOGGER.debug('Translator loop cancelled.') continue except Exception: # pylint: disable=broad-except # Log any exception but keep going _LOGGER.error( "Exception waiting for message to be delivered", exc_info=True) continue finally: self._get_task = None # Do subscribed topic callback to handle message try: subscribetopic = self._subscribetopics_lookup[ message.topic] subscribetopic.on_message(subscribetopic, message) except Exception: # pylint: disable=broad-except _LOGGER.error( "Exception processing message from subscribed topic: %s", message.topic, exc_info=True) finally: self._pending_messages.async_q.task_done() # Turn off is_connected flag before leaving self._publish_baseunit_property(BaseUnit.PROP_IS_CONNECTED, False) await asyncio.sleep(0) finally: signal.signal(signal.SIGINT, signal.SIG_DFL) async def async_stop(self) -> None: """Shuts down the LifeSOS interface and disconnects from MQTT broker.""" # Stop the LifeSOS interface self._baseunit.stop() # Cancel any outstanding auto reset tasks for item in self._auto_reset_handles.copy().items(): item[1].cancel() self._auto_reset_handles.pop(item[0]) # Stop processing MQTT messages self._mqtt.loop_stop() # Disconnect from the MQTT broker self._mqtt.disconnect() def signal_shutdown(self, sig, frame): """Flag shutdown when signal received.""" _LOGGER.debug('%s received; shutting down...', signal.Signals(sig).name) # pylint: disable=no-member self._shutdown = True if self._get_task: self._get_task.cancel() # Issue #8 - Cancel not processed until next message added to queue. # Just put a dummy object on the queue to ensure it is handled immediately. self._pending_messages.sync_q.put_nowait(None) # # METHODS - Private / Internal # def _mqtt_on_connect(self, client: MQTTClient, userdata: Any, flags: Dict[str, Any], result_code: int) -> None: # On error, log it and don't go any further; client will retry if result_code != CONNACK_ACCEPTED: _LOGGER.warning(connack_string(result_code)) # pylint: disable=no-member return # Successfully connected self._mqtt_last_connection = datetime.now() if not self._mqtt_was_connected: _LOGGER.debug("MQTT client connected to broker") self._mqtt_was_connected = True else: try: outage = self._mqtt_last_connection - self._mqtt_last_disconnection _LOGGER.warning( "MQTT client reconnected to broker. " "Outage duration was %s", str(outage)) except Exception: # pylint: disable=broad-except _LOGGER.warning("MQTT client reconnected to broker") # Republish the 'is_connected' state; this will have automatically # been set to False on MQTT client disconnection due to our will # (even though this app might still be connected to the LifeSOS unit) self._publish( '{}/{}'.format(self._config.translator.baseunit.topic, BaseUnit.PROP_IS_CONNECTED), self._baseunit.is_connected, True) # Subscribe to topics we are capable of actioning for subscribetopic in self._subscribetopics: self._mqtt.subscribe(subscribetopic.topic, subscribetopic.qos) def _mqtt_on_disconnect(self, client: MQTTClient, userdata: Any, result_code: int) -> None: # When disconnected from broker and we didn't initiate it... if result_code != MQTT_ERR_SUCCESS: _LOGGER.warning( "MQTT client lost connection to broker (RC: %i). " "Will attempt to reconnect periodically", result_code) self._mqtt_last_disconnection = datetime.now() def _mqtt_on_message(self, client: MQTTClient, userdata: Any, message: MQTTMessage): # Add message to our queue, to be processed on main thread self._pending_messages.sync_q.put_nowait(message) def _baseunit_device_added(self, baseunit: BaseUnit, device: Device) -> None: # Hook up callbacks for device that was added / discovered device.on_event = self._device_on_event device.on_properties_changed = self._device_on_properties_changed # Get configuration settings for device; don't go any further when # device is not included in the config device_config = self._config.translator.devices.get(device.device_id) if not device_config: _LOGGER.warning( "Ignoring device as it was not listed in the config file: %s", device) return # Publish initial property values for device if device_config.topic: props = device.as_dict() for name in props.keys(): self._publish_device_property(device_config.topic, device, name, getattr(device, name)) # When HA discovery is enabled, publish device configuration to it if self._config.translator.ha_discovery_prefix: if device_config.ha_name: self._publish_ha_device_config(device, device_config) if device_config.ha_name_rssi: self._publish_ha_device_rssi_config(device, device_config) if device_config.ha_name_battery: self._publish_ha_device_battery_config(device, device_config) def _baseunit_device_deleted( self, baseunit: BaseUnit, device: Device) -> None: # pylint: disable=no-self-use # Remove callbacks from deleted device device.on_event = None device.on_properties_changed = None def _baseunit_event(self, baseunit: BaseUnit, contact_id: ContactID): # When base unit event occurs, publish the event data # (don't bother retaining; events are time sensitive) event_data = json.dumps(contact_id.as_dict()) self._publish( '{}/event'.format(self._config.translator.baseunit.topic), event_data, False) # For clients that can't handle json, we will also provide the event # qualifier and code via these topics if contact_id.event_code: if contact_id.event_qualifier == EventQualifier.Event: self._publish( '{}/event_code'.format( self._config.translator.baseunit.topic), contact_id.event_code, False) elif contact_id.event_qualifier == EventQualifier.Restore: self._publish( '{}/restore_code'.format( self._config.translator.baseunit.topic), contact_id.event_code, False) # This is just for Home Assistant; the 'alarm_control_panel.mqtt' # component currently requires these hard-coded state values if contact_id.event_qualifier == EventQualifier.Event and \ contact_id.event_category == EventCategory.Alarm: self._ha_state = Translator.HA_STATE_TRIGGERED self._publish( '{}/{}'.format(self._config.translator.baseunit.topic, Translator.TOPIC_HASTATE), self._ha_state, True) def _baseunit_properties_changed( self, baseunit: BaseUnit, changes: List[PropertyChangedInfo]) -> None: # When base unit properties change, publish them has_connected = False for change in changes: self._publish_baseunit_property(change.name, change.new_value) # Also check if connection has just been established if change.name == BaseUnit.PROP_IS_CONNECTED and change.new_value: has_connected = True # On connection, publish config for Home Assistant if needed if has_connected: self._publish_ha_config() def _baseunit_switch_state_changed(self, baseunit: BaseUnit, switch_number: SwitchNumber, state: Optional[bool]) -> None: # When switch state changes, publish it switch_config = self._config.translator.switches.get(switch_number) if switch_config and switch_config.topic: self._publish(switch_config.topic, OnOff.parse_value(state), True) def _device_on_event(self, device: Device, event_code: DeviceEventCode) -> None: device_config = self._config.translator.devices.get(device.device_id) if device_config and device_config.topic: # When device event occurs, publish the event code # (don't bother retaining; events are time sensitive) self._publish('{}/event_code'.format(device_config.topic), event_code, False) # When it is a Trigger event, set state to On and schedule an # auto reset callback to occur after specified interval if event_code == DeviceEventCode.Trigger: self._publish(device_config.topic, OnOff.parse_value(True), True) handle = self._auto_reset_handles.get(device.device_id) if handle: handle.cancel() handle = self._loop.call_later( device_config.auto_reset_interval or Translator.AUTO_RESET_INTERVAL, self._auto_reset, device.device_id) self._auto_reset_handles[device.device_id] = handle def _auto_reset(self, device_id: int): # Auto reset a Trigger device to Off state device_config = self._config.translator.devices.get(device_id) if device_config and device_config.topic: self._publish(device_config.topic, OnOff.parse_value(False), True) self._auto_reset_handles.pop(device_id) def _device_on_properties_changed(self, device: Device, changes: List[PropertyChangedInfo]): # When device properties change, publish them device_config = self._config.translator.devices.get(device.device_id) if device_config and device_config.topic: for change in changes: self._publish_device_property(device_config.topic, device, change.name, change.new_value) def _publish_baseunit_property(self, name: str, value: Any) -> None: topic_parent = self._config.translator.baseunit.topic # Base Unit topic holds the state if name == BaseUnit.PROP_STATE: self._state = value self._publish(topic_parent, value, True) # This is just for Home Assistant; the 'alarm_control_panel.mqtt' # component currently requires these hard-coded state values topic = '{}/{}'.format(topic_parent, Translator.TOPIC_HASTATE) if value in {BaseUnitState.Disarm, BaseUnitState.Monitor}: self._ha_state = Translator.HA_STATE_DISARMED self._publish(topic, self._ha_state, True) elif value == BaseUnitState.Home: self._ha_state = Translator.HA_STATE_ARMED_HOME self._publish(topic, self._ha_state, True) elif value == BaseUnitState.Away: self._ha_state = Translator.HA_STATE_ARMED_AWAY self._publish(topic, self._ha_state, True) elif value in { BaseUnitState.AwayExitDelay, BaseUnitState.AwayEntryDelay }: self._ha_state = Translator.HA_STATE_PENDING self._publish(topic, self._ha_state, True) # Other supported properties in a topic using property name elif name in { BaseUnit.PROP_IS_CONNECTED, BaseUnit.PROP_ROM_VERSION, BaseUnit.PROP_EXIT_DELAY, BaseUnit.PROP_ENTRY_DELAY, BaseUnit.PROP_OPERATION_MODE }: self._publish('{}/{}'.format(topic_parent, name), value, True) def _publish_device_property(self, topic_parent: str, device: Device, name: str, value: Any) -> None: # Device topic holds the state if (not isinstance(device, SpecialDevice)) and \ name == Device.PROP_IS_CLOSED: # For regular device; this is the Is Closed property for magnet # sensors, otherwise default to Off for trigger-based devices if device.type == DeviceType.DoorMagnet: self._publish(topic_parent, OpenClosed.parse_value(value), True) else: self._publish(topic_parent, OnOff.Off, True) elif isinstance(device, SpecialDevice) and \ name == SpecialDevice.PROP_CURRENT_READING: # For special device, this is the current reading self._publish(topic_parent, value, True) # Category will have sub-topics for it's properties elif name == Device.PROP_CATEGORY: for prop in value.as_dict().items(): if prop[0] in {'code', 'description'}: self._publish( '{}/{}/{}'.format(topic_parent, name, prop[0]), prop[1], True) # Flag enums; expose as sub-topics with a bool state per flag elif name == Device.PROP_CHARACTERISTICS: for item in iter(DCFlags): self._publish('{}/{}/{}'.format(topic_parent, name, item.name), bool(value & item.value), True) elif name == Device.PROP_ENABLE_STATUS: for item in iter(ESFlags): self._publish('{}/{}/{}'.format(topic_parent, name, item.name), bool(value & item.value), True) elif name == Device.PROP_SWITCHES: for item in iter(SwitchFlags): self._publish('{}/{}/{}'.format(topic_parent, name, item.name), bool(value & item.value), True) elif name == SpecialDevice.PROP_SPECIAL_STATUS: for item in iter(SSFlags): self._publish('{}/{}/{}'.format(topic_parent, name, item.name), bool(value & item.value), True) # Device ID; value should be formatted as hex elif name == Device.PROP_DEVICE_ID: self._publish('{}/{}'.format(topic_parent, name), '{:06x}'.format(value), True) # Other supported properties in a topic using property name elif name in { Device.PROP_DEVICE_ID, Device.PROP_ZONE, Device.PROP_TYPE, Device.PROP_RSSI_DB, Device.PROP_RSSI_BARS, SpecialDevice.PROP_HIGH_LIMIT, SpecialDevice.PROP_LOW_LIMIT, SpecialDevice.PROP_CONTROL_LIMIT_FIELDS_EXIST, SpecialDevice.PROP_CONTROL_HIGH_LIMIT, SpecialDevice.PROP_CONTROL_LOW_LIMIT }: self._publish('{}/{}'.format(topic_parent, name), value, True) def _publish_ha_config(self): # Skip if Home Assistant discovery disabled if not self._config.translator.ha_discovery_prefix: return # Publish config for the base unit when enabled if self._config.translator.baseunit.ha_name: self._publish_ha_baseunit_config(self._config.translator.baseunit) # Publish config for each device when enabled for device_id in self._config.translator.devices.keys(): if self._shutdown: return device_config = self._config.translator.devices[device_id] device = self._baseunit.devices.get(device_id) if device: if device_config.ha_name: self._publish_ha_device_config(device, device_config) if device_config.ha_name_rssi: self._publish_ha_device_rssi_config(device, device_config) if device_config.ha_name_battery: self._publish_ha_device_battery_config( device, device_config) # Publish config for each switch when enabled for switch_number in self._config.translator.switches.keys(): if self._shutdown: return switch_config = self._config.translator.switches[switch_number] if switch_config.ha_name: self._publish_ha_switch_config(switch_number, switch_config) def _publish_ha_baseunit_config(self, baseunit_config: TranslatorBaseUnitConfig): # Generate message that can be used to automatically configure the # alarm control panel in Home Assistant using MQTT Discovery message = { Translator.HA_NAME: baseunit_config.ha_name, Translator.HA_UNIQUE_ID: '{}'.format(PROJECT_NAME), Translator.HA_STATE_TOPIC: '{}/{}'.format(baseunit_config.topic, Translator.TOPIC_HASTATE), Translator.HA_COMMAND_TOPIC: '{}/{}/{}'.format(baseunit_config.topic, BaseUnit.PROP_OPERATION_MODE, Translator.TOPIC_SET), Translator.HA_PAYLOAD_DISARM: str(OperationMode.Disarm), Translator.HA_PAYLOAD_ARM_HOME: str(OperationMode.Home), Translator.HA_PAYLOAD_ARM_AWAY: str(OperationMode.Away), Translator.HA_AVAILABILITY_TOPIC: '{}/{}'.format(baseunit_config.topic, BaseUnit.PROP_IS_CONNECTED), Translator.HA_PAYLOAD_AVAILABLE: str(True), Translator.HA_PAYLOAD_NOT_AVAILABLE: str(False), } self._publish( '{}/{}/{}/config'.format( self._config.translator.ha_discovery_prefix, Translator.HA_PLATFORM_ALARM_CONTROL_PANEL, message[Translator.HA_UNIQUE_ID]), json.dumps(message), False) def _publish_ha_device_config(self, device: Device, device_config: TranslatorDeviceConfig): # Generate message that can be used to automatically configure the # device in Home Assistant using MQTT Discovery message = { Translator.HA_NAME: device_config.ha_name, Translator.HA_UNIQUE_ID: '{}_{:06x}'.format(PROJECT_NAME, device.device_id), Translator.HA_STATE_TOPIC: device_config.topic, Translator.HA_AVAILABILITY_TOPIC: '{}/{}'.format(self._config.translator.baseunit.topic, BaseUnit.PROP_IS_CONNECTED), Translator.HA_PAYLOAD_AVAILABLE: str(True), Translator.HA_PAYLOAD_NOT_AVAILABLE: str(False), } if device.type in { DeviceType.FloodDetector, DeviceType.FloodDetector2 }: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_MOISTURE message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in {DeviceType.MedicalButton}: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_SAFETY message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in { DeviceType.AnalogSensor, DeviceType.AnalogSensor2 }: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in {DeviceType.SmokeDetector}: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_SMOKE message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in { DeviceType.PressureSensor, DeviceType.PressureSensor2 }: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_MOTION message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in { DeviceType.CODetector, DeviceType.CO2Sensor, DeviceType.CO2Sensor2, DeviceType.GasDetector }: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_GAS message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in {DeviceType.DoorMagnet}: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_DOOR message[Translator.HA_PAYLOAD_ON] = str(OpenClosed.Open) message[Translator.HA_PAYLOAD_OFF] = str(OpenClosed.Closed) elif device.type in {DeviceType.VibrationSensor}: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_VIBRATION message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in {DeviceType.PIRSensor}: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_MOTION message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in {DeviceType.GlassBreakDetector}: ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_WINDOW message[Translator.HA_PAYLOAD_ON] = str(OnOff.On) message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off) elif device.type in {DeviceType.HumidSensor, DeviceType.HumidSensor2}: ha_platform = Translator.HA_PLATFORM_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_HUMIDITY message[ Translator.HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_HUMIDITY elif device.type in {DeviceType.TempSensor, DeviceType.TempSensor2}: ha_platform = Translator.HA_PLATFORM_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_TEMPERATURE message[Translator. HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_TEMPERATURE elif device.type in {DeviceType.LightSensor, DeviceType.LightDetector}: ha_platform = Translator.HA_PLATFORM_SENSOR message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_ILLUMINANCE message[Translator. HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_ILLUMINANCE elif device.type in { DeviceType.ACCurrentMeter, DeviceType.ACCurrentMeter2, DeviceType.ThreePhaseACMeter }: ha_platform = Translator.HA_PLATFORM_SENSOR message[ Translator.HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_CURRENT else: _LOGGER.warning( "Device type '%s' cannot be represented in Home " "Assistant and will be skipped.", str(device.type)) return self._publish( '{}/{}/{}/config'.format( self._config.translator.ha_discovery_prefix, ha_platform, message[Translator.HA_UNIQUE_ID]), json.dumps(message), False) def _publish_ha_device_rssi_config(self, device: Device, device_config: TranslatorDeviceConfig): # Generate message that can be used to automatically configure a sensor # for the device's RSSI in Home Assistant using MQTT Discovery message = { Translator.HA_NAME: device_config.ha_name_rssi, Translator.HA_UNIQUE_ID: '{}_{:06x}_RSSI'.format(PROJECT_NAME, device.device_id), Translator.HA_ICON: Translator.HA_ICON_RSSI, Translator.HA_STATE_TOPIC: '{}/{}'.format(device_config.topic, Device.PROP_RSSI_DB), Translator.HA_UNIT_OF_MEASUREMENT: Translator.HA_UOM_RSSI, Translator.HA_AVAILABILITY_TOPIC: '{}/{}'.format(self._config.translator.baseunit.topic, BaseUnit.PROP_IS_CONNECTED), Translator.HA_PAYLOAD_AVAILABLE: str(True), Translator.HA_PAYLOAD_NOT_AVAILABLE: str(False), } self._publish( '{}/{}/{}/config'.format( self._config.translator.ha_discovery_prefix, Translator.HA_PLATFORM_SENSOR, message[Translator.HA_UNIQUE_ID]), json.dumps(message), False) def _publish_ha_device_battery_config( self, device: Device, device_config: TranslatorDeviceConfig): # Generate message that can be used to automatically configure a binary # sensor for the device's battery state in Home Assistant using # MQTT Discovery message = { Translator.HA_NAME: device_config.ha_name_battery, Translator.HA_UNIQUE_ID: '{}_{:06x}_battery'.format(PROJECT_NAME, device.device_id), Translator.HA_DEVICE_CLASS: Translator.HA_DC_BATTERY, Translator.HA_PAYLOAD_ON: str(DeviceEventCode.BatteryLow), Translator.HA_PAYLOAD_OFF: str(DeviceEventCode.PowerOnReset), Translator.HA_STATE_TOPIC: '{}/event_code'.format(device_config.topic), Translator.HA_AVAILABILITY_TOPIC: '{}/{}'.format(self._config.translator.baseunit.topic, BaseUnit.PROP_IS_CONNECTED), Translator.HA_PAYLOAD_AVAILABLE: str(True), Translator.HA_PAYLOAD_NOT_AVAILABLE: str(False), } self._publish( '{}/{}/{}/config'.format( self._config.translator.ha_discovery_prefix, Translator.HA_PLATFORM_BINARY_SENSOR, message[Translator.HA_UNIQUE_ID]), json.dumps(message), False) def _publish_ha_switch_config(self, switch_number: SwitchNumber, switch_config: TranslatorSwitchConfig): # Generate message that can be used to automatically configure the # switch in Home Assistant using MQTT Discovery message = { Translator.HA_NAME: switch_config.ha_name, Translator.HA_UNIQUE_ID: '{}_{}'.format(PROJECT_NAME, str(switch_number).lower()), Translator.HA_STATE_TOPIC: switch_config.topic, Translator.HA_COMMAND_TOPIC: '{}/{}'.format(switch_config.topic, Translator.TOPIC_SET), Translator.HA_PAYLOAD_ON: str(OnOff.On), Translator.HA_PAYLOAD_OFF: str(OnOff.Off), Translator.HA_AVAILABILITY_TOPIC: '{}/{}'.format(self._config.translator.baseunit.topic, BaseUnit.PROP_IS_CONNECTED), Translator.HA_PAYLOAD_AVAILABLE: str(True), Translator.HA_PAYLOAD_NOT_AVAILABLE: str(False), } self._publish( '{}/{}/{}/config'.format( self._config.translator.ha_discovery_prefix, Translator.HA_PLATFORM_SWITCH, message[Translator.HA_UNIQUE_ID]), json.dumps(message), False) def _publish(self, topic: str, payload: Any, retain: bool) -> None: self._mqtt.publish(topic, payload, QOS_1, retain) def _on_message_baseunit(self, subscribetopic: SubscribeTopic, message: MQTTMessage) -> None: if subscribetopic.args == BaseUnit.PROP_OPERATION_MODE: # Set operation mode name = None if not message.payload else message.payload.decode() operation_mode = OperationMode.parse_name(name) if operation_mode is None: _LOGGER.warning("Cannot set operation_mode to '%s'", name) return if operation_mode == OperationMode.Disarm and \ self._state == BaseUnitState.Disarm and \ self._ha_state == Translator.HA_STATE_TRIGGERED: # Special case to ensure HA can return from triggered state # when triggered by an alarm in Disarm mode (eg. panic, # tamper)... the set disarm operation will not generate a # response from the base unit as there is no change, so we # need to reset 'ha_state' here. _LOGGER.debug("Resetting triggered ha_state in disarmed mode") self._ha_state = Translator.HA_STATE_DISARMED self._publish( '{}/{}'.format(self._config.translator.baseunit.topic, Translator.TOPIC_HASTATE), self._ha_state, True) self._loop.create_task( self._baseunit.async_set_operation_mode(operation_mode)) else: raise NotImplementedError def _on_message_clear_status(self, subscribetopic: SubscribeTopic, message: MQTTMessage) -> None: # Clear the alarm/warning LEDs on base unit and stop siren self._loop.create_task(self._baseunit.async_clear_status()) def _on_message_set_datetime(self, subscribetopic: SubscribeTopic, message: MQTTMessage) -> None: # Set remote date/time to specified date/time (or current if None) value = None if not message.payload else message.payload.decode() if value: value = dateutil.parser.parse(value) self._loop.create_task(self._baseunit.async_set_datetime(value)) def _on_message_switch(self, subscribetopic: SubscribeTopic, message: MQTTMessage) -> None: # Turn a switch on / off switch_number = subscribetopic.args name = None if not message.payload else message.payload.decode() state = OnOff.parse_name(name) if state is None: _LOGGER.warning("Cannot set switch %s to '%s'", switch_number, name) return self._loop.create_task( self._baseunit.async_set_switch_state(switch_number, bool(state.value))) def _on_ha_message(self, subscribetopic: SubscribeTopic, message: MQTTMessage) -> None: # When Home Assistant comes online, publish our configuration to it payload = None if not message.payload else message.payload.decode() if not payload: return if payload == self._config.translator.ha_birth_payload: self._publish_ha_config()
def monitor(client=None): if client is None: client = Client() client.on_connect = on_connect client.connect_async('localhost') client.loop_start() client_created = True else: client_created = False signals = blinker.Namespace() @asyncio.coroutine def _on_dropbot_connected(sender, **message): monitor_task.connected.clear() dropbot_ = message['dropbot'] monitor_task.dropbot = dropbot_ client.on_message = ft.partial(on_message, 'dropbot', proxy=dropbot_) device_id = str(dropbot_.uuid) connect_topic = '/dropbot/%(uuid)s/signal' % {'uuid': device_id} send_topic = '/dropbot/%(uuid)s/send-signal' % {'uuid': device_id} # Bind blinker signals namespace to corresponding MQTT topics. bind(signals=signals, paho_client=client, connect_topic=connect_topic, send_topic=send_topic) dropbot_.update_state(event_mask=EVENT_CHANNELS_UPDATED | EVENT_SHORTS_DETECTED | EVENT_ENABLE) client.publish('/dropbot/%(uuid)s/properties' % {'uuid': device_id}, payload=dropbot_.properties.to_json(), qos=1, retain=True) prefix = '/dropbot/' + device_id monitor_task.device_id = device_id monitor_task.property = ft.partial(wait_for_result, client, 'property', prefix) monitor_task.call = ft.partial(wait_for_result, client, 'call', prefix) monitor_task.connected.set() @asyncio.coroutine def _on_dropbot_disconnected(sender, **message): monitor_task.connected.clear() monitor_task.dropbot = None unbind(signals) client.publish('/dropbot/%(uuid)s/properties' % {'uuid': monitor_task.device_id}, payload=None, qos=1, retain=True) signals.signal('connected').connect(_on_dropbot_connected, weak=False) signals.signal('disconnected').connect(_on_dropbot_disconnected, weak=False) def stop(): if getattr(monitor_task, 'dropbot', None) is not None: monitor_task.dropbot.set_state_of_channels(pd.Series(), append=False) monitor_task.dropbot.update_state(capacitance_update_interval_ms=0, hv_output_enabled=False) try: unbind(monitor_task.signals) except RuntimeError as e: _L().warning('%s', e) monitor_task.cancel() if client_created: client.loop_stop() client.disconnect() monitor_task = cancellable(catch_cancel(db.monitor.monitor)) monitor_task.connected = threading.Event() thread = threading.Thread(target=monitor_task, args=(signals, )) thread.daemon = True thread.start() monitor_task.signals = signals monitor_task.stop = stop monitor_task.close = stop return monitor_task
class PahoIoTClient: """ Responsible for connecting to AWS IoT. Handles all connection lifecycle events and attempts to reconnect whenever possible if disconnected. Data is sent to IoT via the method .send_telemetry(TelemetryMessage). """ MQTT_QOS_RETRY_INTERVAL_S = 60 KEEP_ALIVE_TIMEOUT_S = 2 * 60 config: IotClientConfig mqtt_client: Client() event_listener: IoTEventListener topic_subscriptions: list important_paho_messages = [ "Sending PUBLISH (d1", "Connection failed, retrying" ] def __init__(self, config: IotClientConfig, event_listener: IoTEventListener): self.set_config(config=config) self.event_listener = event_listener self.mqtt_client = Client(config.client_id) self.mqtt_client.message_retry_set(self.MQTT_QOS_RETRY_INTERVAL_S) # self.mqtt_client.tls_set(ca_certs = config.ca_path, certfile = config.certificate_path, keyfile = config.private_key_path) self.mqtt_client.username_pw_set(username=config.username, password=config.password) self.mqtt_client.on_connect = self.on_connected self.mqtt_client.on_disconnect = self.on_disconnected self.mqtt_client.on_publish = self.on_message_published self.mqtt_client.on_message = self.on_message_received def set_config(self, config: IotClientConfig): self.config = config def is_connected(self) -> bool: return self.mqtt_client.is_connected() def connect(self): self.mqtt_client.connect_async(host=self.config.endpoint, port=self.config.port, keepalive=self.KEEP_ALIVE_TIMEOUT_S) self.mqtt_client.loop_start() def disconnect(self): self.mqtt_client.disconnect() self.mqtt_client.loop_stop() # async def reconnect(self, delay_in_s: int = 5): # await asyncio.sleep(delay_in_s) # self.disconnect() # await asyncio.sleep(delay_in_s) # self.connect() def publish(self, topic: str, data: str, qos: int = 1, reconnect_after_fail: bool = False) -> (int, int): message_info = self.mqtt_client.publish(topic=topic, payload=data, qos=qos) if message_info.rc is not MQTT_ERR_SUCCESS: pass else: pass return (message_info.rc, message_info.mid) def is_response_code_success(self, response_code: int) -> bool: return response_code is MQTT_ERR_SUCCESS def initialise_subscriptions_list(self): self.topic_subscriptions = [] def subscribe(self, subscriptions: list): for subscription in subscriptions: if not self.is_topic_subscribed(subscription): self.topic_subscriptions.append(subscription) self.mqtt_client.subscribe(topic=self.topic_subscriptions) def is_topic_subscribed(self, subscription): for existing_subscription in self.topic_subscriptions: if subscription[0] == existing_subscription[0]: return True return False def on_connected(self, client, userdata, flags, rc): self.initialise_subscriptions_list() if self.event_listener is not None: self.event_listener.on_iot_connected(iot_client=self) def on_disconnected(self, client, userdata, rc): # Clear the list of subscriptions if self.event_listener is not None: self.event_listener.on_iot_disconnected(iot_client=self) def on_message_published(self, client, userdata, mid): if self.event_listener is not None: self.event_listener.on_iot_message_published( self, message_id=mid, payload_size=self.payload_sizes.pop("{0}".format(mid), None)) def on_message_received(self, client, userdata, message): if self.event_listener is not None: self.event_listener.on_iot_message_received( iot_client=self, topic=message.topic, payload=message.payload)
class Gateway3(Thread): pair_model = None pair_payload = None def __init__(self, host: str, token: str, config: dict, ble: bool = True, zha: bool = False): super().__init__(daemon=True) self.host = host self.ble = ble self.zha = zha self.miio = Device(host, token) self.mqtt = Client() self.mqtt.on_connect = self.on_connect self.mqtt.on_disconnect = self.on_disconnect self.mqtt.on_message = self.on_message self.mqtt.connect_async(host) self._debug = config['debug'] if 'debug' in config else '' self.default_devices = config['devices'] self.devices = {} self.updates = {} self.setups = {} @property def device(self): return self.devices['lumi.0'] def add_update(self, did: str, handler): """Add handler to device update event.""" self.updates.setdefault(did, []).append(handler) def add_setup(self, domain: str, handler): """Add hass device setup funcion.""" self.setups[domain] = handler def debug(self, message: str): _LOGGER.debug(f"{self.host} | {message}") def run(self): """Main thread loop.""" while True: # if not telnet - enable it if not self._check_port(23) and not self._enable_telnet(): time.sleep(30) continue devices = self._prepeare_gateway(with_devices=True) if devices: self.setup_devices(devices) break while True: # if not telnet - enable it if not self._check_port(23) and not self._enable_telnet(): time.sleep(30) continue if not self.zha: # if not mqtt - enable it if not self._mqtt_connect() and not self._prepeare_gateway(): time.sleep(60) continue self.mqtt.loop_forever() elif not self._check_port(8888) and not self._prepeare_gateway(): time.sleep(60) continue else: # ZHA works fine, check every 60 seconds time.sleep(60) def _check_port(self, port: int): """Check if gateway port open.""" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: return s.connect_ex((self.host, port)) == 0 finally: s.close() def _enable_telnet(self): """Enable telnet with miio protocol.""" self.debug("Try enable telnet") try: resp = self.miio.send("enable_telnet_service") return resp[0] == 'ok' except Exception as e: self.debug(f"Can't enable telnet: {e}") return False def _prepeare_gateway(self, with_devices: bool = False): """Launching the required utilities on the hub, if they are not already running. """ self.debug("Prepare Gateway") try: shell = TelnetShell(self.host) ps = shell.get_running_ps() if "mosquitto -d" not in ps: self.debug("Run public mosquitto") shell.run_public_mosquitto() # TODO: fix me if MIIO_PTRN not in ps: self.debug("Redirect miio to MQTT") shell.redirect_miio2mqtt() if self.zha: if "socat" not in ps: if "Received" in shell.check_or_download_socat(): self.debug("Download socat") self.debug("Run socat") shell.run_socat() if "Lumi_Z3GatewayHost_MQTT" in ps: self.debug("Stop Lumi Zigbee") shell.stop_lumi_zigbee() # elif "Lumi_Z3GatewayHost_MQTT -n 1 -b 115200 -v" not in ps: # self.debug("Run public Zigbee console") # shell.run_public_zb_console() if with_devices: self.debug("Get devices") return self._get_devices(shell) return True except (ConnectionRefusedError, socket.timeout): return False except Exception as e: _LOGGER.debug(f"Can't read devices: {e}") return False def _mqtt_connect(self) -> bool: try: self.mqtt.reconnect() return True except: return False def _miio_connect(self) -> bool: try: self.miio.send_handshake() return True except: self.debug("Can't send handshake") return False def _get_devices(self, shell: TelnetShell): """Load devices info for Coordinator, Zigbee and Mesh.""" # 1. Read coordinator info raw = shell.read_file('/data/zigbee/coordinator.info') device = json.loads(raw) devices = [{ 'did': 'lumi.0', 'model': 'lumi.gateway.mgl03', 'mac': device['mac'], 'type': 'gateway', 'init': { 'firmware lock': shell.check_firmware_lock() } }] # 2. Read zigbee devices if not self.zha: # https://github.com/AlexxIT/XiaomiGateway3/issues/14 # fw 1.4.6_0012 and below have one zigbee_gw.db file # fw 1.4.6_0030 have many json files in this folder raw = shell.read_file('/data/zigbee_gw/*', as_base64=True) if raw.startswith(b'unqlite'): db = Unqlite(raw) data = db.read_all() else: raw = re.sub(br'}\s+{', b',', raw) data = json.loads(raw) # data = {} or data = {'dev_list': 'null'} dev_list = json.loads(data.get('dev_list', 'null')) or [] for did in dev_list: model = data[did + '.model'] desc = utils.get_device(model) # skip unknown model if desc is None: self.debug(f"{did} has an unsupported modell: {model}") continue retain = json.loads(data[did + '.prop'])['props'] self.debug(f"{did} {model} retain: {retain}") params = { p[2]: retain.get(p[1]) for p in desc['params'] if p[1] is not None } device = { 'did': did, 'mac': '0x' + data[did + '.mac'], 'model': data[did + '.model'], 'type': 'zigbee', 'zb_ver': data[did + '.version'], 'init': utils.fix_xiaomi_props(params), 'online': retain.get('alive', 1) == 1 } devices.append(device) # 3. Read mesh devices if self.ble: raw = shell.read_file('/data/miio/mible_local.db', as_base64=True) db = SQLite(raw) tables = db.read_page(0) device_page = next(table[3] - 1 for table in tables if table[1] == 'mesh_device') rows = db.read_page(device_page) for row in rows: device = { 'did': row[0], 'mac': row[1].replace(':', ''), 'model': row[2], 'type': 'bluetooth' } devices.append(device) return devices def lock_firmware(self, enable: bool): self.debug(f"Set firmware lock to {enable}") try: shell = TelnetShell(self.host) if "Received" in shell.check_or_download_busybox(): self.debug("Download busybox") shell.lock_firmware(enable) locked = shell.check_firmware_lock() shell.close() return enable == locked except Exception as e: self.debug(f"Can't set firmware lock: {e}") return False def on_connect(self, client, userdata, flags, rc): self.debug("MQTT connected") self.mqtt.subscribe('#') self.process_gw_message({'online': True}) def on_disconnect(self, client, userdata, rc): self.debug("MQTT disconnected") # force end mqtt.loop_forever() self.mqtt.disconnect() self.process_gw_message({'online': False}) def on_message(self, client: Client, userdata, msg: MQTTMessage): if 'mqtt' in self._debug: self.debug(f"[MQ] {msg.topic} {msg.payload.decode()}") if msg.topic == 'zigbee/send': payload = json.loads(msg.payload) self.process_message(payload) elif msg.topic == 'log/miio': if 'miio' in self._debug: _LOGGER.debug(f"[MI] {msg.payload}") if b'_async.ble_event' in msg.payload: self.process_ble_event(msg.payload) elif b'properties_changed' in msg.payload: self.process_mesh_data(msg.payload) elif msg.topic.endswith('/heartbeat'): payload = json.loads(msg.payload) self.process_gw_message(payload) elif msg.topic.endswith('/MessageReceived'): payload = json.loads(msg.payload) self.process_zb_message(payload) elif self.pair_model and msg.topic.endswith('/commands'): self.process_pair(msg.payload) def setup_devices(self, devices: list): """Add devices to hass.""" for device in devices: if device['type'] in ('gateway', 'zigbee'): desc = utils.get_device(device['model']) if not desc: self.debug(f"Unsupported model: {device}") continue self.debug(f"Setup Zigbee device {device}") device.update(desc) # update params from config default_config = self.default_devices.get(device['mac']) or \ self.default_devices.get(device['did']) if default_config: device.update(default_config) self.devices[device['did']] = device for param in device['params']: domain = param[3] if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) attr = param[2] self.setups[domain](self, device, attr) elif device['type'] == 'bluetooth': desc = bluetooth.get_device(device['model'], 'Mesh') device.update(desc) self.debug(f"Setup Mesh device {device}") # update params from config default_config = self.default_devices.get(device['did']) if default_config: device.update(default_config) device['online'] = False self.devices[device['did']] = device # wait domain init while 'light' not in self.setups: time.sleep(1) self.setups['light'](self, device, 'light') def process_message(self, data: dict): if data['cmd'] == 'heartbeat': # don't know if only one item assert len(data['params']) == 1, data data = data['params'][0] pkey = 'res_list' elif data['cmd'] == 'report': pkey = 'params' if 'params' in data else 'mi_spec' elif data['cmd'] in ('write_rsp', 'read_rsp'): pkey = 'results' else: _LOGGER.warning(f"Unsupported cmd: {data}") return did = data['did'] # skip without callback if did not in self.updates: return device = self.devices[did] payload = {} # convert codes to names for param in data[pkey]: if param.get('error_code', 0) != 0: continue prop = param['res_name'] if 'res_name' in param else \ f"{param['siid']}.{param['piid']}" if prop in GLOBAL_PROP: prop = GLOBAL_PROP[prop] else: prop = next((p[2] for p in device['params'] if p[0] == prop), prop) if prop in ('temperature', 'humidity', 'pressure'): payload[prop] = param['value'] / 100.0 elif prop == 'battery' and param['value'] > 1000: # xiaomi light sensor payload[prop] = round((min(param['value'], 3200) - 2500) / 7) elif prop == 'alive': # {'res_name':'8.0.2102','value':{'status':'online','time':0}} device['online'] = (param['value']['status'] == 'online') elif prop == 'angle': # xiaomi cube 100 points = 360 degrees payload[prop] = param['value'] * 4 elif prop == 'duration': # xiaomi cube payload[prop] = param['value'] / 1000.0 elif prop in ('consumption', 'power'): payload[prop] = round(param['value'], 2) else: payload[prop] = param['value'] self.debug(f"{device['did']} {device['model']} <= {payload}") for handler in self.updates[did]: handler(payload) if 'added_device' in payload: # {'did': 'lumi.fff', 'mac': 'fff', 'model': 'lumi.sen_ill.mgl01', # 'version': '21', 'zb_ver': '3.0'} device = payload['added_device'] device['mac'] = '0x' + device['mac'] device['type'] = 'zigbee' device['init'] = payload self.setup_devices([device]) def process_gw_message(self, payload: json): self.debug(f"gateway <= {payload}") if 'lumi.0' not in self.updates: return if 'networkUp' in payload: payload = { 'network_pan_id': payload['networkPanId'], 'radio_tx_power': payload['radioTxPower'], 'radio_channel': payload['radioChannel'], } elif 'online' in payload: self.device['online'] = payload['online'] for handler in self.updates['lumi.0']: handler(payload) def process_zb_message(self, payload: dict): did = 'lumi.' + RE_MAC.sub('', payload['eui64']).lower() if did not in self.devices: return device = self.devices[did] device['linq_quality'] = payload['linkQuality'] self.debug(f"{did} <= LQI {payload['linkQuality']}") def process_ble_event(self, raw: Union[bytes, str]): if isinstance(raw, bytes): m = RE_JSON.search(raw) data = json.loads(m[0])['params'] else: data = json.loads(raw) self.debug(f"Process BLE {data}") pdid = data['dev'].get('pdid') did = data['dev']['did'] if did not in self.devices: mac = data['dev']['mac'].replace(':', '').lower() \ if 'mac' in data['dev'] else \ 'ble_' + did.replace('blt.3.', '') self.devices[did] = device = { 'did': did, 'mac': mac, 'init': {}, 'type': 'bluetooth' } desc = bluetooth.get_device(pdid, 'BLE') device.update(desc) # update params from config default_config = self.default_devices.get(did) if default_config: device.update(default_config) else: device = self.devices[did] if isinstance(data['evt'], list): # check if only one assert len(data['evt']) == 1, data payload = bluetooth.parse_xiaomi_ble(data['evt'][0], pdid) elif isinstance(data['evt'], dict): payload = bluetooth.parse_xiaomi_ble(data['evt'], pdid) else: payload = None if payload is None: self.debug(f"Unsupported BLE {data}") return # init entities if needed for k in payload.keys(): if k in device['init']: continue device['init'][k] = payload[k] domain = bluetooth.get_ble_domain(k) if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) self.setups[domain](self, device, k) if did in self.updates: for handler in self.updates[did]: handler(payload) def process_mesh_data(self, raw: Union[bytes, list]): if isinstance(raw, bytes): m = RE_JSON.search(raw) data = json.loads(m[0])['params'] else: data = raw self.debug(f"Process Mesh {data}") data = bluetooth.parse_xiaomi_mesh(data) for did, payload in data.items(): device = self.devices.get(did) if not device: _LOGGER.warning("Unknown mesh device, reboot Hass may helps") return if did in self.updates: for handler in self.updates[did]: handler(payload) def process_pair(self, raw: bytes): # get shortID and eui64 of paired device if b'lumi send-nwk-key' in raw: # create model response payload = f"0x18010105000042{len(self.pair_model):02x}" \ f"{self.pair_model.encode().hex()}" m = RE_NWK_KEY.search(raw.decode()) self.pair_payload = json.dumps( { 'sourceAddress': m[1], 'eui64': '0x' + m[2], 'profileId': '0x0104', 'clusterId': '0x0000', 'sourceEndpoint': '0x01', 'destinationEndpoint': '0x01', 'APSCounter': '0x01', 'APSPlayload': payload }, separators=(',', ':')) # send model response "from device" elif b'zdo active ' in raw: mac = self.device['mac'][2:].upper() self.mqtt.publish(f"gw/{mac}/MessageReceived", self.pair_payload) def send(self, device: dict, data: dict): # convert hass prop to lumi prop params = [{ 'res_name': next(p[0] for p in device['params'] if p[2] == k), 'value': v } for k, v in data.items()] payload = { 'cmd': 'write', 'did': device['did'], 'params': params, } self.debug(f"{device['did']} {device['model']} => {payload}") payload = json.dumps(payload, separators=(',', ':')).encode() self.mqtt.publish('zigbee/recv', payload) def send_telnet(self, *args: str): try: shell = TelnetShell(self.host) for command in args: shell.exec(command) shell.close() except Exception as e: _LOGGER.exception(f"Telnet command error: {e}") def send_mqtt(self, cmd: str): if cmd == 'publishstate': mac = self.device['mac'][2:].upper() self.mqtt.publish(f"gw/{mac}/publishstate") def send_mesh(self, device: dict, data: dict): did = device['did'] payload = bluetooth.pack_xiaomi_mesh(did, data) return self.miio.send('set_properties', payload) def get_device(self, mac: str) -> Optional[dict]: for device in self.devices.values(): if device.get('mac') == mac: return device return None
class Gateway3(Thread, GatewayV, GatewayMesh, GatewayInfo): mesh_thread = None pair_model = None pair_payload = None def __init__(self, host: str, token: str, config: dict, ble: bool = True, zha: bool = False): super().__init__(daemon=True) self.host = host self.ble = ble self.zha = zha # TODO: in the end there can be only one self.miio = SyncmiIO(host, token) self.mqtt = Client() self.mqtt.on_connect = self.on_connect self.mqtt.on_disconnect = self.on_disconnect self.mqtt.on_message = self.on_message self.mqtt.connect_async(host) self._debug = config['debug'] if 'debug' in config else '' self._disable_buzzer = config.get('buzzer') is False self._zigbee_info = config.get('zigbee_info') self.default_devices = config['devices'] self.devices = {} self.updates = {} self.setups = {} self.info = {} @property def device(self): return self.devices['lumi.0'] def add_update(self, did: str, handler): """Add handler to device update event.""" self.updates.setdefault(did, []).append(handler) def remove_update(self, did: str, handler): self.updates.setdefault(did, []).remove(handler) def add_setup(self, domain: str, handler): """Add hass device setup funcion.""" self.setups[domain] = handler def debug(self, message: str): _LOGGER.debug(f"{self.host} | {message}") def run(self): """Main thread loop.""" while True: # if not telnet - enable it if not self._check_port(23) and not self._enable_telnet(): time.sleep(30) continue devices = self._prepeare_gateway(with_devices=True) if devices: self.setup_devices(devices) break self.mesh_start() while True: # if not telnet - enable it if not self._check_port(23) and not self._enable_telnet(): time.sleep(30) continue # if not mqtt - enable it (handle Mi Home and ZHA mode) if not self._mqtt_connect() and not self._prepeare_gateway(): time.sleep(60) continue self.mqtt.loop_forever() def _check_port(self, port: int): """Check if gateway port open.""" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: return s.connect_ex((self.host, port)) == 0 finally: s.close() def _enable_telnet(self): """Enable telnet with miio protocol.""" self.debug("Try enable telnet") if self.miio.send("enable_telnet_service") != 'ok': self.debug(f"Can't enable telnet") return False return True def _prepeare_gateway(self, with_devices: bool = False): """Launching the required utilities on the hub, if they are not already running. """ self.debug("Prepare Gateway") try: shell = TelnetShell(self.host) self.ver = shell.get_version() self.debug(f"Version: {self.ver}") ps = shell.get_running_ps() if "mosquitto -d" not in ps: self.debug("Run public mosquitto") shell.run_public_mosquitto() # all data or only necessary events pattern = '\\{"' if 'miio' in self._debug \ else "ble_event|properties_changed" if f"awk /{pattern} {{" not in ps: self.debug(f"Redirect miio to MQTT") shell.redirect_miio2mqtt(pattern, self.ver_miio) if self._disable_buzzer and "basic_gw -b" in ps: _LOGGER.debug("Disable buzzer") shell.stop_buzzer() if self.zha: if "socat" not in ps: if "Received" in shell.check_or_download_socat(): self.debug("Download socat") self.debug("Run socat") shell.run_socat() if "Lumi_Z3GatewayHost_MQTT" in ps: self.debug("Stop Lumi Zigbee") shell.stop_lumi_zigbee() else: if "socat" in ps: self.debug("Stop socat") shell.stop_socat() if self._zigbee_info: if "Lumi_Z3GatewayHost_MQTT -n 1 -b 115200 -v" not in ps: self.debug("Run public Zigbee console") shell.run_public_zb_console() else: if "Lumi_Z3GatewayHost_MQTT" not in ps: self.debug("Run Lumi Zigbee") shell.run_lumi_zigbee() if with_devices: self.debug("Get devices") return self._get_devices(shell) return True except (ConnectionRefusedError, socket.timeout): return False except Exception as e: _LOGGER.debug(f"Can't read devices: {e}") return False def _mqtt_connect(self) -> bool: try: self.mqtt.reconnect() return True except: return False def _miio_connect(self) -> bool: if not self.miio.ping(): self.debug("Can't send handshake") return False return True def _get_devices(self, shell: TelnetShell): """Load devices info for Coordinator, Zigbee and Mesh.""" # 1. Read coordinator info raw = shell.read_file('/data/zigbee/coordinator.info') device = json.loads(raw) devices = [{ 'did': 'lumi.0', 'model': 'lumi.gateway.mgl03', 'mac': device['mac'], 'type': 'gateway', 'init': { 'firmware lock': shell.check_firmware_lock() } }] # 2. Read zigbee devices if not self.zha: raw = shell.read_file('/data/zigbee_gw/' + self.ver_zigbee_db, as_base64=True) if raw.startswith(b'unqlite'): db = Unqlite(raw) data = db.read_all() else: raw = re.sub(br'}\s+{', b',', raw) data = json.loads(raw) # data = {} or data = {'dev_list': 'null'} dev_list = json.loads(data.get('dev_list', 'null')) or [] for did in dev_list: model = data[did + '.model'] desc = utils.get_device(model) # skip unknown model if desc is None: self.debug(f"{did} has an unsupported modell: {model}") continue retain = json.loads(data[did + '.prop'])['props'] self.debug(f"{did} {model} retain: {retain}") params = { p[2]: retain.get(p[1]) for p in (desc['params'] or desc['mi_spec']) if p[1] is not None } device = { 'did': did, 'mac': '0x' + data[did + '.mac'], 'model': data[did + '.model'], 'type': 'zigbee', 'zb_ver': data[did + '.version'], 'init': utils.fix_xiaomi_props(params), 'online': retain.get('alive', 1) == 1 } devices.append(device) # 3. Read bluetooth devices if self.ble: raw = shell.read_file('/data/miio/mible_local.db', as_base64=True) db = SQLite(raw) # load BLE devices rows = db.read_table('gateway_authed_table') for row in rows: device = { 'did': row[4], 'mac': RE_REVERSE.sub(r'\6\5\4\3\2\1', row[1]), 'model': row[2], 'type': 'ble' } devices.append(device) # load Mesh groups try: mesh_groups = {} rows = db.read_table(self.ver_mesh_group) for row in rows: # don't know if 8 bytes enougth mac = int(row[0]).to_bytes(8, 'big').hex() device = { 'did': 'group.' + row[0], 'mac': mac, 'model': 0, 'childs': [], 'type': 'mesh' } group_addr = row[1] mesh_groups[group_addr] = device # load Mesh bulbs rows = db.read_table('mesh_device') for row in rows: device = { 'did': row[0], 'mac': row[1].replace(':', ''), 'model': row[2], 'type': 'mesh' } devices.append(device) group_addr = row[5] if group_addr in mesh_groups: # add bulb to group if exist mesh_groups[group_addr]['childs'].append(row[0]) for device in mesh_groups.values(): if device['childs']: devices.append(device) except: _LOGGER.exception("Can't read mesh devices") # for testing purposes for k, v in self.default_devices.items(): if k[0] == '_': devices.append(v) return devices def lock_firmware(self, enable: bool): self.debug(f"Set firmware lock to {enable}") try: shell = TelnetShell(self.host) if "Received" in shell.check_or_download_busybox(): self.debug("Download busybox") shell.lock_firmware(enable) locked = shell.check_firmware_lock() shell.close() return enable == locked except Exception as e: self.debug(f"Can't set firmware lock: {e}") return False def on_connect(self, client, userdata, flags, rc): self.debug("MQTT connected") self.mqtt.subscribe('#') self.process_gw_message({'online': True}) def on_disconnect(self, client, userdata, rc): self.debug("MQTT disconnected") # force end mqtt.loop_forever() self.mqtt.disconnect() self.process_gw_message({'online': False}) def on_message(self, client: Client, userdata, msg: MQTTMessage): if 'mqtt' in self._debug: self.debug(f"[MQ] {msg.topic} {msg.payload.decode()}") if msg.topic == 'zigbee/send': payload = json.loads(msg.payload) self.process_message(payload) elif msg.topic == 'log/miio': if 'miio' in self._debug: _LOGGER.debug(f"[MI] {msg.payload}") if self.ble and (b'_async.ble_event' in msg.payload or b'properties_changed' in msg.payload): try: for raw in utils.extract_jsons(msg.payload): if b'_async.ble_event' in raw: self.process_ble_event(raw) elif b'properties_changed' in raw: self.process_mesh_data(raw) except: _LOGGER.warning(f"Can't read BT: {msg.payload}") elif msg.topic.endswith('/heartbeat'): payload = json.loads(msg.payload) self.process_gw_message(payload) elif msg.topic.endswith(('/MessageReceived', '/devicestatechange')): payload = json.loads(msg.payload) self.process_zb_message(payload) # read only retained ble elif msg.topic.startswith('ble') and msg.retain: payload = json.loads(msg.payload) self.process_ble_retain(msg.topic[4:], payload) elif self.pair_model and msg.topic.endswith('/commands'): self.process_pair(msg.payload) def setup_devices(self, devices: list): """Add devices to hass.""" for device in devices: if device['type'] in ('gateway', 'zigbee'): desc = utils.get_device(device['model']) if not desc: self.debug(f"Unsupported model: {device}") continue self.debug(f"Setup Zigbee device {device}") device.update(desc) # update params from config default_config = self.default_devices.get(device['mac']) or \ self.default_devices.get(device['did']) if default_config: device.update(default_config) self.devices[device['did']] = device for param in (device['params'] or device['mi_spec']): domain = param[3] if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) attr = param[2] self.setups[domain](self, device, attr) if self._zigbee_info and device['type'] != 'gateway': self.setups['sensor'](self, device, self._zigbee_info) elif device['type'] == 'mesh': desc = bluetooth.get_device(device['model'], 'Mesh') device.update(desc) self.debug(f"Setup Mesh device {device}") # update params from config default_config = self.default_devices.get(device['did']) if default_config: device.update(default_config) device['online'] = False self.devices[device['did']] = device # wait domain init while 'light' not in self.setups: time.sleep(1) self.setups['light'](self, device, 'light') elif device['type'] == 'ble': # only save info for future desc = bluetooth.get_device(device['model'], 'BLE') device.update(desc) # update params from config default_config = self.default_devices.get(device['did']) if default_config: device.update(default_config) self.devices[device['did']] = device device['init'] = {} def process_message(self, data: dict): if data['cmd'] == 'heartbeat': # don't know if only one item assert len(data['params']) == 1, data data = data['params'][0] pkey = 'res_list' elif data['cmd'] == 'report': pkey = 'params' if 'params' in data else 'mi_spec' elif data['cmd'] in ('write_rsp', 'read_rsp'): pkey = 'results' elif data['cmd'] == 'write_ack': return else: _LOGGER.warning(f"Unsupported cmd: {data}") return did = data['did'] # skip without callback if did not in self.updates: return device = self.devices[did] payload = {} # convert codes to names for param in data[pkey]: if param.get('error_code', 0) != 0: continue prop = param['res_name'] if 'res_name' in param else \ f"{param['siid']}.{param['piid']}" if prop in GLOBAL_PROP: prop = GLOBAL_PROP[prop] else: prop = next((p[2] for p in (device['params'] or device['mi_spec']) if p[0] == prop), prop) if prop in ('temperature', 'humidity', 'pressure'): payload[prop] = param['value'] / 100.0 elif prop == 'battery' and param['value'] > 1000: # xiaomi light sensor payload[prop] = round((min(param['value'], 3200) - 2500) / 7) elif prop == 'alive': # {'res_name':'8.0.2102','value':{'status':'online','time':0}} device['online'] = (param['value']['status'] == 'online') elif prop == 'angle': # xiaomi cube 100 points = 360 degrees payload[prop] = param['value'] * 4 elif prop == 'duration': # xiaomi cube payload[prop] = param['value'] / 1000.0 elif prop in ('consumption', 'power'): payload[prop] = round(param['value'], 2) else: payload[prop] = param['value'] self.debug(f"{device['did']} {device['model']} <= {payload}") for handler in self.updates[did]: handler(payload) if 'added_device' in payload: # {'did': 'lumi.fff', 'mac': 'fff', 'model': 'lumi.sen_ill.mgl01', # 'version': '21', 'zb_ver': '3.0'} device = payload['added_device'] device['mac'] = '0x' + device['mac'] device['type'] = 'zigbee' device['init'] = payload self.setup_devices([device]) def process_gw_message(self, payload: dict): self.debug(f"gateway <= {payload}") if 'lumi.0' not in self.updates: return if 'networkUp' in payload: # {"networkUp":false} if not payload['networkUp']: _LOGGER.warning("Network down") return payload = { 'network_pan_id': payload['networkPanId'], 'radio_tx_power': payload['radioTxPower'], 'radio_channel': payload['radioChannel'], } elif 'online' in payload: self.device['online'] = payload['online'] for handler in self.updates['lumi.0']: handler(payload) def process_ble_event(self, raw: Union[bytes, str]): if isinstance(raw, bytes): data = json.loads(raw)['params'] else: data = json.loads(raw) self.debug(f"Process BLE {data}") pdid = data['dev'].get('pdid') did = data['dev']['did'] if did not in self.devices: mac = data['dev']['mac'].replace(':', '').lower() \ if 'mac' in data['dev'] else \ 'ble_' + did.replace('blt.3.', '') self.devices[did] = device = { 'did': did, 'mac': mac, 'init': {}, 'type': 'bluetooth' } desc = bluetooth.get_device(pdid, 'BLE') device.update(desc) # update params from config default_config = self.default_devices.get(did) if default_config: device.update(default_config) else: device = self.devices[did] if isinstance(data['evt'], list): # check if only one assert len(data['evt']) == 1, data payload = bluetooth.parse_xiaomi_ble(data['evt'][0], pdid) elif isinstance(data['evt'], dict): payload = bluetooth.parse_xiaomi_ble(data['evt'], pdid) else: payload = None if payload is None: self.debug(f"Unsupported BLE {data}") return # init entities if needed init = device['init'] for k in payload.keys(): if k in init: # update for retain init[k] = payload[k] continue init[k] = payload[k] domain = bluetooth.get_ble_domain(k) if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) self.setups[domain](self, device, k) if did in self.updates: for handler in self.updates[did]: handler(payload) raw = json.dumps(init, separators=(',', ':')) self.mqtt.publish(f"ble/{did}", raw, retain=True) def process_ble_retain(self, did: str, payload: dict): if did not in self.devices: _LOGGER.debug(f"BLE device {did} is no longer on the gateway") return _LOGGER.debug(f"{did} retain: {payload}") device = self.devices[did] # init entities if needed for k in payload.keys(): # don't retain action if k in device['init'] or k == 'action': continue device['init'][k] = payload[k] domain = bluetooth.get_ble_domain(k) if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) self.setups[domain](self, device, k) if did in self.updates: for handler in self.updates[did]: handler(payload) def process_pair(self, raw: bytes): # get shortID and eui64 of paired device if b'lumi send-nwk-key' in raw: # create model response payload = f"0x18010105000042{len(self.pair_model):02x}" \ f"{self.pair_model.encode().hex()}" m = RE_NWK_KEY.search(raw.decode()) self.pair_payload = json.dumps( { 'sourceAddress': m[1], 'eui64': '0x' + m[2], 'profileId': '0x0104', 'clusterId': '0x0000', 'sourceEndpoint': '0x01', 'destinationEndpoint': '0x01', 'APSCounter': '0x01', 'APSPlayload': payload }, separators=(',', ':')) # send model response "from device" elif b'zdo active ' in raw: mac = self.device['mac'][2:].upper() self.mqtt.publish(f"gw/{mac}/MessageReceived", self.pair_payload) def send(self, device: dict, data: dict): payload = {'cmd': 'write', 'did': device['did']} # convert hass prop to lumi prop if device['mi_spec']: params = [] for k, v in data.items(): if k == 'switch': v = bool(v) k = next(p[0] for p in device['mi_spec'] if p[2] == k) params.append({'siid': k[0], 'piid': k[1], 'value': v}) payload['mi_spec'] = params else: params = [{ 'res_name': next(p[0] for p in device['params'] if p[2] == k), 'value': v } for k, v in data.items()] payload = { 'cmd': 'write', 'did': device['did'], 'params': params, } self.debug(f"{device['did']} {device['model']} => {payload}") payload = json.dumps(payload, separators=(',', ':')).encode() self.mqtt.publish('zigbee/recv', payload) def send_telnet(self, *args: str): try: shell = TelnetShell(self.host) for command in args: if command == 'ftp': shell.check_or_download_busybox() shell.run_ftp() else: shell.exec(command) shell.close() except Exception as e: _LOGGER.exception(f"Telnet command error: {e}") def send_mqtt(self, cmd: str): if cmd == 'publishstate': mac = self.device['mac'][2:].upper() self.mqtt.publish(f"gw/{mac}/publishstate") def get_device(self, mac: str) -> Optional[dict]: for device in self.devices.values(): if device.get('mac') == mac: return device return None
class Gateway3(Thread): devices: dict = None updates: dict = None setups: dict = None log = None def __init__(self, host: str, token: str): super().__init__(daemon=True) self.host = host self.miio = Device(host, token) self.mqtt = Client() self.mqtt.on_connect = self.on_connect self.mqtt.on_disconnect = self.on_disconnect self.mqtt.on_message = self.on_message self.mqtt.connect_async(host) if isinstance(self.log, str): self.log = utils.get_logger(self.log) @property def device(self): return self.devices['lumi.0'] def add_update(self, did: str, handler): """Add handler to device update event.""" if self.updates is None: self.updates = {} self.updates.setdefault(did, []).append(handler) def add_setup(self, domain: str, handler): """Add hass device setup funcion.""" if self.setups is None: self.setups = {} self.setups[domain] = handler def run(self): """Main loop""" while self.devices is None: if self._miio_connect(): devices = self._get_devices1() if devices: self.setup_devices(devices) # else: # self._enable_telnet() else: time.sleep(30) while True: if self._mqtt_connect(): self.mqtt.loop_forever() elif self._miio_connect() and self._enable_telnet(): self._enable_mqtt() else: _LOGGER.debug("sleep 30") time.sleep(30) def _mqtt_connect(self) -> bool: try: self.mqtt.reconnect() return True except: return False def _miio_connect(self) -> bool: try: self.miio.send_handshake() return True except: return False def _get_devices1(self) -> Optional[list]: """Load devices via miio protocol.""" _LOGGER.debug(f"{self.host} | Read devices") try: devices = {} # endless loop protection for _ in range(16): # load only 8 device per part part = self.miio.send('get_device_list', retry_count=10) if len(part) == 0: return [] for item in part: devices[item['num']] = { 'did': item['did'], 'mac': f"0x{item['did'][5:]}", 'model': item['model'], } if part[0]['total'] == len(devices): break devices = list(devices.values()) for device in devices: desc = utils.get_device(device['model']) # skip unknown model if desc is None: continue # get xiaomi param names params = [p[1] for p in desc['params'] if p[1] is not None] # skip if don't have retain params if not params: continue # load param values values = self.miio.send('get_device_prop', [device['did']] + params) # get hass param names params = [p[2] for p in desc['params'] if p[1] is not None] data = dict(zip(params, values)) # fix some param values for k, v in data.items(): if k in ('temperature', 'humidity'): data[k] = v / 100.0 elif v == 'on': data[k] = 1 elif v == 'off': data[k] = 0 device['init'] = data device = self.miio.info() devices.append({ 'did': 'lumi.0', 'mac': device.mac_address, # wifi mac!!! 'model': device.model }) return devices except Exception as e: return None def _get_devices2(self) -> Optional[list]: """Load device list via Telnet. Device desc example: mac: '0x158d0002c81234' shortId: '0x0691' manuCode: '0x115f' model: 'lumi.sensor_ht' did: 'lumi.158d0002c81234' devType: 0 appVer: 2 hardVer: 0 devID: 770 status: 0 model_ver: 2 """ _LOGGER.debug(f"{self.host} | Read devices") try: telnet = Telnet(self.host) telnet.read_until(b"login: "******"admin\r\n") telnet.read_until(b'\r\n# ') # skip greeting telnet.write(b"cat /data/zigbee/coordinator.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') device = json.loads(raw[:-2]) device.update({ 'did': 'lumi.0', 'model': 'lumi.gateway.mgl03', 'host': self.host }) devices = [device] telnet.write(b"cat /data/zigbee/device.info\r\n") telnet.read_until(b'\r\n') # skip command raw = telnet.read_until(b'# ') raw = json.loads(raw[:-2]) devices += raw['devInfo'] telnet.close() return devices except Exception as e: _LOGGER.exception(f"Can't read devices: {e}") return None def _enable_telnet(self): _LOGGER.debug(f"{self.host} | Try enable telnet") try: resp = self.miio.send("enable_telnet_service") return resp[0] == 'ok' except Exception as e: _LOGGER.exception(f"Can't enable telnet: {e}") return False def _enable_mqtt(self): _LOGGER.debug(f"{self.host} | Try run public MQTT") try: telnet = Telnet(self.host) telnet.read_until(b"login: "******"admin\r\n") telnet.read_very_eager() # skip response telnet.write(b"killall mosquitto\r\n") telnet.read_very_eager() # skip response telnet.write(b"mosquitto -d\r\n") telnet.read_very_eager() # skip response time.sleep(1) telnet.close() return True except Exception as e: _LOGGER.exception(f"Can't run MQTT: {e}") return False def on_connect(self, client, userdata, flags, rc): _LOGGER.debug(f"{self.host} | MQTT connected") # self.mqtt.subscribe('#') self.mqtt.subscribe('zigbee/send') def on_disconnect(self, client, userdata, rc): _LOGGER.debug(f"{self.host} | MQTT disconnected") # force end mqtt.loop_forever() self.mqtt.disconnect() def on_message(self, client: Client, userdata, msg: MQTTMessage): if self.log: self.log.debug(f"[{self.host}] {msg.topic} {msg.payload.decode()}") if msg.topic == 'zigbee/send': payload = json.loads(msg.payload) self.process_message(payload) def setup_devices(self, devices: list): """Add devices to hass.""" for device in devices: desc = utils.get_device(device['model']) if not desc: _LOGGER.debug(f"Unsupported model: {device}") continue _LOGGER.debug(f"Setup device {device['model']}") device.update(desc) if self.devices is None: self.devices = {} self.devices[device['did']] = device for param in device['params']: domain = param[3] if not domain: continue # wait domain init while domain not in self.setups: time.sleep(1) attr = param[2] self.setups[domain](self, device, attr) def process_message(self, data: dict): if data['cmd'] == 'heartbeat': # don't know if only one item assert len(data['params']) == 1, data data = data['params'][0] pkey = 'res_list' elif data['cmd'] == 'report': pkey = 'params' elif data['cmd'] == 'write_rsp': pkey = 'results' else: raise NotImplemented(f"Unsupported cmd: {data}") did = data['did'] # skip without callback if did not in self.updates: return device = self.devices[did] payload = {} # convert codes to names for param in data[pkey]: if param.get('error_code', 0) != 0: continue prop = param['res_name'] if prop in GLOBAL_PROP: prop = GLOBAL_PROP[prop] else: prop = next((p[2] for p in device['params'] if p[0] == prop), prop) payload[prop] = (param['value'] / 100.0 if prop in DIV_100 else param['value']) _LOGGER.debug(f"{self.host} | {device['did']} {device['model']} <= " f"{payload}") for handler in self.updates[did]: handler(payload) if 'added_device' in payload: device = payload['added_device'] device['mac'] = '0x' + device['mac'] self.setup_devices([device]) def send(self, device: dict, param: str, value): # convert hass prop to lumi prop prop = next(p[0] for p in device['params'] if p[2] == param) payload = { 'cmd': 'write', 'did': device['did'], 'params': [{ 'res_name': prop, 'value': value }], } _LOGGER.debug(f"{self.host} | {device['did']} {device['model']} => " f"{payload}") payload = json.dumps(payload, separators=(',', ':')).encode() self.mqtt.publish('zigbee/recv', payload)
class Bridge(object): WEBSOCKETS = "websocket" SSE = "sse" def __init__(self, args, ioloop, dynamic_subscriptions): """ parse config values and setup Routes. """ self.mqtt_topics = [] try: self.mqtt_host = args["mqtt-to-server"]["broker"]["host"] self.mqtt_port = args["mqtt-to-server"]["broker"]["port"] self.bridge_port = args["server-to-client"]["port"] self.stream_protocol = args["server-to-client"]["protocol"] logger.info("Using protocol %s" % self.stream_protocol.lower()) if self.stream_protocol.lower( ) != "websocket" and self.stream_protocol.lower() != "sse": raise ConfigException("Invalid protocol") self.dynamic_subscriptions = dynamic_subscriptions if not self.dynamic_subscriptions: self.mqtt_topics = args["mqtt-to-server"]["topics"] except KeyError as e: raise ConfigException("Error when accessing field", e) from e logger.info("connecting to mqtt") self.topic_dict = {} self.ioloop = ioloop self.mqtt_client = Client() self.mqtt_client.on_message = self.on_mqtt_message self.mqtt_client.on_connect = self.on_mqtt_connect self.mqtt_client.connect_async(host=self.mqtt_host, port=self.mqtt_port) self.mqtt_client.loop_start() self._app = web.Application([ (r'.*', WebsocketHandler if self.stream_protocol == "websocket" else ServeSideEventsHandler, dict(parent=self)), ], debug=True) def get_app(self): return self._app def get_port(self): return self.bridge_port async def parse_req_path(self, req_path): candidate_path = req_path if len(candidate_path) is 1 and candidate_path[0] is "/": return "#" if candidate_path[len(req_path) - 1] is "/": candidate_path = candidate_path + "#" if candidate_path[0] == "/": candidate_path = candidate_path[1:] return candidate_path def on_mqtt_message(self, client, userdata, message): logger.debug("received message on topic %s" % message.topic) async def socket_write_message(self, socket, message): try: await socket.write_message(json.dumps(message)) except Exception as e: logger.error(e) try: await socket.write_message(message) except Exception as e: logger.error(e) def append_dynamic(self, topic): logger.info("adding dynamic subscription for %s " % topic) self.message_callback_add_with_sub_topic(topic, dynamic=True) def remove_dynamic(self, topic): logger.info("removing dynamic subscription for %s " % topic) self.topic_dict.pop(topic, None) self.mqtt_client.unsubscribe(topic) def message_callback_add_with_sub_topic(self, sub_topic, dynamic): logger.info("adding callback for mqtt topic: %s" % sub_topic) def message_callback(client, userdata, message): logger.debug( "Recieved Mqtt Message on %s as result of subscription on %s" % (message.topic, sub_topic)) if sub_topic is not message.topic: if message.topic not in self.topic_dict[sub_topic]["matches"]: self.topic_dict[sub_topic]["matches"].append(message.topic) for topic in self.topic_dict: if topic == message.topic: for socket in self.topic_dict[topic]["sockets"]: logger.debug("sending to socket:") logger.debug(socket) self.ioloop.add_callback( self.socket_write_message, socket=socket, message={ "topic": message.topic, "payload": message.payload.decode('utf-8') }) elif message.topic in self.topic_dict[topic]["matches"]: for socket in self.topic_dict[topic]["sockets"]: logger.debug("sending to socket:") logger.debug(socket) self.ioloop.add_callback( self.socket_write_message, socket=socket, message={ "topic": message.topic, "payload": message.payload.decode('utf-8') }) if sub_topic not in self.topic_dict: self.mqtt_client.message_callback_add(sub_topic, message_callback) self.topic_dict[sub_topic] = { "matches": [], "sockets": [], "dynamic": dynamic } self.mqtt_client.subscribe(sub_topic) def on_mqtt_connect(self, client, userdata, flags, rc): logger.info("mqtt connected to broker %s:%s" % (self.mqtt_host, str(self.mqtt_port))) for topic in self.mqtt_topics: self.message_callback_add_with_sub_topic(topic, dynamic=False) async def mqtt_disconnect(self): t = Thread(target=self.mqtt_client.disconnect, daemon=True) t.start() self.mqtt_client.loop_stop()