コード例 #1
0
ファイル: brute_forcer.py プロジェクト: whoot/mqtt-pwn
    def _are_valid_credentials(self, username, password):
        """Checks whether credentials are valid"""
        def _on_connect(client, userdata, flags, rc):
            """A callback that matches the MQTT client signature, that triggers when connection was established"""
            userdata.set_return_code(rc)
            client.disconnect()

            client.loop_stop()
            client.should_stop = True

        con_result = ConnectionResult()
        client = Client(userdata=con_result)

        client.username_pw_set(username, password)
        client.on_connect = _on_connect

        start_time = time.time()
        client.should_stop = False
        client.connect_async(self.host, self.port)
        client.loop_start()

        while not client.should_stop and time.time(
        ) - start_time < self.timeout:
            time.sleep(0.001)

        return con_result.did_succeed
コード例 #2
0
ファイル: yqmiot.py プロジェクト: hbally/yqmiot_python
class MqttClient(object):
    """Mqtt通讯封装"""
    def __init__(self, address):
        if not isinstance(address, tuple) or len(address) != 2:
            raise ValueError("Invalid address.")

        def on_connect(client, userdata, flags, rc):
            self.handleConnected()

        def on_message(client, userdata, msg):
            self.handleMessage(msg.topic, msg.payload)

        self.client = Mqtt()
        self.address = address
        self.client.on_connect = on_connect
        self.client.on_message = on_message

    def handleConnected(self):
        pass

    def handleMessage(self, topic, payload):
        pass

    def publish(self, topic, payload=None, qos=0, retain=False):
        self.client.publish(topic, payload, qos, retain)

    def subscribe(self, topic, qos=0):
        self.client.subscribe(topic, qos)

    def start(self):
        self.client.connect_async(self.address[0], self.address[1])
        self.client.loop_start()

    def stop(self):
        self.client.loop_stop()

    def username_pw_set(self, username, password=None):
        self.client.username_pw_set(username, password)

    def will_set(self, topic, payload=None, qos=0, retain=False):
        self.client.will_set(topic, payload, qos, retain)
コード例 #3
0
    def __init__(self, client_id, config, wait=True):
        """
        initialize mqtt client
        :param client_id: client id
        :param config: keeper configuration
        :param wait: whether to wait for connection
        """

        self.logger = Logger()
        user = config.get("mqtt.user")
        pwd = config.get("mqtt.pass")
        client = Client(client_id=client_id)
        client.on_connect = self._on_connect
        client.on_disconnect = self._on_disconnect
        client.on_message = self._on_message
        client.enable_logger(self.logger)
        if user and pwd:
            client.username_pw_set(user, pwd)

        client.connect_async(config["mqtt.broker"], config["mqtt.port"], 30)
        self.client = client
        self.connected = False
        self.manager = None
        self.wait = wait
コード例 #4
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
コード例 #5
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
コード例 #6
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
コード例 #7
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)
コード例 #8
0
class MqttHandler:
    def __init__(self, client_id='DEFAULT_CLIENT_ID', topic='DEFAULT_TOPIC', broker_host='localhost',
                 broker_port=MQTT_BROKER_PORT):
        self.subscribed = False
        self.client_id = client_id
        self.client = Client(client_id=self.client_id, protocol=MQTT_PROTOCOL_VERSION)
        self.client.on_message = self.on_message_callback
        self.client.on_publish = self.on_publish_callback
        self.client.on_connect = self.connect_callback
        self.client.on_disconnect = self.disconnect_callback
        self.topic = topic
        self.broker_host = broker_host
        self.broker_port = broker_port
        self.message_received = 0
        userdata = {
            USER_DATA_MESSAGE_RECEIVED: 0,
        }
        self.client.user_data_set(userdata)

    def connect(self):
        self.client.connect(host=self.broker_host, port=self.broker_port)

    def connect_async(self):
        self.client.connect_async(host=self.broker_host, port=self.broker_port)

    def connect_callback(self, client, userdata, flags, rc):
        print('connect_callback: result code[' + str(rc) + ']')
        (result, _) = client.subscribe(topic=self.topic)
        self.subscribed = result

    def disconnect(self):
        self.client.disconnect()

    def disconnect_callback(self, client, userdata, rc):
        print('disconnect_callback')

    def is_valid(self, my_json: json):
        if app.config.get('DEBUG', False):
            print("json_validation")
        # try:
        #     if my_json['id'] is None or my_json['byte_stream'] is None:
        #         return False
        # except KeyError:
        #     return False
        return True

    def on_message_callback(self, client, userdata, message):
        from core.socketio_runner import emit_command

        userdata[USER_DATA_MESSAGE_RECEIVED] += 1

        topic = message.topic
        payload = json.loads(message.payload)

        if app.config.get('DEBUG', False):
            print('on_message_callback: topic[' + topic + ']')

        if self.is_valid(payload):
            emit_command(topic, payload)
        else:
            raise Exception('Message payload not valid')

    @staticmethod
    def publish_single_message(topic, payload=None, qos=0, retain=False, hostname="localhost",
                               port=MQTT_BROKER_PORT, client_id="", keepalive=60, will=None, auth=None, tls=None):
        if app.config.get('DEBUG', False):
            print("publish_single_message")
        single(topic=topic, payload=payload, qos=qos, retain=retain, hostname=hostname, port=port, client_id=client_id,
               keepalive=keepalive, will=will, auth=auth, tls=tls)

    def on_publish_callback(self, client, userdata, mid):
        print('on_publish_callback')

    def loop_for_ever(self):
        self.client.loop_forever()

    def loop_start(self):
        self.client.loop_start()

    def loop_stop(self, force=False):
        self.client.loop_stop(force=force)
コード例 #9
0
class MQTTConnection():
    client: Client
    _instance = None

    @classmethod
    def get_instance(cls) -> "MQTTConnection":
        if cls._instance is None:
            cls._instance = MQTTConnection()

        return cls._instance

    def __init__(self):
        self.client = Client(
            "pai" + os.urandom(8).hex(),
            protocol=protocol_map.get(str(cfg.MQTT_PROTOCOL), MQTTv311),
            transport=cfg.MQTT_TRANSPORT,
        )
        self._last_pai_status = "unknown"
        self.pai_status_topic = "{}/{}/{}".format(cfg.MQTT_BASE_TOPIC,
                                                  cfg.MQTT_INTERFACE_TOPIC,
                                                  "pai_status")
        self.availability_topic = "{}/{}/{}".format(cfg.MQTT_BASE_TOPIC,
                                                    cfg.MQTT_INTERFACE_TOPIC,
                                                    "availability")
        self.client.on_connect = self._on_connect_cb
        self.client.on_disconnect = self._on_disconnect_cb
        self.state = ConnectionState.NEW
        # self.client.enable_logger(logger)

        # self.client.on_subscribe = lambda client, userdata, mid, granted_qos: logger.debug("Subscribed: %s" %(mid))
        # self.client.on_message = lambda client, userdata, message: logger.debug("Message received: %s" % str(message))
        # self.client.on_publish = lambda client, userdata, mid: logger.debug("Message published: %s" % str(mid))

        ps.subscribe(self.on_run_state_change, "run-state")

        self.registrars = []

        if cfg.MQTT_USERNAME is not None and cfg.MQTT_PASSWORD is not None:
            self.client.username_pw_set(username=cfg.MQTT_USERNAME,
                                        password=cfg.MQTT_PASSWORD)

        if cfg.MQTT_TLS_CERT_PATH is not None:
            self.client.tls_set(
                ca_certs=cfg.MQTT_TLS_CERT_PATH,
                certfile=None,
                keyfile=None,
                cert_reqs=ssl.CERT_REQUIRED,
                tls_version=ssl.PROTOCOL_TLSv1_2,
                ciphers=None,
            )
            self.client.tls_insecure_set(False)

        self.client.will_set(self.availability_topic,
                             "offline",
                             0,
                             retain=True)

        self.client.on_log = self.on_client_log

    def on_client_log(self, client, userdata, level, buf):
        level_std = LOGGING_LEVEL[level]
        exc_info = None

        type_, exc, trace = sys.exc_info()
        if exc:  # Can be (socket.error, OSError, WebsocketConnectionError, ...)
            if hasattr(exc, "errno"):
                exc_msg = f"{os.strerror(exc.errno)}({exc.errno})"
                if exc.errno in [22, 49]:
                    level_std = logging.ERROR
                    buf = f"{buf}: Please check MQTT connection settings. Especially MQTT_BIND_ADDRESS and MQTT_BIND_PORT"
            else:
                exc_msg = str(exc)

            buf = f"{buf}: {exc_msg}"
            if "Connection failed" in buf:
                level_std = logging.WARNING

        if level_std > logging.DEBUG:
            logger.log(level_std, buf, exc_info=exc_info)

    def on_run_state_change(self, state: RunState):
        v = RUN_STATE_2_PAYLOAD.get(state, "unknown")
        self._report_pai_status(v)

    def start(self):
        if self.state == ConnectionState.NEW:
            self.client.loop_start()

            # TODO: Some initial connection retry mechanism required
            try:
                self.client.connect_async(
                    host=cfg.MQTT_HOST,
                    port=cfg.MQTT_PORT,
                    keepalive=cfg.MQTT_KEEPALIVE,
                    bind_address=cfg.MQTT_BIND_ADDRESS,
                    bind_port=cfg.MQTT_BIND_PORT,
                )

                self.state = ConnectionState.CONNECTING

                logger.info("MQTT loop started")
            except socket.gaierror:
                logger.exception("Failed to connect to MQTT (%s:%d)",
                                 cfg.MQTT_HOST, cfg.MQTT_PORT)

    def stop(self):
        if self.state in [
                ConnectionState.CONNECTING, ConnectionState.CONNECTED
        ]:
            self.disconnect()
            self.client.loop_stop()
            logger.info("MQTT loop stopped")

    def publish(self, topic, payload=None, *args, **kwargs):
        logger.debug("MQTT: {}={}".format(topic, payload))

        self.client.publish(topic, payload, *args, **kwargs)

    def _call_registars(self, method, *args, **kwargs):
        for r in self.registrars:
            try:
                if hasattr(r, method) and isinstance(getattr(r, method),
                                                     typing.Callable):
                    getattr(r, method)(*args, **kwargs)
            except:
                logger.exception('Failed to call "%s" on "%s"', method,
                                 r.__class__.__name__)

    def register(self, cls):
        self.registrars.append(cls)

        self.start()

    def unregister(self, cls):
        self.registrars.remove(cls)

        if len(self.registrars) == 0:
            self.stop()

    @property
    def connected(self):
        return self.state == ConnectionState.CONNECTED

    def _report_pai_status(self, status):
        self._last_pai_status = status
        self.publish(self.pai_status_topic,
                     status,
                     qos=cfg.MQTT_QOS,
                     retain=True)
        self.publish(
            self.availability_topic,
            "online" if status in ["online", "paused"] else "offline",
            qos=cfg.MQTT_QOS,
            retain=True,
        )

    def _on_connect_cb(self, client, userdata, flags, result, properties=None):
        # called on Thread-6
        if result == MQTT_ERR_SUCCESS:
            logger.info("MQTT Broker Connected")
            self.state = ConnectionState.CONNECTED
            self._report_pai_status(self._last_pai_status)
            self._call_registars("on_connect", client, userdata, flags, result)
        else:
            logger.error(
                f"Failed to connect to MQTT: {connack_string(result)} ({result})"
            )

    def _on_disconnect_cb(self, client, userdata, rc):
        # called on Thread-6
        if rc == MQTT_ERR_SUCCESS:
            logger.info("MQTT Broker Disconnected")
        else:
            logger.error(f"MQTT Broker unexpectedly disconnected. Code: {rc}")

        self.state = ConnectionState.NEW
        self._call_registars("on_disconnect", client, userdata, rc)

    def disconnect(self, reasoncode=None, properties=None):
        self.state = ConnectionState.DISCONNECTING
        self._report_pai_status("offline")
        self.client.disconnect()

    def message_callback_add(self, *args, **kwargs):
        self.client.message_callback_add(*args, **kwargs)

    def subscribe(self, *args, **kwargs):
        self.client.subscribe(*args, **kwargs)
