├── tests ├── __init__.py ├── pytest.ini ├── test_gateway.py ├── test_miio_log.py ├── test_conv_mesh.py ├── test_conv_gate.py ├── test_gate_z3.py ├── test_automations.py ├── test_entities.py ├── test_conv_mibeacon.py ├── test_conv_stats.py └── test_conv_silabs.py ├── .gitignore ├── logo.png ├── hacs.json ├── cloud_tokens.png ├── integrations.png ├── zigbee_table.png ├── bluetooth_lock.png ├── occupancy_timeout.png ├── .github ├── workflows │ └── validate.yml └── ISSUE_TEMPLATE │ └── bug-report-or-feature-request.md ├── custom_components └── xiaomi_gateway3 │ ├── services.yaml │ ├── manifest.json │ ├── core │ ├── shell │ │ ├── shell_e1.py │ │ ├── shell_mgw2.py │ │ ├── shell_arm.py │ │ ├── __init__.py │ │ └── base.py │ ├── const.py │ ├── converters │ │ ├── const.py │ │ ├── __init__.py │ │ └── stats.py │ ├── gateway │ │ ├── mesh.py │ │ ├── miot.py │ │ ├── gate_e1.py │ │ ├── gate_mgw2.py │ │ ├── ble.py │ │ ├── lumi.py │ │ ├── gate_mgw.py │ │ ├── z3.py │ │ ├── base.py │ │ ├── silabs.py │ │ └── __init__.py │ ├── logger.py │ ├── ezsp.py │ ├── unqlite.py │ ├── mini_mqtt.py │ └── utils.py │ ├── translations │ ├── select.en.json │ ├── zh-Hans.json │ ├── zh-Hant.json │ ├── ua.json │ ├── en.json │ ├── pt-BR.json │ ├── ro.json │ ├── ru.json │ └── pl.json │ ├── switch.py │ ├── number.py │ ├── diagnostics.py │ ├── alarm_control_panel.py │ ├── device_trigger.py │ ├── cover.py │ ├── climate.py │ ├── system_health.py │ ├── binary_sensor.py │ ├── sensor.py │ ├── light.py │ └── __init__.py └── print_models.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | .homeassistant/ 4 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvldz/XiaomiGateway3/HEAD/logo.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Xiaomi Gateway 3", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /cloud_tokens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvldz/XiaomiGateway3/HEAD/cloud_tokens.png -------------------------------------------------------------------------------- /integrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvldz/XiaomiGateway3/HEAD/integrations.png -------------------------------------------------------------------------------- /zigbee_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvldz/XiaomiGateway3/HEAD/zigbee_table.png -------------------------------------------------------------------------------- /bluetooth_lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvldz/XiaomiGateway3/HEAD/bluetooth_lock.png -------------------------------------------------------------------------------- /occupancy_timeout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zvldz/XiaomiGateway3/HEAD/occupancy_timeout.png -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # -x - stop at first error 3 | # -p no:cacheprovider - don't create `.cache` folder 4 | addopts = -x -p no:cacheprovider 5 | -------------------------------------------------------------------------------- /tests/test_gateway.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from custom_components.xiaomi_gateway3.core.gateway import XGateway 4 | 5 | 6 | # def test_prepare_gateway(): 7 | # gw = XGateway('192.168.10.28', '316d4369747442437667464933747142') 8 | # coro = gw.prepare_gateway() 9 | # ok = asyncio.get_event_loop().run_until_complete(coro) 10 | # assert ok 11 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | # schedule: 7 | # - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/services.yaml: -------------------------------------------------------------------------------- 1 | send_command: 2 | description: Send command to Gateway 3 | fields: 4 | host: 5 | description: Gateway IP address 6 | example: 192.168.1.123 7 | selector: 8 | text: 9 | command: 10 | description: Command 11 | example: reboot 12 | selector: 13 | text: 14 | data: 15 | description: Data 16 | selector: 17 | text: -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "xiaomi_gateway3", 3 | "name": "Xiaomi Gateway 3", 4 | "config_flow": true, 5 | "documentation": "https://github.com/AlexxIT/XiaomiGateway3", 6 | "issue_tracker": "https://github.com/AlexxIT/XiaomiGateway3/issues", 7 | "codeowners": ["@AlexxIT"], 8 | "dependencies": ["http"], 9 | "requirements": ["zigpy>=0.42.0"], 10 | "version": "3.0a1", 11 | "iot_class": "local_push" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/shell/shell_e1.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from .shell_arm import ShellARM 4 | 5 | TAR_DATA = "tar -czOC /data mha_master miio storage zigbee devices.txt gatewayInfoJson.info 2>/dev/null | base64" 6 | 7 | 8 | # noinspection PyAbstractClass 9 | class ShellE1(ShellARM): 10 | model = "e1" 11 | 12 | async def tar_data(self): 13 | raw = await self.exec(TAR_DATA, as_bytes=True) 14 | return base64.b64decode(raw) 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report-or-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report or Feature request 3 | about: Default issue 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 18 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/shell/shell_mgw2.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from .base import ShellMultimode 4 | from .shell_arm import ShellARM 5 | 6 | TAR_DATA = "tar -czOC /data mha_master miio storage zigbee devices.txt gatewayInfoJson.info 2>/dev/null | base64" 7 | 8 | 9 | # noinspection PyAbstractClass 10 | class ShellMGW2(ShellARM, ShellMultimode): 11 | model = "mgw2" 12 | 13 | async def tar_data(self): 14 | raw = await self.exec(TAR_DATA, as_bytes=True) 15 | return base64.b64decode(raw) 16 | 17 | @property 18 | def mesh_db(self) -> str: 19 | return "/data/local/miio_bt/mible_local.db" 20 | 21 | @property 22 | def mesh_group_table(self) -> str: 23 | return "mesh_group_v3" 24 | 25 | @property 26 | def mesh_device_table(self) -> str: 27 | return "mesh_device_v3" 28 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = 'xiaomi_gateway3' 2 | TITLE = "Xiaomi Gateway 3" 3 | 4 | 5 | def source_hash() -> str: 6 | if source_hash.__doc__: 7 | return source_hash.__doc__ 8 | 9 | try: 10 | import hashlib 11 | import os 12 | 13 | m = hashlib.md5() 14 | path = os.path.dirname(os.path.dirname(__file__)) 15 | for root, dirs, files in os.walk(path): 16 | dirs.sort() 17 | for file in sorted(files): 18 | if not file.endswith(".py"): 19 | continue 20 | path = os.path.join(root, file) 21 | with open(path, "rb") as f: 22 | m.update(f.read()) 23 | 24 | source_hash.__doc__ = m.hexdigest()[:7] 25 | return source_hash.__doc__ 26 | 27 | except Exception as e: 28 | return f"{type(e).__name__}: {e}" 29 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/select.en.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "command": { 4 | "pair": "Zigbee pairing", 5 | "bind": "Zigbee binding", 6 | "ota": "Zigbee OTA", 7 | "config": "Zigbee reconfig", 8 | "parentscan": "Zigbee parent scan", 9 | "firmwarelock": "Gateway firmware lock", 10 | "reboot": "Gateway reboot", 11 | "ftp": "Gateway run FTP", 12 | "flashzb": "Zigbee flash EZSP" 13 | }, 14 | "data": { 15 | "enabled": "Enabled", 16 | "disabled": "Disabled", 17 | "unknown": "Unknown", 18 | "ok": "OK", 19 | "error": "ERROR", 20 | "permit_join": "Ready to join", 21 | "stop_join": "Pairing stopped", 22 | "cancel": "Cancel", 23 | "key_secure": "Send network key (secure)", 24 | "key_legacy": "Send network key (legacy)", 25 | "no_firmware": "No firmware", 26 | "bind": "Bind", 27 | "unbind": "Unbind", 28 | "no_devices": "No devices", 29 | "original": "Original", 30 | "custom": "Custom" 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /tests/test_miio_log.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def test_gw_heartbeat(): 5 | raw = b'{"method":"event.gw.heartbeat","params":[{"free_mem":11872,"ip":"192.168.1.123","load_avg":"1.24|1.39|1.38|4\\/88|720","rssi":58,"run_time":358537,"ssid":"WiFi"}],"id":151593}' 6 | p = json.loads(raw) 7 | assert p["params"][0]["free_mem"] == 11872 8 | 9 | 10 | def test_properties_changed(): 11 | raw = b'{"method":"properties_changed","params":[{"did":"<10 numb>","siid":2,"piid":1,"value":true}],"id":152814}' 12 | p = json.loads(raw) 13 | assert p["params"][0]["siid"] == 2 14 | 15 | 16 | def test_ble_event(): 17 | raw = b'{"method":"_async.ble_event","params":{"dev":{"did":"blt.3.","mac":"","pdid":2038},"evt":[{"eid":4119,"edata":"00000000"}],"frmCnt":233,"gwts":1634192427},"id":151482}' 18 | p = json.loads(raw) 19 | assert p["params"]["dev"]["pdid"] == 2038 20 | 21 | 22 | def test_miot_event(): 23 | # https://github.com/AlexxIT/XiaomiGateway3/issues/689#issuecomment-1066048885 24 | raw = b'{"method":"event_occured","params":{"did":"<10 numb>","siid":8,"eiid":1,"tid":44,"ts":1647158504,"arguments":[]},"id":548629}' 25 | p = json.loads(raw) 26 | assert p["params"]["siid"] == 8 27 | -------------------------------------------------------------------------------- /tests/test_conv_mesh.py: -------------------------------------------------------------------------------- 1 | from custom_components.xiaomi_gateway3.core.converters import MESH 2 | from custom_components.xiaomi_gateway3.core.device import XDevice 3 | 4 | 5 | def test_mesh(): 6 | device = XDevice(MESH, 1771, "123", "112233aabbcc") 7 | assert device.info.name == 'Xiaomi Mesh Bulb' 8 | device.setup_converters() 9 | 10 | p = device.decode_lumi([ 11 | {'did': '1234567890', 'siid': 2, 'piid': 1, 'value': True, 'code': 0}, 12 | {'did': '1234567890', 'siid': 2, 'piid': 2, 'value': 65535, 'code': 0}, 13 | {'did': '1234567890', 'siid': 2, 'piid': 3, 'value': 4000, 'code': 0} 14 | ]) 15 | assert p == {'light': True, 'brightness': 255.0, 'color_temp': 250} 16 | 17 | 18 | def test_event(): 19 | device = XDevice(MESH, 1946, "123", "112233aabbcc") 20 | device.setup_converters() 21 | 22 | p = device.decode_miot([ 23 | {"did": "1234567890", "siid": 8, "eiid": 1, "arguments": []} 24 | ]) 25 | assert p == {'button_1': 1, 'action': 'button_1_single'} 26 | 27 | 28 | def test_brightness(): 29 | device = XDevice(MESH, 3164, "123", "112233aabbcc") 30 | device.setup_converters() 31 | 32 | p = device.encode({'light': True, 'brightness': 15.0, 'color_temp': 300}) 33 | assert p['mi_spec'] == [ 34 | {'siid': 2, 'piid': 1, 'value': True}, 35 | {'siid': 2, 'piid': 2, 'value': 6}, 36 | {'siid': 2, 'piid': 3, 'value': 3333} 37 | ] 38 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/switch.py: -------------------------------------------------------------------------------- 1 | from homeassistant.const import STATE_ON 2 | from homeassistant.core import callback 3 | from homeassistant.helpers.entity import ToggleEntity 4 | from homeassistant.helpers.restore_state import RestoreEntity 5 | 6 | from . import DOMAIN 7 | from .core.converters import Converter 8 | from .core.device import XDevice 9 | from .core.entity import XEntity 10 | from .core.gateway import XGateway 11 | 12 | 13 | async def async_setup_entry(hass, config_entry, add_entities): 14 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 15 | if conv.attr in device.entities: 16 | entity: XEntity = device.entities[conv.attr] 17 | entity.gw = gateway 18 | else: 19 | entity = XiaomiSwitch(gateway, device, conv) 20 | add_entities([entity]) 21 | 22 | gw: XGateway = hass.data[DOMAIN][config_entry.entry_id] 23 | gw.add_setup(__name__, setup) 24 | 25 | 26 | # noinspection PyAbstractClass 27 | class XiaomiSwitch(XEntity, ToggleEntity, RestoreEntity): 28 | _attr_is_on: bool = None 29 | 30 | @callback 31 | def async_set_state(self, data: dict): 32 | """Handle state update from gateway.""" 33 | if self.attr in data: 34 | self._attr_is_on = data[self.attr] 35 | 36 | @callback 37 | def async_restore_last_state(self, state: str, attrs: dict): 38 | self._attr_is_on = state == STATE_ON 39 | 40 | async def async_turn_on(self): 41 | await self.device_send({self.attr: True}) 42 | 43 | async def async_turn_off(self): 44 | await self.device_send({self.attr: False}) 45 | 46 | async def async_update(self): 47 | await self.device_read(self.subscribed_attrs) 48 | -------------------------------------------------------------------------------- /tests/test_conv_gate.py: -------------------------------------------------------------------------------- 1 | from custom_components.xiaomi_gateway3.core.converters import GATEWAY 2 | from custom_components.xiaomi_gateway3.core.device import XDevice 3 | 4 | DID = "123456789" 5 | MAC = "112233aabbcc" 6 | 7 | 8 | def test_gateway(): 9 | device = XDevice(GATEWAY, "lumi.gateway.mgl03", DID, MAC) 10 | assert device.info.name == "Xiaomi Multimode Gateway" 11 | device.setup_converters() 12 | 13 | p = device.decode_lumi([{"res_name": "8.0.2109", "value": 60}]) 14 | assert p == {'pair': True} 15 | 16 | p = device.encode({'pair': False}) 17 | assert p == {"params": [{"res_name": "8.0.2109", "value": 0}]} 18 | 19 | # old zigbee pairing 20 | p = device.decode_lumi([{ 21 | "res_name": "8.0.2111", "value": { 22 | "code": 0, "install_code": "", "mac": "", "message": "no data" 23 | }, "error_code": 0 24 | }]) 25 | assert p 26 | 27 | # _sync.zigbee3_get_install_code error 28 | p = device.decode_lumi([{ 29 | "res_name": "8.0.2111", "value": { 30 | "code": -4001002, "install_code": "", "mac": "", 31 | "message": "no data" 32 | }, "error_code": 0 33 | }]) 34 | assert p 35 | 36 | # zigbee3 pairing 37 | p = device.decode_lumi([{ 38 | "res_name": "8.0.2111", "value": { 39 | "code": 0, "install_code": "<36 hex>", "mac": "<16 hex>" 40 | }, "error_code": 0 41 | }]) 42 | assert p 43 | 44 | p = device.decode_lumi([{ 45 | "res_name": "8.0.2155", 46 | "value": "{\"cloud_link\":1,\"tz_updated\":\"GMT3\"}" 47 | }]) 48 | assert p == {'cloud_link': True} 49 | 50 | p = device.decode_lumi([{"res_name": "8.0.2155", "value": 1}]) 51 | assert p == {'cloud_link': True} 52 | -------------------------------------------------------------------------------- /tests/test_gate_z3.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from custom_components.xiaomi_gateway3.core.converters import ZIGBEE 4 | from custom_components.xiaomi_gateway3.core.device import XDevice 5 | from custom_components.xiaomi_gateway3.core.gateway.z3 import Z3Gateway 6 | 7 | 8 | def test_console(): 9 | gw = Z3Gateway() 10 | gw.options = {} 11 | gw.z3_buffer = { 12 | "plugin device-table print": 13 | "0 E265: 00158D0000000000 0 JOINED 882\r" 14 | "1 7585: 00158D0000000001 0 JOINED 335\r" 15 | "2 131E: 00158D0000000002 0 JOINED 335\r" 16 | "3 1F0C: 00158D0000000003 0 JOINED 247\r", 17 | "plugin stack-diagnostics child-table": 18 | "0: Sleepy 0xE265 (>)00158D0000000000 512 min debug timeout:249\r" 19 | "1: Sleepy 0x7585 (>)00158D0000000001 512 min debug timeout:249\r", 20 | "plugin stack-diagnostics neighbor-table": 21 | "0: 0x131E 201 1 1 3 (>)00158D0000000002\r" 22 | "1: 0x1F0C 172 1 0 7 (>)00158D0000000003\r", 23 | "buffer": 24 | "0: 0x1F0C -> 0x0000 (Me)\r" 25 | "1: 0x131E -> 0x1F0C -> 0x0000 (Me)\r" 26 | } 27 | 28 | payload = {} 29 | 30 | def update(data: dict): 31 | payload.update(data) 32 | 33 | device = XDevice( 34 | ZIGBEE, "", "lumi.158d0000000002", "0x1234567890123456", "0x131e" 35 | ) 36 | device.entities = {ZIGBEE} 37 | device.update = update 38 | gw.devices[device.did] = device 39 | 40 | asyncio.run(gw.z3_process_log( 41 | "CLI command executed: plugin concentrator print-table\r" 42 | )) 43 | 44 | assert payload == { 45 | # 'eui64': '0x00158D0000000002', 'nwk': '0x131e', 'ago': 335, 46 | 'type': 'router', 'parent': '0x1f0c' 47 | } 48 | -------------------------------------------------------------------------------- /tests/test_automations.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import DOMAIN 2 | 3 | from custom_components.xiaomi_gateway3.core import converters 4 | from custom_components.xiaomi_gateway3.core.converters import GATEWAY, ZIGBEE, \ 5 | BLE, MESH 6 | from custom_components.xiaomi_gateway3.core.device import XDevice 7 | 8 | assert DOMAIN # fix circular import 9 | 10 | BDID = "blt.3.abc" 11 | GDID = "1234567890" 12 | ZDID = "lumi.112233aabbcc" 13 | 14 | IEEE = "0x0000112233aabbcc" 15 | MAC = "aabbccddeeff" 16 | NWK = "0x12ab" 17 | 18 | 19 | def test_buttons(): 20 | device = XDevice(GATEWAY, 'lumi.gateway.mgl03', GDID, MAC) 21 | b = converters.get_buttons(device.info.model) 22 | assert b is None 23 | 24 | device = XDevice(ZIGBEE, 'lumi.sensor_switch', ZDID, IEEE, NWK) 25 | b = converters.get_buttons(device.info.model) 26 | assert b == ["button"] 27 | 28 | device = XDevice(ZIGBEE, 'lumi.ctrl_ln2', ZDID, IEEE, NWK) 29 | b = converters.get_buttons(device.info.model) 30 | assert b == ["button_1", "button_2", "button_both"] 31 | 32 | device = XDevice(ZIGBEE, 'lumi.switch.l3acn3', ZDID, IEEE, NWK) 33 | b = converters.get_buttons(device.info.model) 34 | assert b == [ 35 | "button_1", "button_2", "button_3", 36 | "button_both_12", "button_both_13", "button_both_23" 37 | ] 38 | 39 | device = XDevice(ZIGBEE, 'lumi.remote.acn004', ZDID, IEEE, NWK) 40 | b = converters.get_buttons(device.info.model) 41 | assert b == ["button_1", "button_2", "button_both"] 42 | 43 | device = XDevice(BLE, 1983, BDID, MAC) 44 | b = converters.get_buttons(device.info.model) 45 | assert b == ["button"] 46 | 47 | device = XDevice(MESH, 1946, GDID, MAC) 48 | b = converters.get_buttons(device.info.model) 49 | assert b == ["button_1", "button_2"] 50 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/converters/const.py: -------------------------------------------------------------------------------- 1 | GATEWAY = "gateway" 2 | ZIGBEE = "zigbee" 3 | BLE = "ble" 4 | MESH = "mesh" 5 | 6 | MESH_GROUP_MODEL = 1054 7 | 8 | TIME = { 9 | "s": 1, 10 | "m": 60, 11 | "h": 3600, 12 | "d": 86400 13 | } 14 | 15 | UNKNOWN = "unknown" 16 | SINGLE = "single" 17 | DOUBLE = "double" 18 | TRIPLE = "triple" 19 | QUADRUPLE = "quadruple" 20 | HOLD = "hold" 21 | RELEASE = "release" 22 | 23 | # https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/converters/fromZigbee.js#L4738 24 | BUTTON = { 25 | 1: SINGLE, 26 | 2: DOUBLE, 27 | 3: TRIPLE, 28 | 4: QUADRUPLE, 29 | 5: "quintuple", # only Yeelight Dimmer 30 | 16: HOLD, 31 | 17: RELEASE, 32 | 18: "shake", 33 | 128: "many", 34 | } 35 | BUTTON_BOTH = { 36 | 4: SINGLE, 37 | 5: DOUBLE, 38 | 6: TRIPLE, 39 | 16: HOLD, 40 | 17: RELEASE, 41 | } 42 | VIBRATION = {1: "vibration", 2: "tilt", 3: "drop"} 43 | DOOR_STATE = {0: "open", 1: "close", 2: "ajar"} 44 | LOCK_STATE = { 45 | 0: "door_cannot_locked", 1: "door_opened", 2: "door_without_lift", 46 | 3: "door_locked", 4: "reverse_locked", 47 | } 48 | LOCK_CONTROL = { 49 | 0: "in_unlocked", 1: "out_unlocked", 2: "in_locked", 3: "out_locked", 50 | } 51 | LOCK_ALARM = { 52 | 0: "off", 1: "key_open", 4: "unlocked", 8: "hijack", 16: "pry", 53 | 32: "normally_open", 256: "less_storage", 500: "low_bat", 512: "doorbell" 54 | } 55 | MOTOR = {0: "close", 1: "open", 2: "stop"} 56 | RUN_STATE = {0: "closing", 1: "opening", 2: "stop"} 57 | GATE_ALARM = { 58 | 0: "disarmed", 1: "armed_home", 2: "armed_away", 3: "armed_night" 59 | } 60 | BULB_MEMORY = {0: "on", 1: "previous"} 61 | POWEROFF_MEMORY = {0: "off", 1: "previous"} 62 | # Hass: On means low, Off means normal 63 | BATTERY_LOW = {1: False, 2: True} 64 | SWITCH_MODE = {1: "250 ms", 2: "500 ms", 3: "750 ms", 4: "1 sec"} 65 | INVERSE = {0: True, 1: False} 66 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/shell/shell_arm.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from .base import ShellOpenMiio 4 | 5 | URL_AGENT = "http://master.dl.sourceforge.net/project/aqcn02/openmiio_agent/openmiio_agent?viasf=1" 6 | MD5_AGENT = "56f591a3307a7e5b7489a88b7de98efd" 7 | 8 | 9 | # noinspection PyAbstractClass 10 | class ShellARM(ShellOpenMiio): 11 | async def login(self): 12 | self.writer.write(b"root\n") 13 | await asyncio.sleep(.1) 14 | self.writer.write(b"\n") # empty password 15 | 16 | coro = self.reader.readuntil(b"/ # ") 17 | await asyncio.wait_for(coro, timeout=3) 18 | 19 | async def prepare(self): 20 | # change bash end symbol to gw3 style 21 | self.writer.write(b"export PS1='# '\n") 22 | coro = self.reader.readuntil(b"\r\n# ") 23 | await asyncio.wait_for(coro, timeout=3) 24 | 25 | await self.exec("stty -echo") 26 | 27 | async def get_version(self): 28 | raw1 = await self.exec("agetprop ro.sys.mi_fw_ver") 29 | raw2 = await self.exec("agetprop ro.sys.mi_build_num") 30 | self.ver = f"{raw1.rstrip()}_{raw2.rstrip()}" 31 | 32 | async def get_token(self) -> str: 33 | raw = await self.exec( 34 | "agetprop persist.app.miio_dtoken", as_bytes=True 35 | ) 36 | return raw.rstrip().hex() 37 | 38 | async def get_did(self): 39 | raw = await self.exec("agetprop persist.sys.miio_did") 40 | return raw.rstrip() 41 | 42 | async def get_wlan_mac(self): 43 | raw = await self.exec("agetprop persist.sys.miio_mac") 44 | return raw.rstrip().replace(":", "").lower() 45 | 46 | async def run_ftp(self): 47 | await self.exec("tcpsvd -E 0.0.0.0 21 ftpd -w &") 48 | 49 | async def prevent_unpair(self): 50 | await self.exec("killall mha_master") 51 | 52 | async def check_openmiio_agent(self) -> int: 53 | return await self.check_bin("openmiio_agent", MD5_AGENT, URL_AGENT) 54 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/number.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.number import NumberEntity 2 | from homeassistant.const import MAJOR_VERSION, MINOR_VERSION 3 | from homeassistant.core import callback 4 | 5 | from . import DOMAIN 6 | from .core.converters import Converter 7 | from .core.device import XDevice 8 | from .core.entity import XEntity 9 | from .core.gateway import XGateway 10 | 11 | 12 | async def async_setup_entry(hass, config_entry, async_add_entities): 13 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 14 | if conv.attr in device.entities: 15 | entity: XEntity = device.entities[conv.attr] 16 | entity.gw = gateway 17 | else: 18 | entity = XiaomiNumber(gateway, device, conv) 19 | async_add_entities([entity]) 20 | 21 | gw: XGateway = hass.data[DOMAIN][config_entry.entry_id] 22 | gw.add_setup(__name__, setup) 23 | 24 | 25 | # noinspection PyAbstractClass 26 | class XiaomiNumber(XEntity, NumberEntity): 27 | _attr_value: float = None 28 | 29 | def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter): 30 | super().__init__(gateway, device, conv) 31 | 32 | if hasattr(conv, "min"): 33 | self._attr_min_value = conv.min 34 | if hasattr(conv, "max"): 35 | self._attr_max_value = conv.max 36 | 37 | @callback 38 | def async_set_state(self, data: dict): 39 | if self.attr in data: 40 | self._attr_value = data[self.attr] 41 | 42 | @callback 43 | def async_restore_last_state(self, state: float, attrs: dict): 44 | self._attr_value = state 45 | 46 | async def async_update(self): 47 | await self.device_read(self.subscribed_attrs) 48 | 49 | # backward compatibility fix 50 | if (MAJOR_VERSION, MINOR_VERSION) >= (2022, 8): 51 | async def async_set_native_value(self, value: float) -> None: 52 | await self.device_send({self.attr: value}) 53 | else: 54 | async def async_set_value(self, value: float) -> None: 55 | await self.device_send({self.attr: value}) 56 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/mesh.py: -------------------------------------------------------------------------------- 1 | from .base import GatewayBase, SIGNAL_PREPARE_GW 2 | from .. import shell 3 | from ..converters import MESH, MESH_GROUP_MODEL 4 | from ..device import XDevice 5 | 6 | 7 | # noinspection PyMethodMayBeStatic,PyUnusedLocal 8 | class MeshGateway(GatewayBase): 9 | def mesh_init(self): 10 | if not self.ble_mode: 11 | return 12 | self.dispatcher_connect(SIGNAL_PREPARE_GW, self.mesh_prepare_gateway) 13 | 14 | async def mesh_read_devices(self, sh: shell.ShellMultimode): 15 | try: 16 | # prevent read database two times 17 | db = await sh.read_db_bluetooth() 18 | 19 | childs = {} 20 | 21 | # load Mesh bulbs 22 | rows = sh.db.read_table(sh.mesh_device_table) 23 | for row in rows: 24 | did = row[0] 25 | mac = row[1].replace(':', '').lower() 26 | device = self.devices.get(did) 27 | if not device: 28 | device = XDevice(MESH, row[2], did, mac) 29 | self.add_device(did, device) 30 | 31 | # add bulb to group address 32 | childs.setdefault(row[5], []).append(did) 33 | 34 | # load Mesh groups 35 | rows = sh.db.read_table(sh.mesh_group_table) 36 | for row in rows: 37 | did = 'group.' + row[0] 38 | device = self.devices.get(did) 39 | if not device: 40 | # don't know if 8 bytes enougth 41 | mac = int(row[0]).to_bytes(8, 'big').hex() 42 | device = XDevice(MESH, MESH_GROUP_MODEL, did, mac) 43 | # update childs of device 44 | device.extra["childs"] = childs.get(row[1]) 45 | self.add_device(did, device) 46 | 47 | except Exception as e: 48 | self.debug("Can't read mesh DB", exc_info=e) 49 | 50 | async def mesh_prepare_gateway(self, sh: shell.ShellMGW): 51 | if self.available is None: 52 | await self.mesh_read_devices(sh) 53 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging can be setup from: 3 | 4 | 1. Hass default config 5 | 6 | ```yaml 7 | logger: 8 | logs: 9 | custom_components.xiaomi_gateway3: debug 10 | ``` 11 | 12 | 2. Integration config (YAML) 13 | 14 | ```yaml 15 | xiaomi_gateway3: 16 | logger: 17 | filename: xiaomi_gateway3.log 18 | propagate: False # disable log to home-assistant.log and console 19 | max_bytes: 100000000 20 | backup_count: 3 21 | ``` 22 | 23 | 3. Integration config (GUI) 24 | 25 | Configuration > Xiaomi Gateway 3 > Configure > Debug 26 | """ 27 | import logging 28 | import os 29 | from logging import Formatter 30 | from logging.handlers import RotatingFileHandler 31 | 32 | import voluptuous as vol 33 | from homeassistant.const import CONF_FILENAME 34 | from homeassistant.helpers import config_validation as cv 35 | 36 | FMT = "%(asctime)s %(message)s" 37 | 38 | CONFIG_SCHEMA = vol.Schema({ 39 | vol.Optional('level', default='debug'): cv.string, 40 | vol.Optional('propagate', default=True): cv.boolean, 41 | vol.Optional(CONF_FILENAME): cv.string, 42 | vol.Optional('mode', default='a'): cv.string, 43 | vol.Optional('max_bytes', default=0): cv.positive_int, 44 | vol.Optional('backup_count', default=0): cv.positive_int, 45 | vol.Optional('format', default=FMT): cv.string, 46 | }, extra=vol.ALLOW_EXTRA) 47 | 48 | 49 | def init(logger_name: str, config: dict, config_dir: str = None): 50 | level = config['level'].upper() 51 | 52 | logger = logging.getLogger(logger_name) 53 | logger.propagate = config['propagate'] 54 | logger.setLevel(level) 55 | 56 | filename = config.get(CONF_FILENAME) 57 | if filename: 58 | if config_dir: 59 | filename = os.path.join(config_dir, filename) 60 | 61 | handler = RotatingFileHandler( 62 | filename, 63 | config['mode'], 64 | config['max_bytes'], 65 | config['backup_count'], 66 | ) 67 | 68 | fmt = Formatter(config['format']) 69 | handler.setFormatter(fmt) 70 | 71 | logger.addHandler(handler) 72 | -------------------------------------------------------------------------------- /tests/test_entities.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | from homeassistant.components.sensor import DOMAIN 5 | from homeassistant.core import HomeAssistant 6 | 7 | from custom_components.xiaomi_gateway3.core.converters import ZIGBEE 8 | from custom_components.xiaomi_gateway3.core.device import XDevice 9 | from custom_components.xiaomi_gateway3.core.gateway import XGateway 10 | from custom_components.xiaomi_gateway3.sensor import XiaomiAction 11 | 12 | assert DOMAIN 13 | 14 | ZDID = "lumi.112233aabbcc" 15 | ZMAC = "0x0000112233aabbcc" 16 | ZNWK = "0x12ab" 17 | 18 | 19 | class Hass(HomeAssistant): 20 | def __init__(self): 21 | asyncio.get_running_loop = lambda: asyncio.new_event_loop() 22 | HomeAssistant.__init__(self) 23 | self.bus.async_fire = self.async_fire 24 | self.events = [] 25 | 26 | def async_fire(self, *args, **kwargs): 27 | self.events.append(args) 28 | 29 | 30 | def test_button(): 31 | gw = XGateway("", "") 32 | device = XDevice(ZIGBEE, 'lumi.sensor_86sw2', ZDID, ZMAC, ZNWK) 33 | device.setup_converters() 34 | device.available = True 35 | conv = next(conv for conv in device.converters if conv.attr == "action") 36 | 37 | button = XiaomiAction(gw, device, conv) 38 | button.hass = Hass() 39 | button.async_write_ha_state() 40 | 41 | state = button.hass.states.get(button.entity_id) 42 | assert state.state == "" 43 | assert state.attributes == { 44 | 'device_class': 'action', 45 | 'friendly_name': 'Aqara Double Wall Button Action', 46 | 'icon': 'mdi:bell' 47 | } 48 | 49 | data = device.decode_lumi([{"res_name": "13.1.85", "value": 1}]) 50 | button.async_set_state(data) 51 | button.async_write_ha_state() 52 | 53 | state = button.hass.states.get(button.entity_id) 54 | assert state.state == 'button_1_single' 55 | assert button.hass.events[1] == ('xiaomi_aqara.click', { 56 | 'entity_id': 'sensor.0x0000112233aabbcc_action', 57 | 'click_type': 'button_1_single' 58 | }) 59 | 60 | button.hass.loop.run_until_complete(asyncio.sleep(.3)) 61 | state = button.hass.states.get(button.entity_id) 62 | assert state.state == '' 63 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/diagnostics.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from homeassistant.config_entries import ConfigEntry 4 | from homeassistant.core import HomeAssistant 5 | from homeassistant.helpers.device_registry import DeviceEntry 6 | 7 | from .core.const import DOMAIN, source_hash 8 | from .core.converters import GATEWAY 9 | from .core.device import logger 10 | from .core.gateway import XGateway 11 | 12 | 13 | async def async_get_config_entry_diagnostics( 14 | hass: HomeAssistant, entry: ConfigEntry 15 | ): 16 | options = { 17 | k: "***" if k in ("host", "token") else v 18 | for k, v in entry.options.items() 19 | } 20 | 21 | try: 22 | ts = time.time() 23 | devices = { 24 | device.unique_id: device.as_dict(ts) 25 | for device in XGateway.devices.values() 26 | } 27 | except Exception as e: 28 | devices = f"{type(e).__name__}: {e}" 29 | 30 | try: 31 | errors = [ 32 | entry.to_dict() 33 | for key, entry in hass.data["system_log"].records.items() 34 | if DOMAIN in key 35 | ] 36 | except Exception as e: 37 | errors = f"{type(e).__name__}: {e}" 38 | 39 | return { 40 | "version": source_hash(), 41 | "options": options, 42 | "errors": errors, 43 | "devices": devices, 44 | } 45 | 46 | 47 | async def async_get_device_diagnostics( 48 | hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry 49 | ): 50 | info = await async_get_config_entry_diagnostics(hass, entry) 51 | try: 52 | uid = next(i[1] for i in device.identifiers if i[0] == DOMAIN) 53 | info["device"] = info.pop("devices")[uid] 54 | info["device"]["unique_id"] = uid 55 | 56 | if device.model.startswith(GATEWAY): 57 | gw: XGateway = hass.data[DOMAIN][entry.entry_id] 58 | info["data.tar.gz.b64"] = await gw.tar_data() 59 | else: 60 | device = next( 61 | d for d in XGateway.devices.values() if d.unique_id == uid 62 | ) 63 | info["logger"] = logger(device) 64 | 65 | except Exception as e: 66 | info["error"] = f"{type(e).__name__}: {e}" 67 | 68 | return info 69 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.alarm_control_panel import \ 2 | SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, \ 3 | SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntity 4 | from homeassistant.const import STATE_ALARM_TRIGGERED 5 | from homeassistant.core import callback 6 | 7 | from . import DOMAIN 8 | from .core.converters import Converter 9 | from .core.device import XDevice 10 | from .core.entity import XEntity 11 | from .core.gateway import XGateway 12 | 13 | 14 | async def async_setup_entry(hass, config_entry, async_add_entities): 15 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 16 | if conv.attr in device.entities: 17 | entity: XEntity = device.entities[conv.attr] 18 | entity.gw = gateway 19 | else: 20 | entity = XiaomiAlarm(gateway, device, conv) 21 | async_add_entities([entity]) 22 | 23 | gw: XGateway = hass.data[DOMAIN][config_entry.entry_id] 24 | gw.add_setup(__name__, setup) 25 | 26 | 27 | # noinspection PyAbstractClass 28 | class XiaomiAlarm(XEntity, AlarmControlPanelEntity): 29 | _attr_code_arm_required = False 30 | _attr_supported_features = ( 31 | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | 32 | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER 33 | ) 34 | 35 | @callback 36 | def async_set_state(self, data: dict): 37 | if self.attr in data: 38 | self._attr_state = data[self.attr] 39 | if data.get("alarm_trigger") is True: 40 | self._attr_state = STATE_ALARM_TRIGGERED 41 | 42 | async def async_alarm_disarm(self, code=None): 43 | await self.device_send({self.attr: "disarmed"}) 44 | 45 | async def async_alarm_arm_home(self, code=None): 46 | await self.device_send({self.attr: "armed_home"}) 47 | 48 | async def async_alarm_arm_away(self, code=None): 49 | await self.device_send({self.attr: "armed_away"}) 50 | 51 | async def async_alarm_arm_night(self, code=None): 52 | await self.device_send({self.attr: "armed_night"}) 53 | 54 | async def async_alarm_trigger(self, code=None): 55 | await self.device_send({"alarm_trigger": True}) 56 | 57 | async def async_update(self): 58 | await self.device_read(self.subscribed_attrs) 59 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "请选择至少一个服务器", 5 | "cant_login": "无法登录,请检查用户名和密码", 6 | "cant_connect": "无法连接到网络", 7 | "wrong_model": "网关型号不支持", 8 | "wrong_token": "Wrong Mi Home token", 9 | "wrong_telnet": "Wrong open telnet command", 10 | "verify": "Click to verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "选择动作", 15 | "data": { 16 | "action": "动作" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "添加小米云账号", 21 | "description": "选择设备所属的服务器{verify}", 22 | "data": { 23 | "username": "邮箱/小米 ID", 24 | "password": "密码", 25 | "servers": "服务器" 26 | } 27 | }, 28 | "token": { 29 | "description": "需要[获取](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md)米家 token。\n注意:若网关固件版本高于 **1.4.6_0043** ,需要先[焊接](https://github.com/AlexxIT/XiaomiGateway3/wiki)才能支持。", 30 | "data": { 31 | "host": "主机", 32 | "token": "Token", 33 | "ble": "支持 BLE 设备", 34 | "zha": "Zigbee Home Automation 模式" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "title": "小米云的设备信息", 43 | "data": { 44 | "did": "设备" 45 | }, 46 | "description": "{device_info}" 47 | }, 48 | "user": { 49 | "title": "网关设定", 50 | "data": { 51 | "host": "主机", 52 | "token": "Token", 53 | "ble": "支持 BLE 设备", 54 | "stats": "Zigbee 和 BLE 效能数据", 55 | "debug": "调试信息", 56 | "buzzer": "[DANGER] 关闭蜂鸣器", 57 | "memory": "[DANGER] Use storage in memory", 58 | "zha": "[DANGER] Mode ZHA or zigbee2mqtt" 59 | }, 60 | "description": "如果您不清楚了解带有 **[DANGER]** 选项的作用,建议不要更改它们。" 61 | } 62 | } 63 | }, 64 | "device_automation": { 65 | "trigger_type": { 66 | "button": "按下按键", 67 | "button_1": "按下第 1 键", 68 | "button_2": "按下第 2 键", 69 | "button_3": "按下第 3 键", 70 | "button_4": "按下第 4 键", 71 | "button_5": "按下第 5 键", 72 | "button_6": "按下第 6 键", 73 | "button_both": "两键同时按下", 74 | "button_both_12": "同时按下第 1、2 键", 75 | "button_both_13": "同时按下第 1、3 键", 76 | "button_both_23": "同时按下第 2、3 键" 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/device_trigger.py: -------------------------------------------------------------------------------- 1 | import voluptuous as vol 2 | from homeassistant.components.device_automation import \ 3 | DEVICE_TRIGGER_BASE_SCHEMA 4 | from homeassistant.components.homeassistant.triggers import \ 5 | state as state_trigger 6 | from homeassistant.const import * 7 | from homeassistant.helpers import config_validation as cv 8 | from homeassistant.helpers import ( 9 | device_registry as dr, entity_registry as er 10 | ) 11 | 12 | from . import DOMAIN 13 | from .core import converters 14 | from .core.converters.base import BUTTON 15 | 16 | TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( 17 | { 18 | vol.Required(CONF_TYPE): cv.string, 19 | vol.Required('action'): cv.string 20 | } 21 | ) 22 | 23 | 24 | async def async_attach_trigger(hass, config, action, automation_info): 25 | entity_registry = er.async_get(hass) 26 | 27 | device_id = config[CONF_DEVICE_ID] 28 | entry = next(( 29 | entry for entry in entity_registry.entities.values() if 30 | entry.device_id == device_id and entry.unique_id.endswith('action') 31 | ), None) 32 | 33 | if not entry: 34 | return None 35 | 36 | to_state = ( 37 | config['action'] if config[CONF_TYPE] == 'button' else 38 | f"{config[CONF_TYPE]}_{config['action']}" 39 | ) 40 | 41 | state_config = { 42 | CONF_PLATFORM: CONF_STATE, 43 | CONF_ENTITY_ID: entry.entity_id, 44 | state_trigger.CONF_TO: to_state 45 | } 46 | 47 | state_config = state_trigger.TRIGGER_STATE_SCHEMA(state_config) 48 | return await state_trigger.async_attach_trigger( 49 | hass, state_config, action, automation_info, platform_type="device" 50 | ) 51 | 52 | 53 | async def async_get_triggers(hass, device_id): 54 | device_registry = dr.async_get(hass) 55 | device: dr.DeviceEntry = device_registry.async_get(device_id) 56 | buttons = converters.get_buttons(device.model) 57 | if not buttons: 58 | return None 59 | 60 | return [{ 61 | CONF_PLATFORM: CONF_DEVICE, 62 | CONF_DEVICE_ID: device_id, 63 | CONF_DOMAIN: DOMAIN, 64 | CONF_TYPE: button, 65 | } for button in buttons] 66 | 67 | 68 | # noinspection PyUnusedLocal 69 | async def async_get_trigger_capabilities(hass, config): 70 | return { 71 | "extra_fields": vol.Schema({ 72 | vol.Required('action'): vol.In(BUTTON.values()), 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/cover.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.cover import CoverEntity, ATTR_POSITION, \ 2 | ATTR_CURRENT_POSITION 3 | from homeassistant.const import STATE_CLOSING, STATE_OPENING 4 | from homeassistant.core import callback 5 | from homeassistant.helpers.restore_state import RestoreEntity 6 | 7 | from . import DOMAIN 8 | from .core.converters import Converter 9 | from .core.device import XDevice 10 | from .core.entity import XEntity 11 | from .core.gateway import XGateway 12 | 13 | 14 | async def async_setup_entry(hass, config_entry, async_add_entities): 15 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 16 | if conv.attr in device.entities: 17 | entity: XEntity = device.entities[conv.attr] 18 | entity.gw = gateway 19 | else: 20 | entity = XiaomiCover(gateway, device, conv) 21 | async_add_entities([entity]) 22 | 23 | gw: XGateway = hass.data[DOMAIN][config_entry.entry_id] 24 | gw.add_setup(__name__, setup) 25 | 26 | 27 | # noinspection PyAbstractClass 28 | class XiaomiCover(XEntity, CoverEntity, RestoreEntity): 29 | _attr_current_cover_position = 0 30 | _attr_is_closed = None 31 | 32 | @callback 33 | def async_set_state(self, data: dict): 34 | if 'run_state' in data: 35 | self._attr_state = data["run_state"] 36 | self._attr_is_opening = self._attr_state == STATE_OPENING 37 | self._attr_is_closing = self._attr_state == STATE_CLOSING 38 | if 'position' in data: 39 | self._attr_current_cover_position = data['position'] 40 | # https://github.com/AlexxIT/XiaomiGateway3/issues/771 41 | self._attr_is_closed = self._attr_current_cover_position <= 2 42 | 43 | @callback 44 | def async_restore_last_state(self, state: str, attrs: dict): 45 | if not state: 46 | return 47 | self.async_set_state({ 48 | "run_state": state, 49 | "position": attrs[ATTR_CURRENT_POSITION] 50 | }) 51 | 52 | async def async_open_cover(self, **kwargs): 53 | await self.device_send({self.attr: "open"}) 54 | 55 | async def async_close_cover(self, **kwargs): 56 | await self.device_send({self.attr: "close"}) 57 | 58 | async def async_stop_cover(self, **kwargs): 59 | await self.device_send({self.attr: "stop"}) 60 | 61 | async def async_set_cover_position(self, **kwargs): 62 | await self.device_send({"position": kwargs[ATTR_POSITION]}) 63 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/zh-Hant.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "請至少選擇一個伺服器", 5 | "cant_login": "無法登入,請確認使用者名稱與密碼", 6 | "cant_connect": "無法連線至網關", 7 | "wrong_model": "網關型號不支援", 8 | "wrong_token": "小米智能家庭權杖錯誤", 9 | "wrong_telnet": "開啟 Telnet 指令錯誤", 10 | "verify": "Click to verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "選擇動作", 15 | "data": { 16 | "action": "動作" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "新增小米雲服務帳號", 21 | "description": "僅選擇已榜定設備的伺服器{verify}", 22 | "data": { 23 | "username": "Email / 小米帳號 ID", 24 | "password": "密碼", 25 | "servers": "伺服器" 26 | } 27 | }, 28 | "token": { 29 | "description": "可以透過 [雲端整合](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) 自動或 [手動](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md) 取得米家智能家庭設備權杖 Token。請參閱 [元件文件](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares) 以了解支援的韌體版本。", 30 | "data": { 31 | "host": "主機端", 32 | "token": "權杖", 33 | "telnet_cmd": "開啟 Telnet 指令" 34 | } 35 | } 36 | } 37 | }, 38 | "options": { 39 | "step": { 40 | "cloud": { 41 | "title": "小米雲設備資訊", 42 | "data": { 43 | "did": "裝置" 44 | }, 45 | "description": "{device_info}" 46 | }, 47 | "user": { 48 | "title": "網關設定", 49 | "data": { 50 | "host": "主機端", 51 | "token": "密鑰", 52 | "telnet_cmd": "開啟 Telnet 指令", 53 | "ble": "支援 BLE 設備", 54 | "stats": "Zigbee 與 BLE 效能資料", 55 | "debug": "Debug", 56 | "buzzer": "[DANGER] 關閉蜂鳴器", 57 | "memory": "[DANGER] 使用記憶體空間", 58 | "zha": "[DANGER] ZHA 或 zigbee2mqtt 模式" 59 | }, 60 | "description": "僅於了解可能之風險後,才建議變更 **ADV** 選項" 61 | } 62 | } 63 | }, 64 | "device_automation": { 65 | "trigger_type": { 66 | "button": "按鈕按下", 67 | "button_1": "第 1 個按鈕按下", 68 | "button_2": "第 2 個按鈕按下", 69 | "button_3": "第 3 個按鈕按下", 70 | "button_4": "第 4 個按鈕按下", 71 | "button_5": "第 5 個按鈕按下", 72 | "button_6": "第 6 個按鈕按下", 73 | "button_both": "按鈕同時按下", 74 | "button_both_12": "第 1 與第 2 個按鈕同時按下", 75 | "button_both_13": "第 1 與第 3 個按鈕同時按下", 76 | "button_both_23": "第 2 與第 3 個按鈕同時按下" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /print_models.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from custom_components.xiaomi_gateway3.core.converters.devices import DEVICES 4 | from custom_components.xiaomi_gateway3.core.converters.mibeacon import MiBeacon 5 | 6 | columns = [ 7 | "Brand", "Name", "Model", "Entities", "S" 8 | ] 9 | header = ["---"] * len(columns) 10 | 11 | devices = {} 12 | 13 | for device in DEVICES: 14 | # skip devices with bad support 15 | if device.get("support", 3) < 3: 16 | continue 17 | 18 | for k, v in device.items(): 19 | if not isinstance(v, list) or k in ("spec", "lumi.gateway.aqcn03"): 20 | continue 21 | 22 | brand, name, model = v if len(v) == 3 else v + [k] 23 | 24 | if isinstance(k, str): 25 | if "gateway" in k: 26 | type = "Gateways" 27 | elif k.startswith("lumi.") or k.startswith("ikea."): 28 | type = "Xiaomi Zigbee" 29 | else: 30 | type = "Other Zigbee" 31 | elif MiBeacon in device["spec"]: 32 | type = "Xiaomi BLE" 33 | else: 34 | type = "Xiaomi Mesh" 35 | 36 | if type != "Other Zigbee": 37 | link = f"https://home.miot-spec.com/s/{k}" 38 | else: 39 | link = f"https://www.zigbee2mqtt.io/supported-devices/#s={model}" 40 | 41 | items = devices.setdefault(type, []) 42 | 43 | # skip if model already exists 44 | if any(True for i in items if model in i[2]): 45 | continue 46 | 47 | # skip BLE with unknown spec 48 | if "default" not in device: 49 | spec = ", ".join([ 50 | conv.attr + "*" if conv.enabled is None else conv.attr 51 | for conv in device["spec"] if conv.domain 52 | ]) 53 | else: 54 | spec = "*" 55 | 56 | support = str(device.get("support", "")) 57 | 58 | model = f'[{model}]({link})' 59 | 60 | items.append([brand, name, model, spec, support]) 61 | 62 | out = "\n" 63 | for k, v in devices.items(): 64 | out += f"## Supported {k}\n\nTotal devices: {len(v)}\n\n" 65 | out += "|".join(columns) + "\n" 66 | out += "|".join(header) + "\n" 67 | for line in sorted(v): 68 | out += "|".join(line) + "\n" 69 | out += "\n" 70 | out += "" 71 | 72 | raw = open("README.md", "r", encoding="utf-8").read() 73 | raw = re.sub( 74 | r"(.+?)", out, raw, flags=re.DOTALL 75 | ) 76 | open("README.md", "w", encoding="utf-8").write(raw) 77 | -------------------------------------------------------------------------------- /tests/test_conv_mibeacon.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import DOMAIN 2 | 3 | from custom_components.xiaomi_gateway3.core.device import XDevice, BLE 4 | 5 | assert DOMAIN # fix circular import 6 | 7 | DID = "blt.3.abc" 8 | MAC = "112233aabbcc" 9 | DID2 = "123456789" # locks have nubm did 10 | 11 | 12 | def test_night_light(): 13 | device = XDevice(BLE, 2038, DID, MAC) 14 | assert device.info.name == "Xiaomi Night Light 2" 15 | device.setup_converters() 16 | 17 | p = device.decode("mibeacon", {"eid": 15, "edata": "640000"}) 18 | assert p == {'light': True, 'motion': True} 19 | p = device.decode("mibeacon", {'eid': 4103, 'edata': '640000'}) 20 | assert p == {'light': True} 21 | p = device.decode("mibeacon", {'eid': 4106, 'edata': '64'}) 22 | assert p == {'battery': 100} 23 | p = device.decode("mibeacon", {'eid': 4119, 'edata': '78000000'}) 24 | assert p == {'idle_time': 120} 25 | 26 | 27 | def test_kettle(): 28 | device = XDevice(BLE, 131, DID, MAC) 29 | assert device.info.name == "Xiaomi Kettle" 30 | device.setup_converters() 31 | 32 | p = device.decode("mibeacon", {'eid': 4101, 'edata': '0063'}) 33 | assert p == {'power': False, 'state': 'idle', 'temperature': 99} 34 | p = device.decode("mibeacon", {'eid': 4101, 'edata': '0154'}) 35 | assert p == {'power': True, 'state': 'heat', 'temperature': 84} 36 | 37 | 38 | def test_new_th(): 39 | device = XDevice(BLE, 4611, DID, MAC) 40 | assert device.info.name == "Xiaomi TH Sensor" 41 | device.setup_converters() 42 | 43 | p = device.decode("mibeacon", {'eid': 19464, 'edata': 'cdcc3e42'}) 44 | assert p == {'humidity': 47.7} 45 | p = device.decode("mibeacon", {'eid': 19457, 'edata': 'cdcca841'}) 46 | assert p == {'temperature': 21.1} 47 | 48 | 49 | def test_lock(): 50 | device = XDevice(BLE, 1694, DID2, MAC) 51 | assert device.info.name == "Aqara Door Lock N100 (Bluetooth)" 52 | device.setup_converters() 53 | 54 | p = device.decode("mibeacon", {'eid': 4106, 'edata': '329aaecd62'}) 55 | assert p == {'battery': 50} 56 | 57 | p = device.decode("mibeacon", {"eid": 11, "edata": "a400000000b8aecd62"}) 58 | assert p 59 | 60 | p = device.decode("mibeacon", {"eid": 7, "edata": "00c5aecd62"}) 61 | assert p 62 | 63 | p = device.decode("mibeacon", {"eid": 7, "edata": "01cbaecd62"}) 64 | assert p 65 | 66 | p = device.decode("mibeacon", {"eid": 11, "edata": "2002000180c4aecd62"}) 67 | assert p 68 | 69 | p = device.decode("mibeacon", {"eid": 6, "edata": "ffffffff00"}) 70 | assert p 71 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/miot.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | 3 | from .base import GatewayBase, SIGNAL_MQTT_PUB 4 | from ..device import XDevice 5 | from ..mini_mqtt import MQTTMessage 6 | 7 | 8 | # noinspection PyMethodMayBeStatic,PyUnusedLocal 9 | class MIoTGateway(GatewayBase): 10 | def miot_init(self): 11 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.miot_mqtt_publish) 12 | 13 | async def miot_mqtt_publish(self, msg: MQTTMessage): 14 | if msg.topic == "miio/report": 15 | if b'"properties_changed"' in msg.payload: 16 | await self.miot_process_properties(msg.json["params"]) 17 | elif b'"event_occured"' in msg.payload: 18 | await self.miot_process_event(msg.json["params"]) 19 | 20 | async def miot_process_properties(self, data: list): 21 | """Can receive multiple properties from multiple devices. 22 | data = [{'did':123,'siid':2,'piid':1,'value:True}] 23 | """ 24 | # convert miio response format to multiple responses in lumi format 25 | devices: Dict[str, Optional[list]] = {} 26 | for item in data: 27 | if item['did'] not in self.devices: 28 | continue 29 | devices.setdefault(item['did'], []).append(item) 30 | 31 | for did, payload in devices.items(): 32 | device = self.devices[did] 33 | payload = device.decode_miot(payload) 34 | device.update(payload) 35 | 36 | async def miot_process_event(self, data: dict): 37 | # {"did":"123","siid":8,"eiid":1,"tid":123,"ts":123,"arguments":[]} 38 | device = self.devices.get(data["did"]) 39 | if not device: 40 | return 41 | payload = device.decode_miot([data]) 42 | device.update(payload) 43 | 44 | async def miot_send(self, device: XDevice, payload: dict) -> bool: 45 | assert "mi_spec" in payload, payload 46 | self.debug_device(device, "send", payload, tag="MIOT") 47 | for item in payload["mi_spec"]: 48 | item["did"] = device.did 49 | # MIoT properties changes should return in 50 | resp = await self.miio_send("set_properties", payload["mi_spec"]) 51 | return resp and "result" in resp 52 | 53 | async def miot_read(self, device: XDevice, payload: dict) \ 54 | -> Optional[dict]: 55 | assert "mi_spec" in payload, payload 56 | self.debug_device(device, "read", payload, tag="MIOT") 57 | for item in payload["mi_spec"]: 58 | item["did"] = device.did 59 | resp = await self.miio_send("get_properties", payload["mi_spec"]) 60 | if resp is None or "result" not in resp: 61 | return None 62 | return device.decode_miot(resp['result']) 63 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/shell/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | from typing import Union 4 | 5 | from .base import TelnetShell, ShellMultimode 6 | from .shell_e1 import ShellE1 7 | from .shell_mgw import ShellMGW 8 | from .shell_mgw2 import ShellMGW2 9 | 10 | 11 | class Session: 12 | """Support automatic closing session in case of trouble. Example of usage: 13 | 14 | try: 15 | async with shell.Session(host) as session: 16 | sh = await session.login() 17 | return True 18 | except Exception: 19 | return False 20 | """ 21 | reader: asyncio.StreamReader 22 | writer: asyncio.StreamWriter 23 | 24 | def __init__(self, host: str, port=23): 25 | self.coro = asyncio.open_connection(host, port, limit=1_000_000) 26 | 27 | async def __aenter__(self): 28 | await self.connect() 29 | return await self.login() 30 | 31 | async def __aexit__(self, exc_type, exc, tb): 32 | await self.close() 33 | 34 | async def connect(self): 35 | self.reader, self.writer = await asyncio.wait_for(self.coro, 5) 36 | 37 | async def close(self): 38 | self.writer.close() 39 | await self.writer.wait_closed() 40 | 41 | async def login(self) -> Union[TelnetShell, ShellMGW, ShellE1, ShellMGW2]: 42 | coro = self.reader.readuntil(b"login: ") 43 | resp: bytes = await asyncio.wait_for(coro, 3) 44 | 45 | if b"rlxlinux" in resp: 46 | shell = ShellMGW(self.reader, self.writer) 47 | elif b"Aqara-Hub-E1" in resp: 48 | shell = ShellE1(self.reader, self.writer) 49 | elif b"Mijia_Hub_V2" in resp: 50 | shell = ShellMGW2(self.reader, self.writer) 51 | else: 52 | raise Exception(f"Unknown response: {resp}") 53 | 54 | await shell.login() 55 | await shell.prepare() 56 | 57 | return shell 58 | 59 | 60 | NTP_DELTA = 2208988800 # 1970-01-01 00:00:00 61 | NTP_QUERY = b'\x1b' + 47 * b'\0' 62 | 63 | 64 | def ntp_time(host: str) -> float: 65 | """Return server send time""" 66 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 67 | sock.settimeout(2) 68 | try: 69 | sock.sendto(NTP_QUERY, (host, 123)) 70 | raw = sock.recv(1024) 71 | 72 | integ = int.from_bytes(raw[-8:-4], 'big') 73 | fract = int.from_bytes(raw[-4:], 'big') 74 | return integ + float(fract) / 2 ** 32 - NTP_DELTA 75 | except Exception: 76 | return 0 77 | finally: 78 | sock.close() 79 | 80 | 81 | def check_port(host: str, port: int): 82 | """Check if gateway port open.""" 83 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 84 | s.settimeout(2) 85 | try: 86 | return s.connect_ex((host, port)) == 0 87 | finally: 88 | s.close() 89 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/ua.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "Будь ласка, виберіть хоча б один сервер", 5 | "cant_login": "Помилка, перевірте ім'я користувача і пароль", 6 | "cant_connect": "Не вдається підключитися до шлюзу", 7 | "wrong_model": "Модель шлюзу не підтримується", 8 | "wrong_token": "Wrong Mi Home token", 9 | "wrong_telnet": "Wrong open telnet command", 10 | "verify": "Click to verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Виберіть дію", 15 | "data": { 16 | "action": "Дія" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "Додати Mi Cloud аккаунт", 21 | "description": "Вибирайте тільки ті сервери, на яких у вас є пристрої{verify}", 22 | "data": { 23 | "username": "Email / Mi Account ID", 24 | "password": "Пароль", 25 | "servers": "сервери" 26 | } 27 | }, 28 | "token": { 29 | "description": "[Отримайте](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md) Mi Home токен. Шлюз з прошивкою **1.4.6_0043** і вище підтримується тільки після [перепрошивки](https://github.com/AlexxIT/XiaomiGateway3/wiki)", 30 | "data": { 31 | "host": "IP-адреса", 32 | "token": "Токен" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "cloud": { 40 | "title": "Інформація про пристрої MiCloud", 41 | "data": { 42 | "did": "Пристрій" 43 | }, 44 | "description": "{device_info}" 45 | }, 46 | "user": { 47 | "title": "Налаштування шлюзу", 48 | "data": { 49 | "host": "IP-адреса", 50 | "token": "Токен", 51 | "ble": "Підтримка BLE пристроїв", 52 | "stats": "Деталізація роботи Zigbee і BLE", 53 | "debug": "Відлагодження", 54 | "buzzer": "[DANGER] Вимкнути спікер", 55 | "memory": "[DANGER] Use storage in memory", 56 | "zha": "[DANGER] Mode ZHA or zigbee2mqtt" 57 | }, 58 | "description": "Змінюйте **[DANGER]** налаштування ТІЛЬКИ якщо знаєте, що ви робите" 59 | } 60 | } 61 | }, 62 | "device_automation": { 63 | "trigger_type": { 64 | "button": "Натискання кнопки", 65 | "button_1": "Натискання першої кнопки", 66 | "button_2": "Натискання другої кнопки", 67 | "button_3": "Натискання третьої кнопки", 68 | "button_4": "Натискання четвертої кнопки", 69 | "button_5": "Натискання п'ятої кнопки", 70 | "button_6": "Натискання шостої кнопки", 71 | "button_both": "Натискання двох кнопок", 72 | "button_both_12": "Натискання першої та другої кнопок", 73 | "button_both_13": "Натискання першої і третьої кнопок", 74 | "button_both_23": "Натискання другої і третьої кнопок" 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "Please select at least one server", 5 | "cant_login": "Can't login, check username and password", 6 | "cant_connect": "Can't connect to gateway", 7 | "wrong_model": "Unsupported gateway model", 8 | "wrong_token": "Wrong Mi Home token", 9 | "wrong_telnet": "Wrong open telnet command", 10 | "verify": "Click to verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Select Action", 15 | "data": { 16 | "action": "Action" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "Add Mi Cloud Account", 21 | "description": "Select only those servers where you have devices{verify}", 22 | "data": { 23 | "username": "Email / Mi Account ID", 24 | "password": "Password", 25 | "servers": "Servers" 26 | } 27 | }, 28 | "token": { 29 | "description": "You can obtain Mi Home token automatically with [Cloud integration](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) or [manually](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Read about **open telnet command** in [documentation](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).", 30 | "data": { 31 | "host": "Host", 32 | "token": "Token", 33 | "telnet_cmd": "Open Telnet command" 34 | } 35 | } 36 | } 37 | }, 38 | "options": { 39 | "step": { 40 | "cloud": { 41 | "title": "MiCloud devices info", 42 | "data": { 43 | "did": "Device" 44 | }, 45 | "description": "{device_info}" 46 | }, 47 | "user": { 48 | "title": "Gateway Config", 49 | "data": { 50 | "host": "Host", 51 | "token": "Token", 52 | "telnet_cmd": "Open Telnet command", 53 | "ble": "Support Bluetooth devices", 54 | "stats": "Add statistic sensors", 55 | "debug": "Debug logs", 56 | "buzzer": "[DANGER] Disable buzzer", 57 | "memory": "[DANGER] Use storage in memory", 58 | "zha": "[DANGER] Mode ZHA or zigbee2mqtt" 59 | }, 60 | "description": "Change **[DANGER]** options ONLY if you know what you doing" 61 | } 62 | } 63 | }, 64 | "device_automation": { 65 | "trigger_type": { 66 | "button": "Button press", 67 | "button_1": "1st button press", 68 | "button_2": "2nd button press", 69 | "button_3": "3rd button press", 70 | "button_4": "4th button press", 71 | "button_5": "5th button press", 72 | "button_6": "6th button press", 73 | "button_both": "Both button press", 74 | "button_both_12": "1st and 2nd button press", 75 | "button_both_13": "1st and 3rd button press", 76 | "button_both_23": "2nd and 3rd button press" 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/climate.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.climate import * 2 | from homeassistant.components.climate.const import * 3 | from homeassistant.core import callback 4 | 5 | from . import DOMAIN 6 | from .core.converters import Converter 7 | from .core.device import XDevice 8 | from .core.entity import XEntity 9 | from .core.gateway import XGateway 10 | 11 | ACTIONS = { 12 | HVAC_MODE_OFF: CURRENT_HVAC_OFF, 13 | HVAC_MODE_COOL: CURRENT_HVAC_COOL, 14 | HVAC_MODE_HEAT: CURRENT_HVAC_HEAT 15 | } 16 | 17 | 18 | async def async_setup_entry(hass, config_entry, async_add_entities): 19 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 20 | if conv.attr in device.entities: 21 | entity: XEntity = device.entities[conv.attr] 22 | entity.gw = gateway 23 | else: 24 | entity = XiaomiClimate(gateway, device, conv) 25 | async_add_entities([entity]) 26 | 27 | gw: XGateway = hass.data[DOMAIN][config_entry.entry_id] 28 | gw.add_setup(__name__, setup) 29 | 30 | 31 | # noinspection PyAbstractClass 32 | class XiaomiClimate(XEntity, ClimateEntity): 33 | _attr_fan_mode = None 34 | _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO] 35 | _attr_hvac_mode = None 36 | _attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_COOL, HVAC_MODE_HEAT] 37 | _attr_precision = PRECISION_WHOLE 38 | _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE 39 | _attr_temperature_unit = TEMP_CELSIUS 40 | # support only KTWKQ03ES for now 41 | _attr_max_temp = 30 42 | _attr_min_temp = 17 43 | _attr_target_temperature_step = 1 44 | 45 | @callback 46 | def async_set_state(self, data: dict): 47 | self._attr_current_temperature = data.get("current_temp") 48 | self._attr_fan_mode = data.get("fan_mode") 49 | self._attr_hvac_mode = data.get("hvac_mode") 50 | # better support HomeKit 51 | # https://github.com/AlexxIT/XiaomiGateway3/issues/707#issuecomment-1099109552 52 | self._attr_hvac_action = ACTIONS.get(self._attr_hvac_mode) 53 | # fix scenes with turned off climate 54 | # https://github.com/AlexxIT/XiaomiGateway3/issues/101#issuecomment-757781988 55 | self._attr_target_temperature = data.get("target_temp", 0) 56 | 57 | async def async_update(self): 58 | await self.device_read(self.subscribed_attrs) 59 | 60 | async def async_set_temperature(self, **kwargs) -> None: 61 | if kwargs[ATTR_TEMPERATURE] == 0: 62 | return 63 | payload = {"target_temp": kwargs[ATTR_TEMPERATURE]} 64 | await self.device_send({self.attr: payload}) 65 | 66 | async def async_set_fan_mode(self, fan_mode: str) -> None: 67 | payload = {"fan_mode": fan_mode} 68 | await self.device_send({self.attr: payload}) 69 | 70 | async def async_set_hvac_mode(self, hvac_mode: str) -> None: 71 | payload = {"hvac_mode": hvac_mode} 72 | await self.device_send({self.attr: payload}) 73 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/gate_e1.py: -------------------------------------------------------------------------------- 1 | from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB, SIGNAL_TIMER 2 | from .lumi import LumiGateway 3 | from .silabs import SilabsGateway 4 | from .z3 import Z3Gateway 5 | from .. import shell 6 | from ..device import XDevice, GATEWAY 7 | from ..mini_mqtt import MQTTMessage 8 | 9 | MODEL = "lumi.gateway.aqcn02" 10 | 11 | 12 | class GateE1(LumiGateway, SilabsGateway, Z3Gateway): 13 | e1_ts = 0 14 | 15 | def e1_init(self): 16 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.e1_mqtt_publish) 17 | self.dispatcher_connect(SIGNAL_TIMER, self.e1_timer) 18 | 19 | async def e1_read_device(self, sh: shell.ShellE1): 20 | self.did = await sh.get_did() 21 | mac = await sh.get_wlan_mac() 22 | device = self.devices.get(self.did) 23 | if not device: 24 | device = XDevice(GATEWAY, MODEL, self.did, mac) 25 | device.extra = {"fw_ver": sh.ver} 26 | self.add_device(self.did, device) 27 | 28 | async def e1_prepare_gateway(self, sh: shell.ShellE1): 29 | self.e1_init() 30 | self.silabs_init() 31 | self.lumi_init() 32 | self.z3_init() 33 | 34 | msg = await sh.run_openmiio_agent() 35 | self.debug("openmiio_agent: " + msg) 36 | 37 | if self.available is None and self.did is None: 38 | await self.e1_read_device(sh) 39 | 40 | await self.dispatcher_send(SIGNAL_PREPARE_GW, sh=sh) 41 | 42 | return True 43 | 44 | async def e1_mqtt_publish(self, msg: MQTTMessage): 45 | if msg.topic.endswith('/heartbeat'): 46 | payload = self.device.decode(GATEWAY, msg.json) 47 | self.device.update(payload) 48 | 49 | async def e1_timer(self, ts: float): 50 | if ts < self.e1_ts: 51 | return 52 | await self.e1_update_stats() 53 | self.e1_ts = ts + 300 # 5 min 54 | 55 | async def e1_update_stats(self): 56 | try: 57 | async with shell.Session(self.host) as sh: 58 | serial = await sh.read_file( 59 | "/proc/tty/driver/ms_uart | grep -v ^0 | sort -r" 60 | ) 61 | free_mem = await sh.read_file( 62 | "/proc/meminfo | grep MemFree: | awk '{print $2}'" 63 | ) 64 | load_avg = await sh.read_file("/proc/loadavg | sed 's/ /|/g'") 65 | run_time = await sh.read_file("/proc/uptime | cut -f1 -d.") 66 | rssi = await sh.read_file( 67 | "/proc/net/wireless | grep wlan0 | awk '{print $4}' | cut -f1 -d." 68 | ) 69 | payload = self.device.decode(GATEWAY, { 70 | "serial": serial.decode(), 71 | "free_mem": int(free_mem), 72 | "load_avg": load_avg.decode(), 73 | "run_time": int(run_time), 74 | "rssi": int(rssi) + 100 75 | }) 76 | self.device.update(payload) 77 | 78 | except Exception as e: 79 | self.warning("Can't update gateway stats", e) 80 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "Selecione pelo menos um servidor", 5 | "cant_login": "Não consigo fazer login, verifique o nome de usuário e a senha", 6 | "cant_connect": "Não é possível conectar ao gateway", 7 | "wrong_model": "Modelo de gateway não compatível", 8 | "wrong_token": "Token Mi Home errado", 9 | "wrong_telnet": "Comando telnet aberto errado", 10 | "verify": "Click to verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Selecionar ação", 15 | "data": { 16 | "action": "Ação" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "Adicionar conta Mi Cloud", 21 | "description": "Selecione apenas os servidores em que você possui dispositivos{verify}", 22 | "data": { 23 | "username": "E-mail / ID da conta Mi", 24 | "password": "Senha", 25 | "servers": "Servidores" 26 | } 27 | }, 28 | "token": { 29 | "description": "Você pode obter o token Mi Home automaticamente com [integração na nuvem](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) ou [manualmente](https://github.com/Maxmudjon/ com.xiaomi-miio/blob/master/docs/obtain_token.md). Leia sobre **comando telnet aberto** em [documentação](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).", 30 | "data": { 31 | "host": "Host", 32 | "token": "Token", 33 | "telnet_cmd": "Abra o comando Telnet" 34 | } 35 | } 36 | } 37 | }, 38 | "options": { 39 | "step": { 40 | "cloud": { 41 | "title": "Informações de dispositivos MiCloud", 42 | "data": { 43 | "did": "Dispositivo" 44 | }, 45 | "description": "{device_info}" 46 | }, 47 | "user": { 48 | "title": "Configuração do Gateway", 49 | "data": { 50 | "host": "Host", 51 | "token": "Token", 52 | "telnet_cmd": "Abra o comando Telnet", 53 | "ble": "Suporta dispositivos Bluetooth", 54 | "stats": "Adicionar sensores estatísticos", 55 | "debug": "Debug", 56 | "buzzer": "Desativar campainha [ADV]", 57 | "memory": "Usar armazenamento na memória [ADV]", 58 | "zha": "Modo ZHA ou zigbee2mqtt [ADV]" 59 | }, 60 | "description": "Altere as opções **ADV** SOMENTE se você souber o que está fazendo" 61 | } 62 | } 63 | }, 64 | "device_automation": { 65 | "trigger_type": { 66 | "button": "Pressione o botão", 67 | "button_1": "1º botão pressionado", 68 | "button_2": "2º botão pressionado", 69 | "button_3": "3º botão pressionado", 70 | "button_4": "4º botão pressionado", 71 | "button_5": "5º botão pressionado", 72 | "button_6": "6º botão pressionado", 73 | "button_both": "Ambos os botões pressionados", 74 | "button_both_12": "1º e 2º botão pressionado", 75 | "button_both_13": "1º e 3º botão pressionado", 76 | "button_both_23": "2º e 3º botão pressionado" 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/ro.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "Te rog sa selectezi macar un server!", 5 | "cant_login": "Nu ma pot conecta. Mai verifica o data username-ul sau parola!", 6 | "cant_connect": "Nu ma pot conecta la gateway!", 7 | "wrong_model": "Modelul tau de gateway nu este suportat!", 8 | "wrong_token": "Wrong Mi Home token", 9 | "wrong_telnet": "Wrong open telnet command", 10 | "verify": "Click to verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Selecteaza actiunea", 15 | "data": { 16 | "action": "Actiune" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "Adauga un cont Mi Cloud", 21 | "description": "Selecteaza doar serverul unde ai dispozitivele{verify}", 22 | "data": { 23 | "username": "Email / ID-ul contului Mi", 24 | "password": "Parola", 25 | "servers": "Servere" 26 | } 27 | }, 28 | "token": { 29 | "description": "Poti obtine tokenul Mi Home in mod automat prin [Cloud integration](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) sau [manual](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Verifica versiunile de firmware suportate in [component docs](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).", 30 | "data": { 31 | "host": "IP Gateway", 32 | "token": "Token", 33 | "telnet_cmd": "Comanda de deschidere Telnet" 34 | } 35 | } 36 | } 37 | }, 38 | "options": { 39 | "step": { 40 | "cloud": { 41 | "title": "Informatii despre dispozitivele din MiCloud", 42 | "data": { 43 | "did": "Dispozitiv" 44 | }, 45 | "description": "{device_info}" 46 | }, 47 | "user": { 48 | "title": "Configurare Gateway", 49 | "data": { 50 | "host": "IP Gateway", 51 | "token": "Token", 52 | "telnet_cmd": "Comanda de deschidere Telnet", 53 | "ble": "Dispozitive BLE suportate", 54 | "stats": "Date despre performanta dispozitivelor Zigbee si BLE", 55 | "debug": "Debug", 56 | "buzzer": "[DANGER] Dezactiveaza buzzer", 57 | "memory": "[DANGER] Use storage in memory", 58 | "zha": "[DANGER] Mode ZHA or zigbee2mqtt" 59 | }, 60 | "description": "Nu schimba optiunile **[DANGER]** DECAT daca stii ce fac!" 61 | } 62 | } 63 | }, 64 | "device_automation": { 65 | "trigger_type": { 66 | "button": "Apasare Buton", 67 | "button_1": "Prima apasare de buton", 68 | "button_2": "A 2-a apasare de buton", 69 | "button_3": "A 3-a apasare de buton", 70 | "button_4": "A 4-a apasare de buton", 71 | "button_5": "A 5-a apasare de buton", 72 | "button_6": "A 6-a apasare de buton", 73 | "button_both": "Ambele butoane apasate", 74 | "button_both_12": "Primul si al 2-lea 2nd buton apasat", 75 | "button_both_13": "Primul si al 3-lea buton apasat", 76 | "button_both_23": "Al 2-lea si al 3-lea buton apasat" 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "Пожалуйста, выберите хотя бы один сервер", 5 | "cant_login": "Ошибка, проверьте имя пользователя и пароль", 6 | "cant_connect": "Не получается подключиться к шлюзу", 7 | "wrong_model": "Модель шлюза не поддерживается", 8 | "wrong_token": "Неверный Mi Home токен", 9 | "wrong_telnet": "Неверная команда для открытия Telnet", 10 | "verify": "Перейдите по verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Выберите действие", 15 | "data": { 16 | "action": "Действие" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "Добавить Mi Cloud аккаунт", 21 | "description": "Выбирайте только те серверы, на которых у вас есть устройства{verify}", 22 | "data": { 23 | "username": "Email / Mi Account ID", 24 | "password": "Пароль", 25 | "servers": "Серверы" 26 | } 27 | }, 28 | "token": { 29 | "description": "Вы можете получить Mi Home токен автоматически с помощью [облачной интеграции](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) или [вручную](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Читайте о настройке **команда для открытия telnet** в [документации](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).", 30 | "data": { 31 | "host": "IP-адрес", 32 | "token": "Токен", 33 | "telnet_cmd": "Команда для открытия Telnet" 34 | } 35 | } 36 | } 37 | }, 38 | "options": { 39 | "step": { 40 | "cloud": { 41 | "title": "Информация о устройствах MiCloud", 42 | "data": { 43 | "did": "Устройство" 44 | }, 45 | "description": "{device_info}" 46 | }, 47 | "user": { 48 | "title": "Настройка шлюза", 49 | "data": { 50 | "host": "IP-адрес", 51 | "token": "Токен", 52 | "telnet_cmd": "Команда для открытия Telnet", 53 | "ble": "Поддержка Bluetooth устройств", 54 | "stats": "Добавить сенсоры статистики", 55 | "debug": "Логи отладки (debug)", 56 | "buzzer": "[DANGER] Выключить пищалку", 57 | "memory": "[DANGER] Режим хранилища в памяти", 58 | "zha": "[DANGER] Режим ZHA и zigbee2mqtt" 59 | }, 60 | "description": "Изменяйте **[DANGER]** настройки ТОЛЬКО если знаете, что вы делаете" 61 | } 62 | } 63 | }, 64 | "device_automation": { 65 | "trigger_type": { 66 | "button": "Нажатие кнопки", 67 | "button_1": "Нажатие первой кнопки", 68 | "button_2": "Нажатие второй кнопки", 69 | "button_3": "Нажатие третьей кнопки", 70 | "button_4": "Нажатие четвёртой кнопки", 71 | "button_5": "Нажатие пятой кнопки", 72 | "button_6": "Нажатие шестой кнопки", 73 | "button_both": "Нажатие двух кнопок", 74 | "button_both_12": "Нажатие первой и второй кнопок", 75 | "button_both_13": "Нажатие первой и третей кнопок", 76 | "button_both_23": "Нажатие второй и третьей кнопок" 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "no_servers": "Wybierz co najmniej jeden serwer", 5 | "cant_login": "Nie można się zalogować, sprawdź czy wpisałeś poprawny login i hasło", 6 | "cant_connect": "Nie można nawiązać połączenia z bramką", 7 | "wrong_model": "Niewspierany model bramki", 8 | "wrong_token": "Niepoprawny token Mi Home", 9 | "wrong_telnet": "Nieprawidłowa komenda otwarcia protokołu telnet", 10 | "verify": "Click to verify url" 11 | }, 12 | "step": { 13 | "user": { 14 | "title": "Wybierz akcję", 15 | "data": { 16 | "action": "Akcja" 17 | } 18 | }, 19 | "cloud": { 20 | "title": "Dodaj konto Xiaomi Home", 21 | "description": "Wybierz tylko te serwery na której masz urządzenia{verify}", 22 | "data": { 23 | "username": "Email / ID konta Xiaomi", 24 | "password": "Hasło", 25 | "servers": "Serwery" 26 | } 27 | }, 28 | "token": { 29 | "description": "Możesz uzyskać token automatycznie używając [danych logowania do konta Xiaomi](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) bądź [ręcznie](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Listę wspieranych wersji oprogramowania możesz znaleźć [tutaj](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).", 30 | "data": { 31 | "host": "Host (Adres IP bramki)", 32 | "token": "Token", 33 | "telnet_cmd": "Komenda do otwarcia protokołu telnet" 34 | } 35 | } 36 | } 37 | }, 38 | "options": { 39 | "step": { 40 | "init": { 41 | "title": "Informacje o urządzeniach z Xiaomi Home", 42 | "data": { 43 | "did": "Urządzenie" 44 | }, 45 | "description": "{device_info}" 46 | }, 47 | "user": { 48 | "title": "Ustawienia bramki", 49 | "data": { 50 | "host": "Host", 51 | "token": "Token", 52 | "telnet_cmd": "Komenda do otwarcia protokołu telnet", 53 | "ble": "Wsparcie dla urządzeń BLE (Bluetoth Low Energy)", 54 | "stats": "Dane dotyczące wydajności Zigbee i BLE", 55 | "debug": "Debugowanie", 56 | "buzzer": "[DANGER] Wyłącz głośnik w bramce", 57 | "memory": "[DANGER] Use storage in memory", 58 | "zha": "[DANGER] Mode ZHA or zigbee2mqtt" 59 | }, 60 | "description": "Jeśli nie wiesz co robisz to NIGDY nie dotykaj ustawień **[DANGER]**" 61 | } 62 | } 63 | }, 64 | "device_automation": { 65 | "trigger_type": { 66 | "button": "Naciśnięcie przycisku", 67 | "button_1": "Naciśnięcie 1 przycisku", 68 | "button_2": "Naciśnięcie 2 przycisku", 69 | "button_3": "Naciśnięcie 3 przycisku", 70 | "button_4": "Naciśnięcie 4 przycisku", 71 | "button_5": "Naciśnięcie 5 przycisku", 72 | "button_6": "Naciśnięcie 6 przycisku", 73 | "button_both": "Naciśnięcie obydwu przycisków", 74 | "button_both_12": "Naciśnięcie przycisków 1 i 2", 75 | "button_both_13": "Naciśnięcie przycisków 1 i 3", 76 | "button_both_23": "Naciśnięcie przycisków 2 i 3" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/converters/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from dataclasses import dataclass 4 | from typing import List, Optional 5 | 6 | from .base import Converter, LUMI_GLOBALS, parse_time 7 | from .const import GATEWAY, ZIGBEE, BLE, MESH, MESH_GROUP_MODEL 8 | from .devices import DEVICES 9 | from .stats import STAT_GLOBALS 10 | 11 | try: 12 | # loading external converters 13 | # noinspection PyUnresolvedReferences 14 | from xiaomi_gateway3 import DEVICES 15 | except ModuleNotFoundError: 16 | pass 17 | except Exception as e: 18 | logger = logging.getLogger(__name__) 19 | logger.error("Can't load external converters", exc_info=e) 20 | 21 | 22 | @dataclass 23 | class XDeviceInfo: 24 | manufacturer: str 25 | model: str 26 | name: str 27 | url: str 28 | spec: List[Converter] 29 | ttl: float 30 | 31 | 32 | def is_mihome_zigbee(model: str) -> bool: 33 | return model.startswith(("lumi.", "ikea.")) 34 | 35 | 36 | def get_device_info(model: str, type: str) -> Optional[XDeviceInfo]: 37 | """Type is used to select the default spec if the model didn't match 38 | earlier. Should be the latest spec in the list. 39 | """ 40 | for desc in DEVICES: 41 | if model not in desc and desc.get("default") != type: 42 | continue 43 | info = desc.get(model) or ["Unknown", type.upper(), None] 44 | brand, name, market = info if len(info) == 3 else info + [None] 45 | 46 | if type == ZIGBEE and not is_mihome_zigbee(model): 47 | url = "https://www.zigbee2mqtt.io/supported-devices/#s=" + market \ 48 | if market else None 49 | else: 50 | url = f"https://home.miot-spec.com/s/{model}" 51 | 52 | if market and type == ZIGBEE: 53 | market = f"{type} {market} ({model})" 54 | elif market: 55 | market = f"{type} {market}" 56 | else: 57 | market = f"{type} ({model})" 58 | 59 | ttl = desc.get("ttl") 60 | if isinstance(ttl, str): 61 | ttl = parse_time(ttl) 62 | 63 | return XDeviceInfo( 64 | manufacturer=brand, 65 | model=market, 66 | name=f"{brand} {name}", 67 | url=url, 68 | spec=desc["spec"], 69 | ttl=ttl 70 | ) 71 | raise RuntimeError 72 | 73 | 74 | RE_INFO_MODEL = re.compile(r"^(zigbee|ble|mesh)(?: ([^ ]+))?(?: \((.+?)\))?$") 75 | 76 | 77 | def get_buttons(info_model: str) -> Optional[List[str]]: 78 | """Gets a list of buttons using the device info model.""" 79 | m = RE_INFO_MODEL.search(info_model) 80 | if not m: 81 | return None 82 | 83 | market = m[2] 84 | model = m[3] 85 | 86 | # Yeelight Button S1 87 | if market == "YLAI003": 88 | return ["button"] 89 | 90 | for device in DEVICES: 91 | if model in device or any( 92 | info[2] == market for info in device.values() 93 | if isinstance(info, list) and len(info) == 3 94 | ): 95 | return sorted(set([ 96 | conv.attr for conv in device["spec"] 97 | if conv.attr.startswith("button") 98 | ])) 99 | 100 | return None 101 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/gate_mgw2.py: -------------------------------------------------------------------------------- 1 | from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB, SIGNAL_TIMER 2 | from .ble import BLEGateway 3 | from .lumi import LumiGateway 4 | from .mesh import MeshGateway 5 | from .miot import MIoTGateway 6 | from .silabs import SilabsGateway 7 | from .z3 import Z3Gateway 8 | from .. import shell 9 | from ..device import XDevice, GATEWAY 10 | from ..mini_mqtt import MQTTMessage 11 | 12 | MODEL = "lumi.gateway.mcn001" 13 | 14 | 15 | class GateMGW2( 16 | MIoTGateway, LumiGateway, MeshGateway, BLEGateway, SilabsGateway, Z3Gateway 17 | ): 18 | mgw2_ts = 0 19 | 20 | def mgw2_init(self): 21 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.mgw2_mqtt_publish) 22 | self.dispatcher_connect(SIGNAL_TIMER, self.mgw2_timer) 23 | 24 | async def mgw2_prepare_gateway(self, sh: shell.ShellMGW2): 25 | self.mgw2_init() 26 | self.miot_init() # Gateway and Mesh depends on MIoT 27 | self.silabs_init() 28 | self.lumi_init() 29 | self.mesh_init() 30 | self.ble_init() 31 | 32 | msg = await sh.run_openmiio_agent() 33 | self.debug("openmiio_agent: " + msg) 34 | 35 | if self.available is None and self.did is None: 36 | await self.mgw2_read_device(sh) 37 | 38 | await self.dispatcher_send(SIGNAL_PREPARE_GW, sh=sh) 39 | 40 | return True 41 | 42 | async def mgw2_read_device(self, sh: shell.ShellMGW2): 43 | self.did = await sh.get_did() 44 | mac = await sh.get_wlan_mac() 45 | device = self.devices.get(self.did) 46 | if not device: 47 | device = XDevice(GATEWAY, MODEL, self.did, mac) 48 | device.extra = {"fw_ver": sh.ver} 49 | self.add_device(self.did, device) 50 | 51 | async def mgw2_mqtt_publish(self, msg: MQTTMessage): 52 | if msg.topic.endswith('/heartbeat'): 53 | payload = self.device.decode(GATEWAY, msg.json) 54 | self.device.update(payload) 55 | 56 | async def mgw2_timer(self, ts: float): 57 | if ts < self.mgw2_ts: 58 | return 59 | await self.mgw2_update_stats() 60 | self.mgw2_ts = ts + 300 # 5 min 61 | 62 | async def mgw2_update_stats(self): 63 | try: 64 | async with shell.Session(self.host) as sh: 65 | serial = await sh.read_file( 66 | "/proc/tty/driver/ms_uart | sed 's/1:/2=/' | sed 's/2:/1=/' | sed 's/=/:/' | sort -k1 -g" 67 | ) 68 | free_mem = await sh.read_file( 69 | "/proc/meminfo | grep MemFree: | awk '{print $2}'" 70 | ) 71 | load_avg = await sh.read_file("/proc/loadavg | sed 's/ /|/g'") 72 | run_time = await sh.read_file("/proc/uptime | cut -f1 -d.") 73 | rssi = await sh.read_file( 74 | "/proc/net/wireless | grep wlan0 | awk '{print $4}' | cut -f1 -d." 75 | ) 76 | if not rssi: rssi = 0 77 | payload = self.device.decode(GATEWAY, { 78 | "serial": serial.decode(), 79 | "free_mem": int(free_mem), 80 | "load_avg": load_avg.decode(), 81 | "run_time": int(run_time), 82 | "rssi": int(rssi) + 100 83 | }) 84 | self.device.update(payload) 85 | 86 | except Exception as e: 87 | self.warning("Can't update gateway stats", e) 88 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/ble.py: -------------------------------------------------------------------------------- 1 | from . import miot 2 | from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB 3 | from .. import shell 4 | from ..device import XDevice, BLE 5 | from ..mini_mqtt import MQTTMessage 6 | 7 | 8 | # noinspection PyMethodMayBeStatic,PyUnusedLocal 9 | class BLEGateway(GatewayBase): 10 | def ble_init(self): 11 | if not self.ble_mode: 12 | return 13 | self.dispatcher_connect(SIGNAL_PREPARE_GW, self.ble_prepare_gateway) 14 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.ble_mqtt_publish) 15 | 16 | async def ble_read_devices(self, sh: shell.ShellMGW): 17 | try: 18 | # prevent read database two times 19 | db = await sh.read_db_bluetooth() 20 | 21 | # load BLE devices 22 | rows = sh.db.read_table('gateway_authed_table') 23 | for row in rows: 24 | # BLE key is mac 25 | mac = reverse_mac(row[1]) 26 | device = self.devices.get(mac) 27 | if not device: 28 | device = XDevice(BLE, row[2], row[4], mac) 29 | self.add_device(mac, device) 30 | except Exception: 31 | pass 32 | 33 | async def ble_prepare_gateway(self, sh: shell.ShellMGW): 34 | if self.available is None: 35 | await self.ble_read_devices(sh) 36 | 37 | if self.options.get('memory') and sh.model == "mgw": 38 | self.debug("Init Bluetooth in memory storage") 39 | sh.patch_memory_bluetooth() 40 | 41 | async def ble_mqtt_publish(self, msg: MQTTMessage): 42 | if msg.topic == "miio/report" and b'"_async.ble_event"' in msg.payload: 43 | await self.ble_process_event(msg.json["params"]) 44 | 45 | async def ble_process_event(self, data: dict): 46 | # {'dev': {'did': 'blt.3.xxx', 'mac': 'AA:BB:CC:DD:EE:FF', 'pdid': 2038}, 47 | # 'evt': [{'eid': 15, 'edata': '010000'}], 48 | # 'frmCnt': 36, 'gwts': 1636208932} 49 | 50 | # some devices doesn't send mac, only number did 51 | # https://github.com/AlexxIT/XiaomiGateway3/issues/24 52 | if 'mac' in data['dev']: 53 | mac = data['dev']['mac'].replace(':', '').lower() 54 | device = self.devices.get(mac) 55 | if not device: 56 | device = XDevice( 57 | BLE, data['dev']['pdid'], data['dev']['did'], mac 58 | ) 59 | self.add_device(mac, device) 60 | else: 61 | device = next(( 62 | d for d in self.devices.values() if d.did == data['dev']['did'] 63 | ), None) 64 | if not device: 65 | self.debug(f"Unregistered BLEE device {data}") 66 | return 67 | 68 | if device.extra.get('seq') == data['frmCnt']: 69 | return 70 | device.extra['seq'] = data['frmCnt'] 71 | 72 | if isinstance(data['evt'], list): 73 | payload = data['evt'][0] 74 | elif isinstance(data['evt'], dict): 75 | payload = data['evt'] 76 | else: 77 | raise NotImplementedError 78 | 79 | if BLE in device.entities: 80 | device.update(device.decode(BLE, payload)) 81 | 82 | payload = device.decode("mibeacon", payload) 83 | device.update(payload) 84 | self.debug_device(device, "recv", payload, "BLEE") 85 | 86 | 87 | def reverse_mac(s: str): 88 | return f"{s[10:]}{s[8:10]}{s[6:8]}{s[4:6]}{s[2:4]}{s[:2]}" 89 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/system_health.py: -------------------------------------------------------------------------------- 1 | """Provide info to system health.""" 2 | import logging 3 | import re 4 | import traceback 5 | import uuid 6 | from collections import deque 7 | from datetime import datetime 8 | from logging import Logger 9 | 10 | from aiohttp import web 11 | from homeassistant.components import system_health 12 | from homeassistant.components.http import HomeAssistantView 13 | from homeassistant.core import HomeAssistant, callback 14 | 15 | from .core.const import DOMAIN, source_hash 16 | 17 | 18 | @callback 19 | def async_register( 20 | hass: HomeAssistant, register: system_health.SystemHealthRegistration 21 | ) -> None: 22 | register.async_register_info(system_health_info) 23 | 24 | 25 | async def system_health_info(hass: HomeAssistant): 26 | integration = hass.data["integrations"][DOMAIN] 27 | info = {"version": f"{integration.version} ({source_hash()})"} 28 | 29 | if DebugView.url: 30 | info["debug"] = { 31 | "type": "failed", "error": "", "more_info": DebugView.url 32 | } 33 | 34 | return info 35 | 36 | 37 | async def setup_debug(hass: HomeAssistant, logger: Logger): 38 | if DebugView.url: 39 | return 40 | 41 | view = DebugView(logger) 42 | hass.http.register_view(view) 43 | 44 | integration = hass.data["integrations"][DOMAIN] 45 | info = await hass.helpers.system_info.async_get_system_info() 46 | info[DOMAIN + "_version"] = f"{integration.version} ({source_hash()})" 47 | logger.debug(f"SysInfo: {info}") 48 | 49 | integration.manifest["issue_tracker"] = view.url 50 | 51 | 52 | class DebugView(logging.Handler, HomeAssistantView): 53 | """Class generate web page with component debug logs.""" 54 | name = DOMAIN 55 | requires_auth = False 56 | 57 | def __init__(self, logger: Logger): 58 | super().__init__() 59 | 60 | # https://waymoot.org/home/python_string/ 61 | self.text = deque(maxlen=10000) 62 | 63 | self.propagate_level = logger.getEffectiveLevel() 64 | 65 | # random url because without authorization!!! 66 | DebugView.url = f"/api/{DOMAIN}/{uuid.uuid4()}" 67 | 68 | logger.addHandler(self) 69 | logger.setLevel(logging.DEBUG) 70 | 71 | def handle(self, rec: logging.LogRecord): 72 | dt = datetime.fromtimestamp(rec.created).strftime("%Y-%m-%d %H:%M:%S") 73 | msg = f"{dt} [{rec.levelname[0]}] {rec.msg}" 74 | if rec.exc_info: 75 | exc = traceback.format_exception(*rec.exc_info, limit=1) 76 | msg += "|" + "".join(exc[-2:]).replace("\n", "|") 77 | self.text.append(msg) 78 | 79 | # prevent debug to Hass log if user don't want it 80 | if self.propagate_level > rec.levelno: 81 | rec.levelno = -1 82 | 83 | async def get(self, request: web.Request): 84 | try: 85 | lines = self.text 86 | 87 | if 'q' in request.query: 88 | reg = re.compile(fr"({request.query['q']})", re.IGNORECASE) 89 | lines = [p for p in lines if reg.search(p)] 90 | 91 | if 't' in request.query: 92 | tail = int(request.query['t']) 93 | lines = lines[-tail:] 94 | 95 | body = "\n".join(lines) 96 | r = request.query.get('r', '') 97 | 98 | return web.Response( 99 | text='' 100 | f'' 101 | f'
{body}
' 102 | '', 103 | content_type="text/html" 104 | ) 105 | except Exception: 106 | return web.Response(status=500) 107 | -------------------------------------------------------------------------------- /tests/test_conv_stats.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from homeassistant.components.sensor import DOMAIN 4 | 5 | from custom_components.xiaomi_gateway3.core.converters import stats, GATEWAY, \ 6 | ZIGBEE 7 | from custom_components.xiaomi_gateway3.core.device import XDevice 8 | 9 | assert DOMAIN # fix circular import 10 | 11 | DID = "123456789" 12 | MAC = "112233aabbcc" 13 | ZDID = "lumi.112233aabbcc" 14 | ZMAC = "0x0000112233aabbcc" 15 | ZNWK = "0x12ab" 16 | 17 | 18 | def test_gateway_stats(): 19 | device = XDevice(GATEWAY, 'lumi.gateway.mgl03', DID, MAC) 20 | device.setup_converters() 21 | 22 | p = device.decode(GATEWAY, {'networkUp': False}) 23 | assert p == { 24 | 'network_pan_id': None, 'radio_channel': None, 'radio_tx_power': None 25 | } 26 | 27 | p = device.decode(GATEWAY, { 28 | 'networkUp': True, 'networkPanId': '0x9180', 'radioTxPower': 7, 29 | 'radioChannel': 15 30 | }) 31 | assert p == { 32 | 'network_pan_id': '0x9180', 'radio_tx_power': 7, 'radio_channel': 15 33 | } 34 | 35 | p = device.decode(GATEWAY, { 36 | 'free_mem': 3488, 'ip': '192.168.1.123', 37 | 'load_avg': '1.92|2.00|2.25|5/91|21135', 'rssi': 58, 38 | 'run_time': 367357, 'setupcode': '123-45-678', 'ssid': 'WiFi', 39 | 'tz': 'GMT3' 40 | }) 41 | assert p == { 42 | 'free_mem': 3488, 'load_avg': '1.92|2.00|2.25|5/91|21135', 'rssi': -42, 43 | 'uptime': '4 days, 06:02:37' 44 | } 45 | 46 | p = device.decode(GATEWAY, { 47 | 'serial': """serinfo:1.0 driver revision: 48 | 0: uart:16550A mmio:0x18147000 irq:17 tx:6337952 rx:0 RTS|CTS|DTR 49 | 1: uart:16550A mmio:0x18147400 irq:46 tx:19370 rx:154557484 oe:1684 RTS|DTR 50 | 2: uart:16550A mmio:0x18147800 irq:47 tx:1846359 rx:3845724 oe:18 RTS|DTR""" 51 | }) 52 | assert p == { 53 | 'bluetooth_tx': 19370, 'bluetooth_rx': 154557484, 'bluetooth_oe': 1684, 54 | 'zigbee_tx': 1846359, 'zigbee_rx': 3845724, 'zigbee_oe': 18 55 | } 56 | 57 | 58 | def test_zigbee_stats(): 59 | stats.now = lambda: datetime(2021, 12, 31, 23, 59) 60 | 61 | device = XDevice(ZIGBEE, 'lumi.plug', ZDID, ZMAC, ZNWK) 62 | device.setup_converters({ZIGBEE: "sensor"}) 63 | 64 | p = device.decode(ZIGBEE, { 65 | 'sourceAddress': '0x9B43', 'eui64': '0x00158D0000AABBCC', 66 | 'destinationEndpoint': '0x01', 'clusterId': '0x000A', 67 | 'profileId': '0x0104', 'sourceEndpoint': '0x01', 'APSCounter': '0x71', 68 | 'APSPlayload': '0x1071000000', 'rssi': -61, 'linkQuality': 156 69 | }) 70 | assert p == { 71 | 'zigbee': p['zigbee'], 72 | # 'ieee': '0x00158D0000AABBCC', 'nwk': '0x9B43', 73 | 'msg_received': 1, 'msg_missed': 0, 74 | 'linkquality': 156, 75 | 'rssi': -61, 'last_msg': 'Time' 76 | } 77 | 78 | p = device.decode(ZIGBEE, { 79 | 'sourceAddress': '0x9B43', 'eui64': '0x00158D0000AABBCC', 80 | 'destinationEndpoint': '0x01', 'clusterId': '0x000A', 81 | 'profileId': '0x0104', 'sourceEndpoint': '0x01', 'APSCounter': '0x73', 82 | 'APSPlayload': '0x1075000000', 'rssi': -61, 'linkQuality': 156 83 | }) 84 | assert p == { 85 | 'zigbee': p['zigbee'], 86 | # 'ieee': '0x00158D0000AABBCC', 'nwk': '0x9B43', 87 | 'msg_received': 2, 'msg_missed': 1, 88 | 'linkquality': 156, 89 | 'rssi': -61, 'last_msg': 'Time' 90 | } 91 | 92 | p = device.decode(ZIGBEE, { 93 | 'sourceAddress': '0x9B43', 'eui64': '0x00158D0000AABBCC', 94 | 'destinationEndpoint': '0x01', 'clusterId': '0x000A', 95 | 'profileId': '0x0104', 'sourceEndpoint': '0x01', 'APSCounter': '0x72', 96 | 'APSPlayload': '0x1074000000', 'rssi': -61, 'linkQuality': 156 97 | }) 98 | assert p == { 99 | 'zigbee': p['zigbee'], 100 | # 'ieee': '0x00158D0000AABBCC', 'nwk': '0x9B43', 101 | 'msg_received': 3, 'msg_missed': 1, 102 | 'linkquality': 156, 103 | 'rssi': -61, 'last_msg': 'Time' 104 | } 105 | 106 | p = device.decode(ZIGBEE, { 107 | 'eui64': '', 'nwk': '0x9B43', 'ago': 60, 'type': 'device', 108 | 'parent': '0xABCD' 109 | }) 110 | assert p == {'parent': '0xABCD'} 111 | 112 | # p = device.decode(ZIGBEE, {'parent': '0xABCD'}) 113 | # assert p == {'parent': '0xABCD'} 114 | 115 | # p = device.decode(ZIGBEE, {'resets': 10}) 116 | # assert p == {'new_resets': 0} 117 | 118 | # p = device.decode(ZIGBEE, {'resets': 15}) 119 | # assert p == {'new_resets': 5} 120 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/lumi.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_CON, \ 4 | SIGNAL_MQTT_PUB 5 | from .. import shell 6 | from ..converters import GATEWAY 7 | from ..device import XDevice, ZIGBEE 8 | from ..mini_mqtt import MQTTMessage 9 | 10 | 11 | class LumiGateway(GatewayBase): 12 | did: str = None # filled by MainGateway 13 | 14 | zigbee_pair_model = None 15 | 16 | pair_payload = None 17 | pair_payload2 = None 18 | 19 | def lumi_init(self): 20 | if self.zha_mode: 21 | return 22 | self.dispatcher_connect(SIGNAL_PREPARE_GW, self.lumi_prepare_gateway) 23 | # self.dispatcher_connect(SIGNAL_MQTT_CON, self.lumi_mqtt_connect) 24 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.lumi_mqtt_publish) 25 | 26 | async def lumi_read_devices(self, sh: shell.TelnetShell): 27 | # 2. Read zigbee devices 28 | raw = await sh.read_file('/data/zigbee/device.info') 29 | lumi = json.loads(raw)['devInfo'] 30 | 31 | for item in lumi: 32 | did = item["did"] 33 | device = self.devices.get(did) 34 | if not device: 35 | # adds leading zeroes to mac 36 | mac = f"0x{item['mac'][2:]:>016s}" 37 | device = XDevice( 38 | ZIGBEE, item['model'], did, mac, item['shortId'] 39 | ) 40 | device.extra = {'fw_ver': item['appVer']} 41 | # 'hw_ver': item['hardVer'], 42 | # 'mod_ver': item['model_ver'], 43 | 44 | self.add_device(did, device) 45 | 46 | async def lumi_prepare_gateway(self, sh: shell.TelnetShell): 47 | if self.available is None: 48 | await self.lumi_read_devices(sh) 49 | 50 | uptime = await sh.read_file("/proc/uptime | cut -f1 -d.") 51 | if int(uptime) >= 3600: 52 | self.dispatcher_connect(SIGNAL_MQTT_CON, self.lumi_mqtt_connect) 53 | 54 | async def lumi_mqtt_connect(self): 55 | payload = {"params": [{"res_name": "8.0.2102"}]} 56 | for device in self.filter_devices("zigbee"): 57 | await self.lumi_read(device, payload) 58 | 59 | async def lumi_mqtt_publish(self, msg: MQTTMessage): 60 | if msg.topic == 'zigbee/send': 61 | await self.lumi_process_lumi(msg.json) 62 | 63 | async def lumi_send(self, device: XDevice, payload: dict): 64 | assert "params" in payload or "mi_spec" in payload, payload 65 | # self.debug_device(device, "send", payload, tag="LUMI") 66 | did = device.did if device.type != GATEWAY else "lumi.0" 67 | payload.update({"cmd": "write", "did": did}) 68 | await self.mqtt.publish('zigbee/recv', payload) 69 | 70 | async def lumi_read(self, device: XDevice, payload: dict): 71 | assert "params" in payload or "mi_spec" in payload, payload 72 | # self.debug_device(device, "read", payload, tag="LUMI") 73 | payload["did"] = device.did if device.type != GATEWAY else "lumi.0" 74 | payload.setdefault("cmd", "read") 75 | await self.mqtt.publish('zigbee/recv', payload) 76 | 77 | async def lumi_process_lumi(self, data: dict): 78 | # cmd: 79 | # - heartbeat - from power device every 5-10 min, from battery - 55 min 80 | # - report - new state from device 81 | # - read, write - action from Hass, MiHome or Gateway software 82 | # - read_rsp, write_rsp - gateway execute command (device may not 83 | # receive it) 84 | # - write_ack - response from device (device receive command) 85 | if data['cmd'] == 'heartbeat': 86 | data = data['params'][0] 87 | elif data['cmd'] in ("report", "read_rsp"): 88 | pass 89 | elif data['cmd'] == 'write_rsp': 90 | # process write response only from Gateway 91 | if data['did'] != 'lumi.0': 92 | return 93 | else: 94 | return 95 | 96 | did = data['did'] if data['did'] != 'lumi.0' else self.did 97 | 98 | # skip without callback and without data 99 | if did not in self.devices: 100 | return 101 | 102 | device: XDevice = self.devices[did] 103 | # support multiple spec in one response 104 | for k in ("res_list", "params", "results", "mi_spec"): 105 | if k not in data: 106 | continue 107 | 108 | payload = device.decode_lumi(data[k]) 109 | device.update(payload) 110 | 111 | # no time in device add command 112 | # ts = round(time.time() - data['time'] * 0.001 + self.time_offset, 2) \ 113 | # if 'time' in data else '?' 114 | # self.debug(f"{device.did} {device.model} <= {payload} [{ts}]") 115 | self.debug_device(device, "recv", payload, tag="LUMI") 116 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/shell/base.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | from asyncio import StreamReader, StreamWriter 4 | from dataclasses import dataclass 5 | from typing import Union 6 | 7 | from ..unqlite import SQLite 8 | 9 | ERROR = 0 10 | OK = 1 11 | DOWNLOAD = 2 12 | 13 | RUN_OPENMIIO = "/data/openmiio_agent miio mqtt cache z3 --zigbee.tcp=8888 > /var/log/openmiio.log 2>&1 &" 14 | 15 | 16 | @dataclass 17 | class TelnetShell: 18 | reader: StreamReader 19 | writer: StreamWriter 20 | model = None 21 | ver = None 22 | 23 | async def close(self): 24 | if not self.writer: 25 | return 26 | self.writer.close() 27 | await self.writer.wait_closed() 28 | 29 | async def exec(self, command: str, as_bytes=False, timeout=10) \ 30 | -> Union[str, bytes]: 31 | """Run command and return it result.""" 32 | self.writer.write(command.encode() + b"\n") 33 | coro = self.reader.readuntil(b"# ") 34 | raw = await asyncio.wait_for(coro, timeout=timeout) 35 | return raw[:-2] if as_bytes else raw[:-2].decode() 36 | 37 | async def read_file(self, filename: str, as_base64=False): 38 | command = f"cat {filename}|base64" if as_base64 else f"cat {filename}" 39 | try: 40 | raw = await self.exec(command, as_bytes=True, timeout=60) 41 | # b"cat: can't open ..." 42 | return base64.b64decode(raw) if as_base64 else raw 43 | except Exception: 44 | return None 45 | 46 | async def reboot(self): 47 | # should not wait for response 48 | self.writer.write(b"reboot\n") 49 | await self.writer.drain() 50 | # have to wait or the magic won't happen 51 | await asyncio.sleep(1) 52 | 53 | async def only_one(self) -> bool: 54 | # run shell with dummy option, so we can check if second Hass connected 55 | # shell will close automatically when disconnected from telnet 56 | raw = await self.exec("(ps|grep -v grep|grep -q 'sh +o') || sh +o") 57 | return "set -o errexit" in raw 58 | 59 | async def run_ntpd(self): 60 | await self.exec("ntpd -l") 61 | 62 | async def check_bin(self, filename: str, md5: str, url: str) -> int: 63 | """ 64 | Check binary md5 and download it if needed. We should use HTTP-link 65 | because wget don't support HTTPS and curl removed in lastest fw. But 66 | it's not a problem because we check md5. 67 | """ 68 | filename = "/data/" + filename 69 | cmd = f"[ -x {filename} ] && md5sum {filename}" 70 | 71 | if md5 in await self.exec(cmd): 72 | return OK 73 | 74 | # download can take up to 3 minutes for Chinese users 75 | await self.exec( 76 | f"wget {url} -O {filename} && chmod +x {filename}", timeout=300 77 | ) 78 | 79 | return DOWNLOAD if md5 in await self.exec(cmd) else ERROR 80 | 81 | async def get_running_ps(self) -> str: 82 | return await self.exec("ps") 83 | 84 | async def get_version(self) -> str: 85 | raise NotImplementedError 86 | 87 | async def get_token(self) -> str: 88 | raise NotImplementedError 89 | 90 | async def prevent_unpair(self): 91 | raise NotImplementedError 92 | 93 | async def run_ftp(self): 94 | raise NotImplementedError 95 | 96 | async def tar_data(self): 97 | raise NotImplementedError 98 | 99 | 100 | # noinspection PyAbstractClass 101 | class ShellOpenMiio(TelnetShell): 102 | async def check_openmiio_agent(self) -> int: 103 | # different binaries for different arch 104 | raise NotImplementedError 105 | 106 | async def run_openmiio_agent(self) -> str: 107 | ok = await self.check_openmiio_agent() 108 | if ok == OK: 109 | # run if not in ps 110 | if "openmiio_agent" in await self.get_running_ps(): 111 | return "The latest version is already running" 112 | 113 | await self.exec(RUN_OPENMIIO) 114 | return "The latest version is launched" 115 | 116 | if ok == DOWNLOAD: 117 | if "openmiio_agent" in await self.get_running_ps(): 118 | await self.exec(f"killall openmiio_agent") 119 | 120 | await self.exec(RUN_OPENMIIO) 121 | return "The latest version is updated and launched" 122 | 123 | return "ERROR: can't download latest version" 124 | 125 | 126 | # noinspection PyAbstractClass 127 | class ShellMultimode(ShellOpenMiio): 128 | db: SQLite = None 129 | 130 | async def read_db_bluetooth(self) -> SQLite: 131 | if not self.db: 132 | raw = await self.read_file(self.mesh_db, as_base64=True) 133 | self.db = SQLite(raw) 134 | return self.db 135 | 136 | @property 137 | def mesh_db(self) -> str: 138 | raise NotImplementedError 139 | 140 | @property 141 | def mesh_group_table(self) -> str: 142 | raise NotImplementedError 143 | 144 | @property 145 | def mesh_device_table(self) -> str: 146 | raise NotImplementedError 147 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from datetime import timedelta 4 | 5 | from homeassistant.components.automation import ATTR_LAST_TRIGGERED 6 | from homeassistant.components.binary_sensor import BinarySensorEntity 7 | from homeassistant.const import STATE_ON 8 | from homeassistant.core import callback 9 | from homeassistant.helpers.restore_state import RestoreEntity 10 | from homeassistant.util.dt import now 11 | 12 | from . import DOMAIN 13 | from .core.converters import Converter, GATEWAY 14 | from .core.device import XDevice 15 | from .core.entity import XEntity 16 | from .core.gateway import XGateway 17 | 18 | SCAN_INTERVAL = timedelta(seconds=60) 19 | 20 | CONF_INVERT_STATE = "invert_state" 21 | CONF_OCCUPANCY_TIMEOUT = "occupancy_timeout" 22 | 23 | 24 | async def async_setup_entry(hass, config_entry, async_add_entities): 25 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 26 | if conv.attr in device.entities: 27 | entity: XEntity = device.entities[conv.attr] 28 | entity.gw = gateway 29 | elif conv.attr == "motion": 30 | entity = XiaomiMotionSensor(gateway, device, conv) 31 | elif conv.attr == GATEWAY: 32 | entity = XiaomiGateway(gateway, device, conv) 33 | else: 34 | entity = XiaomiBinarySensor(gateway, device, conv) 35 | async_add_entities([entity]) 36 | 37 | gw: XGateway = hass.data[DOMAIN][config_entry.entry_id] 38 | gw.add_setup(__name__, setup) 39 | 40 | 41 | class XiaomiBinaryBase(XEntity, BinarySensorEntity): 42 | @callback 43 | def async_set_state(self, data: dict): 44 | if self.attr in data: 45 | # support invert_state for sensor 46 | self._attr_is_on = not data[self.attr] \ 47 | if self.customize.get(CONF_INVERT_STATE, False) \ 48 | else data[self.attr] 49 | 50 | for k, v in data.items(): 51 | if k in self.subscribed_attrs and k != self.attr: 52 | self._attr_extra_state_attributes[k] = v 53 | 54 | 55 | class XiaomiBinarySensor(XiaomiBinaryBase, RestoreEntity): 56 | @callback 57 | def async_restore_last_state(self, state: str, attrs: dict): 58 | self._attr_is_on = state == STATE_ON 59 | for k, v in attrs.items(): 60 | if k in self.subscribed_attrs: 61 | self._attr_extra_state_attributes[k] = v 62 | 63 | async def async_update(self): 64 | await self.device_read(self.subscribed_attrs) 65 | 66 | 67 | class XiaomiGateway(XiaomiBinaryBase): 68 | @callback 69 | def async_update_available(self): 70 | # sensor state=connected when whole gateway available 71 | self._attr_is_on = self.gw.available 72 | 73 | @property 74 | def available(self): 75 | return True 76 | 77 | 78 | class XiaomiMotionSensor(XEntity, BinarySensorEntity): 79 | _attr_is_on = False 80 | _default_delay = None 81 | _last_on = 0 82 | _last_off = 0 83 | _timeout_pos = 0 84 | _clear_task: asyncio.Task = None 85 | 86 | async def async_clear_state(self, delay: float): 87 | await asyncio.sleep(delay) 88 | 89 | self._last_off = time.time() 90 | self._timeout_pos = 0 91 | 92 | self._attr_is_on = False 93 | self.async_write_ha_state() 94 | 95 | async def async_will_remove_from_hass(self): 96 | if self._clear_task: 97 | self._clear_task.cancel() 98 | 99 | if self._attr_is_on: 100 | self._attr_is_on = False 101 | self.async_write_ha_state() 102 | 103 | await super().async_will_remove_from_hass() 104 | 105 | @callback 106 | def async_set_state(self, data: dict): 107 | # fix 1.4.7_0115 heartbeat error (has motion in heartbeat) 108 | if "battery" in data or not self.hass: 109 | return 110 | 111 | assert data[self.attr] is True 112 | 113 | # don't trigger motion right after illumination 114 | ts = time.time() 115 | if ts - self._last_on < 1: 116 | return 117 | 118 | if self._clear_task: 119 | self._clear_task.cancel() 120 | 121 | self._attr_is_on = True 122 | self._attr_extra_state_attributes[ATTR_LAST_TRIGGERED] = \ 123 | now().isoformat(timespec="seconds") 124 | self._last_on = ts 125 | 126 | # if customize of any entity will be changed from GUI - default value 127 | # for all motion sensors will be erased 128 | timeout = self.customize.get(CONF_OCCUPANCY_TIMEOUT, 90) 129 | if timeout: 130 | if isinstance(timeout, list): 131 | pos = min(self._timeout_pos, len(timeout) - 1) 132 | delay = timeout[pos] 133 | self._timeout_pos += 1 134 | else: 135 | delay = timeout 136 | 137 | if delay < 0 and ts + delay < self._last_off: 138 | delay *= 2 139 | 140 | self.debug(f"Extend delay: {delay} seconds") 141 | 142 | self._clear_task = self.hass.loop.create_task( 143 | self.async_clear_state(abs(delay)) 144 | ) 145 | 146 | # repeat event from Aqara integration 147 | self.hass.bus.async_fire("xiaomi_aqara.motion", { 148 | "entity_id": self.entity_id 149 | }) 150 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/gate_mgw.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from typing import Optional 4 | 5 | from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB, SIGNAL_TIMER 6 | from .ble import BLEGateway 7 | from .lumi import LumiGateway 8 | from .mesh import MeshGateway 9 | from .miot import MIoTGateway 10 | from .silabs import SilabsGateway 11 | from .z3 import Z3Gateway 12 | from .. import shell 13 | from ..device import XDevice, GATEWAY 14 | from ..mini_mqtt import MQTTMessage 15 | 16 | MODEL = "lumi.gateway.mgl03" 17 | 18 | 19 | class GateMGW( 20 | MIoTGateway, LumiGateway, MeshGateway, BLEGateway, SilabsGateway, Z3Gateway 21 | ): 22 | gw3_ts = 0 23 | 24 | def gw3_init(self): 25 | # self.dispatcher_connect(SIGNAL_MQTT_CON, self.gw3_mqtt_connect) 26 | # self.dispatcher_connect(SIGNAL_MQTT_DIS, self.gw3_mqtt_disconnect) 27 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.gw3_mqtt_publish) 28 | self.dispatcher_connect(SIGNAL_TIMER, self.gw3_timer) 29 | 30 | async def gw3_read_device(self, sh: shell.ShellMGW): 31 | self.did = await sh.get_did() 32 | mac = await sh.get_wlan_mac() 33 | device = self.devices.get(self.did) 34 | if not device: 35 | device = XDevice(GATEWAY, MODEL, self.did, mac) 36 | device.extra = {"fw_ver": sh.ver} 37 | self.add_device(self.did, device) 38 | 39 | async def gw3_prepare_gateway(self, sh: shell.ShellMGW): 40 | # run all inits from subclasses 41 | self.miot_init() # GW3 and Mesh depends on MIoT 42 | self.gw3_init() 43 | self.silabs_init() 44 | self.lumi_init() 45 | self.mesh_init() 46 | self.ble_init() 47 | self.z3_init() 48 | 49 | ps = await sh.get_running_ps() 50 | if "ntpd" not in ps: 51 | # run NTPd for sync time 52 | await sh.run_ntpd() 53 | 54 | msg = await sh.run_openmiio_agent() 55 | self.debug("openmiio_agent: " + msg) 56 | 57 | if self.available is None and self.did is None: 58 | await self.gw3_read_device(sh) 59 | 60 | if not self.zha_mode and self.options.get('memory'): 61 | self.debug("Init Zigbee in memory storage") 62 | sh.patch_memory_zigbee() 63 | 64 | await self.dispatcher_send(SIGNAL_PREPARE_GW, sh=sh) 65 | 66 | n = await sh.apply_patches(ps) 67 | self.debug(f"Applied {n} patches to daemons") 68 | 69 | return True 70 | 71 | # async def gw3_mqtt_connect(self): 72 | # # change gateway online state 73 | # self.device.update({GATEWAY: True}) 74 | # await self.gw3_update_time_offset() 75 | 76 | # async def gw3_mqtt_disconnect(self): 77 | # # change gateway online state 78 | # self.device.update({GATEWAY: False}) 79 | 80 | async def gw3_mqtt_publish(self, msg: MQTTMessage): 81 | if msg.topic == "miio/report" and \ 82 | b'"event.gw.heartbeat"' in msg.payload: 83 | payload = msg.json['params'][0] 84 | payload = self.device.decode(GATEWAY, payload) 85 | self.device.update(payload) 86 | 87 | # time offset may changed right after gw.heartbeat 88 | await self.gw3_update_time_offset() 89 | 90 | elif msg.topic.endswith('/heartbeat'): 91 | payload = self.device.decode(GATEWAY, msg.json) 92 | self.device.update(payload) 93 | 94 | async def gw3_timer(self, ts: float): 95 | if ts < self.gw3_ts: 96 | return 97 | await self.gw3_update_serial_stats() 98 | self.gw3_ts = ts + 300 # 5 min 99 | 100 | def _time_delta(self) -> float: 101 | t = shell.ntp_time(self.host) 102 | return t - time.time() if t else 0 103 | 104 | async def gw3_update_time_offset(self): 105 | self.time_offset = await asyncio.get_event_loop().run_in_executor( 106 | None, self._time_delta 107 | ) 108 | self.debug(f"Gateway time offset: {self.time_offset}") 109 | 110 | async def gw3_update_serial_stats(self): 111 | try: 112 | async with shell.Session(self.host) as sh: 113 | serial = await sh.read_file('/proc/tty/driver/serial') 114 | payload = self.device.decode( 115 | GATEWAY, {"serial": serial.decode()} 116 | ) 117 | self.device.update(payload) 118 | except Exception as e: 119 | self.warning("Can't update gateway stats", e) 120 | 121 | async def gw3_memory_sync(self): 122 | try: 123 | async with shell.Session(self.host) as sh: 124 | await sh.memory_sync() 125 | except Exception as e: 126 | self.error(f"Can't memory sync", e) 127 | 128 | async def gw3_send_lock(self, enable: bool) -> bool: 129 | try: 130 | async with shell.Session(self.host) as sh: 131 | await sh.lock_firmware(enable) 132 | locked = await sh.check_firmware_lock() 133 | return enable == locked 134 | except Exception as e: 135 | self.error(f"Can't set firmware lock", e) 136 | return False 137 | 138 | async def gw3_read_lock(self) -> Optional[bool]: 139 | try: 140 | async with shell.Session(self.host) as sh: 141 | return await sh.check_firmware_lock() 142 | except Exception as e: 143 | self.error(f"Can't get firmware lock", e) 144 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/ezsp.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import logging 4 | import socket 5 | import time 6 | from typing import Optional 7 | 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 10 | from homeassistant.requirements import async_process_requirements 11 | 12 | from . import shell 13 | from .const import DOMAIN 14 | from .shell.base import RUN_OPENMIIO 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | async def update_zigbee_firmware(hass: HomeAssistant, host: str, custom: bool): 20 | tar_fw = "6.7.10.0" if custom else "6.6.2.0" 21 | 22 | _LOGGER.debug(f"{host} [FWUP] Target zigbee firmware v{tar_fw}") 23 | 24 | session = shell.Session(host) 25 | 26 | try: 27 | await session.connect() 28 | sh = await session.login() 29 | except Exception as e: 30 | _LOGGER.error("Can't connect to gateway", exc_info=e) 31 | await session.close() 32 | return False 33 | 34 | try: 35 | await async_process_requirements(hass, DOMAIN, [ 36 | 'bellows>=0.29.0', 'pyserial>=3.5', 'pyserial-asyncio>=0.5', 37 | ]) 38 | 39 | await sh.exec( 40 | "zigbee_inter_bootloader.sh 1; zigbee_reset.sh 0; zigbee_reset.sh 1; " 41 | "killall openmiio_agent; /data/openmiio_agent --zigbee.tcp=8889 &" 42 | ) 43 | 44 | await asyncio.sleep(1) 45 | 46 | cur_fw = await read_firmware(host) 47 | if not cur_fw: 48 | _LOGGER.error(f"{host} [FWUP] Can't get current firmware") 49 | return False 50 | 51 | if cur_fw.startswith(tar_fw): 52 | _LOGGER.debug(f"{host} [FWUP] No need to update") 53 | return True 54 | 55 | await sh.exec( 56 | "zigbee_inter_bootloader.sh 0; zigbee_reset.sh 0; zigbee_reset.sh 1; " 57 | "killall openmiio_agent; /data/openmiio_agent --zigbee.tcp=8889 --zigbee.baud=115200 &" 58 | ) 59 | 60 | await async_process_requirements(hass, DOMAIN, ['xmodem==0.4.6']) 61 | 62 | client = async_create_clientsession(hass) 63 | r = await client.get( 64 | "https://master.dl.sourceforge.net/project/mgl03/zigbee/mgl03_ncp_6_7_10_b38400_sw.gbl?viasf=1" 65 | if custom else 66 | "https://master.dl.sourceforge.net/project/mgl03/zigbee/ncp-uart-sw_mgl03_6_6_2_stock.gbl?viasf=1" 67 | ) 68 | content = await r.read() 69 | 70 | ok = await hass.async_add_executor_job(flash_firmware, host, content) 71 | if not ok: 72 | return False 73 | 74 | await sh.exec( 75 | "zigbee_inter_bootloader.sh 1; zigbee_reset.sh 0; zigbee_reset.sh 1; " 76 | "killall openmiio_agent; /data/openmiio_agent --zigbee.tcp=8889 &" 77 | ) 78 | 79 | cur_fw = await read_firmware(host) 80 | return cur_fw and cur_fw.startswith(tar_fw) 81 | 82 | except Exception as e: 83 | _LOGGER.error(f"{host} [FWUP] Can't update firmware", exc_info=e) 84 | 85 | finally: 86 | await sh.exec( 87 | "zigbee_inter_bootloader.sh 1; zigbee_reset.sh 0; zigbee_reset.sh 1; " 88 | "killall openmiio_agent; " + RUN_OPENMIIO 89 | ) 90 | await sh.close() 91 | 92 | 93 | async def read_firmware(host: str) -> Optional[str]: 94 | from bellows.ezsp import EZSP 95 | 96 | ezsp = EZSP({"path": f"socket://{host}:8889", "flow_control": None}) 97 | try: 98 | await asyncio.wait_for(ezsp._probe(), timeout=10) 99 | except asyncio.TimeoutError: 100 | return None 101 | _, _, version = await ezsp.get_board_info() 102 | ezsp.close() 103 | 104 | _LOGGER.debug(f"{host} [FWUP] Current zigbee firmware v{version}") 105 | 106 | return version 107 | 108 | 109 | def flash_firmware(host: str, content: bytes) -> bool: 110 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 111 | sock.settimeout(3) 112 | sock.connect((host, 8889)) 113 | 114 | sock.send(b"\x0A") 115 | 116 | if b"Gecko Bootloader v1.8.0" not in read(sock): 117 | _LOGGER.warning(f"{host} [FWUP] Not in boot before flash") 118 | return False 119 | 120 | sock.send(b"1") 121 | 122 | if b"CCC" not in read(sock): 123 | _LOGGER.warning(f"{host} [FWUP] Not in flash mode") 124 | return False 125 | 126 | # STATIC FUNCTIONS 127 | def getc(size, timeout=1): 128 | read_data = sock.recv(size) 129 | return read_data 130 | 131 | def putc(data, timeout=1): 132 | sock.send(data) 133 | time.sleep(0.001) 134 | 135 | # noinspection PyUnresolvedReferences 136 | from xmodem import XMODEM 137 | 138 | modem = XMODEM(getc, putc) 139 | modem.log = _LOGGER.getChild('xmodem') 140 | stream = io.BytesIO(content) 141 | 142 | if not modem.send(stream): 143 | _LOGGER.warning(f"{host} [FWUP] Xmodem send firmware fail") 144 | return False 145 | 146 | if b"Serial upload complete" not in read(sock): 147 | _LOGGER.warning(f"{host} [FWUP] Not in boot after flash") 148 | return False 149 | 150 | return True 151 | 152 | 153 | def read(sock: socket) -> bytes: 154 | raw = b"" 155 | 156 | t = time.time() + sock.gettimeout() 157 | while time.time() < t: 158 | try: 159 | b = sock.recv(1) 160 | if b == 0: 161 | break 162 | raw += b 163 | except socket.timeout: 164 | break 165 | 166 | return raw 167 | -------------------------------------------------------------------------------- /tests/test_conv_silabs.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import DOMAIN 2 | from zigpy.types import EUI64 3 | 4 | from custom_components.xiaomi_gateway3.core.converters import silabs, ZIGBEE 5 | from custom_components.xiaomi_gateway3.core.converters.zigbee import ZConverter 6 | from custom_components.xiaomi_gateway3.core.device import XDevice 7 | 8 | assert DOMAIN # fix circular import 9 | 10 | ZDID = "lumi.112233aabbcc" 11 | ZMAC = "0x0000112233aabbcc" 12 | ZNWK = "0x12ab" 13 | 14 | 15 | def test_cli(): 16 | p = silabs.zcl_read("0x1234", 1, "on_off", "on_off") 17 | assert p == [ 18 | {'commandcli': 'zcl global read 6 0'}, 19 | {'commandcli': 'send 0x1234 1 1'} 20 | ] 21 | 22 | p = silabs.zcl_read( 23 | "0x1234", 1, "electrical_measurement", "rms_voltage", "rms_current", 24 | "active_power" 25 | ) 26 | assert p == [ 27 | {'commandcli': 'raw 2820 {100000050508050b05}'}, 28 | {'commandcli': 'send 0x1234 1 1'} 29 | ] 30 | 31 | p = silabs.zcl_write("0x1234", 1, 0xFCC0, 9, 1, type=0x20, mfg=0x115f) 32 | assert p == [ 33 | {'commandcli': 'zcl mfg-code 4447'}, 34 | {'commandcli': 'zcl global write 64704 9 32 {01}'}, 35 | {'commandcli': 'send 0x1234 1 1'} 36 | ] 37 | 38 | 39 | def test_aqara_cube(): 40 | device = XDevice(ZIGBEE, "lumi.sensor_cube", ZDID, ZMAC, ZNWK) 41 | assert device.info.name == "Aqara Cube" 42 | device.setup_converters() 43 | 44 | p = silabs.decode({ 45 | "clusterId": "0x0012", "sourceEndpoint": "0x02", 46 | "APSPlayload": "0x18140A5500215900" 47 | }) 48 | p = device.decode_zigbee(p) 49 | assert p == {'action': 'flip90', 'from_side': 3, 'to_side': 1} 50 | 51 | 52 | def test_tuya_button(): 53 | device = XDevice(ZIGBEE, "TS004F", ZDID, ZMAC, ZNWK) 54 | device.setup_converters() 55 | 56 | p0 = silabs.decode({ 57 | "clusterId": "0x0006", "sourceEndpoint": "0x03", 58 | "APSPlayload": "0x010AFD02", 59 | }) 60 | p = device.decode_zigbee(p0) 61 | assert p == {'button_3': 'hold', 'action': 'button_3_hold'} 62 | 63 | # test processing same sequence 64 | p = device.decode_zigbee(p0) 65 | assert p == {} 66 | 67 | 68 | def test_config(): 69 | device = XDevice(ZIGBEE, "TS004F", ZDID, ZMAC, ZNWK) 70 | device.setup_converters() 71 | 72 | gw = type("", (), {"ieee": "0xAABBCC"}) 73 | 74 | p = {} 75 | for conv in device.converters: 76 | if isinstance(conv, ZConverter): 77 | conv.config(device, p, gw) 78 | 79 | assert p['commands'] == [ 80 | {'commandcli': 'raw 6 {10000004000000010005000700feff}'}, 81 | {'commandcli': 'send 0x12ab 1 1'}, 82 | {'commandcli': 'zcl global read 57345 53265'}, 83 | {'commandcli': 'send 0x12ab 1 1'}, 84 | {'commandcli': 'zdo bind 0x12ab 1 1 6 {0000112233aabbcc} {0xAABBCC}'}, 85 | {'commandcli': 'zdo bind 0x12ab 2 1 6 {0000112233aabbcc} {0xAABBCC}'}, 86 | {'commandcli': 'zdo bind 0x12ab 3 1 6 {0000112233aabbcc} {0xAABBCC}'}, 87 | {'commandcli': 'zdo bind 0x12ab 4 1 6 {0000112233aabbcc} {0xAABBCC}'}, 88 | {'commandcli': 'zdo bind 0x12ab 1 1 1 {0000112233aabbcc} {0xAABBCC}'}, 89 | {'commandcli': 'zcl global write 6 32772 48 {01}'}, 90 | {'commandcli': 'send 0x12ab 1 1'} 91 | ] 92 | 93 | 94 | def test_(): 95 | device = XDevice(ZIGBEE, "MS01", ZDID, ZMAC, ZNWK) 96 | assert device.info.name == "Sonoff Motion Sensor" 97 | device.setup_converters() 98 | 99 | p = silabs.decode({ 100 | "clusterId": "0x0001", "sourceEndpoint": "0x01", 101 | "APSPlayload": "0x18AC0A2000201E" 102 | }) 103 | p = device.decode_zigbee(p) 104 | assert p == {'battery_voltage': 3000} 105 | 106 | p = silabs.decode({ 107 | "clusterId": "0x0001", "sourceEndpoint": "0x01", 108 | "APSPlayload": "0x18AD0A210020C8" 109 | }) 110 | p = device.decode_zigbee(p) 111 | assert p == {'battery': 100} 112 | 113 | p = silabs.decode({ 114 | "clusterId": "0x0500", "sourceEndpoint": "0x01", 115 | "APSPlayload": "0x190300000000000000", 116 | }) 117 | p = device.decode_zigbee(p) 118 | assert p == {'occupancy': False} 119 | 120 | p = silabs.decode({ 121 | "clusterId": "0x0500", "sourceEndpoint": "0x01", 122 | "APSPlayload": "0x190400010000000000" 123 | }) 124 | p = device.decode_zigbee(p) 125 | assert p == {'occupancy': True} 126 | 127 | 128 | def test_silabs_decode(): 129 | p = silabs.decode({ 130 | "clusterId": "0x0006", "sourceEndpoint": "0x01", 131 | "APSPlayload": "0x08080A04803001" 132 | }) 133 | assert p == { 134 | 'endpoint': 1, 'seq': 8, 'cluster': 'on_off', 135 | 'command': 'Report_Attributes', 32772: 1 136 | } 137 | 138 | p = silabs.decode({ 139 | "clusterId": "0x0006", "sourceEndpoint": "0x03", 140 | "APSPlayload": "0x010AFD02" 141 | }) 142 | assert p == { 143 | 'endpoint': 3, 'seq': 10, 'cluster': 'on_off', 'command_id': 253, 144 | 'value': b'\x02' 145 | } 146 | 147 | 148 | def test_ias_zone(): 149 | p = silabs.decode({ 150 | "clusterId": "0x0500", "sourceEndpoint": "0x01", 151 | "APSPlayload": "0x096700210000000000" 152 | }) 153 | p['value'] = list(p['value']) 154 | assert p == { 155 | 'endpoint': 1, 'seq': 103, 'cluster': 'ias_zone', 'command_id': 0, 156 | 'command': 'enroll_response', 'value': [33, 0, 0, 0] 157 | } 158 | 159 | 160 | def test_misc(): 161 | p = silabs.decode({ 162 | "clusterId": "0x8000", "sourceEndpoint": "0x00", 163 | "APSPlayload": "0x02005D6A9303008D15002723" 164 | }) 165 | assert p == { 166 | 'command': 'NWK_addr_rsp', 'status': 0, 'nwk': 0x2327, 167 | 'ieee': EUI64.convert("00:15:8d:00:03:93:6a:5d"), 168 | } 169 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/z3.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | 4 | from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_CON, \ 5 | SIGNAL_MQTT_PUB, SIGNAL_TIMER 6 | from .. import shell 7 | from ..device import ZIGBEE, XDevice 8 | from ..mini_mqtt import MQTTMessage 9 | 10 | 11 | # noinspection PyMethodMayBeStatic,PyUnusedLocal 12 | class Z3Gateway(GatewayBase): 13 | ieee: str = None 14 | 15 | z3_parent_scan: float = 0 16 | # collected data from MQTT topic log/z3 (zigbee console) 17 | z3_buffer: dict = None 18 | 19 | def z3_init(self): 20 | if self.zha_mode or not self.stats_enable: 21 | return 22 | self.dispatcher_connect(SIGNAL_PREPARE_GW, self.z3_prepare_gateway) 23 | self.dispatcher_connect(SIGNAL_MQTT_CON, self.z3_mqtt_connect) 24 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.z3_mqtt_publish) 25 | self.dispatcher_connect(SIGNAL_TIMER, self.z3_timer) 26 | 27 | async def z3_prepare_gateway(self, sh: shell.TelnetShell): 28 | assert self.ieee, "Z3Gateway depends on SilabsGateway" 29 | 30 | async def z3_mqtt_connect(self): 31 | # delay first scan 32 | self.z3_parent_scan = time.time() + 10 33 | 34 | async def z3_mqtt_publish(self, msg: MQTTMessage): 35 | if msg.topic == 'log/z3': 36 | await self.z3_process_log(msg.text) 37 | 38 | async def z3_timer(self, ts: float): 39 | if ts >= self.z3_parent_scan: 40 | await self.z3_run_parent_scan() 41 | 42 | async def z3_run_parent_scan(self): 43 | self.debug("Run zigbee parent scan process") 44 | 45 | # block any auto updates in 10 seconds 46 | self.z3_parent_scan = time.time() + 10 47 | 48 | payload = {"commands": [ 49 | {"commandcli": "debugprint all_on"}, 50 | {"commandcli": "plugin device-table print"}, 51 | {"commandcli": "plugin stack-diagnostics child-table"}, 52 | {"commandcli": "plugin stack-diagnostics neighbor-table"}, 53 | {"commandcli": "plugin concentrator print-table"}, 54 | {"commandcli": "debugprint all_off"}, 55 | ]} 56 | await self.mqtt.publish(f"gw/{self.ieee}/commands", payload) 57 | 58 | async def z3_process_log(self, payload: str): 59 | if payload.startswith("CLI command executed"): 60 | cmd = payload[22:] 61 | if cmd == "debugprint all_on" or self.z3_buffer is None: 62 | # reset all buffers 63 | self.z3_buffer = {} 64 | else: 65 | self.z3_buffer[cmd] = self.z3_buffer['buffer'] 66 | 67 | self.z3_buffer['buffer'] = '' 68 | 69 | if cmd == "plugin concentrator print-table": 70 | await self.z3_process_parent_scan() 71 | 72 | elif self.z3_buffer: 73 | self.z3_buffer['buffer'] += payload 74 | 75 | async def z3_process_parent_scan(self): 76 | self.debug("Process zigbee parent scan response") 77 | try: 78 | raw = self.z3_buffer["plugin device-table print"] 79 | dt = re.findall( 80 | r'\d+ ([A-F0-9]{4}): {2}([A-F0-9]{16}) 0 {2}(\w+) (\d+)', raw 81 | ) 82 | 83 | raw = self.z3_buffer["plugin stack-diagnostics child-table"] 84 | ct = re.findall(r'\(>\)([A-F0-9]{16})', raw) 85 | 86 | raw = self.z3_buffer["plugin stack-diagnostics neighbor-table"] 87 | rt = re.findall(r'\(>\)([A-F0-9]{16})', raw) 88 | 89 | raw = self.z3_buffer["plugin concentrator print-table"] 90 | pt = re.findall(r': (.+?) \(Me\)', raw) 91 | pt = [i.replace('0x', '').split(' -> ') for i in pt] 92 | pt = {i[0]: i[1:] for i in pt} 93 | 94 | self.debug(f"Total zigbee devices: {len(dt)}") 95 | 96 | # nwk: FFFF, ieee: FFFFFFFFFFFFFFFF, ago: int 97 | # state: JOINED, LEAVE_SENT (32) 98 | for nwk, ieee, state, ago in dt: 99 | if state == "LEAVE_SENT": 100 | continue 101 | 102 | if ieee in ct: 103 | type_ = 'device' 104 | elif ieee in rt: 105 | type_ = 'router' 106 | elif nwk in pt: 107 | type_ = 'device' 108 | else: 109 | type_ = '?' 110 | 111 | if nwk in pt: 112 | if len(pt[nwk]) > 1: 113 | parent = '0x' + pt[nwk][0].lower() 114 | else: 115 | parent = '-' 116 | elif ieee in ct: 117 | parent = '-' 118 | else: 119 | parent = '?' 120 | 121 | nwk = '0x' + nwk.lower() # 0xffff 122 | 123 | payload = { 124 | # 'eui64': '0x' + ieee, 125 | # 'nwk': nwk, 126 | # 'ago': int(ago), 127 | 'type': type_, 128 | 'parent': parent 129 | } 130 | 131 | did = 'lumi.' + ieee.lstrip('0').lower() 132 | device = self.devices.get(did) 133 | if not device: 134 | mac = '0x' + ieee.lower() 135 | device = XDevice(ZIGBEE, None, did, mac, nwk) 136 | self.add_device(did, device) 137 | self.debug_device(device, "new unknown device", tag=" Z3 ") 138 | continue 139 | 140 | if ZIGBEE not in device.entities: 141 | continue 142 | 143 | # the device remains in the gateway database after 144 | # deletion and may appear on another gw with another nwk 145 | if nwk == device.nwk: 146 | # payload = device.decode(ZIGBEE, payload) 147 | device.update(payload) 148 | else: 149 | self.debug(f"Zigbee device with wrong NWK: {ieee}") 150 | 151 | # one hour later 152 | self.z3_parent_scan = time.time() + 3600 153 | 154 | except Exception as e: 155 | self.debug(f"Can't update parents", exc_info=e) 156 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/base.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import re 4 | from logging import Logger 5 | from typing import Callable, Dict, List, Optional, Union 6 | 7 | from ..converters import Converter 8 | from ..device import XDevice 9 | from ..mini_miio import AsyncMiIO 10 | from ..mini_mqtt import MiniMQTT 11 | 12 | RE_ENTITIES = re.compile(r"[a-z_]+") 13 | 14 | SIGNAL_PREPARE_GW = "prepare_gateway" 15 | SIGNAL_MQTT_CON = "mqtt_connect" 16 | SIGNAL_MQTT_DIS = "mqtt_disconnect" 17 | SIGNAL_MQTT_PUB = "mqtt_publish" 18 | SIGNAL_TIMER = "timer" 19 | 20 | 21 | class GatewayBase: 22 | """devices and defaults are global between all gateways.""" 23 | # keys: 24 | # - Gateway: did, "123456789", X digits 25 | # - Zigbee: did, "lumi.abcdef", 8 byte hex mac without leading zeros 26 | # - BLE: mac, "abcdef", 6 byte hex mac 27 | # - Mesh: did, "123456789", X digits 28 | # - Mesh group: did, "group.123456789" 29 | devices: Dict[str, XDevice] = {} 30 | # key - mac, 6 byte hex for gw and bluetooth, 8 byte hex for zb with "0x" 31 | defaults: Dict[str, dict] = {} 32 | 33 | log: Logger = None 34 | 35 | host: str = None 36 | options: dict = None 37 | available: bool = None 38 | 39 | dispatcher: Dict[str, List[Callable]] = None 40 | setups: Dict[str, Callable] = None 41 | tasks: List[asyncio.Task] = None 42 | miio_ack: Dict[int, asyncio.Future] = None 43 | 44 | mqtt: MiniMQTT = None 45 | miio: AsyncMiIO = None 46 | 47 | did: str = None 48 | time_offset = 0 49 | 50 | @property 51 | def ble_mode(self): 52 | return self.options.get('ble', True) 53 | 54 | @property 55 | def debug_mode(self): 56 | return self.options.get('debug', '') 57 | 58 | @property 59 | def zha_mode(self) -> bool: 60 | return self.options.get('zha', False) 61 | 62 | @property 63 | def stats_enable(self): 64 | return self.options.get('stats', False) 65 | 66 | @property 67 | def device(self) -> Optional[XDevice]: 68 | return self.devices.get(self.did) 69 | 70 | def debug(self, msg: str, exc_info=None): 71 | """Global debug messages. Passed only if default debug enabled.""" 72 | if 'true' in self.debug_mode: 73 | self.log.debug(f"{self.host} [BASE] {msg}", exc_info=exc_info) 74 | 75 | def warning(self, msg: str, exc_info=None): 76 | self.log.warning(f"{self.host} | {msg}", exc_info=exc_info) 77 | 78 | def error(self, msg: str, exc_info=None): 79 | self.log.error(f"{self.host} | {msg}", exc_info=exc_info) 80 | 81 | def exception(self, msg: str): 82 | self.log.exception(f"{self.host} | {msg}") 83 | 84 | def debug_tag(self, msg: str, tag: str): 85 | """Debug message with tag. Tag should be in upper case. `debug_mode` 86 | must be checked before calling. 87 | """ 88 | self.log.debug(f"{self.host} [{tag}] {msg}") 89 | 90 | def debug_device(self, device: XDevice, msg: str, payload=None, 91 | tag: str = "BASE"): 92 | """Debug message with device. Passed only if default debug enabled.""" 93 | if 'true' in self.debug_mode: 94 | adv = device.nwk if device.nwk else device.model 95 | self.log.debug( 96 | f"{self.host} [{tag}] {device.mac} ({adv}) {msg} {payload}" 97 | if payload else 98 | f"{self.host} [{tag}] {device.mac} ({adv}) {msg}" 99 | ) 100 | 101 | def dispatcher_connect(self, signal: str, target: Callable): 102 | targets = self.dispatcher.setdefault(signal, []) 103 | if target not in targets: 104 | targets.append(target) 105 | 106 | async def dispatcher_send(self, signal: str, **kwargs): 107 | if not self.dispatcher.get(signal): 108 | return 109 | # better not to use asyncio.gather 110 | for handler in self.dispatcher[signal]: 111 | await handler(**kwargs) 112 | 113 | def add_setup(self, domain: str, handler): 114 | """Add hass entity setup funcion.""" 115 | if "." in domain: 116 | _, domain = domain.rsplit(".", 1) 117 | self.setups[domain] = handler 118 | 119 | def setup_entity(self, domain: str, device: XDevice, conv: Converter): 120 | handler = self.setups.get(domain) 121 | if handler: 122 | handler(self, device, conv) 123 | 124 | def add_device(self, did: str, device: XDevice): 125 | if did not in self.devices: 126 | self.devices[did] = device 127 | 128 | if self not in device.gateways: 129 | device.gateways.append(self) 130 | 131 | # don't setup device with unknown model 132 | if not device.model: 133 | return 134 | 135 | # if device.entities: 136 | # # don't setup if device already has setup entities 137 | # self.debug_device(device, "Join to gateway", device.model) 138 | # return 139 | 140 | # don't setup device from second gateway 141 | if len(device.gateways) > 1: 142 | return 143 | 144 | device.setup_entitites(self, stats=self.stats_enable) 145 | self.debug_device( 146 | device, f"setup {device.info.model}:", 147 | ", ".join(device.entities.keys()) 148 | ) 149 | 150 | def filter_devices(self, feature: str) -> List[XDevice]: 151 | return [ 152 | device for device in self.devices.values() 153 | if self in device.gateways and device.has_support(feature) 154 | ] 155 | 156 | async def miio_send( 157 | self, method: str, params: Union[dict, list] = None, 158 | timeout: int = 5 159 | ): 160 | fut = asyncio.get_event_loop().create_future() 161 | 162 | cid = random.randint(1_000_000_000, 2_147_483_647) 163 | self.miio_ack[cid] = fut 164 | 165 | await self.mqtt.publish("miio/command", { 166 | "id": cid, "method": method, "params": params 167 | }) 168 | 169 | try: 170 | await asyncio.wait_for(self.miio_ack[cid], timeout) 171 | except asyncio.TimeoutError: 172 | return None 173 | finally: 174 | del self.miio_ack[cid] 175 | 176 | return fut.result() 177 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/sensor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from asyncio import Task 3 | from datetime import datetime, timedelta, timezone 4 | 5 | from homeassistant.components.sensor import SensorEntity, SensorStateClass 6 | from homeassistant.const import * 7 | from homeassistant.core import callback 8 | from homeassistant.helpers.restore_state import RestoreEntity 9 | 10 | from . import DOMAIN 11 | from .core.converters import Converter, STAT_GLOBALS 12 | from .core.device import XDevice 13 | from .core.entity import XEntity 14 | from .core.gateway import XGateway 15 | 16 | SCAN_INTERVAL = timedelta(seconds=60) 17 | 18 | 19 | async def async_setup_entry(hass, entry, add_entities): 20 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 21 | if conv.attr in device.entities: 22 | entity: XEntity = device.entities[conv.attr] 23 | entity.gw = gateway 24 | elif conv.attr == "action": 25 | entity = XiaomiAction(gateway, device, conv) 26 | elif conv.attr in STAT_GLOBALS: 27 | entity = XiaomiStats(gateway, device, conv) 28 | else: 29 | entity = XiaomiSensor(gateway, device, conv) 30 | add_entities([entity]) 31 | 32 | gw: XGateway = hass.data[DOMAIN][entry.entry_id] 33 | gw.add_setup(__name__, setup) 34 | 35 | 36 | UNITS = { 37 | "battery": PERCENTAGE, 38 | "humidity": PERCENTAGE, 39 | # zb light and motion and ble flower - lux 40 | "illuminance": LIGHT_LUX, 41 | "power": POWER_WATT, 42 | "voltage": ELECTRIC_POTENTIAL_VOLT, 43 | "current": ELECTRIC_CURRENT_AMPERE, 44 | "pressure": PRESSURE_HPA, 45 | "temperature": TEMP_CELSIUS, 46 | "energy": ENERGY_KILO_WATT_HOUR, 47 | "chip_temperature": TEMP_CELSIUS, 48 | "conductivity": CONDUCTIVITY, 49 | "gas_density": "% LEL", 50 | "idle_time": TIME_SECONDS, 51 | "linkquality": "lqi", 52 | "max_power": POWER_WATT, 53 | "moisture": PERCENTAGE, 54 | "msg_received": "msg", 55 | "msg_missed": "msg", 56 | "new_resets": "rst", 57 | "resets": "rst", 58 | "rssi": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 59 | "smoke_density": "% obs/ft", 60 | "supply": PERCENTAGE, 61 | "tvoc": CONCENTRATION_PARTS_PER_BILLION, 62 | # "link_quality": "lqi", 63 | # "rssi": "dBm", 64 | # "msg_received": "msg", 65 | # "msg_missed": "msg", 66 | # "unresponsive": "times" 67 | } 68 | 69 | # https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics 70 | STATE_CLASSES = { 71 | "energy": SensorStateClass.TOTAL_INCREASING, 72 | } 73 | 74 | 75 | class XiaomiBaseSensor(XEntity, SensorEntity): 76 | def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter): 77 | XEntity.__init__(self, gateway, device, conv) 78 | 79 | if self.attr in UNITS: 80 | # by default all sensors with units is measurement sensors 81 | self._attr_state_class = SensorStateClass.MEASUREMENT 82 | self._attr_native_unit_of_measurement = UNITS[self.attr] 83 | 84 | if self.attr in STATE_CLASSES: 85 | self._attr_state_class = STATE_CLASSES[self.attr] 86 | 87 | @callback 88 | def async_set_state(self, data: dict): 89 | if self.attr in data: 90 | self._attr_native_value = data[self.attr] 91 | for k, v in data.items(): 92 | if k in self.subscribed_attrs and k != self.attr: 93 | self._attr_extra_state_attributes[k] = v 94 | 95 | 96 | class XiaomiSensor(XiaomiBaseSensor, RestoreEntity): 97 | @callback 98 | def async_set_state(self, data: dict): 99 | if self.attr in data: 100 | self._attr_extra_state_attributes["native_value"] = data[self.attr] 101 | XiaomiBaseSensor.async_set_state(self, data) 102 | 103 | @callback 104 | def async_restore_last_state(self, state: str, attrs: dict): 105 | """Restore previous state.""" 106 | self._attr_native_value = attrs.get("native_value", state) 107 | for k, v in attrs.items(): 108 | if k in self.subscribed_attrs or k == "native_value": 109 | self._attr_extra_state_attributes[k] = v 110 | 111 | async def async_update(self): 112 | await self.device_read(self.subscribed_attrs) 113 | 114 | 115 | class XiaomiStats(XiaomiBaseSensor): 116 | @property 117 | def available(self): 118 | return True 119 | 120 | @callback 121 | def async_update_available(self): 122 | super().async_update_available() 123 | self._attr_extra_state_attributes["available"] = self.available 124 | 125 | async def async_added_to_hass(self): 126 | await super().async_added_to_hass() 127 | 128 | data = {"available": self.available} 129 | if self.device.decode_ts: 130 | data[self.attr] = datetime.fromtimestamp( 131 | self.device.decode_ts, timezone.utc 132 | ) 133 | if self.device.nwk: 134 | data["ieee"] = self.device.mac 135 | data["nwk"] = self.device.nwk 136 | else: 137 | data["mac"] = self.device.mac 138 | 139 | self.async_set_state(data) 140 | 141 | 142 | class XiaomiAction(XiaomiBaseSensor): 143 | _attr_native_value = "" 144 | native_attrs: dict = None 145 | clear_task: Task = None 146 | 147 | async def clear_state(self): 148 | await asyncio.sleep(.3) 149 | 150 | self._attr_native_value = "" 151 | self.async_write_ha_state() 152 | 153 | async def async_added_to_hass(self): 154 | await XEntity.async_added_to_hass(self) 155 | self.native_attrs = self._attr_extra_state_attributes 156 | 157 | async def async_will_remove_from_hass(self): 158 | if self.clear_task: 159 | self.clear_task.cancel() 160 | 161 | if self.native_value != "": 162 | self._attr_native_value = "" 163 | self.async_write_ha_state() 164 | 165 | await XEntity.async_will_remove_from_hass(self) 166 | 167 | @callback 168 | def async_set_state(self, data: dict): 169 | # fix 1.4.7_0115 heartbeat error (has button in heartbeat) 170 | if "battery" in data or self.attr not in data or not self.hass: 171 | return 172 | 173 | if self.clear_task: 174 | self.clear_task.cancel() 175 | 176 | self._attr_native_value = data[self.attr] 177 | if self.native_attrs: 178 | self._attr_extra_state_attributes = {**self.native_attrs, **data} 179 | else: 180 | self._attr_extra_state_attributes = data 181 | 182 | # repeat event from Aqara integration 183 | self.hass.bus.async_fire("xiaomi_aqara.click", { 184 | "entity_id": self.entity_id, "click_type": self.native_value 185 | }) 186 | 187 | self.clear_task = self.hass.loop.create_task(self.clear_state()) 188 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/light.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from homeassistant.components.light import * 4 | from homeassistant.const import STATE_ON 5 | from homeassistant.core import callback 6 | from homeassistant.helpers.restore_state import RestoreEntity 7 | 8 | from . import DOMAIN 9 | from .core.converters import ZIGBEE, MESH_GROUP_MODEL, Converter 10 | from .core.device import XDevice 11 | from .core.entity import XEntity 12 | from .core.gateway import XGateway 13 | 14 | CONF_DEFAULT_TRANSITION = 'default_transition' 15 | 16 | 17 | async def async_setup_entry(hass, config_entry, async_add_entities): 18 | def setup(gateway: XGateway, device: XDevice, conv: Converter): 19 | if conv.attr in device.entities: 20 | entity: XEntity = device.entities[conv.attr] 21 | entity.gw = gateway 22 | elif device.type == ZIGBEE: 23 | entity = XiaomiZigbeeLight(gateway, device, conv) 24 | elif device.model == MESH_GROUP_MODEL: 25 | entity = XiaomiMeshGroup(gateway, device, conv) 26 | else: 27 | entity = XiaomiMeshLight(gateway, device, conv) 28 | async_add_entities([entity]) 29 | 30 | gw: XGateway = hass.data[DOMAIN][config_entry.entry_id] 31 | gw.add_setup(__name__, setup) 32 | 33 | 34 | # noinspection PyAbstractClass 35 | class XiaomiLight(XEntity, LightEntity, RestoreEntity): 36 | _attr_is_on = None 37 | 38 | def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter): 39 | super().__init__(gateway, device, conv) 40 | 41 | for conv in device.converters: 42 | if conv.attr == ATTR_BRIGHTNESS: 43 | self._attr_supported_features |= ( 44 | SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION 45 | ) 46 | elif conv.attr == ATTR_COLOR_TEMP: 47 | self._attr_supported_features |= SUPPORT_COLOR_TEMP 48 | if hasattr(conv, "minm") and hasattr(conv, "maxm"): 49 | self._attr_min_mireds = conv.minm 50 | self._attr_max_mireds = conv.maxm 51 | elif hasattr(conv, "mink") and hasattr(conv, "maxk"): 52 | self._attr_min_mireds = int(1000000 / conv.maxk) 53 | self._attr_max_mireds = int(1000000 / conv.mink) 54 | 55 | @callback 56 | def async_set_state(self, data: dict): 57 | if self.attr in data: 58 | self._attr_is_on = data[self.attr] 59 | # sometimes brightness and color_temp stored as string in Xiaomi DB 60 | if ATTR_BRIGHTNESS in data: 61 | self._attr_brightness = data[ATTR_BRIGHTNESS] 62 | if ATTR_COLOR_TEMP in data: 63 | self._attr_color_temp = data[ATTR_COLOR_TEMP] 64 | 65 | @callback 66 | def async_restore_last_state(self, state: str, attrs: dict): 67 | self._attr_is_on = state == STATE_ON 68 | self._attr_brightness = attrs.get(ATTR_BRIGHTNESS) 69 | self._attr_color_temp = attrs.get(ATTR_COLOR_TEMP) 70 | 71 | async def async_update(self): 72 | await self.device_read(self.subscribed_attrs) 73 | 74 | 75 | # noinspection PyAbstractClass 76 | class XiaomiZigbeeLight(XiaomiLight): 77 | async def async_turn_on(self, **kwargs): 78 | if ATTR_TRANSITION in kwargs: 79 | tr = kwargs.pop(ATTR_TRANSITION) 80 | elif CONF_DEFAULT_TRANSITION in self.customize: 81 | tr = self.customize[CONF_DEFAULT_TRANSITION] 82 | else: 83 | tr = None 84 | 85 | if tr is not None: 86 | if kwargs: 87 | # For the Aqara bulb, it is important that the brightness 88 | # parameter comes before the color_temp parameter. Only this 89 | # way transition will work. So we use `kwargs.pop` func to set 90 | # the exact order of parameters. 91 | for k in (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP): 92 | if k in kwargs: 93 | kwargs[k] = (kwargs.pop(k), tr) 94 | else: 95 | kwargs[ATTR_BRIGHTNESS] = (255, tr) 96 | 97 | if not kwargs: 98 | kwargs[self.attr] = True 99 | 100 | await self.device_send(kwargs) 101 | 102 | async def async_turn_off(self, **kwargs): 103 | if ATTR_TRANSITION in kwargs: 104 | tr = kwargs[ATTR_TRANSITION] 105 | elif CONF_DEFAULT_TRANSITION in self.customize: 106 | tr = self.customize[CONF_DEFAULT_TRANSITION] 107 | else: 108 | tr = None 109 | 110 | if tr is not None: 111 | await self.device_send({ATTR_BRIGHTNESS: (0, tr)}) 112 | else: 113 | await self.device_send({self.attr: False}) 114 | 115 | 116 | # noinspection PyAbstractClass 117 | class XiaomiMeshBase(XiaomiLight): 118 | async def async_turn_on(self, **kwargs): 119 | kwargs[self.attr] = True 120 | await self.device_send(kwargs) 121 | 122 | async def async_turn_off(self, **kwargs): 123 | kwargs[self.attr] = False 124 | await self.device_send(kwargs) 125 | 126 | 127 | # noinspection PyAbstractClass 128 | class XiaomiMeshLight(XiaomiMeshBase): 129 | @callback 130 | def async_set_state(self, data: dict): 131 | super().async_set_state(data) 132 | 133 | if "group" not in self.device.entities: 134 | return 135 | # convert light attr to group attr 136 | if self.attr in data: 137 | data["group"] = data.pop(self.attr) 138 | group = self.device.entities["group"] 139 | group.async_set_state(data) 140 | group.async_write_ha_state() 141 | 142 | 143 | # noinspection PyAbstractClass 144 | class XiaomiMeshGroup(XiaomiMeshBase): 145 | def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter): 146 | super().__init__(gateway, device, conv) 147 | 148 | if not device.extra["childs"]: 149 | device.available = False 150 | return 151 | 152 | for did in device.extra["childs"]: 153 | child = gateway.devices[did] 154 | child.entities[self.attr] = self 155 | 156 | async def async_will_remove_from_hass(self) -> None: 157 | await super().async_will_remove_from_hass() 158 | if not self.device.extra["childs"]: 159 | return 160 | for did in self.device.extra["childs"]: 161 | child = self.gw.devices[did] 162 | child.entities.pop(self.attr) 163 | 164 | async def async_update(self): 165 | # To update a group - request an update of its children 166 | # update_ha_state for all child light entities 167 | try: 168 | childs = [] 169 | for did in self.device.extra["childs"]: 170 | light = self.gw.devices[did].entities.get("light") 171 | childs.append(light.async_update_ha_state(True)) 172 | if childs: 173 | await asyncio.gather(*childs) 174 | 175 | except Exception as e: 176 | self.debug("Can't update child states", exc_info=e) 177 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/unqlite.py: -------------------------------------------------------------------------------- 1 | """Two classes for read Unqlite and SQLite DB files frow raw bytes. Default 2 | python sqlite3 library can't read DB from memory. 3 | """ 4 | 5 | 6 | # noinspection PyUnusedLocal 7 | class Unqlite: 8 | page_size = 0 9 | pos = 0 10 | 11 | def __init__(self, raw: bytes): 12 | self.raw = raw 13 | self.read_db_header() 14 | 15 | @property 16 | def size(self): 17 | return len(self.raw) 18 | 19 | def read(self, length: int): 20 | self.pos += length 21 | return self.raw[self.pos - length:self.pos] 22 | 23 | def read_int(self, length: int): 24 | return int.from_bytes(self.read(length), 'big') 25 | 26 | def read_db_header(self): 27 | assert self.read(7) == b'unqlite', "Wrong file signature" 28 | assert self.read(4) == b'\xDB\x7C\x27\x12', "Wrong DB magic" 29 | creation_time = self.read_int(4) 30 | sector_size = self.read_int(4) 31 | self.page_size = self.read_int(4) 32 | assert self.read(6) == b'\x00\x04hash', "Unsupported hash" 33 | 34 | # def read_header2(self): 35 | # self.pos = self.page_size 36 | # magic_numb = self.read(4) 37 | # hash_func = self.read(4) 38 | # free_pages = self.read_int(8) 39 | # split_bucket = self.read_int(8) 40 | # max_split_bucket = self.read_int(8) 41 | # next_page = self.read_int(8) 42 | # num_rect = self.read_int(4) 43 | # for _ in range(num_rect): 44 | # logic_page = self.read_int(8) 45 | # real_page = self.read_int(8) 46 | 47 | def read_cell(self): 48 | key_hash = self.read(4) 49 | key_len = self.read_int(4) 50 | data_len = self.read_int(8) 51 | next_offset = self.read_int(2) 52 | overflow_page = self.read_int(8) 53 | if overflow_page: 54 | self.pos = overflow_page * 0x1000 + 8 55 | data_page = self.read_int(8) 56 | data_offset = self.read_int(2) 57 | name = self.read(key_len) 58 | self.pos = data_page * 0x1000 + data_offset 59 | value = self.read(data_len) 60 | else: 61 | name = self.read(key_len) 62 | value = self.read(data_len) 63 | return name, value, next_offset 64 | 65 | def read_all(self) -> dict: 66 | result = {} 67 | 68 | page_offset = 2 * self.page_size 69 | while page_offset < self.size: 70 | self.pos = page_offset 71 | next_offset = self.read_int(2) 72 | while next_offset: 73 | self.pos = page_offset + next_offset 74 | k, v, next_offset = self.read_cell() 75 | # data sometimes corrupted: b'lumi.158d0004\xb4f9abb.prop' 76 | result[k.decode(errors='replace')] = v.decode(errors='replace') 77 | page_offset += self.page_size 78 | 79 | return result 80 | 81 | 82 | # noinspection PyUnusedLocal 83 | class SQLite: 84 | page_size = 0 85 | pos = 0 86 | 87 | def __init__(self, raw: bytes): 88 | self.raw = raw 89 | self.read_db_header() 90 | self.tables = self.read_page(0) 91 | 92 | @property 93 | def size(self): 94 | return len(self.raw) 95 | 96 | def read(self, length: int): 97 | self.pos += length 98 | return self.raw[self.pos - length:self.pos] 99 | 100 | def read_int(self, length: int): 101 | return int.from_bytes(self.read(length), 'big') 102 | 103 | def read_varint(self): 104 | result = 0 105 | while True: 106 | i = self.read_int(1) 107 | result += i & 0x7f 108 | if i < 0x80: 109 | break 110 | result <<= 7 111 | 112 | return result 113 | 114 | def read_db_header(self): 115 | assert self.read(16) == b'SQLite format 3\0', "Wrong file signature" 116 | self.page_size = self.read_int(2) 117 | 118 | def read_page(self, page_num: int): 119 | self.pos = 100 if page_num == 0 else self.page_size * page_num 120 | 121 | # B-tree Page Header Format 122 | page_type = self.read(1) 123 | 124 | if page_type == b'\x0D': 125 | return self._read_leaf_table(page_num) 126 | elif page_type == b'\x05': 127 | return self._read_interior_table(page_num) 128 | else: 129 | raise NotImplemented 130 | 131 | def _read_leaf_table(self, page_num: int): 132 | first_block = self.read_int(2) 133 | cells_num = self.read_int(2) 134 | cells_pos = self.read_int(2) 135 | fragmented_free_bytes = self.read_int(1) 136 | 137 | cells_pos = [self.read_int(2) for _ in range(cells_num)] 138 | rows = [] 139 | 140 | for cell_pos in cells_pos: 141 | self.pos = self.page_size * page_num + cell_pos 142 | 143 | payload_len = self.read_varint() 144 | rowid = self.read_varint() 145 | 146 | columns_type = [] 147 | 148 | payload_pos = self.pos 149 | header_size = self.read_varint() 150 | while self.pos < payload_pos + header_size: 151 | column_type = self.read_varint() 152 | columns_type.append(column_type) 153 | 154 | cells = [] 155 | 156 | for column_type in columns_type: 157 | if column_type == 0: 158 | data = rowid 159 | elif 1 <= column_type <= 4: 160 | data = self.read_int(column_type) 161 | elif column_type == 5: 162 | data = self.read_int(6) 163 | elif column_type == 6: 164 | data = self.read_int(8) 165 | elif column_type == 7: 166 | # TODO: float 167 | data = self.read(8) 168 | elif column_type == 8: 169 | data = 0 170 | elif column_type == 9: 171 | data = 1 172 | elif column_type >= 12 and column_type % 2 == 0: 173 | length = int((column_type - 12) / 2) 174 | data = self.read(length) 175 | else: 176 | length = int((column_type - 13) / 2) 177 | data = self.read(length).decode() 178 | 179 | cells.append(data) 180 | 181 | rows.append(cells) 182 | 183 | return rows 184 | 185 | def _read_interior_table(self, page_num: int): 186 | first_block = self.read_int(2) 187 | cells_num = self.read_int(2) 188 | cells_pos = self.read_int(2) 189 | fragmented_free_bytes = self.read_int(1) 190 | last_page_num = self.read_int(4) 191 | 192 | cells_pos = [self.read_int(2) for _ in range(cells_num)] 193 | rows = [] 194 | 195 | for cell_pos in cells_pos: 196 | self.pos = self.page_size * page_num + cell_pos 197 | child_page_num = self.read_int(4) 198 | rowid = self.read_varint() 199 | rows += self.read_page(child_page_num - 1) 200 | 201 | return rows + self.read_page(last_page_num - 1) 202 | 203 | def read_table(self, name: str): 204 | page = next(t[3] - 1 for t in self.tables if t[1] == name) 205 | return self.read_page(page) 206 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/converters/stats.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime, timezone 3 | from typing import TYPE_CHECKING 4 | 5 | from .base import Converter 6 | from .const import GATEWAY, ZIGBEE, BLE, MESH 7 | 8 | if TYPE_CHECKING: 9 | from ..device import XDevice 10 | 11 | RE_SERIAL = re.compile(r'(tx|rx|oe|fe|brk):(\d+)') 12 | 13 | ZIGBEE_CLUSTERS = { 14 | 0x0000: 'Basic', 15 | 0x0001: 'PowerCfg', 16 | 0x0003: 'Identify', 17 | 0x0006: 'OnOff', 18 | 0x0008: 'LevelCtrl', 19 | 0x000A: 'Time', 20 | 0x000C: 'AnalogInput', # cube, gas sensor 21 | 0x0012: 'Multistate', 22 | 0x0019: 'OTA', # illuminance sensor 23 | 0x0101: 'DoorLock', 24 | 0x0300: 'LightColor', 25 | 0x0400: 'Illuminance', # motion sensor 26 | 0x0402: 'Temperature', 27 | 0x0403: 'Pressure', 28 | 0x0405: 'Humidity', 29 | 0x0406: 'Occupancy', # motion sensor 30 | 0x0500: 'IasZone', # gas sensor 31 | 0x0B04: 'ElectrMeasur', 32 | 0xFCC0: 'Xiaomi' 33 | } 34 | 35 | BLE_EVENTS = { 36 | 0x0006: "LockFinger", 37 | 0x0007: "LockDoor", 38 | 0x0008: "LockArmed", 39 | 0x000B: "LockAction", 40 | 0x000F: "Motion", 41 | 0x0010: "Toothbrush", 42 | 0x1001: "Action", 43 | 0x1002: "Sleep", 44 | 0x1003: "RSSI", 45 | 0x1004: "Temperature", 46 | 0x1005: "Kettle", 47 | 0x1006: "Humidity", 48 | 0x1007: "Illuminance", 49 | 0x1008: "Moisture", 50 | 0x1009: "Conductivity", 51 | 0x100A: "Battery", 52 | 0x100D: "TempHum", 53 | 0x100E: "Lock", 54 | 0x100F: "Door", 55 | 0x1010: "Formaldehyde", 56 | 0x1012: "Opening", 57 | 0x1013: "Supply", 58 | 0x1014: "WaterLeak", 59 | 0x1015: "Smoke", 60 | 0x1016: "Gas", 61 | 0x1017: "IdleTime", 62 | 0x1018: "Light", 63 | 0x1019: "Contact", 64 | 0x4803: "Battery2", 65 | 0x4c01: "Temperature2", 66 | 0x4c08: "Humidity2", 67 | } 68 | 69 | 70 | class GatewayStatsConverter(Converter): 71 | childs = { 72 | "network_pan_id", "radio_tx_power", "radio_channel", 73 | "free_mem", "load_avg", "rssi", "uptime", 74 | "bluetooth_tx", "bluetooth_rx", "bluetooth_oe", 75 | "zigbee_tx", "zigbee_rx", "zigbee_oe" 76 | } 77 | 78 | def decode(self, device: 'XDevice', payload: dict, value: dict): 79 | if self.attr in value: 80 | payload[self.attr] = value[self.attr] 81 | 82 | if 'networkUp' in value: 83 | payload.update({ 84 | 'network_pan_id': value.get('networkPanId'), 85 | 'radio_tx_power': value.get('radioTxPower'), 86 | 'radio_channel': value.get('radioChannel'), 87 | }) 88 | 89 | if 'free_mem' in value: 90 | s = value['run_time'] 91 | d = s // (3600 * 24) 92 | h = s % (3600 * 24) // 3600 93 | m = s % 3600 // 60 94 | s = s % 60 95 | payload.update({ 96 | 'free_mem': value['free_mem'], 97 | 'load_avg': value['load_avg'], 98 | 'rssi': value['rssi'] - 100, 99 | 'uptime': f"{d} days, {h:02}:{m:02}:{s:02}", 100 | }) 101 | 102 | if 'serial' in value: 103 | lines = value['serial'].split('\n') 104 | for k, v in RE_SERIAL.findall(lines[2]): 105 | payload[f"bluetooth_{k}"] = int(v) 106 | for k, v in RE_SERIAL.findall(lines[3]): 107 | payload[f"zigbee_{k}"] = int(v) 108 | 109 | 110 | class ZigbeeStatsConverter(Converter): 111 | childs = { 112 | "ieee", "nwk", "msg_received", "msg_missed", "linkquality", "rssi", 113 | "last_msg", "type", "parent", "new_resets" 114 | } 115 | 116 | def decode(self, device: 'XDevice', payload: dict, value: dict): 117 | if 'sourceAddress' in value: 118 | cid = int(value['clusterId'], 0) 119 | 120 | if 'msg_received' in device.extra: 121 | device.extra['msg_received'] += 1 122 | else: 123 | device.extra.update({'msg_received': 1, 'msg_missed': 0}) 124 | 125 | # For some devices better works APSCounter, for other - sequence 126 | # number in payload. Sometimes broken messages arrived. 127 | try: 128 | raw = value['APSPlayload'] 129 | manufact_spec = int(raw[2:4], 16) & 4 130 | new_seq1 = int(value['APSCounter'], 0) 131 | new_seq2 = int(raw[8:10] if manufact_spec else raw[4:6], 16) 132 | # new_seq2 == 0 -> probably device reset 133 | if 'last_seq1' in device.extra and new_seq2 != 0: 134 | miss = min( 135 | (new_seq1 - device.extra['last_seq1'] - 1) & 0xFF, 136 | (new_seq2 - device.extra['last_seq2'] - 1) & 0xFF 137 | ) 138 | # sometimes device repeat message, skip this situation: 139 | # 0xF6 > 0xF7 > 0xF8 > 0xF7 > 0xF8 > 0xF9 140 | if 0 < miss < 254: 141 | device.extra['msg_missed'] += miss 142 | 143 | device.extra['last_seq1'] = new_seq1 144 | device.extra['last_seq2'] = new_seq2 145 | except Exception: 146 | pass 147 | 148 | payload.update({ 149 | ZIGBEE: datetime.now(timezone.utc), 150 | # 'ieee': value['eui64'], 151 | # 'nwk': value['sourceAddress'], 152 | 'msg_received': device.extra['msg_received'], 153 | 'msg_missed': device.extra['msg_missed'], 154 | 'linkquality': value['linkQuality'], 155 | 'rssi': value['rssi'], 156 | 'last_msg': ZIGBEE_CLUSTERS.get(cid, cid), 157 | }) 158 | 159 | # if 'ago' in value: 160 | # payload.update({ 161 | # ZIGBEE: dt.now() - timedelta(seconds=value['ago']), 162 | # 'type': value['type'], 163 | # }) 164 | 165 | if 'parent' in value: 166 | payload['parent'] = value['parent'] 167 | 168 | # if 'resets' in value: 169 | # if 'resets0' not in device.extra: 170 | # device.extra['resets0'] = value['resets'] 171 | # payload['new_resets'] = value['resets'] - device.extra['resets0'] 172 | 173 | 174 | class BLEStatsConv(Converter): 175 | childs = {"mac", "msg_received", "last_msg"} 176 | 177 | def decode(self, device: 'XDevice', payload: dict, value: dict): 178 | if "msg_received" in device.extra: 179 | device.extra["msg_received"] += 1 180 | else: 181 | device.extra["msg_received"] = 1 182 | 183 | eid = value.get("eid") 184 | 185 | payload.update({ 186 | BLE: datetime.now(timezone.utc), 187 | "mac": device.mac, 188 | "msg_received": device.extra["msg_received"], 189 | "last_msg": BLE_EVENTS.get(eid, eid), 190 | }) 191 | 192 | 193 | class MeshStatsConv(Converter): 194 | childs = {"mac", "msg_received", "last_msg"} 195 | 196 | def decode(self, device: 'XDevice', payload: dict, value: list): 197 | if "msg_received" in device.extra: 198 | device.extra["msg_received"] += 1 199 | else: 200 | device.extra["msg_received"] = 1 201 | 202 | param = value[0] 203 | if "piid" in param: 204 | prop = f"{param['siid']}.p.{param['piid']}" 205 | elif "eiid" in param: 206 | prop = f"{param['siid']}.e.{param['eiid']}" 207 | else: 208 | raise NotImplementedError 209 | 210 | payload.update({ 211 | MESH: datetime.now(timezone.utc), 212 | "mac": device.mac, 213 | "msg_received": device.extra["msg_received"], 214 | "last_msg": prop 215 | }) 216 | 217 | 218 | GatewayStats = GatewayStatsConverter(GATEWAY, "binary_sensor") 219 | 220 | STAT_GLOBALS = { 221 | # GATEWAY: GatewayStats, 222 | BLE: BLEStatsConv(BLE, "sensor"), 223 | MESH: MeshStatsConv(MESH, "sensor"), 224 | ZIGBEE: ZigbeeStatsConverter(ZIGBEE, "sensor"), 225 | } 226 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/silabs.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB 4 | from .. import shell 5 | from ..converters import silabs, is_mihome_zigbee 6 | from ..converters.zigbee import ZConverter 7 | from ..device import XDevice, ZIGBEE 8 | from ..mini_mqtt import MQTTMessage 9 | 10 | 11 | # noinspection PyMethodMayBeStatic,PyUnusedLocal 12 | class SilabsGateway(GatewayBase): 13 | ieee: str = None 14 | 15 | silabs_pair_model = None 16 | 17 | pair_payload = None 18 | pair_payload2 = None 19 | 20 | def silabs_init(self): 21 | if self.zha_mode: 22 | return 23 | self.dispatcher_connect(SIGNAL_PREPARE_GW, self.silabs_prepare_gateway) 24 | self.dispatcher_connect(SIGNAL_MQTT_PUB, self.silabs_mqtt_publish) 25 | 26 | async def silabs_prepare_gateway(self, sh: shell.TelnetShell): 27 | if self.ieee is not None: 28 | return 29 | # 1. Read coordinator info 30 | raw = await sh.read_file('/data/zigbee/coordinator.info') 31 | info = json.loads(raw) 32 | self.ieee = info["mac"][2:].upper() 33 | assert len(self.ieee) == 16 34 | 35 | async def silabs_mqtt_publish(self, msg: MQTTMessage): 36 | if msg.topic.endswith('/MessageReceived'): 37 | await self.silabs_process_recv(msg.json) 38 | elif msg.topic.endswith("/MessagePreSentCallback"): 39 | await self.silabs_process_send(msg.json) 40 | elif msg.topic == "zigbee/send" and b"8.0.2084" in msg.payload: 41 | data: dict = msg.json["params"][0]["value"] 42 | await self.silabs_process_join(data) 43 | 44 | async def silabs_process_recv(self, data: dict): 45 | mac = data['eui64'].lower() 46 | nwk = data["sourceAddress"].lower() 47 | zb_msg = None 48 | 49 | # print raw zigbee if enabled in logs 50 | if "zigbee" in self.debug_mode: 51 | zb_msg = silabs.decode(data) 52 | self.debug_tag(f"{mac} ({nwk}) recv {zb_msg}", tag="ZIGB") 53 | 54 | if mac == "0x0000000000000000" or nwk == "0x0000": 55 | return 56 | 57 | did = 'lumi.' + mac.lstrip('0x') 58 | device: XDevice = self.devices.get(did) 59 | if not device: 60 | # we need to save device to know its NWK in future 61 | device = XDevice(ZIGBEE, None, did, mac, nwk) 62 | self.add_device(did, device) 63 | self.debug_device(device, "new unknown device", tag="SLBS") 64 | return 65 | 66 | if not device.model: 67 | # Sonoff Mini has a bug: it hasn't app_ver, so gw can't add it 68 | if data["clusterId"] == "0x0000": 69 | if not zb_msg: 70 | zb_msg = silabs.decode(data) 71 | if zb_msg.get("app_version") == "Status.UNSUPPORTED_ATTRIBUTE": 72 | await self.silabs_send_fake_version(device, data) 73 | return 74 | 75 | # process raw zigbee if device supports it 76 | if device.has_zigbee_conv: 77 | if not zb_msg: 78 | zb_msg = silabs.decode(data) 79 | if zb_msg and "cluster" in zb_msg: 80 | payload = device.decode_zigbee(zb_msg) 81 | device.update(payload) 82 | 83 | # process device stats if enabled, also works for LumiGateway 84 | if device and ZIGBEE in device.entities: 85 | payload = device.decode(ZIGBEE, data) 86 | device.update(payload) 87 | 88 | async def silabs_process_send(self, data: dict): 89 | if "zigbee" not in self.debug_mode: 90 | return 91 | did = "lumi." + data["eui64"].lstrip('0x').lower() 92 | if did not in self.devices: 93 | return 94 | device = self.devices[did] 95 | zb_msg = silabs.decode(data) 96 | self.debug_tag(f"{device.mac} {device.nwk} send {zb_msg}", tag="ZIGB") 97 | 98 | async def silabs_process_join(self, data: dict): 99 | if not is_mihome_zigbee(data["model"]): 100 | self.debug("Prevent unpair 3rd party model: " + data["model"]) 101 | await self.silabs_prevent_unpair() 102 | 103 | device = self.devices.get(data["did"]) 104 | if not device.model: 105 | self.debug_device(device, "paired", data) 106 | device.update_model(data["model"]) 107 | device.extra["fw_ver"] = parse_version(data["version"]) 108 | self.add_device(device.did, device) 109 | else: 110 | self.debug_device(device, "model exist on pairing") 111 | 112 | await self.silabs_config(device) 113 | 114 | async def silabs_send(self, device: XDevice, payload: dict): 115 | assert "commands" in payload, payload 116 | self.debug_device(device, "send", payload, tag="SLBS") 117 | await self.mqtt.publish(f"gw/{self.ieee}/commands", payload) 118 | 119 | async def silabs_read(self, device: XDevice, payload: dict): 120 | assert "commands" in payload, payload 121 | self.debug_device(device, "read", payload, tag="SLBS") 122 | await self.mqtt.publish(f"gw/{self.ieee}/commands", payload) 123 | 124 | async def silabs_prevent_unpair(self): 125 | try: 126 | async with shell.Session(self.host) as sh: 127 | await sh.prevent_unpair() 128 | except Exception as e: 129 | self.error("Can't prevent unpair", e) 130 | 131 | async def silabs_config(self, device: XDevice): 132 | """Run some config converters if device spec has them. Binds, etc.""" 133 | payload = {} 134 | for conv in device.converters: 135 | if isinstance(conv, ZConverter): 136 | conv.config(device, payload, self) 137 | 138 | if not payload: 139 | return 140 | 141 | self.debug_device(device, "config") 142 | await self.mqtt.publish(f"gw/{self.ieee}/commands", payload) 143 | 144 | async def silabs_send_fake_version(self, device: XDevice, data: dict): 145 | self.debug_device(device, "send fake version") 146 | data["APSCounter"] = "0x00" 147 | data["APSPlayload"] = "0x1800010100002000" 148 | await self.mqtt.publish(f"gw/{self.ieee}/MessageReceived", data) 149 | 150 | async def silabs_rejoin(self, device: XDevice): 151 | """Emulate first join of device.""" 152 | self.debug_device(device, "rejoin") 153 | payload = { 154 | "nodeId": device.nwk.upper(), 155 | "deviceState": 16, 156 | "deviceType": "0x00FF", 157 | "timeSinceLastMessage": 0, 158 | "deviceEndpoint": { 159 | "eui64": device.mac.upper(), 160 | "endpoint": 0, 161 | "clusterInfo": [] 162 | }, 163 | "firstjoined": 1 164 | } 165 | await self.mqtt.publish(f"gw/{self.ieee}/devicejoined", payload) 166 | 167 | async def silabs_bind(self, bind_from: XDevice, bind_to: XDevice): 168 | cmd = [] 169 | for cluster in ["on_off", "level", "light_color"]: 170 | cmd += silabs.zdo_bind( 171 | bind_from.nwk, 1, cluster, bind_from.mac[2:], bind_to.mac[2:] 172 | ) 173 | await self.mqtt.publish(f"gw/{self.ieee}/commands", {"commands": cmd}) 174 | 175 | async def silabs_unbind(self, bind_from: XDevice, bind_to: XDevice): 176 | cmd = [] 177 | for cluster in ["on_off", "level", "light_color"]: 178 | cmd += silabs.zdo_unbind( 179 | bind_from.nwk, 1, cluster, bind_from.mac[2:], bind_to.mac[2:] 180 | ) 181 | await self.mqtt.publish(f"gw/{self.ieee}/commands", {"commands": cmd}) 182 | 183 | async def silabs_leave(self, device: XDevice): 184 | cmd = silabs.zdo_leave(device.nwk) 185 | await self.mqtt.publish(f"gw/{self.ieee}/commands", {"commands": cmd}) 186 | 187 | 188 | def parse_version(value: str) -> int: 189 | """Support version `0.0.0_0017`.""" 190 | try: 191 | if "_" in value: 192 | _, value = value.split("_") 193 | return int(value) 194 | except Exception: 195 | return 0 196 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/gateway/__init__.py: -------------------------------------------------------------------------------- 1 | """All different technologies divided to independent classes (modules): 2 | 3 | gw3 and e1: 4 | - GatewayBase - base class for all subclasses 5 | - LumiGateway - process Zigbee devices in Lumi and MIoT spec using MQTT 6 | - SilabsGateway - process Zigbee devices in Zigbee spec using Silabs Z3 MQTT 7 | - Z3Gateway - process Zigbee network tables using Silabs Z3 console output 8 | - XGateway - main class for enable Telnet and process MQTT loop 9 | 10 | gw3: 11 | - MIoTGateway - process Gateway and Mesh properties in MIoT spec using miio 12 | - MainGateway - process Gateway device and some global stats using Telent 13 | - BLEGateway - process BLE devices in MiBeacon format using MQTT 14 | - MeshGateway - init Mesh devices but depends on MIoTGateway for control them 15 | """ 16 | import asyncio 17 | import json 18 | import logging 19 | import time 20 | 21 | from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_CON, SIGNAL_MQTT_DIS, \ 22 | SIGNAL_MQTT_PUB, SIGNAL_TIMER 23 | from .gate_e1 import GateE1 24 | from .gate_mgw import GateMGW 25 | from .gate_mgw2 import GateMGW2 26 | from .. import shell 27 | from ..converters import GATEWAY 28 | from ..mini_miio import AsyncMiIO 29 | from ..mini_mqtt import MiniMQTT, MQTTMessage 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | TELNET_CMD = r'{"method":"set_ip_info","params":{"ssid":"\"\"","pswd":"1; passwd -d $USER; riu_w 101e 53 3012 || echo enable > /sys/class/tty/tty/enable; telnetd"}}' 34 | 35 | 36 | class XGateway(GateMGW, GateE1, GateMGW2): 37 | main_task: asyncio.Task = None 38 | timer_task: asyncio.Task = None 39 | 40 | def __init__(self, host: str, token: str, **options): 41 | self.log = _LOGGER 42 | 43 | self.host = host 44 | self.options = options 45 | 46 | self.dispatcher = {} 47 | self.setups = {} 48 | self.tasks = [] 49 | self.miio_ack = {} 50 | 51 | self.miio = AsyncMiIO(host, token) 52 | self.mqtt = MiniMQTT() 53 | 54 | self.miio.debug = 'true' in self.debug_mode 55 | 56 | @property 57 | def telnet_cmd(self): 58 | return self.options.get('telnet_cmd') or TELNET_CMD 59 | 60 | def start(self): 61 | self.main_task = asyncio.create_task(self.run_forever()) 62 | 63 | # noinspection PyUnusedLocal 64 | async def stop(self, *args): 65 | self.debug("Stop all tasks") 66 | 67 | self.main_task.cancel() 68 | 69 | for device in self.devices.values(): 70 | if self in device.gateways: 71 | device.gateways.remove(self) 72 | 73 | async def check_port(self, port: int): 74 | """Check if gateway port open.""" 75 | return await asyncio.get_event_loop().run_in_executor( 76 | None, shell.check_port, self.host, port 77 | ) 78 | 79 | async def enable_telnet(self): 80 | """Enable telnet with miio protocol.""" 81 | raw = json.loads(self.telnet_cmd) 82 | resp = await self.miio.send(raw['method'], raw.get('params')) 83 | if not resp or resp.get('result') != ['ok']: 84 | self.debug(f"Can't enable telnet") 85 | return False 86 | return True 87 | 88 | async def run_forever(self): 89 | self.debug("Start main loop") 90 | 91 | """Main thread loop.""" 92 | while True: 93 | try: 94 | # if not telnet - enable it 95 | if not await self.check_port(23) and \ 96 | not await self.enable_telnet(): 97 | await asyncio.sleep(30) 98 | continue 99 | 100 | # if not mqtt - enable it (handle Mi Home and ZHA mode) 101 | if not await self.prepare_gateway() or \ 102 | not await self.mqtt.connect(self.host): 103 | await asyncio.sleep(60) 104 | continue 105 | 106 | await self.mqtt_connect() 107 | try: 108 | async for msg in self.mqtt: 109 | # noinspection PyTypeChecker 110 | asyncio.create_task(self.mqtt_message(msg)) 111 | except Exception as e: 112 | self.debug(f"MQTT connection issue", exc_info=e) 113 | finally: 114 | await self.mqtt.disconnect() 115 | await self.mqtt.close() 116 | await self.mqtt_disconnect() 117 | 118 | except Exception as e: 119 | self.error("Main loop error", exc_info=e) 120 | 121 | self.debug("Stop main loop") 122 | 123 | async def timer(self): 124 | while True: 125 | ts = time.time() 126 | self.check_available(ts) 127 | await self.dispatcher_send(SIGNAL_TIMER, ts=ts) 128 | await asyncio.sleep(30) 129 | 130 | async def mqtt_connect(self): 131 | self.debug("MQTT connected") 132 | 133 | await self.mqtt.subscribe('#') 134 | 135 | self.update_available(True) 136 | 137 | await self.dispatcher_send(SIGNAL_MQTT_CON) 138 | 139 | self.timer_task = asyncio.create_task(self.timer()) 140 | 141 | async def mqtt_disconnect(self): 142 | self.debug("MQTT disconnected") 143 | 144 | self.timer_task.cancel() 145 | 146 | self.update_available(False) 147 | 148 | await self.dispatcher_send(SIGNAL_MQTT_DIS) 149 | 150 | async def mqtt_message(self, msg: MQTTMessage): 151 | # skip spam from broker/ping 152 | if msg.topic == 'broker/ping': 153 | return 154 | 155 | if 'mqtt' in self.debug_mode: 156 | self.debug_tag(f"{msg.topic} {msg.payload}", tag="MQTT") 157 | 158 | try: 159 | if msg.topic == "miio/command_ack": 160 | if ack := self.miio_ack.get(msg.json["id"]): 161 | ack.set_result(msg.json) 162 | 163 | await self.dispatcher_send(SIGNAL_MQTT_PUB, msg=msg) 164 | except Exception as e: 165 | self.error( 166 | f"Processing MQTT: {msg.topic} {msg.payload}", exc_info=e 167 | ) 168 | 169 | async def prepare_gateway(self) -> bool: 170 | """Launching the required utilities on the gw, if they are not already 171 | running. 172 | """ 173 | try: 174 | async with shell.Session(self.host) as sh: 175 | if not await sh.only_one(): 176 | self.debug("Connection from a second Hass detected") 177 | return False 178 | 179 | await sh.get_version() 180 | 181 | self.debug(f"Prepare gateway {sh.model} with fw {sh.ver}") 182 | if isinstance(sh, shell.ShellMGW): 183 | return await self.gw3_prepare_gateway(sh) 184 | elif isinstance(sh, shell.ShellE1): 185 | return await self.e1_prepare_gateway(sh) 186 | elif isinstance(sh, shell.ShellMGW2): 187 | return await self.mgw2_prepare_gateway(sh) 188 | 189 | except Exception as e: 190 | self.error(f"Can't prepare gateway", e) 191 | return False 192 | 193 | def update_available(self, value: bool): 194 | self.available = value 195 | 196 | for device in self.devices.values(): 197 | if self in device.gateways: 198 | device.update_available() 199 | 200 | async def telnet_send(self, command: str): 201 | try: 202 | async with shell.Session(self.host) as sh: 203 | if command == "ftp": 204 | await sh.run_ftp() 205 | elif command == "tardata": 206 | return await sh.tar_data() 207 | elif command == "reboot": 208 | await sh.reboot() 209 | else: 210 | await sh.exec(command) 211 | return True 212 | 213 | except Exception as e: 214 | self.error(f"Can't run telnet command: {command}", exc_info=e) 215 | return False 216 | 217 | async def tar_data(self) -> str: 218 | try: 219 | async with shell.Session(self.host) as sh: 220 | return await sh.tar_data() 221 | except Exception as e: 222 | return f"{type(e).__name__} {e}" 223 | 224 | def check_available(self, ts: float): 225 | for device in list(self.devices.values()): 226 | if self not in device.gateways or device.type == GATEWAY: 227 | continue 228 | 229 | if (device.poll_timeout and 230 | ts - device.decode_ts > device.poll_timeout and 231 | ts - device.encode_ts > device.poll_timeout 232 | ): 233 | for attr, entity in device.entities.items(): 234 | if entity.added and hasattr(entity, "async_update"): 235 | self.debug_device(device, "poll state", attr) 236 | asyncio.create_task(entity.update_state()) 237 | break 238 | 239 | if (device.available and device.available_timeout and 240 | ts - device.decode_ts > device.available_timeout 241 | ): 242 | self.debug_device(device, "set device offline") 243 | device.available = False 244 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/mini_mqtt.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://stanford-clark.com/MQTT/ 3 | """ 4 | import asyncio 5 | import json 6 | import logging 7 | import random 8 | from asyncio import StreamReader, StreamWriter 9 | from typing import Optional 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | CONNECT = 1 14 | CONNACK = 2 15 | PUBLISH = 3 16 | PUBACK = 4 17 | SUBSCRIBE = 8 18 | SUBACK = 9 19 | PINGREQ = 12 20 | PINGRESP = 13 21 | DISCONNECT = 14 22 | 23 | 24 | class MQTTMessage: 25 | type: int 26 | dup: bool 27 | qos: int 28 | retain: bool 29 | 30 | topic: str 31 | payload: bytes 32 | 33 | @property 34 | def text(self) -> str: 35 | return self.payload.decode() 36 | 37 | @property 38 | def json(self) -> dict: 39 | return json.loads(self.payload) 40 | 41 | def __str__(self): 42 | return f"{self.topic} {self.payload.decode()}" 43 | 44 | 45 | class RawMessage: 46 | def __init__(self, raw=b''): 47 | self.pos = 0 48 | self.raw = raw 49 | 50 | @property 51 | def size(self): 52 | return len(self.raw) 53 | 54 | def read(self, length: int) -> bytes: 55 | self.pos += length 56 | return self.raw[self.pos - length:self.pos] 57 | 58 | def read_int(self, length: int) -> int: 59 | return int.from_bytes(self.read(length), 'big') 60 | 61 | def read_str(self) -> str: 62 | slen = self.read_int(2) 63 | return self.read(slen).decode() 64 | 65 | def read_all(self) -> bytes: 66 | return self.read(self.size - self.pos) 67 | 68 | def write_int(self, value: int, length: int): 69 | self.raw += value.to_bytes(length, 'big') 70 | 71 | def write_str(self, value: str): 72 | self.write_int(len(value), 2) 73 | self.raw += value.encode() 74 | 75 | def write_len(self): 76 | buf = b'' 77 | var = len(self.raw) 78 | for _ in range(4): 79 | if var >= 128: 80 | var, b = divmod(var, 128) 81 | buf += (b | 128).to_bytes(1, 'big') 82 | else: 83 | buf += var.to_bytes(1, 'big') 84 | break 85 | self.raw = buf + self.raw 86 | 87 | def write_header(self, msg_type: int, qos=0, retain=False): 88 | self.write_len() 89 | header = (msg_type << 4) | (qos << 1) | int(retain) 90 | self.raw = header.to_bytes(1, 'big') + self.raw 91 | 92 | @staticmethod 93 | def read_header(header: int) -> MQTTMessage: 94 | msg = MQTTMessage() 95 | msg.type = (header >> 4) & 0b1111 96 | msg.dup = bool((header >> 3) & 1) 97 | msg.qos = (header >> 1) & 0b11 98 | msg.retain = bool(header & 1) 99 | return msg 100 | 101 | @staticmethod 102 | def connect(keep_alive: int = 0): 103 | msg = RawMessage() 104 | msg.write_str('MQIsdp') # protocol name 105 | msg.write_int(3, 1) # protocol version 106 | msg.write_int(0, 1) # flags 107 | msg.write_int(keep_alive, 2) # keep alive 108 | cid = random.randint(1000, 9999) 109 | msg.write_str(f"hass-{cid}") # client ID (should be unique) 110 | msg.write_header(CONNECT, qos=0) 111 | return msg.raw 112 | 113 | @staticmethod 114 | def subscribe(msg_id: int, *topics, qos=0): 115 | msg = RawMessage() 116 | msg.write_int(msg_id, 2) # message ID 117 | for topic in topics: 118 | msg.write_str(topic) 119 | msg.write_int(qos, 1) # requested QoS 120 | msg.write_header(SUBSCRIBE, qos=1) 121 | return msg.raw 122 | 123 | @staticmethod 124 | def publish(topic: str, payload: bytes, retain=False): 125 | msg = RawMessage() 126 | msg.write_str(topic) 127 | # skip msg_id for QoS 0 128 | msg.raw += payload 129 | msg.write_header(PUBLISH, qos=0, retain=retain) 130 | return msg.raw 131 | 132 | @staticmethod 133 | def ping(): 134 | # adds zero length after header 135 | return (PINGREQ << 4).to_bytes(2, 'little') 136 | 137 | @staticmethod 138 | def disconnect(): 139 | # adds zero length after header 140 | return (DISCONNECT << 4).to_bytes(2, 'little') 141 | 142 | 143 | class MiniMQTT: 144 | msg_id: int = None 145 | reader: StreamReader = None 146 | writer: StreamWriter = None 147 | 148 | def __init__(self, keepalive=15, timeout=5): 149 | self.keepalive = keepalive 150 | self.timeout = timeout 151 | self.pub_buffer = [] 152 | 153 | async def read_varlen(self) -> int: 154 | var = 0 155 | for i in range(4): 156 | b = await self.reader.read(1) 157 | var += (b[0] & 0x7F) << (7 * i) 158 | if (b[0] & 0x80) == 0: 159 | break 160 | return var 161 | 162 | async def _connect(self, host: str): 163 | self.reader, self.writer = await asyncio.open_connection(host, 1883) 164 | 165 | msg = RawMessage.connect() 166 | self.writer.write(msg) 167 | await self.writer.drain() 168 | 169 | self.msg_id = 0 170 | 171 | raw = await self.reader.readexactly(4) 172 | assert raw[0] == CONNACK << 4 173 | assert raw[1] == 2 174 | return raw[3] == 0 175 | 176 | async def connect(self, host: str): 177 | try: 178 | resp = await asyncio.wait_for(self._connect(host), self.timeout) 179 | if resp and self.pub_buffer: 180 | asyncio.create_task(self.empty_buffer()) 181 | return resp 182 | except Exception: 183 | return False 184 | 185 | async def disconnect(self): 186 | msg = RawMessage.disconnect() 187 | try: 188 | self.writer.write(msg) 189 | await asyncio.wait_for(self.writer.drain(), self.timeout) 190 | except Exception: 191 | _LOGGER.debug("Can't disconnect from gateway") 192 | 193 | async def subscribe(self, topic: str): 194 | self.msg_id += 1 195 | msg = RawMessage.subscribe(self.msg_id, topic) 196 | try: 197 | self.writer.write(msg) 198 | await asyncio.wait_for(self.writer.drain(), self.timeout) 199 | except Exception: 200 | _LOGGER.debug(f"Can't subscribe to {topic}") 201 | 202 | async def publish(self, topic: str, payload, retain=False): 203 | if self.writer is None: 204 | self.pub_buffer.append([topic, payload, retain]) 205 | return 206 | if isinstance(payload, str): 207 | payload = payload.encode() 208 | elif isinstance(payload, dict): 209 | payload = json.dumps(payload, separators=(',', ':')).encode() 210 | 211 | # no response for QoS 0 212 | msg = RawMessage.publish(topic, payload, retain) 213 | try: 214 | self.writer.write(msg) 215 | await asyncio.wait_for(self.writer.drain(), self.timeout) 216 | except Exception: 217 | _LOGGER.debug(f"Can't publish {payload} to {topic}") 218 | 219 | async def read(self) -> Optional[MQTTMessage]: 220 | raw = await self.reader.read(1) 221 | if raw == b'': 222 | # disconnected 223 | return None 224 | 225 | msg = RawMessage.read_header(raw[0]) 226 | if msg.type == PUBLISH: 227 | varlen = await self.read_varlen() 228 | raw = await self.reader.readexactly(varlen) 229 | 230 | pr = RawMessage(raw) 231 | msg.topic = pr.read_str() 232 | 233 | if msg.qos > 0: 234 | _ = pr.read_int(2) # msg ID 235 | raise NotImplementedError 236 | 237 | msg.payload = pr.read_all() 238 | 239 | elif msg.type == PINGRESP: 240 | await self.reader.readexactly(1) 241 | 242 | elif msg.type == SUBACK: 243 | # 1b header, 1b len, 2b msgID, 1b QOS 244 | varlen = await self.reader.readexactly(1) 245 | await self.reader.readexactly(varlen[0]) 246 | 247 | else: 248 | raise NotImplementedError 249 | 250 | return msg 251 | 252 | async def close(self): 253 | if not self.writer: 254 | return 255 | try: 256 | self.writer.close() 257 | await asyncio.wait_for(self.writer.wait_closed(), self.timeout) 258 | except Exception: 259 | _LOGGER.debug("Can't close connection") 260 | 261 | async def empty_buffer(self): 262 | for args in self.pub_buffer: 263 | await self.publish(*args) 264 | 265 | self.pub_buffer.clear() 266 | 267 | def __aiter__(self): 268 | return self 269 | 270 | async def __anext__(self) -> MQTTMessage: 271 | wait_pong = False 272 | 273 | while True: 274 | try: 275 | msg: MQTTMessage = await asyncio.wait_for( 276 | self.read(), self.keepalive 277 | ) 278 | if msg is None: 279 | raise StopAsyncIteration 280 | 281 | if msg.type == PUBLISH: 282 | return msg 283 | 284 | if msg.type == PINGRESP: 285 | wait_pong = False 286 | 287 | except asyncio.TimeoutError: 288 | if wait_pong: 289 | # second ping without pong 290 | raise StopAsyncIteration 291 | 292 | self.writer.write(RawMessage.ping()) 293 | await asyncio.wait_for(self.writer.drain(), self.timeout) 294 | 295 | wait_pong = True 296 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import time 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.system_log import CONF_LOGGER 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import ( 10 | EVENT_HOMEASSISTANT_STOP, MAJOR_VERSION, MINOR_VERSION 11 | ) 12 | from homeassistant.core import HomeAssistant, ServiceCall 13 | from homeassistant.helpers import ( 14 | aiohttp_client as ac, config_validation as cv, device_registry as dr 15 | ) 16 | from homeassistant.helpers.storage import Store 17 | 18 | from . import system_health 19 | from .core import logger, utils 20 | from .core.const import DOMAIN, TITLE 21 | from .core.entity import XEntity 22 | from .core.ezsp import update_zigbee_firmware 23 | from .core.gateway import XGateway 24 | from .core.xiaomi_cloud import MiCloud 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | DOMAINS = [ 29 | 'alarm_control_panel', 'binary_sensor', 'climate', 'cover', 'light', 30 | 'number', 'select', 'sensor', 'switch' 31 | ] 32 | 33 | CONF_DEVICES = 'devices' 34 | CONF_ATTRIBUTES_TEMPLATE = 'attributes_template' 35 | 36 | CONFIG_SCHEMA = vol.Schema({ 37 | DOMAIN: vol.Schema({ 38 | vol.Optional(CONF_DEVICES): { 39 | cv.string: vol.Schema({ 40 | vol.Optional('occupancy_timeout'): cv.positive_int, 41 | }, extra=vol.ALLOW_EXTRA), 42 | }, 43 | CONF_LOGGER: logger.CONFIG_SCHEMA, 44 | vol.Optional(CONF_ATTRIBUTES_TEMPLATE): cv.template 45 | }, extra=vol.ALLOW_EXTRA), 46 | }, extra=vol.ALLOW_EXTRA) 47 | 48 | 49 | async def async_setup(hass: HomeAssistant, hass_config: dict): 50 | if (MAJOR_VERSION, MINOR_VERSION) < (2021, 12): 51 | _LOGGER.error("Minimum supported Hass version 2021.12") 52 | return False 53 | 54 | config = hass_config.get(DOMAIN) or {} 55 | 56 | if CONF_LOGGER in config: 57 | logger.init(__name__, config[CONF_LOGGER], hass.config.config_dir) 58 | 59 | info = await hass.helpers.system_info.async_get_system_info() 60 | _LOGGER.debug(f"SysInfo: {info}") 61 | 62 | # update global debug_mode for all gateways 63 | if 'debug_mode' in config[CONF_LOGGER]: 64 | setattr(XGateway, 'debug_mode', config[CONF_LOGGER]['debug_mode']) 65 | 66 | if CONF_ATTRIBUTES_TEMPLATE in config: 67 | XEntity.attributes_template = config[CONF_ATTRIBUTES_TEMPLATE] 68 | XEntity.attributes_template.hass = hass 69 | 70 | hass.data[DOMAIN] = {} 71 | 72 | await utils.load_devices(hass, config.get(CONF_DEVICES)) 73 | 74 | _register_send_command(hass) 75 | 76 | return True 77 | 78 | 79 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 80 | """Support two kind of enties - MiCloud and Gateway.""" 81 | 82 | # entry for MiCloud login 83 | if 'servers' in entry.data: 84 | return await _setup_micloud_entry(hass, entry) 85 | 86 | # migrate data (also after first setup) to options 87 | if entry.data: 88 | hass.config_entries.async_update_entry(entry, data={}, 89 | options=entry.data) 90 | 91 | entries = hass.config_entries.async_entries(DOMAIN) 92 | if any(e.options.get('debug') for e in entries): 93 | await system_health.setup_debug(hass, _LOGGER) 94 | 95 | # add options handler 96 | if not entry.update_listeners: 97 | entry.add_update_listener(async_update_options) 98 | 99 | hass.data[DOMAIN][entry.entry_id] = XGateway(**entry.options) 100 | 101 | hass.async_create_task(_setup_domains(hass, entry)) 102 | 103 | return True 104 | 105 | 106 | async def async_update_options(hass: HomeAssistant, entry: ConfigEntry): 107 | await hass.config_entries.async_reload(entry.entry_id) 108 | 109 | 110 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 111 | # check unload cloud integration 112 | if entry.entry_id not in hass.data[DOMAIN]: 113 | return 114 | 115 | # remove all stats entities if disable stats 116 | if not entry.options.get('stats'): 117 | utils.remove_stats(hass, entry.entry_id) 118 | 119 | gw: XGateway = hass.data[DOMAIN][entry.entry_id] 120 | await gw.stop() 121 | 122 | await asyncio.gather(*[ 123 | hass.config_entries.async_forward_entry_unload(entry, domain) 124 | for domain in DOMAINS 125 | ]) 126 | 127 | return True 128 | 129 | 130 | # noinspection PyUnusedLocal 131 | async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry): 132 | return True 133 | 134 | 135 | async def _setup_domains(hass: HomeAssistant, entry: ConfigEntry): 136 | # init setup for each supported domains 137 | await asyncio.gather(*[ 138 | hass.config_entries.async_forward_entry_setup(entry, domain) 139 | for domain in DOMAINS 140 | ]) 141 | 142 | gw: XGateway = hass.data[DOMAIN][entry.entry_id] 143 | gw.start() 144 | 145 | entry.async_on_unload( 146 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw.stop) 147 | ) 148 | 149 | 150 | async def _setup_micloud_entry(hass: HomeAssistant, config_entry): 151 | data: dict = config_entry.data.copy() 152 | 153 | # quick fix Hass 2022.8 - parallel integration loading 154 | # so Gateway loads before the Cloud with default devices names 155 | store = Store(hass, 1, f"{DOMAIN}/{data['username']}.json") 156 | devices = await store.async_load() 157 | if devices: 158 | _LOGGER.debug(f"Loaded from cache {len(devices)} devices") 159 | _update_devices(devices) 160 | 161 | session = ac.async_create_clientsession(hass) 162 | hass.data[DOMAIN]['cloud'] = cloud = MiCloud(session, data['servers']) 163 | 164 | if 'service_token' in data: 165 | # load devices with saved MiCloud auth 166 | cloud.auth = data 167 | devices = await cloud.get_devices() 168 | else: 169 | devices = None 170 | 171 | if devices is None: 172 | _LOGGER.debug(f"Login to MiCloud for {config_entry.title}") 173 | if await cloud.login(data['username'], data['password']): 174 | # update MiCloud auth in .storage 175 | data.update(cloud.auth) 176 | hass.config_entries.async_update_entry(config_entry, data=data) 177 | 178 | devices = await cloud.get_devices() 179 | if devices is None: 180 | _LOGGER.error("Can't load devices from MiCloud") 181 | 182 | else: 183 | _LOGGER.error("Can't login to MiCloud") 184 | 185 | if devices is not None: 186 | _LOGGER.debug(f"Loaded from MiCloud {len(devices)} devices") 187 | _update_devices(devices) 188 | await store.async_save(devices) 189 | else: 190 | _LOGGER.debug("No devices in .storage") 191 | return False 192 | 193 | # TODO: Think about a bunch of devices 194 | if 'devices' not in hass.data[DOMAIN]: 195 | hass.data[DOMAIN]['devices'] = devices 196 | else: 197 | hass.data[DOMAIN]['devices'] += devices 198 | 199 | for device in devices: 200 | # key - mac for BLE, and did for others 201 | did = device['did'] if device['pid'] != 6 else \ 202 | device['mac'].replace(':', '').lower() 203 | XGateway.defaults.setdefault(did, {}) 204 | # don't override name if exists 205 | XGateway.defaults[did].setdefault('name', device['name']) 206 | 207 | return True 208 | 209 | 210 | def _update_devices(devices: list): 211 | for device in devices: 212 | # key - mac for BLE, and did for others 213 | did = device['did'] if device['pid'] != 6 else \ 214 | device['mac'].replace(':', '').lower() 215 | XGateway.defaults.setdefault(did, {}) 216 | # don't override name if exists 217 | XGateway.defaults[did].setdefault('name', device['name']) 218 | 219 | 220 | def _register_send_command(hass: HomeAssistant): 221 | async def send_command(call: ServiceCall): 222 | host = call.data["host"] 223 | gw = next( 224 | gw for gw in hass.data[DOMAIN].values() 225 | if isinstance(gw, XGateway) and gw.host == host 226 | ) 227 | cmd = call.data["command"].split(" ") 228 | if cmd[0] == "miio": 229 | raw = json.loads(call.data["data"]) 230 | resp = await gw.miio_send(raw['method'], raw.get('params')) 231 | hass.components.persistent_notification.async_create( 232 | str(resp), TITLE 233 | ) 234 | elif cmd[0] == "set_state": # for debug purposes 235 | device = gw.devices.get(cmd[1]) 236 | raw = json.loads(call.data["data"]) 237 | device.available = True 238 | device.decode_ts = time.time() 239 | device.update(raw) 240 | 241 | hass.services.async_register(DOMAIN, "send_command", send_command) 242 | 243 | 244 | async def async_remove_config_entry_device( 245 | hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry 246 | ) -> bool: 247 | """Supported from Hass v2022.3""" 248 | dr.async_get(hass).async_remove_device(device.id) 249 | 250 | try: 251 | # check if device is zigbee 252 | if any(c[0] == dr.CONNECTION_ZIGBEE for c in device.connections): 253 | unique_id = next( 254 | i[1] for i in device.identifiers if i[0] == DOMAIN 255 | ) 256 | await utils.remove_zigbee(unique_id) 257 | 258 | return True 259 | except Exception as e: 260 | _LOGGER.error("Can't delete device", exc_info=e) 261 | return False 262 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import random 5 | import string 6 | from typing import Optional 7 | 8 | from homeassistant.const import EVENT_HOMEASSISTANT_STOP 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.core import callback 11 | from homeassistant.helpers import ( 12 | device_registry as dr, entity_registry as er 13 | ) 14 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 15 | from homeassistant.helpers.storage import Store 16 | 17 | from . import shell 18 | from .const import DOMAIN 19 | from .converters import STAT_GLOBALS 20 | from .device import XDevice 21 | from .gateway import XGateway, TELNET_CMD 22 | from .gateway.lumi import LumiGateway 23 | from .mini_miio import AsyncMiIO 24 | from .xiaomi_cloud import MiCloud 25 | 26 | SUPPORTED_MODELS = ( 27 | 'lumi.gateway.mgl03', 'lumi.gateway.aqcn02', 'lumi.gateway.aqcn03', 28 | 'lumi.gateway.mcn001' 29 | ) 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | @callback 35 | def remove_device(hass: HomeAssistant, device: XDevice): 36 | """Remove device by did from Hass""" 37 | registry = dr.async_get(hass) 38 | device = registry.async_get_device({(DOMAIN, device.unique_id)}, None) 39 | if device: 40 | registry.async_remove_device(device.id) 41 | 42 | 43 | @callback 44 | def remove_stats(hass: HomeAssistant, entry_id: str): 45 | suffix = tuple(STAT_GLOBALS.keys()) 46 | registry = er.async_get(hass) 47 | remove = [ 48 | entity.entity_id 49 | for entity in list(registry.entities.values()) 50 | if (entity.config_entry_id == entry_id and 51 | entity.unique_id.endswith(suffix)) 52 | ] 53 | for entity_id in remove: 54 | registry.async_remove(entity_id) 55 | 56 | 57 | async def remove_zigbee(unique_id: str): 58 | try: 59 | device: XDevice = next( 60 | d for d in XGateway.devices.values() if d.unique_id == unique_id 61 | ) 62 | except StopIteration: 63 | return 64 | # delete device from all gateways 65 | for gw in device.gateways: 66 | payload = gw.device.encode({"remove_did": device.did}) 67 | if isinstance(gw, LumiGateway): 68 | await gw.lumi_send(gw.device, payload) 69 | 70 | 71 | @callback 72 | def update_device_info(hass: HomeAssistant, did: str, **kwargs): 73 | # lumi.1234567890 => 0x1234567890 74 | mac = '0x' + did[5:] 75 | registry = dr.async_get(hass) 76 | device = registry.async_get_device({('xiaomi_gateway3', mac)}, None) 77 | if device: 78 | registry.async_update_device(device.id, **kwargs) 79 | 80 | 81 | async def load_devices(hass: HomeAssistant, yaml_devices: dict): 82 | # 1. Load devices settings from YAML 83 | if yaml_devices: 84 | for k, v in yaml_devices.items(): 85 | # AA:BB:CC:DD:EE:FF => aabbccddeeff 86 | k = k.replace(':', '').lower() 87 | XGateway.defaults[k] = v 88 | 89 | # 2. Load unique_id from entity registry (backward support old format) 90 | registry = er.async_get(hass) 91 | for entity in list(registry.entities.values()): 92 | if entity.platform != DOMAIN: 93 | continue 94 | 95 | # split mac and attr in unique id 96 | legacy_id, attr = entity.unique_id.split("_", 1) 97 | if legacy_id.startswith("0x"): 98 | # add leading zeroes to zigbee mac 99 | mac = f"0x{legacy_id[2:]:>016s}" 100 | elif len(legacy_id) == 12: 101 | # make mac lowercase (old Mesh devices) 102 | mac = legacy_id.lower() 103 | else: 104 | mac = legacy_id 105 | 106 | device = XGateway.defaults.setdefault(mac, {}) 107 | device.setdefault("unique_id", legacy_id) 108 | device.setdefault("restore_entities", []).append(attr) 109 | 110 | # 3. Load devices data from .storage 111 | store = Store(hass, 1, f"{DOMAIN}/devices.json") 112 | devices = await store.async_load() 113 | if devices: 114 | for k, v in devices.items(): 115 | XGateway.defaults.setdefault(k, {}).update(v) 116 | 117 | # noinspection PyUnusedLocal 118 | async def stop(*args): 119 | # save devices data to .storage 120 | data = { 121 | d.mac: {"decode_ts": d.decode_ts} 122 | for d in XGateway.devices.values() 123 | if d.decode_ts 124 | } 125 | await store.async_save(data) 126 | 127 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop) 128 | 129 | 130 | def migrate_options(data): 131 | data = dict(data) 132 | options = {k: data.pop(k) for k in ('ble', 'zha') if k in data} 133 | return {'data': data, 'options': options} 134 | 135 | 136 | async def check_gateway( 137 | host: str, token: str, telnet_cmd: str = None 138 | ) -> Optional[str]: 139 | # 1. try connect with telnet (custom firmware)? 140 | try: 141 | async with shell.Session(host) as sh: 142 | if sh.model: 143 | # 1.1. check token with telnet 144 | return None if await sh.get_token() == token else 'wrong_token' 145 | except Exception: 146 | pass 147 | 148 | # 2. try connect with miio 149 | miio = AsyncMiIO(host, token) 150 | info = await miio.info() 151 | 152 | # if info is None - devise doesn't answer on pings 153 | if info is None: 154 | return 'cant_connect' 155 | 156 | # if empty info - device works but not answer on commands 157 | if not info: 158 | return 'wrong_token' 159 | 160 | # 3. check if right model 161 | if info['model'] not in SUPPORTED_MODELS: 162 | return 'wrong_model' 163 | 164 | raw = json.loads(telnet_cmd or TELNET_CMD) 165 | # fw 1.4.6_0043+ won't answer on cmd without cloud, don't check answer 166 | await miio.send(raw['method'], raw.get('params')) 167 | 168 | # waiting for telnet to start 169 | await asyncio.sleep(1) 170 | 171 | try: 172 | async with shell.Session(host) as sh: 173 | if not sh.model: 174 | return 'wrong_telnet' 175 | except Exception: 176 | return None 177 | 178 | 179 | async def get_lan_key(host: str, token: str): 180 | device = AsyncMiIO(host, token) 181 | resp = await device.send('get_lumi_dpf_aes_key') 182 | if not resp: 183 | return "Can't connect to gateway" 184 | if 'result' not in resp: 185 | return f"Wrong response: {resp}" 186 | resp = resp['result'] 187 | if len(resp[0]) == 16: 188 | return resp[0] 189 | key = ''.join(random.choice(string.ascii_lowercase + string.digits) 190 | for _ in range(16)) 191 | resp = await device.send('set_lumi_dpf_aes_key', [key]) 192 | if resp.get('result') == ['ok']: 193 | return key 194 | return "Can't update gateway key" 195 | 196 | 197 | async def get_room_mapping(cloud: MiCloud, host: str, token: str): 198 | try: 199 | device = AsyncMiIO(host, token) 200 | local_rooms = await device.send('get_room_mapping') 201 | cloud_rooms = await cloud.get_rooms() 202 | result = '' 203 | for local_id, cloud_id in local_rooms['result']: 204 | cloud_name = next( 205 | (p['name'] for p in cloud_rooms if p['id'] == cloud_id), '-' 206 | ) 207 | result += f"\n- {local_id}: {cloud_name}" 208 | return result 209 | 210 | except Exception: 211 | return "Can't get from cloud" 212 | 213 | 214 | async def get_bindkey(cloud: MiCloud, did: str): 215 | bindkey = await cloud.get_bindkey(did) 216 | if bindkey is None: 217 | return "Can't get from cloud" 218 | # if bindkey.endswith('FFFFFFFF'): 219 | # return "Not needed" 220 | return bindkey 221 | 222 | 223 | async def enable_bslamp2_lan(host: str, token: str): 224 | device = AsyncMiIO(host, token) 225 | resp = await device.send("get_prop", ["lan_ctrl"]) 226 | if not resp: 227 | return "Can't connect to lamp" 228 | if resp.get("result") == ["1"]: 229 | return "Already enabled" 230 | resp = await device.send("set_ps", ["cfg_lan_ctrl", "1"]) 231 | if resp.get("result") == ["ok"]: 232 | return "Enabled" 233 | return "Can't enable LAN" 234 | 235 | 236 | async def get_ble_remotes(host: str, token: str): 237 | device = AsyncMiIO(host, token) 238 | resp = await device.send("ble_dbg_tbl_dump", {"table": "evtRuleTbl"}) 239 | if not resp: 240 | return "Can't connect to lamp" 241 | if "result" not in resp: 242 | return f"Wrong response" 243 | return "\n".join([ 244 | f"{p['beaconkey']} ({format_mac(p['mac'])})" for p in resp["result"] 245 | ]) 246 | 247 | 248 | def format_mac(s: str) -> str: 249 | return f"{s[10:]}:{s[8:10]}:{s[6:8]}:{s[4:6]}:{s[2:4]}:{s[:2]}".upper() 250 | 251 | 252 | async def get_ota_link(hass: HomeAssistant, device: "XDevice"): 253 | url = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/" 254 | 255 | # Xiaomi Plug should be updated to fw 30 before updating to latest fw 256 | if device.model == 'lumi.plug' and 0 < device.fw_ver < 30: 257 | # waiting pull request https://github.com/Koenkk/zigbee-OTA/pull/49 258 | return url.replace('Koenkk', 'AlexxIT') + \ 259 | 'images/Xiaomi/LM15_SP_mi_V1.3.30_20170929_v30_withCRC.20180514181348.ota' 260 | 261 | r = await async_get_clientsession(hass).get(url + "index.json") 262 | items = await r.json(content_type=None) 263 | for item in items: 264 | if item.get('modelId') == device.model: 265 | return url + item['path'] 266 | 267 | return None 268 | 269 | 270 | async def run_zigbee_ota( 271 | hass: HomeAssistant, gateway: "XGateway", device: "XDevice" 272 | ) -> Optional[bool]: 273 | url = await get_ota_link(hass, device) 274 | if url: 275 | gateway.debug_device(device, "update", url) 276 | resp = await gateway.miio_send('miIO.subdev_ota', { 277 | 'did': device.did, 278 | 'subdev_url': url 279 | }) 280 | if not resp or resp.get('result') != ['ok']: 281 | _LOGGER.error(f"Can't run update process: {resp}") 282 | return None 283 | return True 284 | else: 285 | return False 286 | --------------------------------------------------------------------------------