From 03d01d3543e14f24daa1d1f518c9b2e03e413340 Mon Sep 17 00:00:00 2001 From: Alexey Khit Date: Sun, 30 Aug 2020 17:20:56 +0300 Subject: [PATCH] Initial commit --- README.md | 45 +++ custom_components/xiaomi_gateway3/__init__.py | 93 +++++ .../xiaomi_gateway3/binary_sensor.py | 32 ++ .../xiaomi_gateway3/config_flow.py | 31 ++ custom_components/xiaomi_gateway3/gateway3.py | 350 ++++++++++++++++++ custom_components/xiaomi_gateway3/light.py | 56 +++ .../xiaomi_gateway3/manifest.json | 15 + custom_components/xiaomi_gateway3/remote.py | 42 +++ custom_components/xiaomi_gateway3/sensor.py | 94 +++++ custom_components/xiaomi_gateway3/switch.py | 34 ++ .../xiaomi_gateway3/translations/en.json | 16 + custom_components/xiaomi_gateway3/utils.py | 252 +++++++++++++ hacs.json | 4 + 13 files changed, 1064 insertions(+) create mode 100644 README.md create mode 100644 custom_components/xiaomi_gateway3/__init__.py create mode 100644 custom_components/xiaomi_gateway3/binary_sensor.py create mode 100644 custom_components/xiaomi_gateway3/config_flow.py create mode 100644 custom_components/xiaomi_gateway3/gateway3.py create mode 100644 custom_components/xiaomi_gateway3/light.py create mode 100644 custom_components/xiaomi_gateway3/manifest.json create mode 100644 custom_components/xiaomi_gateway3/remote.py create mode 100644 custom_components/xiaomi_gateway3/sensor.py create mode 100644 custom_components/xiaomi_gateway3/switch.py create mode 100644 custom_components/xiaomi_gateway3/translations/en.json create mode 100644 custom_components/xiaomi_gateway3/utils.py create mode 100644 hacs.json diff --git a/README.md b/README.md new file mode 100644 index 00000000..b976c21b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Xiaomi Gateway 3 integration for Home Assistant + +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) +[![Donate](https://img.shields.io/badge/donate-Coffee-yellow.svg)](https://www.buymeacoffee.com/AlexxIT) +[![Donate](https://img.shields.io/badge/donate-Yandex-red.svg)](https://money.yandex.ru/to/41001428278477) + +Control Zigbee devices from Home Assistant with **Xiaomi Gateway 3 (ZNDMWG03LM)** on original firmware. + +Gateway support **Zigbee 3**, **Bluetooth Mesh** and **HomeKit**. + +This method does not change the device firmware. Gateway continues to work with Mi Home and HomeKit. + +Thanks to **Serrj** for [instruction how to enable Telnet](https://community.home-assistant.io/t/xiaomi-mijia-smart-multi-mode-gateway-zndmwg03lm-support/159586/61) on this device. + +# Supported Devices + +Currently supported and tested several Xiaomi and Aqara Zibee devices officially supported by the Gateway: + +> Aqara Cube, Aqara Double Wall Button, Aqara Motion Sensor, Aqara Opple Six Button, Aqara Relay, Aqara Vibration Sensor, Aqara Water Leak Sensor, IKEA Bulb E14, Xiaomi Button, Xiaomi Plug, Xiaomi TH Sensor + +Plans to support officially supported Bluetooth devices. + +Plans to support for Zigbee devices from other manufacturers. May be support for [ZHA](https://www.home-assistant.io/integrations/zha/). + +# Install + +You can install component with HACS custom repo ([example](https://github.com/AlexxIT/SonoffLAN#install-with-hacs)): `AlexxIT/XiaomiGateway3`. + +Or manually copy `xiaomi_gateway3` folder from latest release to `custom_components` folder in your config folder. + +# Config + +With GUI. Configuration > Integration > Xiaomi Gateway 3. And enter Gateway **IP address** and **Mi Home token**. + +You need [obtain Mi Home token](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). I am using the [method with Mi Home v5.4.54](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md#non-rooted-android-phones) for non-rooted Android. If you don't have an Android - you can install the [emulator on Windows](https://www.bignox.com/). + +# How it works + +The component enables **Telnet** on Gateway via [Miio protocol](https://github.com/rytilahti/python-miio). Only this Gateway supports this command. Do not try to execute it on other Xiaomi/Aqara Gateways. + +The component starts the **MQTT Server** on the public port of the Gateway. All the logic in the Gateway runs on top of the built-in MQTT Server. By default, access to it is closed from the outside. + +**ATTENTION:** Telnet and MQTT work without a password! Do not use this method on public networks. + +After rebooting the device, all changes will be reset. The component will launch Telnet and public MQTT every time it detects that they are disabled. \ No newline at end of file diff --git a/custom_components/xiaomi_gateway3/__init__.py b/custom_components/xiaomi_gateway3/__init__.py new file mode 100644 index 00000000..51eb7bd6 --- /dev/null +++ b/custom_components/xiaomi_gateway3/__init__.py @@ -0,0 +1,93 @@ +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.entity import Entity + +from . import utils +from .gateway3 import Gateway3 + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'xiaomi_gateway3' + + +async def async_setup(hass: HomeAssistant, hass_config: dict): + hass.data[DOMAIN] = {} + + if DOMAIN in hass_config and 'log' in hass_config[DOMAIN]: + Gateway3.log = hass.config.path(hass_config[DOMAIN]['log']) + + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + hass.data[DOMAIN][config_entry.unique_id] = \ + gw = Gateway3(**config_entry.data) + + # init setup for each supported domains + for domain in ('binary_sensor', 'light', 'remote', 'sensor', 'switch'): + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + config_entry, domain)) + + gw.start() + + return True + + +class Gateway3Device(Entity): + _state = STATE_UNKNOWN + + def __init__(self, gateway: Gateway3, device: dict, attr: str): + self.gw = gateway + self.device = device + + self._attr = attr + + self._unique_id = f"{self.device['mac']}_{self._attr}" + self._name = self.device['device_name'] + ' ' + self._attr.title() + + self.entity_id = '.' + self._unique_id + + gateway.add_update(device['did'], self.update) + + async def async_added_to_hass(self): + if 'init' in self.device: + self.update(self.device['init']) + + @property + def should_poll(self) -> bool: + return False + + @property + def unique_id(self): + return self._unique_id + + @property + def name(self): + return self._name + + @property + def device_info(self): + if self.device['did'] == 'lumi.0': + return { + 'identifiers': {(DOMAIN, self.device['mac'])}, + 'manufacturer': self.device['device_manufacturer'], + 'model': self.device['device_model'], + 'name': self.device['device_name'] + } + else: + return { + 'connections': {(CONNECTION_ZIGBEE, self.device['mac'])}, + 'identifiers': {(DOMAIN, self.device['mac'])}, + 'manufacturer': self.device['device_manufacturer'], + 'model': self.device['device_model'], + 'name': self.device['device_name'], + # 'sw_version': None, + 'via_device': (DOMAIN, self.gw.device['mac']) + } + + def update(self, data: dict): + pass diff --git a/custom_components/xiaomi_gateway3/binary_sensor.py b/custom_components/xiaomi_gateway3/binary_sensor.py new file mode 100644 index 00000000..5ecd0a71 --- /dev/null +++ b/custom_components/xiaomi_gateway3/binary_sensor.py @@ -0,0 +1,32 @@ +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity + +from . import DOMAIN, Gateway3Device +from .gateway3 import Gateway3 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + def setup(gateway: Gateway3, device: dict, attr: str): + async_add_entities([Gateway3BinarySensor(gateway, device, attr)]) + + gw: Gateway3 = hass.data[DOMAIN][config_entry.unique_id] + gw.add_setup('binary_sensor', setup) + + +class Gateway3BinarySensor(Gateway3Device, BinarySensorEntity): + @property + def is_on(self): + return self._state is True + + @property + def device_class(self): + return self._attr + + def update(self, data: dict = None): + if self._attr not in data: + return + self._state = data[self._attr] == 1 + self.schedule_update_ha_state() diff --git a/custom_components/xiaomi_gateway3/config_flow.py b/custom_components/xiaomi_gateway3/config_flow.py new file mode 100644 index 00000000..8d328918 --- /dev/null +++ b/custom_components/xiaomi_gateway3/config_flow.py @@ -0,0 +1,31 @@ +import logging + +import voluptuous as vol +from homeassistant.config_entries import ConfigFlow + +from . import DOMAIN, gateway3 + +_LOGGER = logging.getLogger(__name__) + + +class XiaomiGateway3FlowHandler(ConfigFlow, domain=DOMAIN): + async def async_step_user(self, user_input=None): + """GUI > Configuration > Integrations > Plus > Xiaomi Gateway 3""" + error = None + + if user_input is not None: + error = gateway3.is_gw3(user_input['host'], user_input['token']) + if not error: + return self.async_create_entry(title=user_input['host'], + data=user_input) + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required('host'): str, + vol.Required('token'): str, + }), + description_placeholders={ + 'error_text': "\nERROR: " + error if error else '' + } + ) diff --git a/custom_components/xiaomi_gateway3/gateway3.py b/custom_components/xiaomi_gateway3/gateway3.py new file mode 100644 index 00000000..26609230 --- /dev/null +++ b/custom_components/xiaomi_gateway3/gateway3.py @@ -0,0 +1,350 @@ +import json +import logging +import time +from telnetlib import Telnet +from threading import Thread +from typing import Optional + +from paho.mqtt.client import Client, MQTTMessage + +from miio import Device +from . import utils +from .utils import GLOBAL_PROP + +_LOGGER = logging.getLogger(__name__) + + +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: ") + telnet.write(b"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: ") + telnet.write(b"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) + + +DIV_100 = ['temperature', 'humidity'] + + +def is_gw3(host: str, token: str) -> Optional[str]: + try: + device = Device(host, token) + info = device.info() + if info.model != 'lumi.gateway.mgl03': + raise Exception(f"Wrong device model: {info.model}") + except Exception as e: + return str(e) + + return None diff --git a/custom_components/xiaomi_gateway3/light.py b/custom_components/xiaomi_gateway3/light.py new file mode 100644 index 00000000..baff170c --- /dev/null +++ b/custom_components/xiaomi_gateway3/light.py @@ -0,0 +1,56 @@ +import logging + +from homeassistant.components.light import LightEntity, SUPPORT_BRIGHTNESS, \ + ATTR_BRIGHTNESS + +from . import DOMAIN, Gateway3Device +from .gateway3 import Gateway3 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + def setup(gateway: Gateway3, device: dict, attr: str): + async_add_entities([Gateway3Light(gateway, device, attr)]) + + gw: Gateway3 = hass.data[DOMAIN][config_entry.unique_id] + gw.add_setup('light', setup) + + +class Gateway3Light(Gateway3Device, LightEntity): + _brightness = None + + @property + def is_on(self) -> bool: + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + features = 0 + if self._brightness is not None: + features |= SUPPORT_BRIGHTNESS + return features + + def update(self, data: dict = None): + if self._attr in data: + self._state = data[self._attr] == 1 + if 'brightness' in data: + self._brightness = data['brightness'] / 100.0 * 255.0 + + self.schedule_update_ha_state() + + def turn_on(self, **kwargs): + if ATTR_BRIGHTNESS in kwargs: + br = int(kwargs[ATTR_BRIGHTNESS] / 255.0 * 100.0) + self.gw.send(self.device, 'brightness', br) + else: + self.gw.send(self.device, self._attr, 1) + + def turn_off(self): + self.gw.send(self.device, self._attr, 0) diff --git a/custom_components/xiaomi_gateway3/manifest.json b/custom_components/xiaomi_gateway3/manifest.json new file mode 100644 index 00000000..e990ef72 --- /dev/null +++ b/custom_components/xiaomi_gateway3/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "xiaomi_gateway3", + "name": "Xiaomi Gateway 3", + "config_flow": true, + "documentation": "https://github.com/AlexxIT/XiaomiGateway3", + "dependencies": [ + ], + "codeowners": [ + "@AlexxIT" + ], + "requirements": [ + "python-miio>=0.5.3", + "paho-mqtt>=1.5.0" + ] +} \ No newline at end of file diff --git a/custom_components/xiaomi_gateway3/remote.py b/custom_components/xiaomi_gateway3/remote.py new file mode 100644 index 00000000..0133cda5 --- /dev/null +++ b/custom_components/xiaomi_gateway3/remote.py @@ -0,0 +1,42 @@ +import logging + +from homeassistant.helpers.entity import ToggleEntity + +from . import DOMAIN, Gateway3Device +from .gateway3 import Gateway3 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + def setup(gateway: Gateway3, device: dict, attr: str): + async_add_entities([Gateway3Entity(gateway, device, attr)]) + + gw: Gateway3 = hass.data[DOMAIN][config_entry.unique_id] + gw.add_setup('remote', setup) + + +class Gateway3Entity(Gateway3Device, ToggleEntity): + _state = False + + @property + def is_on(self) -> bool: + return self._state + + @property + def icon(self): + return 'mdi:zigbee' + + def update(self, data: dict = None): + if 'pairing_start' in data: + self._state = True + self.schedule_update_ha_state() + elif 'pairing_stop' in data: + self._state = False + self.schedule_update_ha_state() + + def turn_on(self): + self.gw.send(self.device, 'pairing_start', 60) + + def turn_off(self): + self.gw.send(self.device, 'pairing_stop', 0) diff --git a/custom_components/xiaomi_gateway3/sensor.py b/custom_components/xiaomi_gateway3/sensor.py new file mode 100644 index 00000000..9de1d64f --- /dev/null +++ b/custom_components/xiaomi_gateway3/sensor.py @@ -0,0 +1,94 @@ +import logging + +from homeassistant.const import * + +from . import DOMAIN, Gateway3Device +from .gateway3 import Gateway3 + +_LOGGER = logging.getLogger(__name__) + +UNITS = { + DEVICE_CLASS_TEMPERATURE: TEMP_CELSIUS, + DEVICE_CLASS_HUMIDITY: UNIT_PERCENTAGE, + DEVICE_CLASS_ILLUMINANCE: 'lm', + DEVICE_CLASS_POWER: POWER_WATT, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + def setup(gateway: Gateway3, device: dict, attr: str): + async_add_entities([ + Gateway3Action(gateway, device, attr) + if attr == 'action' else + Gateway3Sensor(gateway, device, attr) + ]) + + gw: Gateway3 = hass.data[DOMAIN][config_entry.unique_id] + gw.add_setup('sensor', setup) + + +class Gateway3Sensor(Gateway3Device): + @property + def state(self): + return self._state + + @property + def device_class(self): + return self._attr + + @property + def unit_of_measurement(self): + return UNITS.get(self._attr) + + def update(self, data: dict = None): + if self._attr not in data: + return + self._state = data[self._attr] + self.schedule_update_ha_state() + + +# https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/converters/fromZigbee.js#L4738 +BUTTON = { + 1: 'single', + 2: 'double', + 3: 'triple', + 4: 'quadruple', + 16: 'hold', + 17: 'release', + 18: 'shake', + 128: 'many', +} + +BUTTON_BOTH = { + 4: 'single', + 5: 'double', + 6: 'triple', + 16: 'hold', + 17: 'release', +} + +VIBRATION = { + 1: 'vibration', + 2: 'tilt', + 3: 'drop', +} + + +class Gateway3Action(Gateway3Device): + @property + def state(self): + return self._state + + def update(self, data: dict = None): + for k, v in data.items(): + if k == 'button': + self._state = BUTTON[v] + elif k == 'button_both': + self._state = k + '_' + BUTTON_BOTH[v] + elif k.startswith('button'): + self._state = k + '_' + BUTTON[v] + elif k == 'vibration': + self._state = VIBRATION[v] + elif k == 'action': + self._state = v + self.schedule_update_ha_state() diff --git a/custom_components/xiaomi_gateway3/switch.py b/custom_components/xiaomi_gateway3/switch.py new file mode 100644 index 00000000..e24193e6 --- /dev/null +++ b/custom_components/xiaomi_gateway3/switch.py @@ -0,0 +1,34 @@ +import logging + +from homeassistant.helpers.entity import ToggleEntity + +from . import DOMAIN, Gateway3Device +from .gateway3 import Gateway3 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + def setup(gateway: Gateway3, device: dict, attr: str): + async_add_entities([Gateway3Switch(gateway, device, attr)]) + + gw: Gateway3 = hass.data[DOMAIN][config_entry.unique_id] + gw.add_setup('switch', setup) + + +class Gateway3Switch(Gateway3Device, ToggleEntity): + @property + def is_on(self) -> bool: + return self._state + + def update(self, data: dict = None): + if self._attr not in data: + return + self._state = data[self._attr] == 1 + self.schedule_update_ha_state() + + def turn_on(self): + self.gw.send(self.device, self._attr, 1) + + def turn_off(self): + self.gw.send(self.device, self._attr, 0) diff --git a/custom_components/xiaomi_gateway3/translations/en.json b/custom_components/xiaomi_gateway3/translations/en.json new file mode 100644 index 00000000..3eb3bf58 --- /dev/null +++ b/custom_components/xiaomi_gateway3/translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "error":{ + "text": "{error_text}" + }, + "step": { + "user": { + "description": "[Obtain](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md) Mi Home token {error_text}", + "data": { + "host": "Host", + "token": "Token" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_gateway3/utils.py b/custom_components/xiaomi_gateway3/utils.py new file mode 100644 index 00000000..745ee9cf --- /dev/null +++ b/custom_components/xiaomi_gateway3/utils.py @@ -0,0 +1,252 @@ +import logging +from logging import FileHandler, Formatter +from typing import Optional + +GLOBAL_PROP = { + '8.0.2001': 'battery', + '8.0.2002': 'reset_cnt', + '8.0.2003': 'send_all_cnt', + '8.0.2004': 'send_fail_cnt', + '8.0.2005': 'send_retry_cnt', + '8.0.2006': 'chip_temperature', + '8.0.2007': 'lqi', + '8.0.2008': 'voltage', + '8.0.2009': 'pv_state', + '8.0.2010': 'cur_state', + '8.0.2011': 'pre_state', + '8.0.2012': 'power_tx', + '8.0.2013': 'CCA', + '8.0.2014': 'protect', + '8.0.2015': 'power', + '8.0.2022': 'fw_ver', + '8.0.2023': 'hw_ver', + '8.0.2030': 'poweroff_memory', + '8.0.2031': 'charge_protect', + '8.0.2032': 'en_night_tip_light', + '8.0.2034': 'load_s0', # ctrl_dualchn + '8.0.2035': 'load_s1', # ctrl_dualchn + '8.0.2036': 'parent', + '8.0.2041': 'model', + '8.0.2042': 'max_power', + '8.0.2044': 'plug_detection', + '8.0.2101': 'nl_invert', # ctrl_86plug + '8.0.9001': 'battery_end_of_life' +} + +# https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/devices.js#L390 +# Zigbee Model: [Manufacturer, Device Name, Device Model] +# params: [lumi res name, xiaomi prop name, hass attr name, hass domain] +DEVICES = [{ + 'lumi.gateway.mgl03': ["Xiaomi", "Gateway 3", "ZNDMWG03LM"], + 'params': [ + ['8.0.2081', None, 'pairing_stop', None], + ['8.0.2082', None, 'removed_did', None], + ['8.0.2084', None, 'added_device', None], # new devices added (info) + ['8.0.2103', None, 'device_model', None], # new device model + ['8.0.2109', None, 'pairing_start', None], + ['8.0.2110', None, 'discovered_mac', None], # new device discovered + ['8.0.2111', None, 'pair_command', None], # add new device + ['8.0.2155', None, 'cloud', None], # {"cloud_link":0} + [None, None, 'pair', 'remote'], + ] +}, { + # on/off, power measurement + 'lumi.plug': ["Xiaomi", "Plug", "ZNCZ02LM"], # tested + 'lumi.plug.mitw01': ["Xiaomi", "Plug TW", "ZNCZ03LM"], + 'lumi.plug.mmeu01': ["Xiaomi", "Plug EU", "ZNCZ04LM"], + 'lumi.plug.maus01': ["Xiaomi", "Plug US", "ZNCZ12LM"], + 'lumi.ctrl_86plug': ["Aqara", "Socket", "QBCZ11LM"], + 'lumi.ctrl_86plug.aq1': ["Aqara", "Socket", "QBCZ11LM"], + 'lumi.ctrl_ln1': ["Aqara", "Wall Single Switch", "QBKG11LM"], + 'lumi.ctrl_ln1.aq1': ["Aqara", "Wall Single Switch", "QBKG11LM"], + 'lumi.switch.b1nacn02': ["Aqara", "D1 Wall Single Switch", "QBKG23LM"], + 'params': [ + ['0.12.85', 'load_power', 'power', 'sensor'], + ['4.1.85', 'neutral_0', 'switch', 'switch'], # or channel_0? + ] +}, { + # dual channel on/off, power measurement + 'lumi.relay.c2acn01': ["Aqara", "Relay", "LLKZMK11LM"], # tested + 'lumi.ctrl_ln2': ["Aqara", "Wall Double Switch", "QBKG12LM"], + 'lumi.ctrl_ln2.aq1': ["Aqara", "Wall Double Switch", "QBKG12LM"], + 'lumi.switch.b2nacn02': ["Aqara", "D1 Wall Double Switch", "QBKG24LM"], + 'params': [ + ['0.12.85', 'load_power', 'power', 'sensor'], + ['4.1.85', 'channel_0', 'channel 1', 'switch'], + ['4.2.85', 'channel_1', 'channel 2', 'switch'], + # [?, 'enable_motor_mode', 'interlock', None] + ] +}, { + # on/off + 'lumi.ctrl_neutral1': ["Aqara", "Wall Single Switch", "QBKG04LM"], + 'lumi.switch.b1lacn02': ["Aqara", "D1 Wall Single Switch", "QBKG21LM"], + 'params': [ + ['4.1.85', 'channel_0', 'switch', 'switch'], # or neutral_0? + ] +}, { + # dual channel on/off + 'lumi.ctrl_neutral2': ["Aqara", "Wall Double Switch", "QBKG03LM"], + 'lumi.switch.b2lacn02': ["Aqara", "D1 Wall Single Switch", "QBKG22LM"], + 'params': [ + ['4.1.85', 'channel_0', 'channel 1', 'switch'], + ['4.2.85', 'channel_1', 'channel 2', 'switch'], + ] +}, { + # triple channel on/off + 'lumi.switch.l3acn3': ["Aqara", "D1 Wall Triple Switch", "QBKG25LM"], + 'params': [ + ['4.1.85', 'channel_0', 'channel 1', 'switch'], + ['4.2.85', 'channel_1', 'channel 2', 'switch'], + ['4.3.85', 'channel_2', 'channel 3', 'switch'], + ] +}, { + # cube action, no retain + 'lumi.sensor_cube': ["Aqara", "Cube", "MFKZQ01LM"], + 'lumi.sensor_cube.aqgl01': ["Aqara", "Cube", "MFKZQ01LM"], # tested + 'params': [ + # ['0.2.85', '?', '?', '?'], + ['0.3.85', None, 'angle', None], + ['13.1.85', None, 'action', 'sensor'], + ] +}, { + # light with brightness + 'lumi.light.aqcn02': ["Aqara", "Bulb", "ZNLDP12LM"], + 'lumi.light.cwopcn02': ["Aqara", "Opple MX650", "XDD12LM"], + 'lumi.light.cwopcn03': ["Aqara", "Opple MX480", "XDD13LM"], + 'ikea.light.led1649c5': ["IKEA", "Bulb E14"], # tested + 'params': [ + ['4.1.85', 'power_status', 'light', 'light'], + ['14.1.85', 'light_level', 'brightness', None], + ] +}, { + # button action, no retain + 'lumi.sensor_switch': ["Xiaomi", "Button", "WXKG01LM"], + 'lumi.sensor_switch.aq2': ["Aqara", "Button", "WXKG11LM"], + 'lumi.remote.b1acn01': ["Aqara", "Button", "WXKG11LM"], + 'lumi.sensor_switch.aq3': ["Aqara", "Shake Button", "WXKG12LM"], + 'lumi.sensor_86sw1': ["Aqara", "Single Wall Button", "WXKG03LM"], + 'lumi.remote.b186acn01': ["Aqara", "Single Wall Button", "WXKG03LM"], + 'lumi.remote.b186acn02': ["Aqara", "D1 Single Wall Button", "WXKG06LM"], + 'params': [ + ['13.1.85', None, 'button', None], + [None, None, 'action', 'sensor'], + ] +}, { + # multi button action, no retain + 'lumi.sensor_86sw2': ["Aqara", "Double Wall Button", "WXKG02LM"], + 'lumi.remote.b286acn01': ["Aqara", "Double Wall Button", "WXKG02LM"], + 'lumi.sensor_86sw2.es1': ["Aqara", "Double Wall Button", "WXKG02LM"], + 'lumi.remote.b286acn02': ["Aqara", "D1 Double Wall Button", "WXKG07LM"], + 'lumi.remote.b286opcn01': ["Aqara", "Opple Two Button", "WXCJKG11LM"], + 'lumi.remote.b486opcn01': ["Aqara", "Opple Four Button", "WXCJKG12LM"], + 'lumi.remote.b686opcn01': ["Aqara", "Opple Six Button", "WXCJKG13LM"], + 'params': [ + ['13.1.85', None, 'button_1', None], + ['13.2.85', None, 'button_2', None], + ['13.3.85', None, 'button_3', None], + ['13.4.85', None, 'button_4', None], + ['13.5.85', None, 'button_both', None], + ['13.6.85', None, 'button_5', None], + ['13.7.85', None, 'button_6', None], + [None, None, 'action', 'sensor'], + ] +}, { + # temperature and humidity sensor + 'lumi.sensor_ht': ["Xiaomi", "TH Sensor", "WSDCGQ01LM"], + 'params': [ + ['0.1.85', 'temperature', 'temperature', 'sensor'], + ['0.2.85', 'humidity', 'humidity', 'sensor'], + ] +}, { + # temperature, humidity and pressure sensor + 'lumi.weather': ["Aqara", "TH Sensor", "WSDCGQ11LM"], + 'lumi.sensor_ht.agl02': ["Aqara", "TH Sensor", "WSDCGQ12LM"], + 'params': [ + ['0.1.85', 'temperature', 'temperature', 'sensor'], + ['0.2.85', 'humidity', 'humidity', 'sensor'], + ['0.3.85', 'pressure', 'pressure', 'sensor'], + ] +}, { + # door window sensor + 'lumi.sensor_magnet': ["Xiaomi", "Door Sensor", "MCCGQ01LM"], + 'lumi.sensor_magnet.aq2': ["Aqara", "Door Sensor", "MCCGQ11LM"], + 'params': [ + ['3.1.85', 'status', 'occupancy', 'binary_sensor'], + ] +}, { + # motion sensor + 'lumi.sensor_motion': ["Xiaomi", "Motion Sensor", "RTCGQ01LM"], + 'params': [ + ['3.1.85', None, 'motion', 'binary_sensor'], + ] +}, { + # motion sensor with illuminance + 'lumi.sensor_motion.aq2': ["Aqara", "Motion Sensor", "RTCGQ11LM"], + 'params': [ + ['0.4.85', 'illumination', 'illuminance', 'sensor'], + ['3.1.85', None, 'motion', 'binary_sensor'], + ] +}, { + # water leak sensor + 'lumi.sensor_wleak.aq1': ["Aqara", "Water Leak Sensor", "SJCGQ11LM"], + 'params': [ + ['3.1.85', 'alarm', 'moisture', 'binary_sensor'], + ] +}, { + # vibration sensor + 'lumi.vibration.aq1': ["Aqara", "Vibration Sensor", "DJT11LM"], + 'params': [ + ['0.1.85', None, 'bed_activity', None], + ['0.2.85', None, 'final_tilt_angle', None], + ['0.3.85', None, 'vibrate_intensity', None], + ['13.1.85', None, 'vibration', None], + ['14.1.85', None, 'vibration_level', None], + [None, None, 'action', 'sensor'] + ] +}, { # OTHER MANUFACTURERS + 'TRADFRI bulb E27 W opal 1000lm': ["IKEA", "Bulb E27"], + 'LWB010': ["Philips", "Hue Bulb E27"], + 'FNB56-ZSC01LX1.2': ["Ali", "Dimmer"], + 'params': [ + [None, None, 'light', 'light'], + ] +}, { + 'MS01': ["Sonoff", "Motion Sensor"], + 'params': [ + [None, None, 'motion', 'binary_sensor'], + ] +}] + + +def get_device(zigbee_model: str) -> Optional[dict]: + # the model has an extra tail when added + if zigbee_model.endswith(('.v1', '.v2')): + zigbee_model = zigbee_model[:-3] + + for device in DEVICES: + if zigbee_model in device: + desc = device[zigbee_model] + return { + # 'model': zigbee_model, + 'device_manufacturer': desc[0], + 'device_name': desc[0] + ' ' + desc[1], + 'device_model': zigbee_model + ' ' + desc[2] + if len(desc) > 2 else zigbee_model, + 'params': device['params'] + } + + return None + + +def get_logger(filename: str): + fmt = Formatter('%(asctime)s %(message)s') + + hdlt = FileHandler(filename) + hdlt.setFormatter(fmt) + + log = logging.getLogger('mqtt') + log.propagate = False + log.setLevel(logging.DEBUG) + log.addHandler(hdlt) + + return log diff --git a/hacs.json b/hacs.json new file mode 100644 index 00000000..149ff216 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Xiaomi Gateway 3", + "render_readme": true +} \ No newline at end of file