コード例 #10
0
class MqttServer(Thread):
    """Server fuer die Uebertragung des Prozessabbilds per MQTT."""
    def __init__(self,
                 basetopic,
                 sendinterval,
                 broker_address,
                 port=1883,
                 tls_set=False,
                 username="",
                 password=None,
                 client_id="",
                 send_events=False,
                 write_outputs=False,
                 replace_ios=None):
        """Init MqttServer class.

        @param basetopic Basis-Topic fuer Datenaustausch
        @param sendinterval Prozessabbild alle n Sekunden senden / 0 = aus
        @param broker_address Adresse <class 'str'> des MQTT-Servers
        @param port Portnummer <class 'int'> des MQTT-Servers
        @param tls_set TLS fuer Verbindung zum MQTT-Server verwenden
        @param username Optional Benutzername fuer MQTT-Server
        @param password Optional Password fuer MQTT-Server
        @param client_id MQTT ClientID, wenn leer automatisch random erzeugung
        @param send_events Sendet Werte bei IO Wertaenderung
        @param write_outputs Per MQTT auch Outputs schreiben
        @param replace_ios Replace IOs of RevPiModIO

        """
        if not isinstance(basetopic, str):
            raise ValueError("parameter topic must be <class 'str'>")
        if not (isinstance(sendinterval, int) and sendinterval >= 0):
            raise ValueError(
                "parameter sendinterval must be <class 'int'> and >= 0")
        if not (isinstance(broker_address, str) and broker_address != ""):
            raise ValueError(
                "parameter broker_address must be <class 'str'> and not empty")
        if not (isinstance(port, int) and 0 < port < 65535):
            raise ValueError(
                "parameter sendinterval must be <class 'int'> and 1 - 65535")
        if not isinstance(tls_set, bool):
            raise ValueError("parameter tls_set must be <class 'bool'>")
        if not isinstance(username, str):
            raise ValueError("parameter username must be <class 'str'>")
        if not (password is None or isinstance(password, str)):
            raise ValueError("parameter password must be <class 'str'>")
        if not isinstance(client_id, str):
            raise ValueError("parameter client_id must be <class 'str'>")
        if not isinstance(send_events, bool):
            raise ValueError("parameter send_events must be <class 'bool'>")
        if not isinstance(write_outputs, bool):
            raise ValueError("parameter write_outputs must be <class 'bool'>")
        if not (replace_ios is None or isinstance(replace_ios, str)):
            raise ValueError("parameter replace_ios must be <class 'str'>")

        super().__init__()

        # Klassenvariablen
        self.__exit = False
        self._evt_data = Event()
        self._exported_ios = []
        self._broker_address = broker_address
        self._port = port
        self._reloadmodio = False
        self._replace_ios = replace_ios
        self._rpi = None
        self._send_events = send_events
        self._sendinterval = sendinterval
        self._write_outputs = write_outputs

        # RevPiModIO laden oder mit Exception aussteigen
        self._loadrevpimodio()

        # Topics konfigurieren
        self._mqtt_evt_io = join(basetopic, "event/{0}")
        self._mqtt_got_io = join(basetopic, "got/{0}")
        self._mqtt_io = join(basetopic, "io/{0}")
        self._mqtt_ioget = join(basetopic, "get/#")
        self._mqtt_ioset = join(basetopic, "set/#")
        self._mqtt_ioreset = join(basetopic, "reset/#")
        self._mqtt_pictory = join(basetopic, "pictory")
        self._mqtt_senddata = join(basetopic, "get")
        self._mqtt_sendpictory = join(basetopic, "needpictory")

        self._mq = Client(client_id)
        if username != "":
            self._mq.username_pw_set(username, password)
        if tls_set:
            self._mq.tls_set(cert_reqs=CERT_NONE)
            self._mq.tls_insecure_set(True)

        # Handler konfigurieren
        self._mq.on_connect = self._on_connect
        self._mq.on_message = self._on_message

    def _evt_io(self, name, value, requested=False):
        """Sendet Daten aus Events.

        @param name IO-Name
        @param value IO-Value
        @param requested Wenn True, wird 'got' Topic verwendet

        """
        if requested:
            topic = self._mqtt_got_io.format(name)
        else:
            topic = self._mqtt_evt_io.format(name)

        if isinstance(value, bytes):
            value = int.from_bytes(value, "little")
        self._mq.publish(topic, int(value))

    def _loadrevpimodio(self):
        """Instantiiert das RevPiModIO Modul.
        @return None or Exception"""
        self._reloadmodio = False
        self._exported_ios = []

        # RevPiModIO-Modul Instantiieren
        if self._rpi is not None:
            self._rpi.cleanup()

        proginit.logger.debug("create revpimodio2 object for MQTT")
        try:
            # Vollzugriff und Eventüberwachung
            self._rpi = revpimodio2.RevPiModIO(
                autorefresh=self._send_events,
                monitoring=not self._write_outputs,
                configrsc=proginit.pargs.configrsc,
                procimg=proginit.pargs.procimg,
                replace_io_file=self._replace_ios,
                shared_procimg=True,
            )
            self._rpi.debug = -1

            if self._replace_ios:
                proginit.logger.info("loaded replace_ios to MQTT")

        except Exception as e:
            try:
                # Lesend und Eventüberwachung
                self._rpi = revpimodio2.RevPiModIO(
                    autorefresh=self._send_events,
                    monitoring=not self._write_outputs,
                    configrsc=proginit.pargs.configrsc,
                    procimg=proginit.pargs.procimg,
                    shared_procimg=True,
                )
                self._rpi.debug = -1
                proginit.logger.warning(
                    "replace_ios_file not loadable for MQTT - using "
                    "defaults now | {0}".format(e))

            except Exception as e:
                self._rpi = None
                proginit.logger.error(
                    "piCtory configuration not loadable for MQTT | "
                    "{0}".format(e))
                raise e

        # Exportierte IOs laden
        for dev in self._rpi.device:
            for io in dev.get_allios(export=True):
                io.reg_event(self._evt_io)
                self._exported_ios.append(io)

        # CoreIOs prüfen und zu export hinzufügen
        lst_coreio = []
        if self._rpi.core:
            if self._rpi.core.a1green.export:
                lst_coreio.append(self._rpi.core.a1green)
            if self._rpi.core.a1red.export:
                lst_coreio.append(self._rpi.core.a1red)
            if self._rpi.core.a2green.export:
                lst_coreio.append(self._rpi.core.a2green)
            if self._rpi.core.a2red.export:
                lst_coreio.append(self._rpi.core.a2red)
            if self._rpi.core.wd.export:
                lst_coreio.append(self._rpi.core.wd)

            # Connect-IOs anhängen
            if type(self._rpi.core) == revpimodio2.device.Connect:
                if self._rpi.core.a3green.export:
                    lst_coreio.append(self._rpi.core.a3green)
                if self._rpi.core.a3red.export:
                    lst_coreio.append(self._rpi.core.a3red)
                if self._rpi.core.x2in.export:
                    lst_coreio.append(self._rpi.core.x2in)
                if self._rpi.core.x2out.export:
                    lst_coreio.append(self._rpi.core.x2out)

        # IOs exportieren und Events anmelden
        for io in lst_coreio:
            io.reg_event(self._evt_io)
            self._exported_ios.append(io)

        proginit.logger.debug("created revpimodio2 object")

    def _on_connect(self, client, userdata, flags, rc):
        """Verbindung zu MQTT Broker."""
        proginit.logger.debug("enter MqttServer._on_connect()")

        if rc > 0:
            proginit.logger.warning(
                "can not connect to mqtt broker '{0}' - error '{1}' - "
                "will retry".format(self._broker_address, connack_string(rc)))
        else:
            # Subscribe piCtory Anforderung
            client.subscribe(self._mqtt_ioget)
            client.subscribe(self._mqtt_senddata)
            client.subscribe(self._mqtt_sendpictory)
            if self._write_outputs:
                client.subscribe(self._mqtt_ioset)
                client.subscribe(self._mqtt_ioreset)

        proginit.logger.debug("leave MqttServer._on_connect()")

    def _on_disconnect(self, client, userdata, rc):
        """Wertet Verbindungsabbruch aus."""
        proginit.logger.debug("enter MqttServer._on_disconnect()")

        if rc != 0:
            proginit.logger.warning(
                "unexpected disconnection from mqtt broker - "
                "will try to reconnect")

        proginit.logger.debug("leave MqttServer._on_disconnect()")

    def _on_message(self, client, userdata, msg):
        """Sendet piCtory Konfiguration."""
        if msg.topic == self._mqtt_pictory:
            # piCtory Konfiguration senden
            self._send_pictory_conf()

        elif msg.topic == self._mqtt_senddata:
            # Alle zyklischen Daten senden
            self._evt_data.set()

        else:
            lst_topic = msg.topic.split("/")
            if len(lst_topic) < 2:
                proginit.logger.info(
                    "wrong topic format - need ./get/ioname or ./set/ioname")
                return

            # Aktion und IO auswerten
            ioget = lst_topic[-2].lower() == "get"
            ioset = lst_topic[-2].lower() == "set"
            ioreset = lst_topic[-2].lower() == "reset"
            ioname = lst_topic[-1]
            coreio = ioname.find(".") != -1

            try:
                # IO holen
                if coreio:
                    coreio = ioname.split(".")[-1]
                    io = getattr(self._rpi.core, coreio)
                    if not isinstance(io, revpimodio2.io.IOBase):
                        raise RuntimeError()
                else:
                    io = self._rpi.io[ioname]
                io_needbytes = type(io.value) == bytes
            except Exception:
                proginit.logger.error(
                    "can not find io '{0}' for MQTT".format(ioname))
                return

            # Aktion verarbeiten
            if not io.export:
                proginit.logger.error(
                    "io '{0}' is not marked as export in piCtory for MQTT use"
                    "".format(ioname))

            elif ioget:
                # Werte laden, wenn nicht autorefresh
                if not self._send_events:
                    io._parentdevice.readprocimg()

                # Publish Wert von IO
                self._evt_io(io.name, io.value, requested=True)

            elif ioset and io.type != revpimodio2.OUT:
                proginit.logger.error("can not write to inputs with MQTT")

            elif ioset:
                # Convert MQTT Payload to valid Output-Value
                value = msg.payload.decode("utf8")

                if value.isdecimal():
                    value = int(value)

                    # Muss eine Byteumwandlung vorgenommen werden?
                    if io_needbytes:
                        try:
                            value = value.to_bytes(io.length, io.byteorder)
                        except OverflowError:
                            proginit.logger.error(
                                "can not convert value '{0}' to fitting bytes"
                                "".format(value))
                            return

                elif value == "false" and not io_needbytes:
                    value = 0
                elif value == "true" and not io_needbytes:
                    value = 1
                else:
                    proginit.logger.error(
                        "can not convert value '{0}' for output '{1}'"
                        "".format(value, ioname))
                    return

                # Write Value to RevPi
                try:
                    io.value = value
                except Exception:
                    proginit.logger.error(
                        "could not write '{0}' to Output '{1}'"
                        "".format(value, ioname))

            elif ioreset:
                # Counter zurücksetzen
                if not isinstance(io, revpimodio2.io.IntIOCounter):
                    proginit.logger.warning("this io has no counter")
                else:
                    io.reset()

            else:
                # Aktion nicht erkennbar
                proginit.logger.warning(
                    "can not see get/set in topic '{0}'".format(msg.topic))

    def _send_pictory_conf(self):
        """Sendet piCtory Konfiguration per MQTT."""
        try:
            fh = open(proginit.pargs.configrsc, "rb")
            self._mq.publish(self._mqtt_pictory, fh.read())
            fh.close()
        except Exception:
            proginit.logger.error(
                "can not read and publish piCtory config '{0}'"
                "".format(proginit.pargs.configrsc))

    def newlogfile(self):
        """Konfiguriert die FileHandler auf neue Logdatei."""
        pass

    def reload_revpimodio(self):
        """Fuehrt im naechsten Zyklus zum Reload."""
        proginit.logger.debug("enter MqttServer.reload_revpimodio()")

        self._reloadmodio = True
        self._evt_data.set()

        proginit.logger.debug("leave MqttServer.reload_revpimodio()")

    def run(self):
        """Startet die Uebertragung per MQTT."""
        proginit.logger.debug("enter MqttServer.run()")

        # MQTT verbinden
        proginit.logger.info("connecting to mqtt broker {0}".format(
            self._broker_address))
        try:
            self._mq.connect(self._broker_address, self._port, keepalive=60)
        except Exception:
            self._on_connect(self._mq, None, None, 3)
            self._mq.connect_async(self._broker_address,
                                   self._port,
                                   keepalive=60)
        self._mq.loop_start()

        # Eventüberwachung starten
        if self._send_events:
            proginit.logger.debug("start non blocking mainloop of revpimodio")
            self._rpi.mainloop(blocking=False)

        # mainloop
        send_cycledata = self._sendinterval > 0
        while not self.__exit:
            self._evt_data.clear()

            # RevPiModIO neu laden
            if self._reloadmodio:
                proginit.logger.info("reload revpimodio for mqtt")
                self._loadrevpimodio()

                # Eventüberwachung erneut starten
                if self._send_events:
                    proginit.logger.debug(
                        "start non blocking mainloop of revpimodio")
                    self._rpi.mainloop(blocking=False)

            if send_cycledata:
                # Werte laden, wenn nicht autorefresh
                if not self._send_events:
                    self._rpi.readprocimg()

                # Exportierte IOs übertragen
                for io in self._exported_ios:
                    value = io.value
                    if isinstance(value, bytes):
                        value = int.from_bytes(value, "little")
                    self._mq.publish(self._mqtt_io.format(io.name), int(value))

            self._evt_data.wait(
                10 if not send_cycledata else self._sendinterval)

        # MQTT trennen
        proginit.logger.info("disconnecting from mqtt broker {0}".format(
            self._broker_address))
        # NOTE: dies gab dead-locks: self._mq.loop_stop()
        self._mq.disconnect()

        # RevPiModIO aufräumen
        self._rpi.cleanup()

        proginit.logger.debug("leave MqttServer.run()")

    def stop(self):
        """Stoppt die Uebertragung per MQTT."""
        proginit.logger.debug("enter MqttServer.stop()")
        self.__exit = True
        self._evt_data.set()
        proginit.logger.debug("leave MqttServer.stop()")
