Example #1
0
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
Example #2
0
    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)
Example #3
0
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)
Example #4
0
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
Example #5
0
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
Example #6
0
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
Example #7
0
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
Example #8
0
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
Example #10
0
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()
Example #11
0
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
Example #12
0
 def _on_disconnet(self, client: mqtt.Client, userdata: Any, rc: Any):
     self._connected = False
     if rc != 0:
         client.reconnect()
Example #13
0
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()
Example #14
0
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
Example #15
0
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