def on_disconnect(client: mqtt_client.Client, userdata, rc): if rc != 0: # Network failure, try to reconnect client.reconnect() else: # We have called disconnect pass
def _on_socket_close(self, client: mqtt.Client, userdata: Dict, socket): if self.resubscribe: status = client.reconnect() if status == 0: return else: print( f"{self._tag} Error reconnecting to websocket: {self.error_lookup[status]}. Aborting reconnect." ) self.clients.remove(client)
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 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): 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 Mqtt(): def __init__(self, app=None): # type: (Flask) -> None self.app = app self.client = Client() self.client.on_connect = self._handle_connect self.client.on_disconnect = self._handle_disconnect self.topics = [] # type: List[str] self.connected = False if app is not None: self.init_app(app) def init_app(self, app): # type: (Flask) -> None 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.app = app 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: if self.tls_insecure: self.client.tls_insecure_set(self.tls_insecure) 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, ) self.client.loop_start() res = self.client.connect(self.broker_url, self.broker_port, keepalive=self.keepalive) def _disconnect(self): # type: () -> None self.client.loop_stop() self.client.disconnect() def _handle_connect(self, client, userdata, flags, rc): # type: (Client, Any, Dict, int) -> None if rc == MQTT_ERR_SUCCESS: self.connected = True for topic in self.topics: self.client.subscribe(topic) def _handle_disconnect(self, client, userdata, rc): # type: (str, Any, int) -> None self.connected = False def on_topic(self, topic): # type: (str) -> Callable """ 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, qos) # if successful add to topics if result == MQTT_ERR_SUCCESS: if topic not in self.topics: self.topics.append(topic) return (result, mid) def unsubscribe(self, topic): # type: (str) -> 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 not in self.topics: return result, mid = self.client.unsubscribe(topic) # if successful remove from topics if result == MQTT_ERR_SUCCESS: self.topics.remove(topic) return result, mid def unsubscribe_all(self): # type: () -> None """ Unsubscribe from all topics. """ topics = self.topics[:] 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() return self.client.publish(topic, payload, qos, retain) def on_message(self): # type: () -> Callable """ 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): """ 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): self.client.on_publish = handler return handler return decorator def on_subscribe(self): """ Decorator to handle subscribe callbacks. **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): self.client.on_subscribe = handler return handler return decorator def on_unsubscribe(self): """ Decorator to handle unsubscribe callbacks. **Usage:**:: @mqtt.unsubscribe() def handle_unsubscribe(client, userdata, mid) print('Unsubscribed from topic (id: {})' .format(mid)') """ def decorator(handler): self.client.on_unsubscribe = handler return handler return decorator def on_log(self): # type: () -> Callable """ Decorator 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 HUB: def __init__(self, config_file='./hub_config.ini'): log_format = '<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> <level>{level}: {message}</level>' logger.remove() logger.add(sys.stdout, format=log_format, colorize=True) config = configparser.ConfigParser() if not os.path.isfile(config_file): logger.critical(f"Cannot find '{config_file}', aborting...") exit(1) config.read(config_file) logger.add(os.path.join(config['logging']['logs_path'], 'log_{time:YYYY-MM-DD}.log'), format=log_format, colorize=True, compression='zip', rotation='00:00') logger.info('Booting up...') self.mqtt_broker = config['mqtt']['mqtt_broker'] self.mqtt_topic = config['mqtt']['mqtt_topic'] self.scan_rate = int(config['scanning']['rate']) self.max_threads = int(config['scanning']['threads']) logger.info( f"[Scan configuration]:\nScan rate: {self.scan_rate}s\nScan threads: {self.max_threads}" ) mqtt_id = config['mqtt']['mqtt_id'] logger.info( f"[MQTT configuration]\nBroker: {self.mqtt_broker}\nTopic: {self.mqtt_topic}\nID: {mqtt_id}" ) self.mqtt_client = Client(client_id=mqtt_id) self.mqtt_client.enable_logger(logger) self.mqtt_client.on_message = self.toggle_bulbs self.mqtt_client.on_connect = self.mqtt_subscribe self.mqtt_client.on_disconnect = self.mqtt_connect self.mqtt_client.loop_start() self.mqtt_connect() self.bulbs = [] # [{'hostname': '<>', 'ip': '<>'}] self.threads_buffer = [] self.startup() self.loop() def mqtt_connect(self): logger.debug(f'Connecting to MQTT broker {self.mqtt_broker}...') try: response = self.mqtt_client.connect(host=self.mqtt_broker, port=1883) except Exception as e: logger.error(f"Can't connect to MQTT broker; {e}") response = 1 if response != 0: logger.error(f'Not connected to MQTT broker {self.mqtt_broker}') def mqtt_subscribe(self, *args): self.mqtt_client.subscribe(topic=self.mqtt_topic, qos=1) logger.info(f'Subscribed to {self.mqtt_topic}') def get_subnet(self): ip_scanner = IPRoute() info = [{ 'iface': x['index'], 'addr': x.get_attr('IFA_ADDRESS'), 'mask': x['prefixlen'] } for x in ip_scanner.get_addr()] ip_scanner.close() subnet = None for interface in info: if '192.168' in interface['addr']: subnet = f'192.168.{interface["addr"].split(".")[2]}' return subnet def scanner(self, ips_list): for ip in ips_list: try: hostname = socket.gethostbyaddr(ip)[0] if 'yeelink' in hostname: self.threads_buffer.append({ 'hostname': hostname, 'ip': ip }) logger.info(f'Found new bulb: {hostname} at {ip}') except socket.herror as se: if 'Unknown host' not in str(se): logger.error(str(se)) def spawn_scanners(self, ips: list): max_threads = self.max_threads ips_for_thread = int(len(ips) / max_threads) limits = [i * ips_for_thread for i in range(max_threads)] ranges = [ips[limit:limit + ips_for_thread + 1] for limit in limits] threads = [] for r in ranges: t = threading.Thread(target=self.scanner, args=(r, )) t.start() threads.append(t) t: threading.Thread for t in threads: t.join() def get_bulbs_ips(self): logger.info('Scanning network for bulbs...') subnet = self.get_subnet() if subnet is None: logger.error('No router connection! Aborting...') return None #subnet = '192.168.178' logger.debug(f'Subnet: {subnet}') ips = [f"{subnet}.{i}" for i in range(0, 256)] self.threads_buffer = [] self.spawn_scanners(ips) bulbs = self.threads_buffer result = [ f"hostname: {bulb['hostname']}, ip: {bulb['ip']}" for bulb in bulbs ] if len(bulbs) > 0: logger.info(f'Network scan ended, result:\n' + '\n'.join(result)) else: logger.warning(f'Network scan ended, no bulbs found') return bulbs def toggle_bulb(self, bulb): bulb_obj = Bulb(bulb['ip']) response = bulb_obj.toggle() if response == 'ok': logger.info(f'Toggled {bulb["hostname"]} at {bulb["ip"]}') else: logger.error( f'Toggle error for {bulb["hostname"]} at {bulb["ip"]}') def toggle_bulbs(self, bulbs, *args): if type(bulbs) is not list: available_bulbs = self.bulbs else: available_bulbs = bulbs logger.info('Toggling bulbs...') for bulb in available_bulbs: self.toggle_bulb(bulb) logger.info('All bulbs toggled.') def check_mqtt_connection(self): logger.debug("Checking mqtt broker connection...") if not self.mqtt_client.is_connected(): logger.warning("Broker connection error, trying reconnection...") self.mqtt_client.reconnect() if not self.mqtt_client.is_connected(): logger.error("Reconnection error") def loop(self): SCAN_RATE = self.scan_rate while True: try: self.bulbs = self.get_bulbs_ips() time.sleep(SCAN_RATE) except KeyboardInterrupt as ki: logger.critical("HUB killed, stopping mqtt loop...") try: self.mqtt_client.loop_stop() except: self.mqtt_client.loop_stop(force=True) except Exception as e: logger.critical(f"Unhandled exception: {e}") time.sleep(1) def turn_off_bulbs(self): logger.info("Turning off bulbs...") for bulb in self.bulbs: bulb_obj = Bulb(bulb['ip']) response = bulb_obj.turn_off() if response == 'ok': logger.info(f'{bulb["hostname"]} turned off at {bulb["ip"]}') else: logger.error( f'Turn off error for {bulb["hostname"]} at {bulb["ip"]}') def startup(self): logger.info("Setting up...") self.bulbs = self.get_bulbs_ips() self.turn_off_bulbs()
class Gateway(Thread): # pylint: disable=too-many-instance-attributes, unused-argument """ Aqara Gateway """ def __init__(self, hass, host: str, config: dict, **options): """Initialize the Xiaomi/Aqara device.""" super().__init__(daemon=True) self.hass = hass self.host = host self.options = options # if mqtt server connected self.enabled = False self.available = False self._mqttc = Client() self._mqttc.on_connect = self.on_connect self._mqttc.on_disconnect = self.on_disconnect self._mqttc.on_message = self.on_message self._debug = options.get('debug', '') # for fast access self.parent_scan_interval = (-1 if options.get('parent') is None else options['parent']) self.default_devices = config['devices'] if config else None self.devices = {} self.updates = {} self.setups = {} self._extra_state_attributes = {} self._info_ts = None self._gateway_did = '' self._model = options.get(CONF_MODEL, '') # long model, will replace to short later self.cloud = 'aiot' # for fast access @property def device(self): """ get device """ return self.devices[list(self.devices)[0]] # 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): """remove update""" 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): """ deubug function """ if 'true' in self._debug: _LOGGER.debug(f"{self.host}: {message}") def stop(self): """ stop function """ self.enabled = False async def async_connect(self) -> str: """Connect to the host. Does not process messages yet.""" result: int = None try: result = await self.hass.async_add_executor_job( self._mqttc.connect, self.host ) except OSError as err: _LOGGER.error( f"Failed to connect to MQTT server {self.host} due to exception: {err}") if result is not None and result != 0: _LOGGER.error( f"Failed to connect to MQTT server: {self.host}" ) self._mqttc.loop_start() self.enabled = True async def async_disconnect(self): """Stop the MQTT client.""" self.available = False def stop(): """Stop the MQTT client.""" # Do not disconnect, we want the broker to always publish will self._mqttc.loop_stop() await self.hass.async_add_executor_job(stop) def run(self): """ Main thread loop. """ telnetshell = False if "telnet" not in self.hass.data[DOMAIN]: self.hass.data[DOMAIN]["telnet"] = [] if "mqtt" not in self.hass.data[DOMAIN]: self.hass.data[DOMAIN]["mqtt"] = [] while not self.enabled and not self.available: if not self._check_port(23): if self.host in self.hass.data[DOMAIN]["telnet"]: self.hass.data[DOMAIN]["telnet"].remove(self.host) time.sleep(30) continue telnetshell = True devices = self._prepare_gateway(get_devices=True) if isinstance(devices, list): self._gw_topic = "gw/{}/".format(devices[0]['mac'][2:].upper()) self.setup_devices(devices) break if telnetshell: if self.host not in self.hass.data[DOMAIN]["telnet"]: self.hass.data[DOMAIN]["telnet"].append(self.host) while not self.available: if not self._mqtt_connect() or not self._prepare_gateway(): if self.host in self.hass.data[DOMAIN]["mqtt"]: self.hass.data[DOMAIN]["mqtt"].remove(self.host) time.sleep(60) continue self._mqttc.loop_start() self.available = True # self._mqttc.loop_forever() if self.available: if self.host not in self.hass.data[DOMAIN]["mqtt"]: self.hass.data[DOMAIN]["mqtt"].append(self.host) def _mqtt_connect(self) -> bool: try: self._mqttc.reconnect() return True except Exception: return False def _check_port(self, port: int): """Check if gateway port open.""" skt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: return skt.connect_ex((self.host, port)) == 0 finally: skt.close() def _prepare_gateway(self, get_devices: bool = False): """Launching the required utilities on the hub, if they are not already running. """ try: device_name = Utils.get_device_name(self._model).lower() if "g2h" in device_name: shell = TelnetShellG2H(self.host, self.options.get(CONF_PASSWORD, '')) elif "e1" in device_name: shell = TelnetShellE1(self.host, self.options.get(CONF_PASSWORD, '')) elif "g3" in device_name: shell = TelnetShellG3(self.host, self.options.get(CONF_PASSWORD, '')) else: shell = TelnetShell(self.host, self.options.get(CONF_PASSWORD, '')) shell.login() processes = shell.get_running_ps() public_mosquitto = shell.check_public_mosquitto() if not public_mosquitto: self.debug("mosquitto is not running as public!") if "/data/bin/mosquitto -d" not in processes: if "mosquitto" not in processes or not public_mosquitto: shell.run_public_mosquitto() if get_devices: devices = self._get_devices(shell) shell.close() return devices return True except (ConnectionRefusedError, socket.timeout): return False except Exception as expt: self.debug("Can't read devices: {}".format(expt)) return False def _get_devices(self, shell): """Load devices info for Coordinator, Zigbee and Mesh.""" devices = {} try: # 1. Read coordinator info value = {} zb_coordinator = shell.get_prop("sys.zb_coordinator") model = shell.get_prop("persist.sys.model") if len(zb_coordinator) >= 1: raw = shell.read_file(zb_coordinator, with_newline=False) did = shell.get_prop("persist.sys.did") model = shell.get_prop("ro.sys.model") elif any(name in model for name in [ 'lumi.gateway', 'lumi.aircondition', 'lumi.camera.gwpagl01']): raw = shell.read_file( '/data/zigbee/coordinator.info', with_newline=False) did = shell.get_prop("persist.sys.did") model = shell.get_prop("ro.sys.model") else: raw = str(shell.read_file('/mnt/config/miio/device.conf')) data = re.search(r"did=([0-9]+).+", raw) did = data.group(1) if data else '' data = re.search(r"model=([a-zA-Z0-9.-]+).+", raw) model = data.group(1) if data else '' raw = shell.read_file( '/mnt/config/zigbee/coordinator.info', with_newline=False) value = json.loads(raw) devices = [{ 'coordinator': 'lumi.0', 'did': did, 'model': model, 'mac': value['mac'], 'manufacturer': value['manufacturer'], 'channel': value['channel'], 'cloudLink': value['cloudLink'], 'debugStatus': value['debugStatus'], 'type': 'gateway', }] self._model = model # zigbee devices zb_device = shell.get_prop("sys.zb_device") if len(zb_device) >= 1: raw = shell.read_file(zb_device, with_newline=False) else: raw = shell.read_file('{}/zigbee/device.info'.format( Utils.get_info_store_path(self._model)), with_newline=False) value = json.loads(raw) dev_info = value.get("devInfo", 'null') or [] if not Utils.gateway_is_aiot_only(model): self.cloud = shell.get_prop("persist.sys.cloud") for dev in dev_info: model = dev['model'] desc = Utils.get_device(model, self.cloud) # skip unknown model if desc is None: self.debug("{} has an unsupported model: {}".format( dev['did'], model )) continue device = { 'coordinator': 'lumi.0', 'did': dev['did'], 'mac': dev['mac'], 'model': dev['model'], 'type': 'zigbee', 'zb_ver': dev.get('zb_ver', "1.2"), 'model_ver': dev['model_ver'], 'status': dev['status'] } devices.append(device) except Exception as expt: self.debug("Can't get devices: {}".format(expt)) return devices def setup_devices(self, devices: list): """Add devices to hass.""" for device in devices: timeout = 300 if device['type'] in ('gateway', 'zigbee'): desc = Utils.get_device(device['model'], self.cloud) if not desc: self.debug("Unsupported model: {}".format(device)) continue 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 and timeout > 0: time.sleep(1) timeout = timeout - 1 attr = param[2] if (attr in ('illuminance', 'light') and device['type'] == 'gateway'): self._gateway_did = device['did'] self.setups[domain](self, device, attr) if self.options.get('stats'): while 'sensor' not in self.setups: time.sleep(1) self.setups['sensor'](self, device, device['type']) def add_stats(self, ieee: str, handler): """ add gateway stats """ self._extra_state_attributes[ieee] = handler if self.parent_scan_interval > 0: self._info_ts = time.time() + 5 def remove_stats(self, ieee: str, handler): """ remove gateway stats """ self._extra_state_attributes.pop(ieee) def process_gateway_stats(self, payload: dict = None): """ process gateway status """ # empty payload - update available state self.debug(f"gateway <= {payload or self.available}") if 'lumi.0' not in self._extra_state_attributes: return if payload: data = {} for param in payload: if 'networkUp' in param: # {"networkUp":false} data = { 'network_pan_id': param['value'].get('networkPanId'), 'radio_tx_power': param['value'].get('radioTxPower'), 'radio_channel': param['value'].get('radioChannel'), } elif 'free_mem' in param: seconds = param['value']['run_time'] hours, mintues, seconds = seconds // 3600, seconds % 3600 // 60, seconds % 60 data = { 'free_mem': param['value']['free_mem'], 'load_avg': param['value']['load_avg'], 'rssi': -param['value']['rssi'], 'uptime': "{:02}:{:02}:{:02}".format( hours, mintues, seconds), } prop = None if 'res_name' in param: if param['res_name'] in GLOBAL_PROP: prop = GLOBAL_PROP[param['res_name']] if prop == 'report': report_list = param['value'].split(',') stat = {} for item in report_list: stat = iter(item.split( ':', 1) if 'time' in item else item.lstrip( ).strip().split(' ')) data.update(dict(zip(stat, stat))) device_name = Utils.get_device_name(self._model).lower() if "g2h" in device_name: shell = TelnetShellG2H(self.host, self.options.get(CONF_PASSWORD, '')) elif "e1" in device_name: shell = TelnetShellE1(self.host, self.options.get(CONF_PASSWORD, '')) elif "g3" in device_name: shell = TelnetShellG3(self.host, self.options.get(CONF_PASSWORD, '')) else: shell = TelnetShell(self.host, self.options.get(CONF_PASSWORD, '')) shell.login() raw = shell.read_file('{}/zigbee/networkBak.info'.format( Utils.get_info_store_path(self._model)), with_newline=False) shell.close() if len(raw) >= 1: value = json.loads(raw) data.update(value) self._extra_state_attributes['lumi.0'](data) def on_connect(self, client, userdata, flags, ret): # pylint: disable=unused-argument """ on connect to mqtt server """ self._mqttc.subscribe("#") self.available = True if self.host not in self.hass.data[DOMAIN]["mqtt"]: self.hass.data[DOMAIN]["mqtt"].append(self.host) # self.process_gateway_stats() def on_disconnect(self, client, userdata, ret): # pylint: disable=unused-argument """ on disconnect to mqtt server """ self._mqttc.disconnect() if self.host in self.hass.data[DOMAIN]["mqtt"]: self.hass.data[DOMAIN]["mqtt"].remove(self.host) self.available = False # self.process_gateway_stats() self.run() def on_message(self, client: Client, userdata, msg: MQTTMessage): # pylint: disable=unused-argument """ on getting messages from mqtt server """ topic = msg.topic if topic == 'broker/ping': return if 'mqtt' in self._debug: try: self.debug("MQTT on_message: {} {}".format( topic, msg.payload.decode())) except UnicodeDecodeError: self.debug("MQTT on_message: {}".format(topic)) self.debug(msg.payload) if topic == 'log/camera': return try: json.loads(msg.payload) except ValueError: self.debug("Decoding JSON failed") return if topic == 'zigbee/send': payload = json.loads(msg.payload) self._process_message(payload) elif topic == 'ioctl/send': payload = json.loads(msg.payload) self._process_message(payload) elif topic == 'ioctl/recv': payload = json.loads(msg.payload) self._process_message(payload) elif topic == 'debug/host': payload = json.loads(msg.payload) self._process_message(payload) def _process_devices_info(self, prop, value): if prop == 'removed_did' and value: Utils.remove_device(self.hass, value) if isinstance(value, dict) and value['did'] in self.devices: self.devices.pop(value['did'], None) if isinstance(value, str) and value in self.devices: self.devices.pop(value, None) return if prop == 'paring' and value == 0: device_name = Utils.get_device_name(self._model).lower() if "g2h" in device_name: shell = TelnetShellG2H(self.host, self.options.get(CONF_PASSWORD, '')) elif "e1" in device_name: shell = TelnetShellE1(self.host, self.options.get(CONF_PASSWORD, '')) elif "g3" in device_name: shell = TelnetShellG3(self.host, self.options.get(CONF_PASSWORD, '')) else: shell = TelnetShell(self.host, self.options.get(CONF_PASSWORD, '')) shell.login() zb_device = shell.get_prop("sys.zb_device") if len(zb_device) >= 1: raw = shell.read_file(zb_device, with_newline=False) else: raw = shell.read_file('{}/zigbee/device.info'.format( Utils.get_info_store_path(self._model)), with_newline=False) shell.close() value = json.loads(raw) dev_info = value.get("devInfo", 'null') or [] for dev in dev_info: model = dev['model'] desc = Utils.get_device(model, self.cloud) # skip unknown model if desc is None: self.debug("{} has an unsupported model: {}".format( dev['did'], model )) continue if prop == 'paring': if dev['did'] not in self.devices: device = { 'coordinator': 'lumi.0', 'did': dev['did'], 'mac': dev['mac'], 'model': dev['model'], 'type': 'zigbee', 'zb_ver': dev.get('zb_ver', "1.2"), 'model_ver': dev['model_ver'], 'status': dev['status'] } self.setup_devices([device]) break def _process_message(self, data: dict): # pylint: disable=too-many-branches, too-many-statements # pylint: disable=too-many-return-statements if data['cmd'] == 'heartbeat': # don't know if only one item if len(data['params']) < 1: return 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 elif data['cmd'] == 'behaved': return else: _LOGGER.warning("Unsupported cmd: %s", data) return did = data['did'] if did == 'lumi.0': device = self.devices.get(self._gateway_did, None) if pkey in ('results', 'params'): for param in data[pkey]: if param.get('error_code', 0) != 0: continue prop = param.get('res_name', None) if prop in GLOBAL_PROP: prop = GLOBAL_PROP[prop] elif device: prop = next(( p[2] for p in (device['params']) if p[0] == prop ), prop) if prop in ('removed_did', 'paring'): self._process_devices_info( prop, param.get('value', None)) # self._handle_device_remove({}) return payload = {} if (prop in ('illuminance', 'light', 'added_device') and self._gateway_did in self.updates): payload[prop] = param.get('value') for handler in self.updates[self._gateway_did]: handler(payload) return self.process_gateway_stats(data[pkey]) return # skip without updates callback if did not in self.updates: return device = self.devices.get(did, None) if device is None: return time_stamp = time.time() 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("Unsupported param: %s", data) return 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) # https://github.com/Koenkk/zigbee2mqtt/issues/798 # https://www.maero.dk/aqara-temperature-humidity-pressure-sensor-teardown/ if prop == 'temperature': if device['model'] == 'lumi.airmonitor.acn01' and self.cloud == 'miot': payload[prop] = param['value'] elif -4000 < param['value'] < 12500: payload[prop] = param['value'] / 100.0 elif prop == 'humidity': if device['model'] == 'lumi.airmonitor.acn01' and self.cloud == 'miot': payload[prop] = param['value'] elif 0 <= param['value'] <= 10000: payload[prop] = param['value'] / 100.0 elif prop == 'pressure': payload[prop] = param['value'] / 100.0 elif prop == 'battery': # I do not know if the formula is correct, so battery is more # important than voltage payload[prop] = Utils.fix_xiaomi_battery(param['value']) elif prop == 'voltage': payload[prop] = Utils.fix_xiaomi_voltage(param['value']) elif prop == 'alive' and param['value']['status'] == 'offline': if not self.options.get('noffline', False): 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 ('power'): payload[prop] = round(param['value'], 2) elif prop in ('consumption'): payload[prop] = round(param['value'], 2) / 1000.0 elif 'value' in param: payload[prop] = param['value'] elif 'arguments' in param: if prop == 'motion': payload[prop] = 1 else: payload[prop] = param['arguments'] self.debug("{} {} <= {} [{}]".format( device['did'], device['model'], payload, time_stamp )) 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]) async def _handle_device_remove(self, payload: dict): """Remove device from Hass. """ async def device_registry_updated(event: Event): if event.data['action'] != 'update': return # registry = self.hass.data['device_registry'] # hass_device = registry.async_get(event.data['device_id']) # check empty identifiers # if not hass_device or not hass_device.identifiers: # return # domain, mac = next(iter(hass_device.identifiers)) # remove from Hass # registry.async_remove_device(hass_device.id) self.hass.bus.async_listen( 'device_registry_updated', device_registry_updated) def send(self, device: dict, data: dict): """ send command """ try: payload = {} if device['type'] == 'zigbee' or 'paring' in data: did = data.get('did', device['did']) data.pop('did', '') params = [] # convert hass prop to lumi prop if device['mi_spec']: payload = {'cmd': 'write', 'did': did, 'id': 5} for key, val in data.items(): if key == 'switch': val = bool(val) key = next( p[0] for p in device['mi_spec'] if p[2] == key) params.append({ 'siid': int(key[0]), 'piid': int(key[2]), 'value': val }) payload['mi_spec'] = params else: params = [{ 'res_name': next( p[0] for p in device['params'] if p[2] == key), 'value': val } for key, val in data.items()] payload = { 'cmd': 'write', 'did': did, 'id': randint(0, 65535), 'params': params, } payload = json.dumps(payload, separators=(',', ':')).encode() self._mqttc.publish('zigbee/recv', payload) elif device['type'] == 'gateway': if ATTR_HS_COLOR in data: hs_color = data.get(ATTR_HS_COLOR, 0) brightness = (hs_color >> 24) & 0xFF payload = { 'cmd': 'control', 'data': { 'blue': int((hs_color & 0xFF) * brightness / 100), 'breath': 500, 'green': int( ((hs_color >> 8) & 0xFF) * brightness / 100), 'red': int( ((hs_color >> 16) & 0xFF) * brightness / 100)}, 'type': 'rgb', 'rev': 1, 'id': randint(0, 65535) } else: payload = { 'cmd': 'control', 'data': data, 'rev': 1, 'id': randint(0, 65535) } payload = json.dumps(payload, separators=(',', ':')).encode() self._mqttc.publish('ioctl/recv', payload) return True except ConnectionError: return False
def _on_disconnet(self, client: mqtt.Client, userdata: Any, rc: Any): self._connected = False if rc != 0: client.reconnect()
class MqttClient(): default_config = { "host": "api.raptorbox.eu", "port": 1883, "reconnect_min_delay": 1, "reconnect_max_delay": 127, } def __init__(self, config): ''' Initialize information for the client provide configurations as parameter, if None provided the configurations would be defaults see MqttClient.default_config for configurations format ''' self.config = MqttClient.default_config if config: for k, v in config.iteritems(): self.config[k] = v self.mqtt_client = Client() self.mqtt_client.username_pw_set(config["username"], config["password"]) if "reconnect_min_delay" in config and "reconnect_max_delay" in config: self.mqtt_client.reconnect_delay_set( config["reconnect_min_delay"], config["reconnect_max_delay"], ) # self.connection_thread = None # self.mqtt_client.tls_set() def connect(self): #TODO consider srv field in dns ''' connect the client to mqtt server with configurations provided by constructor and starts listening for topics ''' self.mqtt_client.connect(self.config["host"], self.config["port"]) # self.mqtt_client.loop_forever() self.mqtt_client.loop_start() def subscribe(self, topic, callback): ''' register 'callback' to "topic". Every message will be passed to callback in order to be executed ''' logging.debug("subscribing to topic: {}".format(topic)) self.mqtt_client.subscribe(topic) self.mqtt_client.message_callback_add(topic, callback) def unsubscribe(self, topic): ''' unsubscribe to topic topic could be either a string or al list of strings containing to topics to unsubscribe to. 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. ''' return self.mqtt_client.unsubscribe(topic) def disconnect(self): ''' Disconnects from the server ''' self.mqtt_client.disconnect() def reconnect(self): ''' Reconnects after a disconnection, this could be called after connection only ''' self.mqtt_client.reconnect()
class MicroUpdater: def __init__(self, config_path=DEFAULT_COMPLETE_CONFIG_PATH): log_format = '<green>{time: YYYY-MM-DD HH:mm:ss.SSS}</green> <level>{level}: {message}</level>' logger.remove() logger.add(sys.stdout, format=log_format, colorize=True) logger.info('Starting MicroUpdater...') self.config = configparser.ConfigParser() self.read_configuration(config_path) logger.add(os.path.join(self.config['logging']['logs_path'], 'log_{time: YYYY-MM-DD}.log'), format=log_format, colorize=True, compression='zip', rotation='00:00') self.github_client = None self.repo_obj = None self.cached_release = None self.github_init() self.mqtt_client = MQTTClient(client_id=self.config['mqtt']['id']) self.mqtt_init() self.threads = {} # ip: thread self.server_thread = None self.server_loop = False def loop(self): while True: try: while True: next(thread for thread in self.threads.values() if thread.is_alive()) sleep(1) except StopIteration: pass tag, files = self.check_repo() files = [file for file in files if ".mpy" in file] # Download compiled files only if tag is not None: self._download_files(files) update_json = self._update_json(files=files, tag=tag) self.start_server(files, update_json) sleep(int(self.config['github']['check_rate'])) def server(self, files, update_json): while self.server_loop: logger.debug('Server waiting for installed tag...') topic = self.config['mqtt']['installed_tags_topic'] broker = self.config['mqtt']['broker'] message = subscribe(topic, hostname=broker) payload = message.payload msg_str = payload.decode("utf-8") try: installed_tag_json = json.loads(msg_str) if 'ip' not in installed_tag_json or 'tag' not in installed_tag_json: logger.warning( 'Server received a malformed installed tag message, skipping it...' ) continue except: logger.warning( 'Server received a malformed installed tag message, skipping it...' ) continue logger.debug( f'New update installed tag from {installed_tag_json["ip"]}') if installed_tag_json['tag'] != update_json['tag']: logger.debug( f"Probe out of date: installed {installed_tag_json['tag']}, latest {update_json['tag']}" ) self.spawn_update_thread(installed_tag_json['ip'], files, update_json) def spawn_update_thread(self, ip: str, files, update_json): logger.debug(f'Spawning new thread for {ip} update...') broker = self.config['mqtt']['broker'] topic = self.config['mqtt']['installed_tags_topic'] port = self.config['updates']['port'] th = DeviceUpdater(ip, port=port, files=files, broker=broker, installed_tags_topic=topic, release_json=update_json, mqtt_client=self.mqtt_client) th.start() self.threads[ip] = th logger.debug(f'Thread spawned and registered.') def mqtt_wait_publish(self, message: MQTTMessageInfo, timeout=1): start = time() t_out = timeout * 1000 while not message.is_published() and time() - start < t_out: sleep(0.1) if message.is_published(): return True return False def start_server(self, files, update_json): logger.debug('Starting update server...') self.server_loop = False if self.server_thread is not None: self.server_thread.join() self.server_loop = True self.server_thread = Thread(target=self.server, args=(files, update_json)) self.server_thread.start() logger.debug('Update server started.') def mqtt_init(self): self.mqtt_client.on_connect = self.mqtt_on_connect self.mqtt_client.on_disconnect = self.mqtt_on_disconnect self.mqtt_connect() self.mqtt_client.loop_start() def mqtt_connect(self): broker = self.config['mqtt']['broker'] self.mqtt_client.connect(broker) def mqtt_on_connect(self, client, userdata, flags, rc) -> bool: if rc == 0: logger.debug( f'MQTT client connected to {self.config["mqtt"]["broker"]}') return True else: logger.error(f'Connection to the broker failed, response: {rc}') return False def mqtt_on_disconnect(self, *args): logger.warning(f'MQTT client disconnect from the broker') self.mqtt_client.reconnect() def read_configuration(self, config_path): logger.debug(f'Reading configuration file "{config_path}"') try: self.config.read(config_path) except Exception as e: logger.critical(f'Error reading configuration file; {e}') logger.critical('Closing...') exit(1) try: sections = self.config.sections() for section in CONFIGURATION_LAYOUT: assert section in sections for key in CONFIGURATION_LAYOUT[section]: assert key in self.config[section] except AssertionError: logger.critical( f'Configuration file malformed, creating sample as "{DEFAULT_COMPLETE_CONFIG_PATH}"...' ) for section in CONFIGURATION_LAYOUT: self.config[section] = {} for key in CONFIGURATION_LAYOUT[section]: self.config[section][key] = f'<{key}>' try: if os.path.isfile(DEFAULT_COMPLETE_CONFIG_PATH): logger.error( "Can't create configuration sample, please provide a custom configuration file" ) exit(1) with open(DEFAULT_COMPLETE_CONFIG_PATH, 'w') as file: self.config.write(file) except Exception as e: logger.critical( f"Can't create a config sample as '{DEFAULT_COMPLETE_CONFIG_PATH}' in working directory; {e}" ) finally: exit(1) logger.info(f'Configuration loaded: \n' f'\tToken: {self.config["github"]["token"]}\n' f'\tLogs path: {self.config["logging"]["logs_path"]}') def github_init(self): logger.debug('Initializing github attributes...') github = Github(self.config['github']['token']) self.github_client = github.get_user() self.repo_obj = self.github_client.get_repo( self.config['github']['repo']) self.load_cached_release() logger.debug('Github attributes initialized.') def load_cached_release(self): cache_path = self.config['github']['release_cache_complete_path'] logger.debug(f'Loading cached release from {cache_path}') try: with open(cache_path, 'r') as file: self.cached_release = file.readline().strip() logger.debug(f'Cached release: {self.cached_release}') except Exception as e: logger.error( f"Can't load cached release, 'default' tag will be used; {e}") self.cached_release = 'default' def save_cached_release(self, tag): release_cache_path = self.config["github"][ "release_cache_complete_path"] logger.debug(f'Saving cached release in {release_cache_path}') self.cached_release = tag try: with open(release_cache_path, 'w') as file: file.write(self.cached_release) logger.debug(f'Cached release saved.') except Exception as e: logger.error(f"Can't save cached release") def check_repo(self): # returns: latest_tag, files logger.debug( f'Checking "{self.config["github"]["repo"]}" latest release tag') try: latest_release = self.repo_obj.get_latest_release() except: logger.error(f"Can't get latest release") return None, None tag = latest_release.tag_name if self.cached_release != tag: logger.info(f"New update found: {tag}") contents = self.repo_obj.get_contents(path='', ref=tag) files = [ RepoFile(file.name, file.sha, file.download_url) for file in contents ] self.save_cached_release(tag) return tag, files else: return None, None def _clean_download_folder(self): download_path = self.config['updates']['download_path'] logger.debug(f'Cleaning download folder "{download_path}"...') if not os.path.isdir(download_path): logger.warning( f'Download folder "{download_path}" does not exists, creating it...' ) os.mkdir(download_path) logger.debug('Download folder ready.') return files = [ os.path.join(download_path, file) for file in os.listdir(download_path) ] logger.debug(f'{len(files)} files will be deleted..') for idx, file in enumerate(files): logger.debug(f'[{idx}/{len(files)-1}] Deleting {file}') if os.path.isfile(file): os.remove(file) else: os.rmdir(file) if len(os.listdir(download_path)) > 0: logger.error("Can't clean download folder") exit(1) logger.debug('Download folder ready.') def _download_files(self, files) -> bool: download_path = self.config['updates']['download_path'] logger.debug(f'Downloading {len(files)} files in {download_path}...') self._clean_download_folder() trusted_files = self.config['trusted_files']['files'].split(',') trusted_files = [file.strip() for file in trusted_files] files_threads = [ FileDownloader(file, download_path, file.name in trusted_files) for file in files ] for thread in files_threads: thread.start() for thread in files_threads: thread.join() if not thread.response: logger.error( f"Error downloading {thread.file.name}, aborting files download" ) for th in files_threads: if th.is_alive(): th.kill_thread() return False logger.debug('Files downloaded') return True def remove_empty_lines(self, file: RepoFile) -> (str, bool): with open(file.path, 'r') as file_opened: content = file_opened.read() #print(content) content = content.rstrip() with open(file.path, 'w') as file_opened: file_opened.write(content) def _update_json(self, tag, files): msg = {} msg['tag'] = tag msg['files'] = [file.name for file in files] msg_json = json.dumps(msg) return msg_json
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