コード例 #11
0
ファイル: connection.py プロジェクト: msoida/MQTTsensors
def on_connect(client, userdata, flags, rc):
    print('MQTT connect: {}'.format(connack_string(rc)))
    client.publish(status_topic, status_connected, retain=True)


def on_disconnect(client, userdata, rc):
    print('MQTT disconnect: {}'.format(error_string(rc)))


def on_message(client, userdata, message):
    pass  # not used


client = Client()
client.will_set(status_topic, status_error, retain=True)
client.connect_async(mqtt_server)
# client.connect(mqtt_server)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.loop_start()


@atexit
def run_at_exit():
    client.publish(status_topic, status_disconnected, retain=True)
    client.loop_stop()
    client.disconnect()


def publish(topic, value):
コード例 #12
0
class Translator(object):
    """Translates messages between the LifeSOS and MQTT interfaces."""

    # Default interval to wait before resetting Trigger device state to Off
    AUTO_RESET_INTERVAL = 30

    # Keys for Home Assistant MQTT discovery configuration
    HA_AVAILABILITY_TOPIC = 'availability_topic'
    HA_COMMAND_TOPIC = 'command_topic'
    HA_DEVICE_CLASS = 'device_class'
    HA_ICON = 'icon'
    HA_NAME = 'name'
    HA_PAYLOAD_ARM_AWAY = 'payload_arm_away'
    HA_PAYLOAD_ARM_HOME = 'payload_arm_home'
    HA_PAYLOAD_AVAILABLE = 'payload_available'
    HA_PAYLOAD_DISARM = 'payload_disarm'
    HA_PAYLOAD_NOT_AVAILABLE = 'payload_not_available'
    HA_PAYLOAD_OFF = 'payload_off'
    HA_PAYLOAD_ON = 'payload_on'
    HA_STATE_TOPIC = 'state_topic'
    HA_UNIQUE_ID = 'unique_id'
    HA_UNIT_OF_MEASUREMENT = 'unit_of_measurement'

    # Device class to classify the sensor type in Home Assistant
    HA_DC_DOOR = 'door'
    HA_DC_GAS = 'gas'
    HA_DC_HUMIDITY = 'humidity'
    HA_DC_ILLUMINANCE = 'illuminance'
    HA_DC_MOISTURE = 'moisture'
    HA_DC_MOTION = 'motion'
    HA_DC_SAFETY = 'safety'
    HA_DC_SMOKE = 'smoke'
    HA_DC_TEMPERATURE = 'temperature'
    HA_DC_VIBRATION = 'vibration'
    HA_DC_WINDOW = 'window'
    HA_DC_BATTERY = 'battery'

    # Icons in Home Assistant
    HA_ICON_RSSI = 'mdi:wifi'

    # Platforms in Home Assistant to represent our devices
    HA_PLATFORM_ALARM_CONTROL_PANEL = 'alarm_control_panel'
    HA_PLATFORM_BINARY_SENSOR = 'binary_sensor'
    HA_PLATFORM_SENSOR = 'sensor'
    HA_PLATFORM_SWITCH = 'switch'

    # Alarm states in Home Assistant
    HA_STATE_ARMED_AWAY = 'armed_away'
    HA_STATE_ARMED_HOME = 'armed_home'
    HA_STATE_DISARMED = 'disarmed'
    HA_STATE_PENDING = 'pending'
    HA_STATE_TRIGGERED = 'triggered'

    # Unit of measurement for Home Assistant sensors
    HA_UOM_CURRENT = 'A'
    HA_UOM_HUMIDITY = '%'
    HA_UOM_ILLUMINANCE = 'Lux'
    HA_UOM_RSSI = 'dB'
    HA_UOM_TEMPERATURE = '°C'

    # Ping MQTT broker this many seconds apart to check we're connected
    KEEP_ALIVE = 30

    # Attempt reconnection this many seconds apart
    # (starts at min, doubles on retry until max reached)
    RECONNECT_MAX_DELAY = 120
    RECONNECT_MIN_DELAY = 15

    # Sub-topic to clear the alarm/warning LEDs on base unit and stop siren
    TOPIC_CLEAR_STATUS = 'clear_status'

    # Sub-topic to access the remote date/time
    TOPIC_DATETIME = 'datetime'

    # Sub-topic to provide alarm state that is recognised by Home Assistant
    TOPIC_HASTATE = 'ha_state'

    # Sub-topic that will be subscribed to on topics that can be set
    TOPIC_SET = 'set'

    def __init__(self, config: Config):
        self._config = config
        self._loop = asyncio.get_event_loop()
        self._shutdown = False
        self._get_task = None
        self._auto_reset_handles = {}
        self._state = None
        self._ha_state = None

        # Create LifeSOS base unit instance and attach callbacks
        self._baseunit = BaseUnit(self._config.lifesos.host,
                                  self._config.lifesos.port)
        if self._config.lifesos.password:
            self._baseunit.password = self._config.lifesos.password
        self._baseunit.on_device_added = self._baseunit_device_added
        self._baseunit.on_device_deleted = self._baseunit_device_deleted
        self._baseunit.on_event = self._baseunit_event
        self._baseunit.on_properties_changed = self._baseunit_properties_changed
        self._baseunit.on_switch_state_changed = self._baseunit_switch_state_changed

        # Create MQTT client instance
        self._mqtt = MQTTClient(client_id=self._config.mqtt.client_id,
                                clean_session=False)
        self._mqtt.enable_logger()
        self._mqtt.will_set(
            '{}/{}'.format(self._config.translator.baseunit.topic,
                           BaseUnit.PROP_IS_CONNECTED),
            str(False).encode(), QOS_1, True)
        self._mqtt.reconnect_delay_set(Translator.RECONNECT_MIN_DELAY,
                                       Translator.RECONNECT_MAX_DELAY)
        if self._config.mqtt.uri.username:
            self._mqtt.username_pw_set(self._config.mqtt.uri.username,
                                       self._config.mqtt.uri.password)
        if self._config.mqtt.uri.scheme == SCHEME_MQTTS:
            self._mqtt.tls_set()
        self._mqtt.on_connect = self._mqtt_on_connect
        self._mqtt.on_disconnect = self._mqtt_on_disconnect
        self._mqtt.on_message = self._mqtt_on_message
        self._mqtt_was_connected = False
        self._mqtt_last_connection = None
        self._mqtt_last_disconnection = None

        # Generate a list of topics we'll need to subscribe to
        self._subscribetopics = []
        self._subscribetopics.append(
            SubscribeTopic(
                '{}/{}'.format(self._config.translator.baseunit.topic,
                               Translator.TOPIC_CLEAR_STATUS),
                self._on_message_clear_status))
        self._subscribetopics.append(
            SubscribeTopic(
                '{}/{}/{}'.format(self._config.translator.baseunit.topic,
                                  Translator.TOPIC_DATETIME,
                                  Translator.TOPIC_SET),
                self._on_message_set_datetime))
        names = [BaseUnit.PROP_OPERATION_MODE]
        for name in names:
            self._subscribetopics.append(
                SubscribeTopic('{}/{}/{}'.format(
                    self._config.translator.baseunit.topic, name,
                    Translator.TOPIC_SET),
                               self._on_message_baseunit,
                               args=name))
        for switch_number in self._config.translator.switches.keys():
            switch_config = self._config.translator.switches.get(switch_number)
            if switch_config and switch_config.topic:
                self._subscribetopics.append(
                    SubscribeTopic('{}/{}'.format(switch_config.topic,
                                                  Translator.TOPIC_SET),
                                   self._on_message_switch,
                                   args=switch_number))
        if self._config.translator.ha_birth_topic:
            self._subscribetopics.append(
                SubscribeTopic(self._config.translator.ha_birth_topic,
                               self._on_ha_message))

        # Also create a lookup dict for the topics to subscribe to
        self._subscribetopics_lookup = \
            {st.topic: st for st in self._subscribetopics}

        # Create queue to store pending messages from our subscribed topics
        self._pending_messages = Queue()

    #
    # METHODS - Public
    #

    async def async_start(self) -> None:
        """Starts up the LifeSOS interface and connects to MQTT broker."""

        self._shutdown = False

        # Start up the LifeSOS interface
        self._baseunit.start()

        # Connect to the MQTT broker
        self._mqtt_was_connected = False
        if self._config.mqtt.uri.port:
            self._mqtt.connect_async(self._config.mqtt.uri.hostname,
                                     self._config.mqtt.uri.port,
                                     keepalive=Translator.KEEP_ALIVE)
        else:
            self._mqtt.connect_async(self._config.mqtt.uri.hostname,
                                     keepalive=Translator.KEEP_ALIVE)

        # Start processing MQTT messages
        self._mqtt.loop_start()

    async def async_loop(self) -> None:
        """Loop indefinitely to process messages from our subscriptions."""

        # Trap SIGINT and SIGTERM so that we can shutdown gracefully
        signal.signal(signal.SIGINT, self.signal_shutdown)
        signal.signal(signal.SIGTERM, self.signal_shutdown)
        try:
            while not self._shutdown:
                # Wait for next message
                self._get_task = self._loop.create_task(
                    self._pending_messages.async_q.get())
                try:
                    message = await self._get_task
                except asyncio.CancelledError:
                    _LOGGER.debug('Translator loop cancelled.')
                    continue
                except Exception:  # pylint: disable=broad-except
                    # Log any exception but keep going
                    _LOGGER.error(
                        "Exception waiting for message to be delivered",
                        exc_info=True)
                    continue
                finally:
                    self._get_task = None

                # Do subscribed topic callback to handle message
                try:
                    subscribetopic = self._subscribetopics_lookup[
                        message.topic]
                    subscribetopic.on_message(subscribetopic, message)
                except Exception:  # pylint: disable=broad-except
                    _LOGGER.error(
                        "Exception processing message from subscribed topic: %s",
                        message.topic,
                        exc_info=True)
                finally:
                    self._pending_messages.async_q.task_done()

            # Turn off is_connected flag before leaving
            self._publish_baseunit_property(BaseUnit.PROP_IS_CONNECTED, False)
            await asyncio.sleep(0)
        finally:
            signal.signal(signal.SIGINT, signal.SIG_DFL)

    async def async_stop(self) -> None:
        """Shuts down the LifeSOS interface and disconnects from MQTT broker."""

        # Stop the LifeSOS interface
        self._baseunit.stop()

        # Cancel any outstanding auto reset tasks
        for item in self._auto_reset_handles.copy().items():
            item[1].cancel()
            self._auto_reset_handles.pop(item[0])

        # Stop processing MQTT messages
        self._mqtt.loop_stop()

        # Disconnect from the MQTT broker
        self._mqtt.disconnect()

    def signal_shutdown(self, sig, frame):
        """Flag shutdown when signal received."""
        _LOGGER.debug('%s received; shutting down...',
                      signal.Signals(sig).name)  # pylint: disable=no-member
        self._shutdown = True
        if self._get_task:
            self._get_task.cancel()

        # Issue #8 - Cancel not processed until next message added to queue.
        # Just put a dummy object on the queue to ensure it is handled immediately.
        self._pending_messages.sync_q.put_nowait(None)

    #
    # METHODS - Private / Internal
    #

    def _mqtt_on_connect(self, client: MQTTClient, userdata: Any,
                         flags: Dict[str, Any], result_code: int) -> None:
        # On error, log it and don't go any further; client will retry
        if result_code != CONNACK_ACCEPTED:
            _LOGGER.warning(connack_string(result_code))  # pylint: disable=no-member
            return

        # Successfully connected
        self._mqtt_last_connection = datetime.now()
        if not self._mqtt_was_connected:
            _LOGGER.debug("MQTT client connected to broker")
            self._mqtt_was_connected = True
        else:
            try:
                outage = self._mqtt_last_connection - self._mqtt_last_disconnection
                _LOGGER.warning(
                    "MQTT client reconnected to broker. "
                    "Outage duration was %s", str(outage))
            except Exception:  # pylint: disable=broad-except
                _LOGGER.warning("MQTT client reconnected to broker")

        # Republish the 'is_connected' state; this will have automatically
        # been set to False on MQTT client disconnection due to our will
        # (even though this app might still be connected to the LifeSOS unit)
        self._publish(
            '{}/{}'.format(self._config.translator.baseunit.topic,
                           BaseUnit.PROP_IS_CONNECTED),
            self._baseunit.is_connected, True)

        # Subscribe to topics we are capable of actioning
        for subscribetopic in self._subscribetopics:
            self._mqtt.subscribe(subscribetopic.topic, subscribetopic.qos)

    def _mqtt_on_disconnect(self, client: MQTTClient, userdata: Any,
                            result_code: int) -> None:
        # When disconnected from broker and we didn't initiate it...
        if result_code != MQTT_ERR_SUCCESS:
            _LOGGER.warning(
                "MQTT client lost connection to broker (RC: %i). "
                "Will attempt to reconnect periodically", result_code)
            self._mqtt_last_disconnection = datetime.now()

    def _mqtt_on_message(self, client: MQTTClient, userdata: Any,
                         message: MQTTMessage):
        # Add message to our queue, to be processed on main thread
        self._pending_messages.sync_q.put_nowait(message)

    def _baseunit_device_added(self, baseunit: BaseUnit,
                               device: Device) -> None:
        # Hook up callbacks for device that was added / discovered
        device.on_event = self._device_on_event
        device.on_properties_changed = self._device_on_properties_changed

        # Get configuration settings for device; don't go any further when
        # device is not included in the config
        device_config = self._config.translator.devices.get(device.device_id)
        if not device_config:
            _LOGGER.warning(
                "Ignoring device as it was not listed in the config file: %s",
                device)
            return

        # Publish initial property values for device
        if device_config.topic:
            props = device.as_dict()
            for name in props.keys():
                self._publish_device_property(device_config.topic, device,
                                              name, getattr(device, name))

        # When HA discovery is enabled, publish device configuration to it
        if self._config.translator.ha_discovery_prefix:
            if device_config.ha_name:
                self._publish_ha_device_config(device, device_config)
            if device_config.ha_name_rssi:
                self._publish_ha_device_rssi_config(device, device_config)
            if device_config.ha_name_battery:
                self._publish_ha_device_battery_config(device, device_config)

    def _baseunit_device_deleted(
            self, baseunit: BaseUnit, device: Device) -> None:  # pylint: disable=no-self-use
        # Remove callbacks from deleted device
        device.on_event = None
        device.on_properties_changed = None

    def _baseunit_event(self, baseunit: BaseUnit, contact_id: ContactID):
        # When base unit event occurs, publish the event data
        # (don't bother retaining; events are time sensitive)
        event_data = json.dumps(contact_id.as_dict())
        self._publish(
            '{}/event'.format(self._config.translator.baseunit.topic),
            event_data, False)

        # For clients that can't handle json, we will also provide the event
        # qualifier and code via these topics
        if contact_id.event_code:
            if contact_id.event_qualifier == EventQualifier.Event:
                self._publish(
                    '{}/event_code'.format(
                        self._config.translator.baseunit.topic),
                    contact_id.event_code, False)
            elif contact_id.event_qualifier == EventQualifier.Restore:
                self._publish(
                    '{}/restore_code'.format(
                        self._config.translator.baseunit.topic),
                    contact_id.event_code, False)

        # This is just for Home Assistant; the 'alarm_control_panel.mqtt'
        # component currently requires these hard-coded state values
        if contact_id.event_qualifier == EventQualifier.Event and \
                contact_id.event_category == EventCategory.Alarm:
            self._ha_state = Translator.HA_STATE_TRIGGERED
            self._publish(
                '{}/{}'.format(self._config.translator.baseunit.topic,
                               Translator.TOPIC_HASTATE), self._ha_state, True)

    def _baseunit_properties_changed(
            self, baseunit: BaseUnit,
            changes: List[PropertyChangedInfo]) -> None:
        # When base unit properties change, publish them
        has_connected = False
        for change in changes:
            self._publish_baseunit_property(change.name, change.new_value)

            # Also check if connection has just been established
            if change.name == BaseUnit.PROP_IS_CONNECTED and change.new_value:
                has_connected = True

        # On connection, publish config for Home Assistant if needed
        if has_connected:
            self._publish_ha_config()

    def _baseunit_switch_state_changed(self, baseunit: BaseUnit,
                                       switch_number: SwitchNumber,
                                       state: Optional[bool]) -> None:
        # When switch state changes, publish it
        switch_config = self._config.translator.switches.get(switch_number)
        if switch_config and switch_config.topic:
            self._publish(switch_config.topic, OnOff.parse_value(state), True)

    def _device_on_event(self, device: Device,
                         event_code: DeviceEventCode) -> None:
        device_config = self._config.translator.devices.get(device.device_id)
        if device_config and device_config.topic:
            # When device event occurs, publish the event code
            # (don't bother retaining; events are time sensitive)
            self._publish('{}/event_code'.format(device_config.topic),
                          event_code, False)

            # When it is a Trigger event, set state to On and schedule an
            # auto reset callback to occur after specified interval
            if event_code == DeviceEventCode.Trigger:
                self._publish(device_config.topic, OnOff.parse_value(True),
                              True)
                handle = self._auto_reset_handles.get(device.device_id)
                if handle:
                    handle.cancel()
                handle = self._loop.call_later(
                    device_config.auto_reset_interval
                    or Translator.AUTO_RESET_INTERVAL, self._auto_reset,
                    device.device_id)
                self._auto_reset_handles[device.device_id] = handle

    def _auto_reset(self, device_id: int):
        # Auto reset a Trigger device to Off state
        device_config = self._config.translator.devices.get(device_id)
        if device_config and device_config.topic:
            self._publish(device_config.topic, OnOff.parse_value(False), True)
        self._auto_reset_handles.pop(device_id)

    def _device_on_properties_changed(self, device: Device,
                                      changes: List[PropertyChangedInfo]):
        # When device properties change, publish them
        device_config = self._config.translator.devices.get(device.device_id)
        if device_config and device_config.topic:
            for change in changes:
                self._publish_device_property(device_config.topic, device,
                                              change.name, change.new_value)

    def _publish_baseunit_property(self, name: str, value: Any) -> None:
        topic_parent = self._config.translator.baseunit.topic

        # Base Unit topic holds the state
        if name == BaseUnit.PROP_STATE:
            self._state = value
            self._publish(topic_parent, value, True)

            # This is just for Home Assistant; the 'alarm_control_panel.mqtt'
            # component currently requires these hard-coded state values
            topic = '{}/{}'.format(topic_parent, Translator.TOPIC_HASTATE)
            if value in {BaseUnitState.Disarm, BaseUnitState.Monitor}:
                self._ha_state = Translator.HA_STATE_DISARMED
                self._publish(topic, self._ha_state, True)
            elif value == BaseUnitState.Home:
                self._ha_state = Translator.HA_STATE_ARMED_HOME
                self._publish(topic, self._ha_state, True)
            elif value == BaseUnitState.Away:
                self._ha_state = Translator.HA_STATE_ARMED_AWAY
                self._publish(topic, self._ha_state, True)
            elif value in {
                    BaseUnitState.AwayExitDelay, BaseUnitState.AwayEntryDelay
            }:
                self._ha_state = Translator.HA_STATE_PENDING
                self._publish(topic, self._ha_state, True)

        # Other supported properties in a topic using property name
        elif name in {
                BaseUnit.PROP_IS_CONNECTED, BaseUnit.PROP_ROM_VERSION,
                BaseUnit.PROP_EXIT_DELAY, BaseUnit.PROP_ENTRY_DELAY,
                BaseUnit.PROP_OPERATION_MODE
        }:
            self._publish('{}/{}'.format(topic_parent, name), value, True)

    def _publish_device_property(self, topic_parent: str, device: Device,
                                 name: str, value: Any) -> None:
        # Device topic holds the state
        if (not isinstance(device, SpecialDevice)) and \
                name == Device.PROP_IS_CLOSED:
            # For regular device; this is the Is Closed property for magnet
            # sensors, otherwise default to Off for trigger-based devices
            if device.type == DeviceType.DoorMagnet:
                self._publish(topic_parent, OpenClosed.parse_value(value),
                              True)
            else:
                self._publish(topic_parent, OnOff.Off, True)
        elif isinstance(device, SpecialDevice) and \
                name == SpecialDevice.PROP_CURRENT_READING:
            # For special device, this is the current reading
            self._publish(topic_parent, value, True)

        # Category will have sub-topics for it's properties
        elif name == Device.PROP_CATEGORY:
            for prop in value.as_dict().items():
                if prop[0] in {'code', 'description'}:
                    self._publish(
                        '{}/{}/{}'.format(topic_parent, name, prop[0]),
                        prop[1], True)

        # Flag enums; expose as sub-topics with a bool state per flag
        elif name == Device.PROP_CHARACTERISTICS:
            for item in iter(DCFlags):
                self._publish('{}/{}/{}'.format(topic_parent, name, item.name),
                              bool(value & item.value), True)
        elif name == Device.PROP_ENABLE_STATUS:
            for item in iter(ESFlags):
                self._publish('{}/{}/{}'.format(topic_parent, name, item.name),
                              bool(value & item.value), True)
        elif name == Device.PROP_SWITCHES:
            for item in iter(SwitchFlags):
                self._publish('{}/{}/{}'.format(topic_parent, name, item.name),
                              bool(value & item.value), True)
        elif name == SpecialDevice.PROP_SPECIAL_STATUS:
            for item in iter(SSFlags):
                self._publish('{}/{}/{}'.format(topic_parent, name, item.name),
                              bool(value & item.value), True)

        # Device ID; value should be formatted as hex
        elif name == Device.PROP_DEVICE_ID:
            self._publish('{}/{}'.format(topic_parent, name),
                          '{:06x}'.format(value), True)

        # Other supported properties in a topic using property name
        elif name in {
                Device.PROP_DEVICE_ID, Device.PROP_ZONE, Device.PROP_TYPE,
                Device.PROP_RSSI_DB, Device.PROP_RSSI_BARS,
                SpecialDevice.PROP_HIGH_LIMIT, SpecialDevice.PROP_LOW_LIMIT,
                SpecialDevice.PROP_CONTROL_LIMIT_FIELDS_EXIST,
                SpecialDevice.PROP_CONTROL_HIGH_LIMIT,
                SpecialDevice.PROP_CONTROL_LOW_LIMIT
        }:
            self._publish('{}/{}'.format(topic_parent, name), value, True)

    def _publish_ha_config(self):
        # Skip if Home Assistant discovery disabled
        if not self._config.translator.ha_discovery_prefix:
            return

        # Publish config for the base unit when enabled
        if self._config.translator.baseunit.ha_name:
            self._publish_ha_baseunit_config(self._config.translator.baseunit)

        # Publish config for each device when enabled
        for device_id in self._config.translator.devices.keys():
            if self._shutdown:
                return
            device_config = self._config.translator.devices[device_id]
            device = self._baseunit.devices.get(device_id)
            if device:
                if device_config.ha_name:
                    self._publish_ha_device_config(device, device_config)
                if device_config.ha_name_rssi:
                    self._publish_ha_device_rssi_config(device, device_config)
                if device_config.ha_name_battery:
                    self._publish_ha_device_battery_config(
                        device, device_config)

        # Publish config for each switch when enabled
        for switch_number in self._config.translator.switches.keys():
            if self._shutdown:
                return
            switch_config = self._config.translator.switches[switch_number]
            if switch_config.ha_name:
                self._publish_ha_switch_config(switch_number, switch_config)

    def _publish_ha_baseunit_config(self,
                                    baseunit_config: TranslatorBaseUnitConfig):
        # Generate message that can be used to automatically configure the
        # alarm control panel in Home Assistant using MQTT Discovery
        message = {
            Translator.HA_NAME:
            baseunit_config.ha_name,
            Translator.HA_UNIQUE_ID:
            '{}'.format(PROJECT_NAME),
            Translator.HA_STATE_TOPIC:
            '{}/{}'.format(baseunit_config.topic, Translator.TOPIC_HASTATE),
            Translator.HA_COMMAND_TOPIC:
            '{}/{}/{}'.format(baseunit_config.topic,
                              BaseUnit.PROP_OPERATION_MODE,
                              Translator.TOPIC_SET),
            Translator.HA_PAYLOAD_DISARM:
            str(OperationMode.Disarm),
            Translator.HA_PAYLOAD_ARM_HOME:
            str(OperationMode.Home),
            Translator.HA_PAYLOAD_ARM_AWAY:
            str(OperationMode.Away),
            Translator.HA_AVAILABILITY_TOPIC:
            '{}/{}'.format(baseunit_config.topic, BaseUnit.PROP_IS_CONNECTED),
            Translator.HA_PAYLOAD_AVAILABLE:
            str(True),
            Translator.HA_PAYLOAD_NOT_AVAILABLE:
            str(False),
        }
        self._publish(
            '{}/{}/{}/config'.format(
                self._config.translator.ha_discovery_prefix,
                Translator.HA_PLATFORM_ALARM_CONTROL_PANEL,
                message[Translator.HA_UNIQUE_ID]), json.dumps(message), False)

    def _publish_ha_device_config(self, device: Device,
                                  device_config: TranslatorDeviceConfig):
        # Generate message that can be used to automatically configure the
        # device in Home Assistant using MQTT Discovery
        message = {
            Translator.HA_NAME:
            device_config.ha_name,
            Translator.HA_UNIQUE_ID:
            '{}_{:06x}'.format(PROJECT_NAME, device.device_id),
            Translator.HA_STATE_TOPIC:
            device_config.topic,
            Translator.HA_AVAILABILITY_TOPIC:
            '{}/{}'.format(self._config.translator.baseunit.topic,
                           BaseUnit.PROP_IS_CONNECTED),
            Translator.HA_PAYLOAD_AVAILABLE:
            str(True),
            Translator.HA_PAYLOAD_NOT_AVAILABLE:
            str(False),
        }
        if device.type in {
                DeviceType.FloodDetector, DeviceType.FloodDetector2
        }:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_MOISTURE
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {DeviceType.MedicalButton}:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_SAFETY
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {
                DeviceType.AnalogSensor, DeviceType.AnalogSensor2
        }:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {DeviceType.SmokeDetector}:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_SMOKE
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {
                DeviceType.PressureSensor, DeviceType.PressureSensor2
        }:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_MOTION
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {
                DeviceType.CODetector, DeviceType.CO2Sensor,
                DeviceType.CO2Sensor2, DeviceType.GasDetector
        }:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_GAS
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {DeviceType.DoorMagnet}:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_DOOR
            message[Translator.HA_PAYLOAD_ON] = str(OpenClosed.Open)
            message[Translator.HA_PAYLOAD_OFF] = str(OpenClosed.Closed)
        elif device.type in {DeviceType.VibrationSensor}:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_VIBRATION
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {DeviceType.PIRSensor}:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_MOTION
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {DeviceType.GlassBreakDetector}:
            ha_platform = Translator.HA_PLATFORM_BINARY_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_WINDOW
            message[Translator.HA_PAYLOAD_ON] = str(OnOff.On)
            message[Translator.HA_PAYLOAD_OFF] = str(OnOff.Off)
        elif device.type in {DeviceType.HumidSensor, DeviceType.HumidSensor2}:
            ha_platform = Translator.HA_PLATFORM_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_HUMIDITY
            message[
                Translator.HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_HUMIDITY
        elif device.type in {DeviceType.TempSensor, DeviceType.TempSensor2}:
            ha_platform = Translator.HA_PLATFORM_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_TEMPERATURE
            message[Translator.
                    HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_TEMPERATURE
        elif device.type in {DeviceType.LightSensor, DeviceType.LightDetector}:
            ha_platform = Translator.HA_PLATFORM_SENSOR
            message[Translator.HA_DEVICE_CLASS] = Translator.HA_DC_ILLUMINANCE
            message[Translator.
                    HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_ILLUMINANCE
        elif device.type in {
                DeviceType.ACCurrentMeter, DeviceType.ACCurrentMeter2,
                DeviceType.ThreePhaseACMeter
        }:
            ha_platform = Translator.HA_PLATFORM_SENSOR
            message[
                Translator.HA_UNIT_OF_MEASUREMENT] = Translator.HA_UOM_CURRENT
        else:
            _LOGGER.warning(
                "Device type '%s' cannot be represented in Home "
                "Assistant and will be skipped.", str(device.type))
            return
        self._publish(
            '{}/{}/{}/config'.format(
                self._config.translator.ha_discovery_prefix, ha_platform,
                message[Translator.HA_UNIQUE_ID]), json.dumps(message), False)

    def _publish_ha_device_rssi_config(self, device: Device,
                                       device_config: TranslatorDeviceConfig):
        # Generate message that can be used to automatically configure a sensor
        # for the device's RSSI in Home Assistant using MQTT Discovery
        message = {
            Translator.HA_NAME:
            device_config.ha_name_rssi,
            Translator.HA_UNIQUE_ID:
            '{}_{:06x}_RSSI'.format(PROJECT_NAME, device.device_id),
            Translator.HA_ICON:
            Translator.HA_ICON_RSSI,
            Translator.HA_STATE_TOPIC:
            '{}/{}'.format(device_config.topic, Device.PROP_RSSI_DB),
            Translator.HA_UNIT_OF_MEASUREMENT:
            Translator.HA_UOM_RSSI,
            Translator.HA_AVAILABILITY_TOPIC:
            '{}/{}'.format(self._config.translator.baseunit.topic,
                           BaseUnit.PROP_IS_CONNECTED),
            Translator.HA_PAYLOAD_AVAILABLE:
            str(True),
            Translator.HA_PAYLOAD_NOT_AVAILABLE:
            str(False),
        }
        self._publish(
            '{}/{}/{}/config'.format(
                self._config.translator.ha_discovery_prefix,
                Translator.HA_PLATFORM_SENSOR,
                message[Translator.HA_UNIQUE_ID]), json.dumps(message), False)

    def _publish_ha_device_battery_config(
            self, device: Device, device_config: TranslatorDeviceConfig):
        # Generate message that can be used to automatically configure a binary
        # sensor for the device's battery state in Home Assistant using
        # MQTT Discovery
        message = {
            Translator.HA_NAME:
            device_config.ha_name_battery,
            Translator.HA_UNIQUE_ID:
            '{}_{:06x}_battery'.format(PROJECT_NAME, device.device_id),
            Translator.HA_DEVICE_CLASS:
            Translator.HA_DC_BATTERY,
            Translator.HA_PAYLOAD_ON:
            str(DeviceEventCode.BatteryLow),
            Translator.HA_PAYLOAD_OFF:
            str(DeviceEventCode.PowerOnReset),
            Translator.HA_STATE_TOPIC:
            '{}/event_code'.format(device_config.topic),
            Translator.HA_AVAILABILITY_TOPIC:
            '{}/{}'.format(self._config.translator.baseunit.topic,
                           BaseUnit.PROP_IS_CONNECTED),
            Translator.HA_PAYLOAD_AVAILABLE:
            str(True),
            Translator.HA_PAYLOAD_NOT_AVAILABLE:
            str(False),
        }

        self._publish(
            '{}/{}/{}/config'.format(
                self._config.translator.ha_discovery_prefix,
                Translator.HA_PLATFORM_BINARY_SENSOR,
                message[Translator.HA_UNIQUE_ID]), json.dumps(message), False)

    def _publish_ha_switch_config(self, switch_number: SwitchNumber,
                                  switch_config: TranslatorSwitchConfig):
        # Generate message that can be used to automatically configure the
        # switch in Home Assistant using MQTT Discovery
        message = {
            Translator.HA_NAME:
            switch_config.ha_name,
            Translator.HA_UNIQUE_ID:
            '{}_{}'.format(PROJECT_NAME,
                           str(switch_number).lower()),
            Translator.HA_STATE_TOPIC:
            switch_config.topic,
            Translator.HA_COMMAND_TOPIC:
            '{}/{}'.format(switch_config.topic, Translator.TOPIC_SET),
            Translator.HA_PAYLOAD_ON:
            str(OnOff.On),
            Translator.HA_PAYLOAD_OFF:
            str(OnOff.Off),
            Translator.HA_AVAILABILITY_TOPIC:
            '{}/{}'.format(self._config.translator.baseunit.topic,
                           BaseUnit.PROP_IS_CONNECTED),
            Translator.HA_PAYLOAD_AVAILABLE:
            str(True),
            Translator.HA_PAYLOAD_NOT_AVAILABLE:
            str(False),
        }
        self._publish(
            '{}/{}/{}/config'.format(
                self._config.translator.ha_discovery_prefix,
                Translator.HA_PLATFORM_SWITCH,
                message[Translator.HA_UNIQUE_ID]), json.dumps(message), False)

    def _publish(self, topic: str, payload: Any, retain: bool) -> None:
        self._mqtt.publish(topic, payload, QOS_1, retain)

    def _on_message_baseunit(self, subscribetopic: SubscribeTopic,
                             message: MQTTMessage) -> None:
        if subscribetopic.args == BaseUnit.PROP_OPERATION_MODE:
            # Set operation mode
            name = None if not message.payload else message.payload.decode()
            operation_mode = OperationMode.parse_name(name)
            if operation_mode is None:
                _LOGGER.warning("Cannot set operation_mode to '%s'", name)
                return
            if operation_mode == OperationMode.Disarm and \
                    self._state == BaseUnitState.Disarm and \
                    self._ha_state == Translator.HA_STATE_TRIGGERED:
                # Special case to ensure HA can return from triggered state
                # when triggered by an alarm in Disarm mode (eg. panic,
                # tamper)... the set disarm operation will not generate a
                # response from the base unit as there is no change, so we
                # need to reset 'ha_state' here.
                _LOGGER.debug("Resetting triggered ha_state in disarmed mode")
                self._ha_state = Translator.HA_STATE_DISARMED
                self._publish(
                    '{}/{}'.format(self._config.translator.baseunit.topic,
                                   Translator.TOPIC_HASTATE), self._ha_state,
                    True)
            self._loop.create_task(
                self._baseunit.async_set_operation_mode(operation_mode))
        else:
            raise NotImplementedError

    def _on_message_clear_status(self, subscribetopic: SubscribeTopic,
                                 message: MQTTMessage) -> None:
        # Clear the alarm/warning LEDs on base unit and stop siren
        self._loop.create_task(self._baseunit.async_clear_status())

    def _on_message_set_datetime(self, subscribetopic: SubscribeTopic,
                                 message: MQTTMessage) -> None:
        # Set remote date/time to specified date/time (or current if None)
        value = None if not message.payload else message.payload.decode()
        if value:
            value = dateutil.parser.parse(value)
        self._loop.create_task(self._baseunit.async_set_datetime(value))

    def _on_message_switch(self, subscribetopic: SubscribeTopic,
                           message: MQTTMessage) -> None:
        # Turn a switch on / off
        switch_number = subscribetopic.args
        name = None if not message.payload else message.payload.decode()
        state = OnOff.parse_name(name)
        if state is None:
            _LOGGER.warning("Cannot set switch %s to '%s'", switch_number,
                            name)
            return
        self._loop.create_task(
            self._baseunit.async_set_switch_state(switch_number,
                                                  bool(state.value)))

    def _on_ha_message(self, subscribetopic: SubscribeTopic,
                       message: MQTTMessage) -> None:
        # When Home Assistant comes online, publish our configuration to it
        payload = None if not message.payload else message.payload.decode()
        if not payload:
            return
        if payload == self._config.translator.ha_birth_payload:
            self._publish_ha_config()
コード例 #13
0
def monitor(client=None):
    if client is None:
        client = Client()
        client.on_connect = on_connect
        client.connect_async('localhost')
        client.loop_start()
        client_created = True
    else:
        client_created = False

    signals = blinker.Namespace()

    @asyncio.coroutine
    def _on_dropbot_connected(sender, **message):
        monitor_task.connected.clear()
        dropbot_ = message['dropbot']
        monitor_task.dropbot = dropbot_
        client.on_message = ft.partial(on_message, 'dropbot', proxy=dropbot_)

        device_id = str(dropbot_.uuid)
        connect_topic = '/dropbot/%(uuid)s/signal' % {'uuid': device_id}
        send_topic = '/dropbot/%(uuid)s/send-signal' % {'uuid': device_id}

        # Bind blinker signals namespace to corresponding MQTT topics.
        bind(signals=signals,
             paho_client=client,
             connect_topic=connect_topic,
             send_topic=send_topic)

        dropbot_.update_state(event_mask=EVENT_CHANNELS_UPDATED
                              | EVENT_SHORTS_DETECTED | EVENT_ENABLE)

        client.publish('/dropbot/%(uuid)s/properties' % {'uuid': device_id},
                       payload=dropbot_.properties.to_json(),
                       qos=1,
                       retain=True)

        prefix = '/dropbot/' + device_id
        monitor_task.device_id = device_id
        monitor_task.property = ft.partial(wait_for_result, client, 'property',
                                           prefix)
        monitor_task.call = ft.partial(wait_for_result, client, 'call', prefix)
        monitor_task.connected.set()

    @asyncio.coroutine
    def _on_dropbot_disconnected(sender, **message):
        monitor_task.connected.clear()
        monitor_task.dropbot = None
        unbind(signals)
        client.publish('/dropbot/%(uuid)s/properties' %
                       {'uuid': monitor_task.device_id},
                       payload=None,
                       qos=1,
                       retain=True)

    signals.signal('connected').connect(_on_dropbot_connected, weak=False)
    signals.signal('disconnected').connect(_on_dropbot_disconnected,
                                           weak=False)

    def stop():
        if getattr(monitor_task, 'dropbot', None) is not None:
            monitor_task.dropbot.set_state_of_channels(pd.Series(),
                                                       append=False)
            monitor_task.dropbot.update_state(capacitance_update_interval_ms=0,
                                              hv_output_enabled=False)
        try:
            unbind(monitor_task.signals)
        except RuntimeError as e:
            _L().warning('%s', e)
        monitor_task.cancel()
        if client_created:
            client.loop_stop()
            client.disconnect()

    monitor_task = cancellable(catch_cancel(db.monitor.monitor))
    monitor_task.connected = threading.Event()
    thread = threading.Thread(target=monitor_task, args=(signals, ))
    thread.daemon = True
    thread.start()
    monitor_task.signals = signals
    monitor_task.stop = stop
    monitor_task.close = stop
    return monitor_task
コード例 #14
0
class PahoIoTClient:
    """
    Responsible for connecting to AWS IoT. Handles all connection lifecycle events and attempts to reconnect whenever possible if disconnected. Data is sent to IoT via the method .send_telemetry(TelemetryMessage).
    """

    MQTT_QOS_RETRY_INTERVAL_S = 60
    KEEP_ALIVE_TIMEOUT_S = 2 * 60

    config: IotClientConfig
    mqtt_client: Client()
    event_listener: IoTEventListener
    topic_subscriptions: list

    important_paho_messages = [
        "Sending PUBLISH (d1", "Connection failed, retrying"
    ]

    def __init__(self, config: IotClientConfig,
                 event_listener: IoTEventListener):
        self.set_config(config=config)
        self.event_listener = event_listener
        self.mqtt_client = Client(config.client_id)
        self.mqtt_client.message_retry_set(self.MQTT_QOS_RETRY_INTERVAL_S)
        # self.mqtt_client.tls_set(ca_certs = config.ca_path, certfile = config.certificate_path, keyfile = config.private_key_path)
        self.mqtt_client.username_pw_set(username=config.username,
                                         password=config.password)
        self.mqtt_client.on_connect = self.on_connected
        self.mqtt_client.on_disconnect = self.on_disconnected
        self.mqtt_client.on_publish = self.on_message_published
        self.mqtt_client.on_message = self.on_message_received

    def set_config(self, config: IotClientConfig):
        self.config = config

    def is_connected(self) -> bool:
        return self.mqtt_client.is_connected()

    def connect(self):
        self.mqtt_client.connect_async(host=self.config.endpoint,
                                       port=self.config.port,
                                       keepalive=self.KEEP_ALIVE_TIMEOUT_S)
        self.mqtt_client.loop_start()

    def disconnect(self):
        self.mqtt_client.disconnect()
        self.mqtt_client.loop_stop()

    # async def reconnect(self, delay_in_s: int = 5):
    #     await asyncio.sleep(delay_in_s)
    #     self.disconnect()
    #     await asyncio.sleep(delay_in_s)
    #     self.connect()

    def publish(self,
                topic: str,
                data: str,
                qos: int = 1,
                reconnect_after_fail: bool = False) -> (int, int):
        message_info = self.mqtt_client.publish(topic=topic,
                                                payload=data,
                                                qos=qos)
        if message_info.rc is not MQTT_ERR_SUCCESS:
            pass
        else:
            pass
        return (message_info.rc, message_info.mid)

    def is_response_code_success(self, response_code: int) -> bool:
        return response_code is MQTT_ERR_SUCCESS

    def initialise_subscriptions_list(self):
        self.topic_subscriptions = []

    def subscribe(self, subscriptions: list):
        for subscription in subscriptions:
            if not self.is_topic_subscribed(subscription):
                self.topic_subscriptions.append(subscription)

        self.mqtt_client.subscribe(topic=self.topic_subscriptions)

    def is_topic_subscribed(self, subscription):
        for existing_subscription in self.topic_subscriptions:
            if subscription[0] == existing_subscription[0]:
                return True
        return False

    def on_connected(self, client, userdata, flags, rc):
        self.initialise_subscriptions_list()

        if self.event_listener is not None:
            self.event_listener.on_iot_connected(iot_client=self)

    def on_disconnected(self, client, userdata, rc):
        # Clear the list of subscriptions
        if self.event_listener is not None:
            self.event_listener.on_iot_disconnected(iot_client=self)

    def on_message_published(self, client, userdata, mid):
        if self.event_listener is not None:
            self.event_listener.on_iot_message_published(
                self,
                message_id=mid,
                payload_size=self.payload_sizes.pop("{0}".format(mid), None))

    def on_message_received(self, client, userdata, message):
        if self.event_listener is not None:
            self.event_listener.on_iot_message_received(
                iot_client=self, topic=message.topic, payload=message.payload)
コード例 #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
コード例 #16
0
ファイル: gateway3.py プロジェクト: lilei-dev/XiaomiGateway3
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
コード例 #17
0
ファイル: gateway3.py プロジェクト: tjay4x4/training
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)
コード例 #18
0
class Bridge(object):
    WEBSOCKETS = "websocket"
    SSE = "sse"

    def __init__(self, args, ioloop, dynamic_subscriptions):
        """ parse config values and setup Routes.
        """
        self.mqtt_topics = []
        try:
            self.mqtt_host = args["mqtt-to-server"]["broker"]["host"]
            self.mqtt_port = args["mqtt-to-server"]["broker"]["port"]
            self.bridge_port = args["server-to-client"]["port"]
            self.stream_protocol = args["server-to-client"]["protocol"]
            logger.info("Using protocol %s" % self.stream_protocol.lower())
            if self.stream_protocol.lower(
            ) != "websocket" and self.stream_protocol.lower() != "sse":
                raise ConfigException("Invalid protocol")
            self.dynamic_subscriptions = dynamic_subscriptions
            if not self.dynamic_subscriptions:
                self.mqtt_topics = args["mqtt-to-server"]["topics"]
        except KeyError as e:
            raise ConfigException("Error when accessing field", e) from e
        logger.info("connecting to mqtt")
        self.topic_dict = {}
        self.ioloop = ioloop
        self.mqtt_client = Client()
        self.mqtt_client.on_message = self.on_mqtt_message
        self.mqtt_client.on_connect = self.on_mqtt_connect
        self.mqtt_client.connect_async(host=self.mqtt_host,
                                       port=self.mqtt_port)
        self.mqtt_client.loop_start()
        self._app = web.Application([
            (r'.*', WebsocketHandler if self.stream_protocol == "websocket"
             else ServeSideEventsHandler, dict(parent=self)),
        ],
                                    debug=True)

    def get_app(self):
        return self._app

    def get_port(self):
        return self.bridge_port

    async def parse_req_path(self, req_path):
        candidate_path = req_path
        if len(candidate_path) is 1 and candidate_path[0] is "/":
            return "#"
        if candidate_path[len(req_path) - 1] is "/":
            candidate_path = candidate_path + "#"
        if candidate_path[0] == "/":
            candidate_path = candidate_path[1:]
        return candidate_path

    def on_mqtt_message(self, client, userdata, message):
        logger.debug("received message on topic %s" % message.topic)

    async def socket_write_message(self, socket, message):
        try:
            await socket.write_message(json.dumps(message))
        except Exception as e:
            logger.error(e)
            try:
                await socket.write_message(message)
            except Exception as e:
                logger.error(e)

    def append_dynamic(self, topic):
        logger.info("adding dynamic subscription for %s " % topic)
        self.message_callback_add_with_sub_topic(topic, dynamic=True)

    def remove_dynamic(self, topic):
        logger.info("removing dynamic subscription for %s " % topic)
        self.topic_dict.pop(topic, None)
        self.mqtt_client.unsubscribe(topic)

    def message_callback_add_with_sub_topic(self, sub_topic, dynamic):
        logger.info("adding callback for mqtt topic: %s" % sub_topic)

        def message_callback(client, userdata, message):
            logger.debug(
                "Recieved Mqtt Message on %s as result of subscription on %s" %
                (message.topic, sub_topic))
            if sub_topic is not message.topic:
                if message.topic not in self.topic_dict[sub_topic]["matches"]:
                    self.topic_dict[sub_topic]["matches"].append(message.topic)
            for topic in self.topic_dict:
                if topic == message.topic:
                    for socket in self.topic_dict[topic]["sockets"]:
                        logger.debug("sending to socket:")
                        logger.debug(socket)
                        self.ioloop.add_callback(
                            self.socket_write_message,
                            socket=socket,
                            message={
                                "topic": message.topic,
                                "payload": message.payload.decode('utf-8')
                            })
                elif message.topic in self.topic_dict[topic]["matches"]:
                    for socket in self.topic_dict[topic]["sockets"]:
                        logger.debug("sending to socket:")
                        logger.debug(socket)
                        self.ioloop.add_callback(
                            self.socket_write_message,
                            socket=socket,
                            message={
                                "topic": message.topic,
                                "payload": message.payload.decode('utf-8')
                            })

        if sub_topic not in self.topic_dict:
            self.mqtt_client.message_callback_add(sub_topic, message_callback)
            self.topic_dict[sub_topic] = {
                "matches": [],
                "sockets": [],
                "dynamic": dynamic
            }
            self.mqtt_client.subscribe(sub_topic)

    def on_mqtt_connect(self, client, userdata, flags, rc):
        logger.info("mqtt connected to broker %s:%s" %
                    (self.mqtt_host, str(self.mqtt_port)))
        for topic in self.mqtt_topics:
            self.message_callback_add_with_sub_topic(topic, dynamic=False)

    async def mqtt_disconnect(self):
        t = Thread(target=self.mqtt_client.disconnect, daemon=True)
        t.start()
        self.mqtt_client.loop_stop()