├── tests ├── __init__.py ├── pytest.ini ├── test_z3.py ├── test_ble.py └── test_zigbee.py ├── .gitignore ├── hacs.json ├── cloud_tokens.png ├── integrations.png ├── zigbee_table.png ├── bluetooth_lock.png ├── occupancy_timeout.png ├── .github └── workflows │ └── validate.yml ├── custom_components └── xiaomi_gateway3 │ ├── manifest.json │ ├── translations │ ├── zh-Hans.json │ ├── zh-Hant.json │ ├── ua.json │ ├── en.json │ ├── ro.json │ ├── ru.json │ └── pl.json │ ├── cover.py │ ├── device_trigger.py │ ├── remote.py │ ├── alarm_control_panel.py │ ├── switch.py │ ├── climate.py │ ├── binary_sensor.py │ ├── core │ ├── unqlite.py │ ├── helpers.py │ ├── xiaomi_cloud.py │ ├── utils.py │ ├── shell.py │ ├── mini_miio.py │ ├── bluetooth.py │ └── zigbee.py │ ├── __init__.py │ ├── light.py │ ├── sensor.py │ ├── config_flow.py │ └── util │ └── elelabs_ezsp_utility.py └── print_models.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | .homeassistant/ 4 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Xiaomi Gateway 3", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /cloud_tokens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pixtxa/XiaomiGateway3/master/cloud_tokens.png -------------------------------------------------------------------------------- /integrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pixtxa/XiaomiGateway3/master/integrations.png -------------------------------------------------------------------------------- /zigbee_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pixtxa/XiaomiGateway3/master/zigbee_table.png -------------------------------------------------------------------------------- /bluetooth_lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pixtxa/XiaomiGateway3/master/bluetooth_lock.png -------------------------------------------------------------------------------- /occupancy_timeout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pixtxa/XiaomiGateway3/master/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 | -------------------------------------------------------------------------------- /.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/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": ["paho-mqtt>=1.5.0"], 10 | "version": "1.3.0", 11 | "iot_class": "local_push" 12 | } 13 | -------------------------------------------------------------------------------- /print_models.py: -------------------------------------------------------------------------------- 1 | from custom_components.xiaomi_gateway3.core.bluetooth import DEVICES as BT 2 | from custom_components.xiaomi_gateway3.core.utils import DEVICES as ZB 3 | 4 | zigbee = {} 5 | 6 | for device in ZB: 7 | for k, v in device.items(): 8 | if k in ('params', 'mi_spec') or v[1] == 'Gateway 3' or len(v) < 3: 9 | continue 10 | name = f"{v[0]} {v[1]}" 11 | zigbee.setdefault(name, []).append(v[2]) 12 | 13 | print('Zigbee') 14 | for k, v in sorted(zigbee.items(), key=lambda kv: kv[0]): 15 | models = ','.join(sorted(set(v))) 16 | print(f"- {k} ({models})") 17 | 18 | print( 19 | 'BLE\n' + '\n'.join(sorted([ 20 | f"- {v[0]} {v[1]} ({v[2]})" 21 | for k, v in BT[0].items() 22 | if len(v) == 3 23 | ])) 24 | ) 25 | 26 | print( 27 | 'Mesh Bulbs\n' + '\n'.join(sorted([ 28 | f"- {v[0]} {v[1]} ({v[2]})" 29 | for k, v in BT[1].items() 30 | if len(v) == 3 and k != 'params' 31 | ])) 32 | ) 33 | 34 | print( 35 | 'Mesh Switches\n' + '\n'.join(sorted([ 36 | f"- {v[0]} {v[1]} ({v[2]})" 37 | for d in BT[2:] 38 | for k, v in d.items() 39 | if len(v) == 3 and k != 'params' 40 | ])) 41 | ) 42 | -------------------------------------------------------------------------------- /tests/test_z3.py: -------------------------------------------------------------------------------- 1 | from custom_components.xiaomi_gateway3.core.gateway3 import Gateway3 2 | 3 | 4 | def test_console(): 5 | def handler(payload: dict): 6 | assert payload == { 7 | 'nwk': '0x131E', 'ago': 335, 'type': 'router', 'parent': '0x1F0C' 8 | } 9 | 10 | gw = Gateway3('', '', {}) 11 | gw.add_stats('0x00158D0000000002', handler) 12 | gw.z3buffer = { 13 | "plugin device-table print": 14 | "0 E265: 00158D0000000000 0 JOINED 882\r" 15 | "1 7585: 00158D0000000001 0 JOINED 335\r" 16 | "2 131E: 00158D0000000002 0 JOINED 335\r" 17 | "3 1F0C: 00158D0000000003 0 JOINED 247\r", 18 | "plugin stack-diagnostics child-table": 19 | "0: Sleepy 0xE265 (>)00158D0000000000 512 min debug timeout:249\r" 20 | "1: Sleepy 0x7585 (>)00158D0000000001 512 min debug timeout:249\r", 21 | "plugin stack-diagnostics neighbor-table": 22 | "0: 0x131E 201 1 1 3 (>)00158D0000000002\r" 23 | "1: 0x1F0C 172 1 0 7 (>)00158D0000000003\r", 24 | "buffer": 25 | "0: 0x1F0C -> 0x0000 (Me)\r" 26 | "1: 0x131E -> 0x1F0C -> 0x0000 (Me)\r" 27 | } 28 | gw.process_z3("CLI command executed: plugin concentrator print-table\r") 29 | assert gw.info_ts == 0 30 | -------------------------------------------------------------------------------- /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 | }, 9 | "step": { 10 | "user": { 11 | "title": "选择动作", 12 | "data": { 13 | "action": "动作" 14 | } 15 | }, 16 | "cloud": { 17 | "title": "添加小米云账号", 18 | "description": "选择设备所属的服务器", 19 | "data": { 20 | "username": "邮箱/小米 ID", 21 | "password": "密码", 22 | "servers": "服务器" 23 | } 24 | }, 25 | "token": { 26 | "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)才能支持。", 27 | "data": { 28 | "host": "主机", 29 | "token": "Token", 30 | "ble": "支持 BLE 设备", 31 | "zha": "Zigbee Home Automation 模式" 32 | } 33 | } 34 | } 35 | }, 36 | "options": { 37 | "step": { 38 | "init": { 39 | "title": "小米云的设备信息", 40 | "data": { 41 | "did": "设备" 42 | }, 43 | "description": "{device_info}" 44 | }, 45 | "user": { 46 | "title": "网关设定", 47 | "data": { 48 | "host": "主机", 49 | "token": "Token", 50 | "ble": "支持 BLE 设备", 51 | "stats": "Zigbee 和 BLE 效能数据", 52 | "debug": "调试信息", 53 | "buzzer": "关闭蜂鸣器 [PRO]", 54 | "parent": "统计上端装置 [PRO]", 55 | "zha": "模式 [PRO]" 56 | }, 57 | "description": "如果您不清楚了解带有 **PRO** 选项的作用,建议不要更改它们。" 58 | } 59 | } 60 | }, 61 | "device_automation": { 62 | "trigger_type": { 63 | "button": "按下按键", 64 | "button_1": "按下第 1 键", 65 | "button_2": "按下第 2 键", 66 | "button_3": "按下第 3 键", 67 | "button_4": "按下第 4 键", 68 | "button_5": "按下第 5 键", 69 | "button_6": "按下第 6 键", 70 | "button_both": "两键同时按下", 71 | "button_both_12": "同时按下第 1、2 键", 72 | "button_both_13": "同时按下第 1、3 键", 73 | "button_both_23": "同时按下第 2、3 键" 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /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 | }, 11 | "step": { 12 | "user": { 13 | "title": "選擇動作", 14 | "data": { 15 | "action": "動作" 16 | } 17 | }, 18 | "cloud": { 19 | "title": "新增小米雲服務帳號", 20 | "description": "僅選擇已榜定設備的伺服器", 21 | "data": { 22 | "username": "Email / 小米帳號 ID", 23 | "password": "密碼", 24 | "servers": "伺服器" 25 | } 26 | }, 27 | "token": { 28 | "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) 以了解支援的韌體版本。", 29 | "data": { 30 | "host": "主機端", 31 | "token": "權杖", 32 | "telnet_cmd": "開啟 Telnet 指令" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "cloud": { 40 | "title": "小米雲設備資訊", 41 | "data": { 42 | "did": "裝置" 43 | }, 44 | "description": "{device_info}" 45 | }, 46 | "user": { 47 | "title": "網關設定", 48 | "data": { 49 | "host": "主機端", 50 | "token": "密鑰", 51 | "telnet_cmd": "開啟 Telnet 指令", 52 | "ble": "支援 BLE 設備", 53 | "stats": "Zigbee 與 BLE 效能資料", 54 | "debug": "Debug", 55 | "buzzer": "關閉蜂鳴器 [PRO]", 56 | "parent": "統計上端裝置 [PRO]", 57 | "zha": "模式 [PRO]" 58 | }, 59 | "description": "僅於了解可能之風險後,才建議變更 **PRO** 選項" 60 | } 61 | } 62 | }, 63 | "device_automation": { 64 | "trigger_type": { 65 | "button": "按鈕按下", 66 | "button_1": "第 1 個按鈕按下", 67 | "button_2": "第 2 個按鈕按下", 68 | "button_3": "第 3 個按鈕按下", 69 | "button_4": "第 4 個按鈕按下", 70 | "button_5": "第 5 個按鈕按下", 71 | "button_6": "第 6 個按鈕按下", 72 | "button_both": "按鈕同時按下", 73 | "button_both_12": "第 1 與第 2 個按鈕同時按下", 74 | "button_both_13": "第 1 與第 3 個按鈕同時按下", 75 | "button_both_23": "第 2 與第 3 個按鈕同時按下" 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/cover.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.cover import CoverEntity, ATTR_POSITION, \ 4 | ATTR_CURRENT_POSITION 5 | from homeassistant.const import STATE_CLOSING, STATE_OPENING 6 | 7 | from . import DOMAIN 8 | from .core.gateway3 import Gateway3 9 | from .core.helpers import XiaomiEntity 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | RUN_STATES = {0: STATE_CLOSING, 1: STATE_OPENING} 14 | 15 | 16 | async def async_setup_entry(hass, config_entry, async_add_entities): 17 | def setup(gateway: Gateway3, device: dict, attr: str): 18 | if device.get('lumi_spec'): 19 | async_add_entities([XiaomiCover(gateway, device, attr)]) 20 | else: 21 | async_add_entities([XiaomiCoverMIOT(gateway, device, attr)]) 22 | 23 | gw: Gateway3 = hass.data[DOMAIN][config_entry.entry_id] 24 | gw.add_setup('cover', setup) 25 | 26 | 27 | class XiaomiCover(XiaomiEntity, CoverEntity): 28 | @property 29 | def current_cover_position(self): 30 | return self._attrs.get(ATTR_CURRENT_POSITION) 31 | 32 | @property 33 | def is_opening(self): 34 | return self._state == STATE_OPENING 35 | 36 | @property 37 | def is_closing(self): 38 | return self._state == STATE_CLOSING 39 | 40 | @property 41 | def is_closed(self): 42 | return self.current_cover_position == 0 43 | 44 | def update(self, data: dict = None): 45 | if 'run_state' in data: 46 | self._state = RUN_STATES[data['run_state']] 47 | 48 | if 'position' in data: 49 | self._attrs[ATTR_CURRENT_POSITION] = data['position'] 50 | 51 | self.schedule_update_ha_state() 52 | 53 | def open_cover(self, **kwargs): 54 | self.gw.send(self.device, {'motor': 1}) 55 | 56 | def close_cover(self, **kwargs): 57 | self.gw.send(self.device, {'motor': 0}) 58 | 59 | def stop_cover(self, **kwargs): 60 | self.gw.send(self.device, {'motor': 2}) 61 | 62 | def set_cover_position(self, **kwargs): 63 | position = kwargs.get(ATTR_POSITION) 64 | self.gw.send(self.device, {'position': position}) 65 | 66 | 67 | class XiaomiCoverMIOT(XiaomiCover): 68 | def open_cover(self, **kwargs): 69 | self.gw.send(self.device, {'motor': 2}) 70 | 71 | def close_cover(self, **kwargs): 72 | self.gw.send(self.device, {'motor': 1}) 73 | 74 | def stop_cover(self, **kwargs): 75 | self.gw.send(self.device, {'motor': 0}) 76 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/device_trigger.py: -------------------------------------------------------------------------------- 1 | import voluptuous as vol 2 | from homeassistant.components.homeassistant.triggers import \ 3 | state as state_trigger 4 | from homeassistant.const import * 5 | from homeassistant.helpers import config_validation as cv 6 | from homeassistant.helpers.device_registry import DeviceEntry 7 | 8 | try: 9 | from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA 10 | except ImportError: 11 | from homeassistant.components.device_automation import ( 12 | DEVICE_TRIGGER_BASE_SCHEMA as TRIGGER_BASE_SCHEMA, 13 | ) 14 | 15 | from . import DOMAIN 16 | from .core import zigbee 17 | from .sensor import BUTTON 18 | 19 | TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( 20 | { 21 | vol.Required(CONF_TYPE): cv.string, 22 | vol.Required('action'): cv.string 23 | } 24 | ) 25 | 26 | 27 | async def async_attach_trigger(hass, config, action, automation_info): 28 | entity_registry = await hass.helpers.entity_registry.async_get_registry() 29 | 30 | device_id = config[CONF_DEVICE_ID] 31 | entry = next(( 32 | entry for entry in entity_registry.entities.values() if 33 | entry.device_id == device_id and entry.unique_id.endswith('action') 34 | ), None) 35 | 36 | if not entry: 37 | return None 38 | 39 | to_state = ( 40 | config['action'] if config[CONF_TYPE] == 'button' else 41 | f"{config[CONF_TYPE]}_{config['action']}" 42 | ) 43 | 44 | state_config = { 45 | CONF_PLATFORM: CONF_STATE, 46 | CONF_ENTITY_ID: entry.entity_id, 47 | state_trigger.CONF_TO: to_state 48 | } 49 | 50 | state_config = state_trigger.TRIGGER_SCHEMA(state_config) 51 | return await state_trigger.async_attach_trigger( 52 | hass, state_config, action, automation_info, platform_type="device" 53 | ) 54 | 55 | 56 | async def async_get_triggers(hass, device_id): 57 | device_registry = await hass.helpers.device_registry.async_get_registry() 58 | device: DeviceEntry = device_registry.async_get(device_id) 59 | buttons = zigbee.get_buttons(device.model) 60 | if not buttons: 61 | return None 62 | 63 | return [{ 64 | CONF_PLATFORM: CONF_DEVICE, 65 | CONF_DEVICE_ID: device_id, 66 | CONF_DOMAIN: DOMAIN, 67 | CONF_TYPE: button, 68 | } for button in buttons] 69 | 70 | 71 | async def async_get_trigger_capabilities(hass, config): 72 | return { 73 | "extra_fields": vol.Schema({ 74 | vol.Required('action'): vol.In(BUTTON.values()), 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /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 | }, 9 | "step": { 10 | "user": { 11 | "title": "Виберіть дію", 12 | "data": { 13 | "action": "Дія" 14 | } 15 | }, 16 | "cloud": { 17 | "title": "Додати Mi Cloud аккаунт", 18 | "description": "Вибирайте тільки ті сервери, на яких у вас є пристрої", 19 | "data": { 20 | "username": "Email / Mi Account ID", 21 | "password": "Пароль", 22 | "servers": "сервери" 23 | } 24 | }, 25 | "token": { 26 | "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)", 27 | "data": { 28 | "host": "IP-адреса", 29 | "token": "Токен" 30 | } 31 | } 32 | } 33 | }, 34 | "options": { 35 | "step": { 36 | "cloud": { 37 | "title": "Інформація про пристрої MiCloud", 38 | "data": { 39 | "did": "Пристрій" 40 | }, 41 | "description": "{device_info}" 42 | }, 43 | "user": { 44 | "title": "Налаштування шлюзу", 45 | "data": { 46 | "host": "IP-адреса", 47 | "token": "Токен", 48 | "ble": "Підтримка BLE пристроїв", 49 | "stats": "Деталізація роботи Zigbee і BLE", 50 | "debug": "Відлагодження", 51 | "buzzer": "Вимкнути спікер [PRO]", 52 | "parent": "Статистика по батьківським пристроям [PRO]", 53 | "zha": "Режим роботи [PRO]" 54 | }, 55 | "description": "Змінюйте **PRO** налаштування ТІЛЬКИ якщо знаєте, що ви робите" 56 | } 57 | } 58 | }, 59 | "device_automation": { 60 | "trigger_type": { 61 | "button": "Натискання кнопки", 62 | "button_1": "Натискання першої кнопки", 63 | "button_2": "Натискання другої кнопки", 64 | "button_3": "Натискання третьої кнопки", 65 | "button_4": "Натискання четвертої кнопки", 66 | "button_5": "Натискання п'ятої кнопки", 67 | "button_6": "Натискання шостої кнопки", 68 | "button_both": "Натискання двох кнопок", 69 | "button_both_12": "Натискання першої та другої кнопок", 70 | "button_both_13": "Натискання першої і третьої кнопок", 71 | "button_both_23": "Натискання другої і третьої кнопок" 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /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 | }, 11 | "step": { 12 | "user": { 13 | "title": "Select Action", 14 | "data": { 15 | "action": "Action" 16 | } 17 | }, 18 | "cloud": { 19 | "title": "Add Mi Cloud Account", 20 | "description": "Select only those servers where you have devices", 21 | "data": { 22 | "username": "Email / Mi Account ID", 23 | "password": "Password", 24 | "servers": "Servers" 25 | } 26 | }, 27 | "token": { 28 | "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).", 29 | "data": { 30 | "host": "Host", 31 | "token": "Token", 32 | "telnet_cmd": "Open Telnet command" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "cloud": { 40 | "title": "MiCloud devices info", 41 | "data": { 42 | "did": "Device" 43 | }, 44 | "description": "{device_info}" 45 | }, 46 | "user": { 47 | "title": "Gateway Config", 48 | "data": { 49 | "host": "Host", 50 | "token": "Token", 51 | "telnet_cmd": "Open Telnet command", 52 | "ble": "Support BLE Devices", 53 | "stats": "Zigbee and BLE performance data", 54 | "debug": "Debug", 55 | "buzzer": "Disable buzzer [PRO]", 56 | "parent": "Parent devices in stats [PRO]", 57 | "zha": "Mode [PRO]" 58 | }, 59 | "description": "Change **PRO** options ONLY if you know what you doing" 60 | } 61 | } 62 | }, 63 | "device_automation": { 64 | "trigger_type": { 65 | "button": "Button press", 66 | "button_1": "1st button press", 67 | "button_2": "2nd button press", 68 | "button_3": "3rd button press", 69 | "button_4": "4th button press", 70 | "button_5": "5th button press", 71 | "button_6": "6th button press", 72 | "button_both": "Both button press", 73 | "button_both_12": "1st and 2nd button press", 74 | "button_both_13": "1st and 3rd button press", 75 | "button_both_23": "2nd and 3rd button press" 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /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 | }, 9 | "step": { 10 | "user": { 11 | "title": "Selecteaza actiunea", 12 | "data": { 13 | "action": "Actiune" 14 | } 15 | }, 16 | "cloud": { 17 | "title": "Adauga un cont Mi Cloud", 18 | "description": "Selecteaza doar serverul unde ai dispozitivele", 19 | "data": { 20 | "username": "Email / ID-ul contului Mi", 21 | "password": "Parola", 22 | "servers": "Servere" 23 | } 24 | }, 25 | "token": { 26 | "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).", 27 | "data": { 28 | "host": "IP Gateway", 29 | "token": "Token", 30 | "telnet_cmd": "Comanda de deschidere Telnet" 31 | } 32 | } 33 | } 34 | }, 35 | "options": { 36 | "step": { 37 | "cloud": { 38 | "title": "Informatii despre dispozitivele din MiCloud", 39 | "data": { 40 | "did": "Dispozitiv" 41 | }, 42 | "description": "{device_info}" 43 | }, 44 | "user": { 45 | "title": "Configurare Gateway", 46 | "data": { 47 | "host": "IP Gateway", 48 | "token": "Token", 49 | "telnet_cmd": "Comanda de deschidere Telnet", 50 | "ble": "Dispozitive BLE suportate", 51 | "stats": "Date despre performanta dispozitivelor Zigbee si BLE", 52 | "debug": "Debug", 53 | "buzzer": "Dezactiveaza buzzer [PRO]", 54 | "parent": "Afiseaza dispozitivele tip parinte in statistici [PRO]", 55 | "zha": "Mod [PRO]" 56 | }, 57 | "description": "Nu schimba optiunile **PRO** DECAT daca stii ce fac!" 58 | } 59 | } 60 | }, 61 | "device_automation": { 62 | "trigger_type": { 63 | "button": "Apasare Buton", 64 | "button_1": "Prima apasare de buton", 65 | "button_2": "A 2-a apasare de buton", 66 | "button_3": "A 3-a apasare de buton", 67 | "button_4": "A 4-a apasare de buton", 68 | "button_5": "A 5-a apasare de buton", 69 | "button_6": "A 6-a apasare de buton", 70 | "button_both": "Ambele butoane apasate", 71 | "button_both_12": "Primul si al 2-lea 2nd buton apasat", 72 | "button_both_13": "Primul si al 3-lea buton apasat", 73 | "button_both_23": "Al 2-lea si al 3-lea buton apasat" 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /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 | }, 11 | "step": { 12 | "user": { 13 | "title": "Выберите действие", 14 | "data": { 15 | "action": "Действие" 16 | } 17 | }, 18 | "cloud": { 19 | "title": "Добавить Mi Cloud аккаунт", 20 | "description": "Выбирайте только те серверы, на которых у вас есть устройства", 21 | "data": { 22 | "username": "Email / Mi Account ID", 23 | "password": "Пароль", 24 | "servers": "Серверы" 25 | } 26 | }, 27 | "token": { 28 | "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).", 29 | "data": { 30 | "host": "IP-адрес", 31 | "token": "Токен", 32 | "telnet_cmd": "Команда для открытия Telnet" 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 | "telnet_cmd": "Команда для открытия Telnet", 52 | "ble": "Поддержка BLE устройств", 53 | "stats": "Детализация работы Zigbee и BLE", 54 | "debug": "Отладка", 55 | "buzzer": "Выключить пищалку [PRO]", 56 | "parent": "Родительские устройства в статистике [PRO]", 57 | "zha": "Режим работы [PRO]" 58 | }, 59 | "description": "Изменяйте **PRO** настройки ТОЛЬКО если знаете, что вы делаете" 60 | } 61 | } 62 | }, 63 | "device_automation": { 64 | "trigger_type": { 65 | "button": "Нажатие кнопки", 66 | "button_1": "Нажатие первой кнопки", 67 | "button_2": "Нажатие второй кнопки", 68 | "button_3": "Нажатие третьей кнопки", 69 | "button_4": "Нажатие четвёртой кнопки", 70 | "button_5": "Нажатие пятой кнопки", 71 | "button_6": "Нажатие шестой кнопки", 72 | "button_both": "Нажатие двух кнопок", 73 | "button_both_12": "Нажатие первой и второй кнопок", 74 | "button_both_13": "Нажатие первой и третей кнопок", 75 | "button_both_23": "Нажатие второй и третьей кнопок" 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /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 | }, 11 | "step": { 12 | "user": { 13 | "title": "Wybierz akcję", 14 | "data": { 15 | "action": "Akcja" 16 | } 17 | }, 18 | "cloud": { 19 | "title": "Dodaj konto Xiaomi Home", 20 | "description": "Wybierz tylko te serwery na której masz urządzenia", 21 | "data": { 22 | "username": "Email / ID konta Xiaomi", 23 | "password": "Hasło", 24 | "servers": "Serwery" 25 | } 26 | }, 27 | "token": { 28 | "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).", 29 | "data": { 30 | "host": "Host (Adres IP bramki)", 31 | "token": "Token", 32 | "telnet_cmd": "Komenda do otwarcia protokołu telnet" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "Informacje o urządzeniach z Xiaomi Home", 41 | "data": { 42 | "did": "Urządzenie" 43 | }, 44 | "description": "{device_info}" 45 | }, 46 | "user": { 47 | "title": "Ustawienia bramki", 48 | "data": { 49 | "host": "Host", 50 | "token": "Token", 51 | "telnet_cmd": "Komenda do otwarcia protokołu telnet", 52 | "ble": "Wsparcie dla urządzeń BLE (Bluetoth Low Energy)", 53 | "stats": "Dane dotyczące wydajności Zigbee i BLE", 54 | "debug": "Debugowanie", 55 | "buzzer": "Wyłącz głośnik w bramce [PRO]", 56 | "parent": "Pokazuj urządzenia centralne w statystykach [PRO]", 57 | "zha": "Tryb [PRO]" 58 | }, 59 | "description": "Jeśli nie wiesz co robisz to NIGDY nie dotykaj ustawień **PRO**" 60 | } 61 | } 62 | }, 63 | "device_automation": { 64 | "trigger_type": { 65 | "button": "Naciśnięcie przycisku", 66 | "button_1": "Naciśnięcie 1 przycisku", 67 | "button_2": "Naciśnięcie 2 przycisku", 68 | "button_3": "Naciśnięcie 3 przycisku", 69 | "button_4": "Naciśnięcie 4 przycisku", 70 | "button_5": "Naciśnięcie 5 przycisku", 71 | "button_6": "Naciśnięcie 6 przycisku", 72 | "button_both": "Naciśnięcie obydwu przycisków", 73 | "button_both_12": "Naciśnięcie przycisków 1 i 2", 74 | "button_both_13": "Naciśnięcie przycisków 1 i 3", 75 | "button_both_23": "Naciśnięcie przycisków 2 i 3" 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/test_ble.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from custom_components.xiaomi_gateway3.core import utils 4 | from custom_components.xiaomi_gateway3.core.gateway3 import Gateway3 5 | 6 | 7 | def test_ble_normal_message(): 8 | raw = b'[20201207 09:07:48] [D] ot_agent_recv_handler_one(): fd:11, msg:{"method":"_async.ble_event","params":{"dev":{"did":"blt.3.iambledevice0","mac":"AA:BB:CC:DD:EE:FF","pdid":426},"evt":[{"eid":4102,"edata":"a801"}],"frmCnt":19,"gwts":1607321268},"id":123456} length:191 bytes' 9 | assert len([json.loads(item) for item in utils.extract_jsons(raw)]) == 1 10 | 11 | 12 | def test_ble_concat_messages(): 13 | """Two concatenated messages""" 14 | raw = b'[20201101 23:25:11] [D] ot_agent_recv_handler_one(): fd:11, msg:{"method":"_async.ble_event","params":{"dev":{"did":"blt.3.iambledevice0","mac":"AA:BB:CC:DD:EE:FF","pdid":2038},"evt":[{"eid":15,"edata":"640000"}],"frmCnt":100,"gwts":1604262311},"id":1234567}{"method":"local.query_status","params":"","id":4422874} length:250 bytes' 15 | assert len([json.loads(item) for item in utils.extract_jsons(raw)]) == 2 16 | 17 | 18 | def test_ble_overflow_message(): 19 | """One overflow message""" 20 | raw = b'[20201124 19:41:24] [D] ot_agent_recv_handler_one(): fd:10, msg:{"method":"_async.ble_event","params":{"dev":{"did":"blt.3.iambledevice0","mac":"AA:BB:CC:DD:EE:FF","pdid":1398},"evt":[{"eid":4106,"edata":"64"}],"frmCnt":184,"gwts":1606236083},"id":1234567}"lumi.sensor_motion.aq2","params":[],"sid":"lumi.1234567890123456","id":7817979}{"method":"props","model":"lumi.sensor_motion.aq2","params":{"no_motion_1800":0},"sid":"lumi.1234567890123456","id":7817985} length:192 bytes' 21 | assert len([json.loads(item) for item in utils.extract_jsons(raw)]) == 1 22 | 23 | 24 | def test_ble_147_miio_func(): 25 | raw = b'\x1b[0;32m2020:12:05:01:47:03.521 [D] miio_client_func: ot_agent_recv_handler_one(): fd:9, msg:{"method":"_async.ble_event","params":{"dev":{"did":"blt.3.iambledevice0","mac":"AA:BB:CC:DD:EE:FF","pdid":426},"evt":[{"eid":4100,"edata":"ea00"}],"frmCnt":44,"gwts":1607104023},"id":1234} length:189 bytes\x1b[0m' 26 | assert len([json.loads(item) for item in utils.extract_jsons(raw)]) == 1 27 | 28 | 29 | def test_ble_147_miio_rpc(): 30 | raw = b'\x1b[0;32m2020:12:05:01:47:03.521 [D] miio_client_rpc: call={"method":"_async.ble_event","params":{"dev":{"did":"blt.3.iambledevice0","mac":"AA:BB:CC:DD:EE:FF","pdid":426},"evt":[{"eid":4100,"edata":"ea00"}],"frmCnt":44,"gwts":1607104023},"id":1234}\x1b[0m' 31 | assert len([json.loads(item) for item in utils.extract_jsons(raw)]) == 1 32 | 33 | 34 | def test_ble_147_ots(): 35 | raw = b'\x1b[0;32m2020:12:05:01:47:03.530 [D] ots: ots_up_rpc_delegate_out_cb(), 289, {"method":"_async.ble_event","params":{"dev":{"did":"blt.3.iambledevice0","mac":"AA:BB:CC:DD:EE:FF","pdid":426},"evt":[{"eid":4100,"edata":"ea00"}],"frmCnt":44,"gwts":1607104023},"id":1234}\x1b[0m' 36 | assert len([json.loads(item) for item in utils.extract_jsons(raw)]) == 1 37 | 38 | 39 | # TODO: fix 40 | def _test_motion2(): 41 | device = {'init': {'motion': 0, 'light': 0}} 42 | 43 | def handler(payload: dict): 44 | assert payload == {'motion': 1, 'light': 0} 45 | 46 | gw = Gateway3('', '', {}) 47 | gw.devices = {'blt.xxx': device} 48 | gw.add_update('blt.xxx', handler) 49 | 50 | payload = { 51 | "dev": {'did': 'blt.xxx', 'mac': 'AA:BB:CC:DD:EE:FF', 'pdid': 2701}, 52 | "evt": [{"eid": 15, "edata": "0000"}], 53 | } 54 | gw.process_ble_event(payload) 55 | -------------------------------------------------------------------------------- /tests/test_zigbee.py: -------------------------------------------------------------------------------- 1 | from custom_components.xiaomi_gateway3.core import zigbee 2 | from custom_components.xiaomi_gateway3.core.gateway3 import Gateway3 3 | 4 | 5 | def _generate_gateway(model: str): 6 | device = {'did': 'lumi.xxx', 'model': model, 'entities': {}} 7 | device.update(zigbee.get_device(model)) 8 | gw = Gateway3('', '', {}) 9 | gw.devices = {'lumi.xxx': device} 10 | return gw 11 | 12 | 13 | def test_lumi_property(): 14 | gw = _generate_gateway('lumi.sensor_motion.aq2') 15 | payload = gw.process_message({ 16 | 'cmd': 'report', 'did': 'lumi.xxx', 17 | 'params': [{'res_name': '3.1.85', 'value': 1}] 18 | }) 19 | assert payload == {'motion': 1} 20 | 21 | 22 | def test_wrong_temperature(): 23 | gw = _generate_gateway('lumi.sensor_motion.aq2') 24 | payload = gw.process_message({ 25 | 'cmd': 'report', 'did': 'lumi.xxx', 26 | 'params': [{'res_name': '0.1.85', 'value': 12300}] 27 | }) 28 | assert payload == {'0.1.85': 12300} 29 | 30 | 31 | def test_mi_spec_property(): 32 | gw = _generate_gateway('lumi.sen_ill.mgl01') 33 | payload = gw.process_message({ 34 | 'cmd': 'report', 'did': 'lumi.xxx', 35 | 'mi_spec': [{'siid': 3, 'piid': 1, 'value': 3100}] 36 | }) 37 | assert payload == {'battery': 83} 38 | 39 | 40 | def test_mi_spec_event(): 41 | gw = _generate_gateway('lumi.motion.agl04') 42 | payload = gw.process_message({ 43 | 'cmd': 'report', 'did': 'lumi.xxx', 44 | 'mi_spec': [{'siid': 4, 'eiid': 1, 'arguments': []}] 45 | }) 46 | assert payload == {'motion': 1} 47 | 48 | 49 | def test_e1_click_event(): 50 | gw = _generate_gateway('lumi.switch.b2lc04') 51 | payload = gw.process_message({ 52 | 'cmd': 'report', 'did': 'lumi.xxx', 53 | 'mi_spec': [{'siid': 8, 'eiid': 2, 'arguments': []}] 54 | }) 55 | assert payload == {'button_2': 2} 56 | 57 | payload = gw.process_message({ 58 | 'cmd': 'report', 'did': 'lumi.xxx', 59 | 'mi_spec': [{'siid': 9, 'eiid': 1, 'arguments': []}] 60 | }) 61 | assert payload == {'button_both': 4} 62 | 63 | 64 | def test_online(): 65 | gw = _generate_gateway('lumi.sensor_motion.aq2') 66 | gw.process_message({ 67 | 'cmd': 'report', 'did': 'lumi.xxx', 68 | 'params': [{'res_name': '3.1.85', 'value': 1}] 69 | }) 70 | assert gw.devices['lumi.xxx']['online'] 71 | 72 | 73 | def test_offline(): 74 | gw = _generate_gateway('lumi.sensor_motion.aq2') 75 | gw.process_message({ 76 | 'cmd': 'report', 'did': 'lumi.xxx', 77 | 'params': [{'res_name': '8.0.2102', 'value': { 78 | 'status': 'offline', 'time': 10800 79 | }}] 80 | }) 81 | assert gw.devices['lumi.xxx']['online'] is False 82 | 83 | 84 | def test_airmonitor_acn01(): 85 | gw = _generate_gateway('lumi.airmonitor.acn01') 86 | payload = gw.process_message({ 87 | 'cmd': 'report', 'did': 'lumi.xxx', 88 | 'mi_spec': [{'siid': 3, 'piid': 1, 'value': 36.6}] 89 | }) 90 | assert payload == {'temperature': 36.6} 91 | 92 | 93 | def test_voltage(): 94 | gw = _generate_gateway('lumi.sensor_magnet') 95 | payload = gw.process_message({ 96 | 'cmd': 'heartbeat', 'params': [{ 97 | 'did': 'lumi.xxx', 'res_list': [ 98 | {'res_name': '8.0.2008', 'value': 3045}, 99 | {'res_name': '8.0.2001', 'value': 74}, 100 | ] 101 | }] 102 | }) 103 | assert payload == {'battery': 74} 104 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/remote.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components import persistent_notification 4 | from homeassistant.components.remote import ATTR_DEVICE 5 | from homeassistant.helpers.entity import ToggleEntity 6 | 7 | from . import DOMAIN 8 | from .core import utils 9 | from .core.gateway3 import Gateway3 10 | from .core.helpers import XiaomiEntity 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | async def async_setup_entry(hass, config_entry, async_add_entities): 16 | def setup(gateway: Gateway3, device: dict, attr: str): 17 | async_add_entities([Gateway3Entity(gateway, device, attr)]) 18 | 19 | gw: Gateway3 = hass.data[DOMAIN][config_entry.entry_id] 20 | gw.add_setup('remote', setup) 21 | 22 | 23 | class Gateway3Entity(XiaomiEntity, ToggleEntity): 24 | @property 25 | def is_on(self): 26 | return self._state 27 | 28 | @property 29 | def icon(self): 30 | return 'mdi:zigbee' 31 | 32 | def update(self, data: dict = None): 33 | if 'pairing_start' in data: 34 | self._state = True 35 | 36 | elif 'pairing_stop' in data: 37 | self._state = False 38 | self.gw.pair_model = None 39 | 40 | elif 'added_device' in data: 41 | text = "New device:\n" + '\n'.join( 42 | f"{k}: {v}" for k, v in data['added_device'].items() 43 | ) 44 | persistent_notification.async_create(self.hass, text, 45 | "Xiaomi Gateway 3") 46 | 47 | elif 'removed_did' in data: 48 | # https://github.com/AlexxIT/XiaomiGateway3/issues/122 49 | did = data['removed_did']['did'] \ 50 | if isinstance(data['removed_did'], dict) \ 51 | else data['removed_did'] 52 | if did.startswith('lumi.'): 53 | self.debug(f"Handle removed_did: {did}") 54 | utils.remove_device(self.hass, did) 55 | 56 | self.schedule_update_ha_state() 57 | 58 | def turn_on(self): 59 | # work for any device model, dev_type: 0 - zb1, 1 - zb3, don't matter 60 | self.gw.miio.send('miIO.zb_start_provision', { 61 | 'dev_type': 0, 'duration': 60, 'method': 0, 62 | 'model': 'lumi.sensor_switch.v2', 'pid': 62 63 | }) 64 | # self.gw.send(self.device, {'pairing_start': 60}) 65 | 66 | def turn_off(self): 67 | self.gw.miio.send('miIO.zb_end_provision', {'code': -1}) 68 | # self.gw.send(self.device, {'pairing_stop': 0}) 69 | 70 | async def async_send_command(self, command, **kwargs): 71 | for cmd in command: 72 | args = cmd.split(' ') 73 | cmd = args[0] 74 | 75 | # for testing purposes 76 | if cmd == 'ble': 77 | raw = kwargs[ATTR_DEVICE].replace('\'', '"') 78 | self.gw.process_ble_event(raw) 79 | elif cmd == 'pair': 80 | model: str = kwargs[ATTR_DEVICE] 81 | self.gw.pair_model = (model[:-3] if model.endswith('.v1') 82 | else model) 83 | self.turn_on() 84 | elif cmd in ('reboot', 'ftp', 'dump'): 85 | self.gw.send_telnet(cmd) 86 | elif cmd == 'power': 87 | self.gw.send(self.device, {'power_tx': int(args[1])}) 88 | elif cmd == 'channel': 89 | self.gw.send(self.device, {'channel': int(args[1])}) 90 | elif cmd == 'publishstate': 91 | self.gw.send_mqtt('publishstate') 92 | elif cmd == 'info': 93 | self.gw.get_gateway_info() 94 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.alarm_control_panel import \ 4 | SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, \ 5 | SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntity 6 | from homeassistant.const import STATE_ALARM_ARMED_AWAY, \ 7 | STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, \ 8 | STATE_ALARM_TRIGGERED 9 | 10 | from . import DOMAIN 11 | from .core.gateway3 import Gateway3 12 | from .core.helpers import XiaomiEntity 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | ALARM_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, 17 | STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT] 18 | 19 | 20 | async def async_setup_entry(hass, config_entry, async_add_entities): 21 | def setup(gateway: Gateway3, device: dict, attr: str): 22 | async_add_entities([XiaomiAlarm(gateway, device, attr)], True) 23 | 24 | gw: Gateway3 = hass.data[DOMAIN][config_entry.entry_id] 25 | gw.add_setup('alarm_control_panel', setup) 26 | 27 | 28 | class XiaomiAlarm(XiaomiEntity, AlarmControlPanelEntity): 29 | @property 30 | def miio_did(self): 31 | return self.device['did'] 32 | 33 | @property 34 | def state(self): 35 | return self._state 36 | 37 | @property 38 | def icon(self): 39 | return "mdi:shield-home" 40 | 41 | @property 42 | def supported_features(self): 43 | return (SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | 44 | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER) 45 | 46 | @property 47 | def code_arm_required(self): 48 | return False 49 | 50 | def alarm_disarm(self, code=None): 51 | self.gw.miio.send('set_properties', [{ 52 | 'did': self.miio_did, 'siid': 3, 'piid': 1, 'value': 0 53 | }]) 54 | 55 | def alarm_arm_home(self, code=None): 56 | self.gw.miio.send('set_properties', [{ 57 | 'did': self.miio_did, 'siid': 3, 'piid': 1, 'value': 1 58 | }]) 59 | 60 | def alarm_arm_away(self, code=None): 61 | self.gw.miio.send('set_properties', [{ 62 | 'did': self.miio_did, 'siid': 3, 'piid': 1, 'value': 2 63 | }]) 64 | 65 | def alarm_arm_night(self, code=None): 66 | self.gw.miio.send('set_properties', [{ 67 | 'did': self.miio_did, 'siid': 3, 'piid': 1, 'value': 3 68 | }]) 69 | 70 | def alarm_trigger(self, code=None): 71 | self.gw.miio.send('set_properties', [{ 72 | 'did': self.miio_did, 'siid': 3, 'piid': 22, 'value': 1 73 | }]) 74 | 75 | def update(self, data: dict = None): 76 | if data: 77 | if self.attr in data: 78 | state = data[self.attr] 79 | self._state = ALARM_STATES[state] 80 | elif data.get('alarm_trigger'): 81 | self._state = STATE_ALARM_TRIGGERED 82 | 83 | else: 84 | try: 85 | resp = self.gw.miio.send('get_properties', [{ 86 | 'did': self.miio_did, 'siid': 3, 'piid': 22 87 | }]) 88 | if resp[0]['value'] == 1: 89 | self._state = STATE_ALARM_TRIGGERED 90 | else: 91 | resp = self.gw.miio.send('get_properties', [{ 92 | 'did': self.miio_did, 'siid': 3, 'piid': 1 93 | }]) 94 | state = resp[0]['value'] 95 | self._state = ALARM_STATES[state] 96 | except: 97 | pass 98 | 99 | self.schedule_update_ha_state() 100 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components import persistent_notification 4 | from homeassistant.helpers.entity import ToggleEntity 5 | 6 | from . import DOMAIN 7 | from .core.gateway3 import Gateway3 8 | from .core.helpers import XiaomiEntity 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | async def async_setup_entry(hass, config_entry, async_add_entities): 14 | def setup(gateway: Gateway3, device: dict, attr: str): 15 | if attr == 'firmware lock': 16 | async_add_entities([FirmwareLock(gateway, device, attr)]) 17 | elif device['type'] == 'mesh': 18 | async_add_entities([XiaomiMeshSwitch(gateway, device, attr)]) 19 | else: 20 | async_add_entities([XiaomiZigbeeSwitch(gateway, device, attr)]) 21 | 22 | gw: Gateway3 = hass.data[DOMAIN][config_entry.entry_id] 23 | gw.add_setup('switch', setup) 24 | 25 | 26 | class XiaomiZigbeeSwitch(XiaomiEntity, ToggleEntity): 27 | @property 28 | def is_on(self): 29 | return self._state 30 | 31 | def update(self, data: dict = None): 32 | # thread.run > mqtt.loop_forever > ... > thread.on_message 33 | # > entity.update 34 | # > entity.schedule_update_ha_state * 35 | # > hass.add_job * 36 | # > loop.call_soon_threadsafe * 37 | # > hass.async_add_job * 38 | # > hass.async_add_hass_job * 39 | # > loop.create_task * 40 | # > entity.async_update_ha_state * 41 | # > entyty._async_write_ha_state 42 | # > hass.states.async_set 43 | # > bus.async_fire 44 | # > hass.async_add_hass_job 45 | # > loop.call_soon 46 | if self.attr in data: 47 | self._state = bool(data[self.attr]) 48 | self.schedule_update_ha_state() 49 | 50 | def turn_on(self): 51 | self.gw.send(self.device, {self.attr: 1}) 52 | 53 | def turn_off(self): 54 | self.gw.send(self.device, {self.attr: 0}) 55 | 56 | 57 | class XiaomiMeshSwitch(XiaomiEntity, ToggleEntity): 58 | @property 59 | def should_poll(self): 60 | return False 61 | 62 | @property 63 | def is_on(self): 64 | return self._state 65 | 66 | def update(self, data: dict = None): 67 | if data is None: 68 | self.gw.mesh_force_update() 69 | return 70 | 71 | if self.attr in data: 72 | # handle main attribute as online state 73 | if data[self.attr] is not None: 74 | self._state = bool(data[self.attr]) 75 | self.device['online'] = True 76 | else: 77 | self.device['online'] = False 78 | 79 | self.schedule_update_ha_state() 80 | 81 | def turn_on(self, **kwargs): 82 | self._state = True 83 | 84 | self.gw.send_mesh(self.device, {self.attr: True}) 85 | 86 | self.schedule_update_ha_state() 87 | 88 | def turn_off(self, **kwargs): 89 | self._state = False 90 | 91 | self.gw.send_mesh(self.device, {self.attr: False}) 92 | 93 | self.schedule_update_ha_state() 94 | 95 | 96 | class FirmwareLock(XiaomiZigbeeSwitch): 97 | @property 98 | def icon(self): 99 | return 'mdi:cloud-lock' 100 | 101 | def turn_on(self): 102 | if self.gw.lock_firmware(enable=True): 103 | self._state = True 104 | self.schedule_update_ha_state() 105 | 106 | persistent_notification.create( 107 | self.hass, "Firmware update is locked. You can sleep well.", 108 | "Xiaomi Gateway 3" 109 | ) 110 | 111 | def turn_off(self): 112 | if self.gw.lock_firmware(enable=False): 113 | self._state = False 114 | self.schedule_update_ha_state() 115 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/climate.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.climate import * 2 | from homeassistant.components.climate.const import * 3 | 4 | from . import DOMAIN 5 | from .core.gateway3 import Gateway3 6 | from .core.helpers import XiaomiEntity 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | HVAC_MODES = [HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_OFF] 11 | FAN_MODES = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO] 12 | 13 | AC_STATE_HVAC = { 14 | HVAC_MODE_OFF: 0x01, 15 | HVAC_MODE_HEAT: 0x10, 16 | HVAC_MODE_COOL: 0x11 17 | } 18 | AC_STATE_FAN = { 19 | FAN_LOW: 0x00, 20 | FAN_MEDIUM: 0x10, 21 | FAN_HIGH: 0x20, 22 | FAN_AUTO: 0x30 23 | } 24 | 25 | 26 | async def async_setup_entry(hass, config_entry, async_add_entities): 27 | def setup(gateway: Gateway3, device: dict, attr: str): 28 | async_add_entities([XiaomiClimate(gateway, device, attr)]) 29 | 30 | gw: Gateway3 = hass.data[DOMAIN][config_entry.entry_id] 31 | gw.add_setup('climate', setup) 32 | 33 | 34 | # noinspection PyAbstractClass 35 | class XiaomiClimate(XiaomiEntity, ClimateEntity): 36 | _current_hvac = None 37 | _current_temp = None 38 | _fan_mode = None 39 | _hvac_mode = None 40 | _is_on = None 41 | _state: bytearray = None 42 | # fix scenes with turned off climate 43 | # https://github.com/AlexxIT/XiaomiGateway3/issues/101#issuecomment-757781988 44 | _target_temp = 0 45 | 46 | @property 47 | def precision(self) -> float: 48 | return PRECISION_WHOLE 49 | 50 | @property 51 | def temperature_unit(self): 52 | return TEMP_CELSIUS 53 | 54 | @property 55 | def hvac_mode(self) -> str: 56 | return self._hvac_mode if self._is_on else HVAC_MODE_OFF 57 | 58 | @property 59 | def hvac_modes(self): 60 | return [HVAC_MODE_OFF, HVAC_MODE_COOL, HVAC_MODE_HEAT] 61 | 62 | @property 63 | def current_temperature(self): 64 | return self._current_temp 65 | 66 | @property 67 | def target_temperature(self): 68 | return self._target_temp 69 | 70 | @property 71 | def fan_mode(self): 72 | return self._fan_mode 73 | 74 | @property 75 | def fan_modes(self): 76 | return FAN_MODES 77 | 78 | @property 79 | def supported_features(self): 80 | return SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE 81 | 82 | def update(self, data: dict = None): 83 | try: 84 | if 'power' in data: # 0 - off, 1 - on 85 | self._is_on = data['power'] 86 | 87 | # with power off all data come with empty values 88 | # https://github.com/AlexxIT/XiaomiGateway3/issues/101#issuecomment-747305596 89 | if self._is_on: 90 | if 'mode' in data: # 0 - heat, 1 - cool, 15 - off 91 | self._hvac_mode = HVAC_MODES[data['mode']] 92 | if 'fan_mode' in data: # 0 - low, 3 - auto, 15 - off 93 | self._fan_mode = FAN_MODES[data['fan_mode']] 94 | if 'target_temperature' in data: # 255 - off 95 | self._target_temp = data['target_temperature'] 96 | 97 | else: 98 | self._fan_mode = None 99 | self._hvac_mode = None 100 | self._target_temp = 0 101 | 102 | if 'current_temperature' in data: 103 | self._current_temp = data['current_temperature'] 104 | 105 | if self.attr in data: 106 | self._state = bytearray( 107 | int(data[self.attr]).to_bytes(4, 'big') 108 | ) 109 | 110 | # only first time when retain from gateway 111 | if isinstance(data[self.attr], str): 112 | self._hvac_mode = next( 113 | k for k, v in AC_STATE_HVAC.items() 114 | if v == self._state[0] 115 | ) 116 | self._fan_mode = next( 117 | k for k, v in AC_STATE_FAN.items() 118 | if v == self._state[1] 119 | ) 120 | self._target_temp = self._state[2] 121 | 122 | except: 123 | _LOGGER.exception(f"Can't read climate data: {data}") 124 | 125 | self.schedule_update_ha_state() 126 | 127 | def set_temperature(self, **kwargs) -> None: 128 | if not self._state or kwargs[ATTR_TEMPERATURE] == 0: 129 | self.debug(f"Can't set climate temperature: {self._state}") 130 | return 131 | self._state[2] = int(kwargs[ATTR_TEMPERATURE]) 132 | state = int.from_bytes(self._state, 'big') 133 | self.gw.send(self.device, {self.attr: state}) 134 | 135 | def set_fan_mode(self, fan_mode: str) -> None: 136 | if not self._state: 137 | return 138 | self._state[1] = AC_STATE_FAN[fan_mode] 139 | state = int.from_bytes(self._state, 'big') 140 | self.gw.send(self.device, {self.attr: state}) 141 | 142 | def set_hvac_mode(self, hvac_mode: str) -> None: 143 | if not self._state: 144 | return 145 | self._state[0] = AC_STATE_HVAC[hvac_mode] 146 | state = int.from_bytes(self._state, 'big') 147 | self.gw.send(self.device, {self.attr: state}) 148 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from homeassistant.components.automation import ATTR_LAST_TRIGGERED 5 | from homeassistant.components.binary_sensor import BinarySensorEntity, \ 6 | DEVICE_CLASS_DOOR, DEVICE_CLASS_MOISTURE 7 | from homeassistant.config import DATA_CUSTOMIZE 8 | from homeassistant.helpers.event import async_call_later 9 | from homeassistant.util.dt import now 10 | 11 | from . import DOMAIN 12 | from .core.gateway3 import Gateway3 13 | from .core.helpers import XiaomiEntity 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | DEVICE_CLASS = { 18 | 'contact': DEVICE_CLASS_DOOR, 19 | 'water_leak': DEVICE_CLASS_MOISTURE, 20 | } 21 | 22 | CONF_INVERT_STATE = 'invert_state' 23 | CONF_OCCUPANCY_TIMEOUT = 'occupancy_timeout' 24 | 25 | 26 | async def async_setup_entry(hass, config_entry, async_add_entities): 27 | def setup(gateway: Gateway3, device: dict, attr: str): 28 | if attr == 'motion': 29 | async_add_entities([XiaomiMotionSensor(gateway, device, attr)]) 30 | elif attr == 'power': 31 | async_add_entities([XiaomiKettleSensor(gateway, device, attr)]) 32 | else: 33 | async_add_entities([XiaomiBinarySensor(gateway, device, attr)]) 34 | 35 | gw: Gateway3 = hass.data[DOMAIN][config_entry.entry_id] 36 | gw.add_setup('binary_sensor', setup) 37 | 38 | 39 | class XiaomiBinarySensor(XiaomiEntity, BinarySensorEntity): 40 | @property 41 | def is_on(self): 42 | return self._state 43 | 44 | @property 45 | def device_class(self): 46 | return DEVICE_CLASS.get(self.attr, self.attr) 47 | 48 | def update(self, data: dict = None): 49 | if self.attr in data: 50 | custom = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) 51 | if not custom.get(CONF_INVERT_STATE): 52 | # gas and smoke => 1 and 2 53 | self._state = bool(data[self.attr]) 54 | else: 55 | self._state = not data[self.attr] 56 | 57 | self.schedule_update_ha_state() 58 | 59 | 60 | KETTLE = { 61 | 0: 'idle', 62 | 1: 'heat', 63 | 2: 'cool_down', 64 | 3: 'warm_up', 65 | } 66 | 67 | 68 | class XiaomiKettleSensor(XiaomiBinarySensor): 69 | def update(self, data: dict = None): 70 | if self.attr in data: 71 | value = data[self.attr] 72 | self._state = bool(value) 73 | self._attrs['action_id'] = value 74 | self._attrs['action'] = KETTLE[value] 75 | 76 | self.schedule_update_ha_state() 77 | 78 | 79 | class XiaomiMotionSensor(XiaomiBinarySensor): 80 | _default_delay = None 81 | _last_on = 0 82 | _last_off = 0 83 | _timeout_pos = 0 84 | _unsub_set_no_motion = None 85 | 86 | async def async_added_to_hass(self): 87 | # old version 88 | self._default_delay = self.device.get(CONF_OCCUPANCY_TIMEOUT, 90) 89 | 90 | custom: dict = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) 91 | custom.setdefault(CONF_OCCUPANCY_TIMEOUT, self._default_delay) 92 | 93 | await super().async_added_to_hass() 94 | 95 | async def _start_no_motion_timer(self, delay: float): 96 | if self._unsub_set_no_motion: 97 | self._unsub_set_no_motion() 98 | 99 | self._unsub_set_no_motion = async_call_later( 100 | self.hass, abs(delay), self._set_no_motion) 101 | 102 | async def _set_no_motion(self, *args): 103 | self.debug("Clear motion") 104 | 105 | self._last_off = time.time() 106 | self._timeout_pos = 0 107 | self._unsub_set_no_motion = None 108 | self._state = False 109 | self.schedule_update_ha_state() 110 | 111 | def update(self, data: dict = None): 112 | # fix 1.4.7_0115 heartbeat error (has motion in heartbeat) 113 | if 'battery' in data: 114 | return 115 | 116 | # https://github.com/AlexxIT/XiaomiGateway3/issues/135 117 | if 'illuminance' in data and ('lumi.sensor_motion.aq2' in 118 | self.device['device_model']): 119 | data[self.attr] = 1 120 | 121 | # check only motion=1 122 | if data.get(self.attr) != 1: 123 | # handle available change 124 | self.schedule_update_ha_state() 125 | return 126 | 127 | # don't trigger motion right after illumination 128 | t = time.time() 129 | if t - self._last_on < 1: 130 | return 131 | 132 | self._state = True 133 | self._attrs[ATTR_LAST_TRIGGERED] = now().isoformat(timespec='seconds') 134 | self._last_on = t 135 | 136 | # handle available change 137 | self.schedule_update_ha_state() 138 | 139 | if self._unsub_set_no_motion: 140 | self._unsub_set_no_motion() 141 | 142 | custom = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) 143 | # if customize of any entity will be changed from GUI - default value 144 | # for all motion sensors will be erased 145 | timeout = custom.get(CONF_OCCUPANCY_TIMEOUT, self._default_delay) 146 | if timeout: 147 | if isinstance(timeout, list): 148 | pos = min(self._timeout_pos, len(timeout) - 1) 149 | delay = timeout[pos] 150 | self._timeout_pos += 1 151 | else: 152 | delay = timeout 153 | 154 | if delay < 0 and t + delay < self._last_off: 155 | delay *= 2 156 | 157 | self.debug(f"Extend delay: {delay} seconds") 158 | 159 | self.hass.add_job(self._start_no_motion_timer, delay) 160 | 161 | # repeat event from Aqara integration 162 | self.hass.bus.fire('xiaomi_aqara.motion', { 163 | 'entity_id': self.entity_id 164 | }) 165 | -------------------------------------------------------------------------------- /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 | class Unqlite: 7 | page_size = 0 8 | pos = 0 9 | 10 | def __init__(self, raw: bytes): 11 | self.raw = raw 12 | self.read_db_header() 13 | 14 | @property 15 | def size(self): 16 | return len(self.raw) 17 | 18 | def read(self, length: int): 19 | self.pos += length 20 | return self.raw[self.pos - length:self.pos] 21 | 22 | def read_int(self, length: int): 23 | return int.from_bytes(self.read(length), 'big') 24 | 25 | def read_db_header(self): 26 | assert self.read(7) == b'unqlite', "Wrong file signature" 27 | assert self.read(4) == b'\xDB\x7C\x27\x12', "Wrong DB magic" 28 | creation_time = self.read_int(4) 29 | sector_size = self.read_int(4) 30 | self.page_size = self.read_int(4) 31 | assert self.read(6) == b'\x00\x04hash', "Unsupported hash" 32 | 33 | # def read_header2(self): 34 | # self.pos = self.page_size 35 | # magic_numb = self.read(4) 36 | # hash_func = self.read(4) 37 | # free_pages = self.read_int(8) 38 | # split_bucket = self.read_int(8) 39 | # max_split_bucket = self.read_int(8) 40 | # next_page = self.read_int(8) 41 | # num_rect = self.read_int(4) 42 | # for _ in range(num_rect): 43 | # logic_page = self.read_int(8) 44 | # real_page = self.read_int(8) 45 | 46 | def read_cell(self): 47 | key_hash = self.read(4) 48 | key_len = self.read_int(4) 49 | data_len = self.read_int(8) 50 | next_offset = self.read_int(2) 51 | overflow_page = self.read_int(8) 52 | if overflow_page: 53 | self.pos = overflow_page * 0x1000 + 8 54 | data_page = self.read_int(8) 55 | data_offset = self.read_int(2) 56 | name = self.read(key_len) 57 | self.pos = data_page * 0x1000 + data_offset 58 | value = self.read(data_len) 59 | else: 60 | name = self.read(key_len) 61 | value = self.read(data_len) 62 | return name, value, next_offset 63 | 64 | def read_all(self) -> dict: 65 | result = {} 66 | 67 | page_offset = 2 * self.page_size 68 | while page_offset < self.size: 69 | self.pos = page_offset 70 | next_offset = self.read_int(2) 71 | while next_offset: 72 | self.pos = page_offset + next_offset 73 | k, v, next_offset = self.read_cell() 74 | # data sometimes corrupted: b'lumi.158d0004\xb4f9abb.prop' 75 | result[k.decode(errors='replace')] = v.decode(errors='replace') 76 | page_offset += self.page_size 77 | 78 | return result 79 | 80 | 81 | class SQLite: 82 | page_size = 0 83 | pos = 0 84 | 85 | def __init__(self, raw: bytes): 86 | self.raw = raw 87 | self.read_db_header() 88 | self.tables = self.read_page(0) 89 | 90 | @property 91 | def size(self): 92 | return len(self.raw) 93 | 94 | def read(self, length: int): 95 | self.pos += length 96 | return self.raw[self.pos - length:self.pos] 97 | 98 | def read_int(self, length: int): 99 | return int.from_bytes(self.read(length), 'big') 100 | 101 | def read_varint(self): 102 | result = 0 103 | while True: 104 | i = self.read_int(1) 105 | result += i & 0x7f 106 | if i < 0x80: 107 | break 108 | result <<= 7 109 | 110 | return result 111 | 112 | def read_db_header(self): 113 | assert self.read(16) == b'SQLite format 3\0', "Wrong file signature" 114 | self.page_size = self.read_int(2) 115 | 116 | def read_page(self, page_num: int): 117 | self.pos = 100 if page_num == 0 else self.page_size * page_num 118 | 119 | # B-tree Page Header Format 120 | page_type = self.read(1) 121 | 122 | if page_type == b'\x0D': 123 | return self._read_leaf_table(page_num) 124 | elif page_type == b'\x05': 125 | return self._read_interior_table(page_num) 126 | else: 127 | raise NotImplemented 128 | 129 | def _read_leaf_table(self, page_num: int): 130 | first_block = self.read_int(2) 131 | cells_num = self.read_int(2) 132 | cells_pos = self.read_int(2) 133 | fragmented_free_bytes = self.read_int(1) 134 | 135 | cells_pos = [self.read_int(2) for _ in range(cells_num)] 136 | rows = [] 137 | 138 | for cell_pos in cells_pos: 139 | self.pos = self.page_size * page_num + cell_pos 140 | 141 | payload_len = self.read_varint() 142 | rowid = self.read_varint() 143 | 144 | columns_type = [] 145 | 146 | payload_pos = self.pos 147 | header_size = self.read_varint() 148 | while self.pos < payload_pos + header_size: 149 | column_type = self.read_varint() 150 | columns_type.append(column_type) 151 | 152 | cells = [] 153 | 154 | for column_type in columns_type: 155 | if column_type == 0: 156 | data = rowid 157 | elif 1 <= column_type <= 4: 158 | data = self.read_int(column_type) 159 | elif column_type == 5: 160 | data = self.read_int(6) 161 | elif column_type == 6: 162 | data = self.read_int(8) 163 | elif column_type == 7: 164 | # TODO: float 165 | data = self.read(8) 166 | elif column_type == 8: 167 | data = 0 168 | elif column_type == 9: 169 | data = 1 170 | elif column_type >= 12 and column_type % 2 == 0: 171 | length = int((column_type - 12) / 2) 172 | data = self.read(length) 173 | else: 174 | length = int((column_type - 13) / 2) 175 | data = self.read(length).decode() 176 | 177 | cells.append(data) 178 | 179 | rows.append(cells) 180 | 181 | return rows 182 | 183 | def _read_interior_table(self, page_num: int): 184 | first_block = self.read_int(2) 185 | cells_num = self.read_int(2) 186 | cells_pos = self.read_int(2) 187 | fragmented_free_bytes = self.read_int(1) 188 | last_page_num = self.read_int(4) 189 | 190 | cells_pos = [self.read_int(2) for _ in range(cells_num)] 191 | rows = [] 192 | 193 | for cell_pos in cells_pos: 194 | self.pos = self.page_size * page_num + cell_pos 195 | child_page_num = self.read_int(4) 196 | rowid = self.read_varint() 197 | rows += self.read_page(child_page_num - 1) 198 | 199 | return rows + self.read_page(last_page_num - 1) 200 | 201 | def read_table(self, name: str): 202 | page = next(t[3] - 1 for t in self.tables if t[1] == name) 203 | return self.read_page(page) 204 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass, field 3 | from typing import * 4 | 5 | from homeassistant.config import DATA_CUSTOMIZE 6 | from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC 7 | from homeassistant.helpers.entity import Entity 8 | 9 | from . import bluetooth, zigbee 10 | from .utils import DOMAIN, attributes_template 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | # TODO: rewrite all usage to dataclass 16 | @dataclass 17 | class XiaomiDevice: 18 | did: str # unique Xiaomi did 19 | model: str # Xiaomi model 20 | mac: str 21 | type: str # gateway, zigbee, ble, mesh 22 | online: bool 23 | 24 | lumi_spec: list 25 | miot_spec: list 26 | 27 | device_info: Dict[str, Any] 28 | 29 | extra: Dict[str, Any] = field(default_factory=dict) 30 | 31 | # all device entities except stats 32 | entities: Dict[str, 'XiaomiEntity'] = field(default_factory=dict) 33 | 34 | gateways: List['Gateway3'] = field(default_factory=list) 35 | 36 | 37 | class DevicesRegistry: 38 | """Global registry for all gateway devices. Because BLE devices updates 39 | from all gateway simultaniosly. 40 | 41 | Key - device did, `numb` for wifi and mesh devices, `lumi.ieee` for zigbee 42 | devices, `blt.3.alphanum` for ble devices, `group.numb` for mesh groups. 43 | """ 44 | devices: Dict[str, dict] = {} 45 | setups: Dict[str, Callable] = None 46 | 47 | defaults: Dict[str, dict] = {} 48 | 49 | def add_setup(self, domain: str, handler): 50 | """Add hass device setup funcion.""" 51 | self.setups[domain] = handler 52 | 53 | def add_entity(self, domain: str, device: dict, attr: str): 54 | if self not in device['gateways']: 55 | device['gateways'].append(self) 56 | 57 | if domain is None or attr in device['entities']: 58 | return 59 | 60 | # instant add entity to prevent double setup 61 | device['entities'][attr] = None 62 | 63 | self.setups[domain](self, device, attr) 64 | 65 | def set_entity(self, entity: 'XiaomiEntity'): 66 | entity.device['entities'][entity.attr] = entity 67 | 68 | def remove_entity(self, entity: 'XiaomiEntity'): 69 | entity.device['entities'].pop(entity.attr) 70 | 71 | def find_or_create_device(self, device: dict) -> dict: 72 | type_ = device['type'] 73 | did = device['did'] if type_ != 'ble' else device['mac'].lower() 74 | if did in self.devices: 75 | return self.devices[did] 76 | 77 | self.devices[did] = device 78 | 79 | # update device with specs 80 | if type_ in ('gateway', 'zigbee'): 81 | device.update(zigbee.get_device(device['model'])) 82 | elif type_ == 'mesh': 83 | device.update(bluetooth.get_device(device['model'], 'Mesh')) 84 | elif type_ == 'ble': 85 | device.update(bluetooth.get_device(device['model'], 'BLE')) 86 | 87 | model = device['model'] 88 | if model in self.defaults: 89 | device.update(self.defaults[model]) 90 | 91 | if did in self.defaults: 92 | device.update(self.defaults[did]) 93 | 94 | mac = device['mac'].lower() 95 | if did != mac and mac in self.defaults: 96 | device.update(self.defaults[mac]) 97 | 98 | device['entities'] = {} 99 | device['gateways'] = [] 100 | 101 | return device 102 | 103 | 104 | class XiaomiEntity(Entity): 105 | _ignore_offline = None 106 | _state = None 107 | 108 | def __init__(self, gateway: 'Gateway3', device: dict, attr: str): 109 | self.gw = gateway 110 | self.device = device 111 | 112 | self.attr = attr 113 | self._attrs = {} 114 | 115 | self._unique_id = f"{device.get('entity_name', device['mac'])}_{attr}" 116 | self._name = (device['device_name'] + ' ' + 117 | attr.replace('_', ' ').title()) 118 | 119 | self.entity_id = f"{DOMAIN}.{self._unique_id}" 120 | 121 | def debug(self, message: str): 122 | self.gw.debug(f"{self.entity_id} | {message}") 123 | 124 | async def async_added_to_hass(self): 125 | """Also run when rename entity_id""" 126 | custom: dict = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) 127 | self._ignore_offline = custom.get('ignore_offline') 128 | 129 | if 'init' in self.device and self._state is None: 130 | self.update(self.device['init']) 131 | 132 | self.render_attributes_template() 133 | 134 | self.gw.set_entity(self) 135 | 136 | async def async_will_remove_from_hass(self) -> None: 137 | """Also run when rename entity_id""" 138 | self.gw.remove_entity(self) 139 | 140 | # @property 141 | # def entity_registry_enabled_default(self): 142 | # return False 143 | 144 | @property 145 | def should_poll(self) -> bool: 146 | return False 147 | 148 | @property 149 | def unique_id(self): 150 | return self._unique_id 151 | 152 | @property 153 | def name(self): 154 | return self._name 155 | 156 | @property 157 | def available(self) -> bool: 158 | gw_available = any( 159 | gateway.available for gateway in self.device['gateways'] 160 | ) 161 | return gw_available and (self.device.get('online', True) or 162 | self._ignore_offline) 163 | 164 | @property 165 | def device_state_attributes(self): 166 | return self._attrs 167 | 168 | @property 169 | def device_info(self): 170 | """ 171 | https://developers.home-assistant.io/docs/device_registry_index/ 172 | """ 173 | type_ = self.device['type'] 174 | if type_ == 'gateway': 175 | return { 176 | 'connections': { 177 | (CONNECTION_NETWORK_MAC, self.device['wlan_mac']) 178 | }, 179 | 'identifiers': {(DOMAIN, self.device['mac'])}, 180 | 'manufacturer': self.device['device_manufacturer'], 181 | 'model': self.device['device_model'], 182 | 'name': self.device['device_name'], 183 | 'sw_version': self.device['fw_ver'], 184 | } 185 | elif type_ == 'zigbee': 186 | return { 187 | 'connections': {(type_, self.device['mac'])}, 188 | 'identifiers': {(DOMAIN, self.device['mac'])}, 189 | 'manufacturer': self.device.get('device_manufacturer'), 190 | 'model': self.device['device_model'], 191 | 'name': self.device['device_name'], 192 | 'sw_version': self.device.get('fw_ver'), 193 | 'via_device': (DOMAIN, self.gw.device['mac']) 194 | } 195 | else: # ble and mesh 196 | return { 197 | 'connections': {('bluetooth', self.device['mac'])}, 198 | 'identifiers': {(DOMAIN, self.device['mac'])}, 199 | 'manufacturer': self.device.get('device_manufacturer'), 200 | 'model': self.device['device_model'], 201 | 'name': self.device['device_name'], 202 | 'via_device': (DOMAIN, self.gw.device['mac']) 203 | } 204 | 205 | def update(self, data: dict): 206 | pass 207 | 208 | def render_attributes_template(self): 209 | try: 210 | attrs = attributes_template(self.hass).async_render({ 211 | 'attr': self.attr, 212 | 'device': self.device, 213 | 'gateway': self.gw.device 214 | }) 215 | if isinstance(attrs, dict): 216 | self._attrs.update(attrs) 217 | except AttributeError: 218 | pass 219 | except: 220 | _LOGGER.exception("Can't render attributes") 221 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/xiaomi_cloud.py: -------------------------------------------------------------------------------- 1 | """ 2 | The base logic was taken from project https://github.com/squachen/micloud 3 | 4 | I had to rewrite the code to work asynchronously and handle timeouts for 5 | requests to the cloud. 6 | 7 | MIT License 8 | 9 | Copyright (c) 2020 Sammy Svensson 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. 28 | """ 29 | import asyncio 30 | import base64 31 | import hashlib 32 | import hmac 33 | import json 34 | import logging 35 | import os 36 | import random 37 | import string 38 | import time 39 | 40 | from aiohttp import ClientSession 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | SERVERS = ['cn', 'de', 'i2', 'ru', 'sg', 'us'] 45 | UA = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-%s APP/xiaomi.smarthome APPV/62830" 46 | 47 | 48 | class MiCloud: 49 | auth = None 50 | 51 | def __init__(self, session: ClientSession, servers: list = None): 52 | self.session = session 53 | self.servers = servers or ['cn'] 54 | self.device_id = get_random_string(16) 55 | 56 | async def login(self, username: str, password: str): 57 | try: 58 | payload = await self._login_step1() 59 | data = await self._login_step2(username, password, payload) 60 | if not data['location']: 61 | return False 62 | 63 | token = await self._login_step3(data['location']) 64 | 65 | self.auth = { 66 | 'user_id': data['userId'], 67 | 'ssecurity': data['ssecurity'], 68 | 'service_token': token 69 | } 70 | 71 | return True 72 | 73 | except Exception as e: 74 | _LOGGER.exception(f"Can't login to Mi Cloud: {e}") 75 | return False 76 | 77 | async def _login_step1(self): 78 | r = await self.session.get( 79 | 'https://account.xiaomi.com/pass/serviceLogin', 80 | cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id}, 81 | headers={'User-Agent': UA % self.device_id}, 82 | params={'sid': 'xiaomiio', '_json': 'true'}) 83 | raw = await r.read() 84 | _LOGGER.debug(f"MiCloud step1") 85 | resp: dict = json.loads(raw[11:]) 86 | return {k: v for k, v in resp.items() 87 | if k in ('sid', 'qs', 'callback', '_sign')} 88 | 89 | async def _login_step2(self, username: str, password: str, payload: dict): 90 | payload['user'] = username 91 | payload['hash'] = hashlib.md5(password.encode()).hexdigest().upper() 92 | 93 | r = await self.session.post( 94 | 'https://account.xiaomi.com/pass/serviceLoginAuth2', 95 | cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id}, 96 | data=payload, 97 | headers={'User-Agent': UA % self.device_id}, 98 | params={'_json': 'true'}) 99 | raw = await r.read() 100 | _LOGGER.debug(f"MiCloud step2") 101 | resp = json.loads(raw[11:]) 102 | return resp 103 | 104 | async def _login_step3(self, location): 105 | r = await self.session.get(location, headers={'User-Agent': UA}) 106 | service_token = r.cookies['serviceToken'].value 107 | _LOGGER.debug(f"MiCloud step3") 108 | return service_token 109 | 110 | async def get_devices(self): 111 | payload = {'getVirtualModel': False, 'getHuamiDevices': 0} 112 | 113 | total = [] 114 | for server in self.servers: 115 | resp = await self.request(server, '/home/device_list', payload) 116 | if resp is None: 117 | return None 118 | total += resp['list'] 119 | return total 120 | 121 | async def get_rooms(self): 122 | payload = {'fg': True, 'fetch_share': True, 'limit': 300} 123 | 124 | total = [] 125 | for server in self.servers: 126 | resp = await self.request(server, '/v2/homeroom/gethome', payload) 127 | if resp is None: 128 | return None 129 | for home in resp['homelist']: 130 | total += home['roomlist'] 131 | return total 132 | 133 | async def get_bindkey(self, did: str): 134 | payload = {'did': did, 'pdid': 1} 135 | for server in self.servers: 136 | resp = await self.request(server, '/v2/device/blt_get_beaconkey', 137 | payload) 138 | if resp: 139 | return resp['beaconkey'] 140 | return None 141 | 142 | async def request(self, server: str, url: str, payload: dict): 143 | assert server in SERVERS, "Wrong server: " + server 144 | baseurl = 'https://api.io.mi.com/app' if server == 'cn' \ 145 | else f"https://{server}.api.io.mi.com/app" 146 | 147 | data = json.dumps(payload, separators=(',', ':')) 148 | 149 | nonce = gen_nonce() 150 | signed_nonce = gen_signed_nonce(self.auth['ssecurity'], nonce) 151 | signature = gen_signature(url, signed_nonce, nonce, data) 152 | 153 | try: 154 | r = await self.session.post(baseurl + url, cookies={ 155 | 'userId': self.auth['user_id'], 156 | 'serviceToken': self.auth['service_token'], 157 | 'locale': 'en_US' 158 | }, headers={ 159 | 'User-Agent': UA, 160 | 'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2' 161 | }, data={ 162 | 'signature': signature, 163 | '_nonce': nonce, 164 | 'data': data 165 | }, timeout=10) 166 | 167 | resp = await r.json(content_type=None) 168 | # _LOGGER.debug(f"Response from MIoT API {url}: {resp}") 169 | assert resp['code'] == 0, resp 170 | return resp['result'] 171 | 172 | except asyncio.TimeoutError: 173 | _LOGGER.error(f"Timeout while requesting MIoT api {url}") 174 | except: 175 | _LOGGER.exception(f"Can't request MIoT API {url}") 176 | 177 | return None 178 | 179 | 180 | def get_random_string(length: int): 181 | seq = string.ascii_uppercase + string.digits 182 | return ''.join((random.choice(seq) for _ in range(length))) 183 | 184 | 185 | def gen_nonce() -> str: 186 | """Time based nonce.""" 187 | nonce = os.urandom(8) + int(time.time() / 60).to_bytes(4, 'big') 188 | return base64.b64encode(nonce).decode() 189 | 190 | 191 | def gen_signed_nonce(ssecret: str, nonce: str) -> str: 192 | """Nonce signed with ssecret.""" 193 | m = hashlib.sha256() 194 | m.update(base64.b64decode(ssecret)) 195 | m.update(base64.b64decode(nonce)) 196 | return base64.b64encode(m.digest()).decode() 197 | 198 | 199 | def gen_signature(url: str, signed_nonce: str, nonce: str, data: str) -> str: 200 | """Request signature based on url, signed_nonce, nonce and data.""" 201 | sign = '&'.join([url, signed_nonce, nonce, 'data=' + data]) 202 | signature = hmac.new(key=base64.b64decode(signed_nonce), 203 | msg=sign.encode(), 204 | digestmod=hashlib.sha256).digest() 205 | return base64.b64encode(signature).decode() 206 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import voluptuous as vol 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import EVENT_HOMEASSISTANT_STOP 7 | from homeassistant.core import HomeAssistant, Event 8 | from homeassistant.helpers import config_validation as cv 9 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 10 | from homeassistant.helpers.entity_registry import EntityRegistry 11 | from homeassistant.helpers.storage import Store 12 | 13 | from .core.gateway3 import Gateway3 14 | from .core.helpers import DevicesRegistry 15 | from .core.utils import DOMAIN, XiaomiGateway3Debug 16 | from .core.xiaomi_cloud import MiCloud 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | DOMAINS = ['binary_sensor', 'climate', 'cover', 'light', 'remote', 'sensor', 21 | 'switch', 'alarm_control_panel'] 22 | 23 | CONF_DEVICES = 'devices' 24 | CONF_ATTRIBUTES_TEMPLATE = 'attributes_template' 25 | 26 | CONFIG_SCHEMA = vol.Schema({ 27 | DOMAIN: vol.Schema({ 28 | vol.Optional(CONF_DEVICES): { 29 | cv.string: vol.Schema({ 30 | vol.Optional('occupancy_timeout'): cv.positive_int, 31 | }, extra=vol.ALLOW_EXTRA), 32 | }, 33 | vol.Optional(CONF_ATTRIBUTES_TEMPLATE): cv.template 34 | }, extra=vol.ALLOW_EXTRA), 35 | }, extra=vol.ALLOW_EXTRA) 36 | 37 | 38 | async def async_setup(hass: HomeAssistant, hass_config: dict): 39 | config = hass_config.get(DOMAIN) or {} 40 | 41 | if 'devices' in config: 42 | for k, v in config['devices'].items(): 43 | # AA:BB:CC:DD:EE:FF => aabbccddeeff 44 | k = k.replace(':', '').lower() 45 | DevicesRegistry.defaults[k] = v 46 | 47 | hass.data[DOMAIN] = { 48 | 'debug': _LOGGER.level > 0, # default debug from Hass config 49 | CONF_ATTRIBUTES_TEMPLATE: config.get(CONF_ATTRIBUTES_TEMPLATE) 50 | } 51 | 52 | await _handle_device_remove(hass) 53 | 54 | # utils.migrate_unique_id(hass) 55 | 56 | return True 57 | 58 | 59 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 60 | """Support two kind of enties - MiCloud and Gateway.""" 61 | 62 | # entry for MiCloud login 63 | if 'servers' in entry.data: 64 | return await _setup_micloud_entry(hass, entry) 65 | 66 | # migrate data (also after first setup) to options 67 | if entry.data: 68 | hass.config_entries.async_update_entry(entry, data={}, 69 | options=entry.data) 70 | 71 | await _setup_logger(hass) 72 | 73 | # add options handler 74 | if not entry.update_listeners: 75 | entry.add_update_listener(async_update_options) 76 | 77 | hass.data[DOMAIN][entry.entry_id] = Gateway3(**entry.options) 78 | 79 | hass.async_create_task(_setup_domains(hass, entry)) 80 | 81 | return True 82 | 83 | 84 | async def async_update_options(hass: HomeAssistant, entry: ConfigEntry): 85 | await hass.config_entries.async_reload(entry.entry_id) 86 | 87 | 88 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 89 | # check unload cloud integration 90 | if entry.entry_id not in hass.data[DOMAIN]: 91 | return 92 | 93 | # remove all stats entities if disable stats 94 | if not entry.options.get('stats'): 95 | suffix = ('_gateway', '_zigbee', '_ble') 96 | registry: EntityRegistry = hass.data['entity_registry'] 97 | remove = [ 98 | entity.entity_id 99 | for entity in list(registry.entities.values()) 100 | if (entity.config_entry_id == entry.entry_id and 101 | entity.unique_id.endswith(suffix)) 102 | ] 103 | for entity_id in remove: 104 | registry.async_remove(entity_id) 105 | 106 | gw = hass.data[DOMAIN][entry.entry_id] 107 | gw.stop() 108 | 109 | await asyncio.gather(*[ 110 | hass.config_entries.async_forward_entry_unload(entry, domain) 111 | for domain in DOMAINS 112 | ]) 113 | 114 | return True 115 | 116 | 117 | async def _setup_domains(hass: HomeAssistant, entry: ConfigEntry): 118 | # init setup for each supported domains 119 | await asyncio.gather(*[ 120 | hass.config_entries.async_forward_entry_setup(entry, domain) 121 | for domain in DOMAINS 122 | ]) 123 | 124 | gw: Gateway3 = hass.data[DOMAIN][entry.entry_id] 125 | gw.start() 126 | 127 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw.stop) 128 | 129 | 130 | async def _setup_micloud_entry(hass: HomeAssistant, config_entry): 131 | data: dict = config_entry.data.copy() 132 | 133 | session = async_create_clientsession(hass) 134 | hass.data[DOMAIN]['cloud'] = cloud = MiCloud(session, data['servers']) 135 | 136 | if 'service_token' in data: 137 | # load devices with saved MiCloud auth 138 | cloud.auth = data 139 | devices = await cloud.get_devices() 140 | else: 141 | devices = None 142 | 143 | if devices is None: 144 | _LOGGER.debug(f"Login to MiCloud for {config_entry.title}") 145 | if await cloud.login(data['username'], data['password']): 146 | # update MiCloud auth in .storage 147 | data.update(cloud.auth) 148 | hass.config_entries.async_update_entry(config_entry, data=data) 149 | 150 | devices = await cloud.get_devices() 151 | if devices is None: 152 | _LOGGER.error("Can't load devices from MiCloud") 153 | 154 | else: 155 | _LOGGER.error("Can't login to MiCloud") 156 | 157 | # load devices from or save to .storage 158 | store = Store(hass, 1, f"{DOMAIN}/{data['username']}.json") 159 | if devices is None: 160 | _LOGGER.debug("Loading a list of devices from the .storage") 161 | devices = await store.async_load() 162 | else: 163 | _LOGGER.debug(f"Loaded from MiCloud {len(devices)} devices") 164 | await store.async_save(devices) 165 | 166 | if devices is None: 167 | _LOGGER.debug("No devices in .storage") 168 | return False 169 | 170 | # TODO: Think about a bunch of devices 171 | if 'devices' not in hass.data[DOMAIN]: 172 | hass.data[DOMAIN]['devices'] = devices 173 | else: 174 | hass.data[DOMAIN]['devices'] += devices 175 | 176 | for device in devices: 177 | # key - mac for BLE, and did for others 178 | did = device['did'] if device['pid'] not in '6' else \ 179 | device['mac'].replace(':', '').lower() 180 | DevicesRegistry.defaults.setdefault(did, {}) 181 | # don't override name if exists 182 | DevicesRegistry.defaults[did].setdefault('device_name', device['name']) 183 | 184 | return True 185 | 186 | 187 | async def _handle_device_remove(hass: HomeAssistant): 188 | """Remove device from Hass and Mi Home if the device is renamed to 189 | `delete`. 190 | """ 191 | 192 | async def device_registry_updated(event: Event): 193 | if event.data['action'] != 'update': 194 | return 195 | 196 | registry = hass.data['device_registry'] 197 | hass_device = registry.async_get(event.data['device_id']) 198 | 199 | # check empty identifiers 200 | if not hass_device or not hass_device.identifiers: 201 | return 202 | 203 | identifier = next(iter(hass_device.identifiers)) 204 | 205 | # handle only our devices 206 | if identifier[0] != DOMAIN or hass_device.name_by_user != 'delete': 207 | return 208 | 209 | # remove from Mi Home 210 | for gw in hass.data[DOMAIN].values(): 211 | if not isinstance(gw, Gateway3): 212 | continue 213 | gw_device = gw.get_device(identifier[1]) 214 | if not gw_device: 215 | continue 216 | gw.debug(f"Remove device: {gw_device['did']}") 217 | gw.miio.send('remove_device', [gw_device['did']]) 218 | break 219 | 220 | # remove from Hass 221 | registry.async_remove_device(hass_device.id) 222 | 223 | hass.bus.async_listen('device_registry_updated', device_registry_updated) 224 | 225 | 226 | async def _setup_logger(hass: HomeAssistant): 227 | entries = hass.config_entries.async_entries(DOMAIN) 228 | any_debug = any(e.options.get('debug') for e in entries) 229 | 230 | # only if global logging don't set 231 | if not hass.data[DOMAIN]['debug']: 232 | # disable log to console 233 | _LOGGER.propagate = not any_debug 234 | # set debug if any of integrations has debug 235 | _LOGGER.setLevel(logging.DEBUG if any_debug else logging.NOTSET) 236 | 237 | # if don't set handler yet 238 | if any_debug and not _LOGGER.handlers: 239 | handler = XiaomiGateway3Debug(hass) 240 | _LOGGER.addHandler(handler) 241 | 242 | info = await hass.helpers.system_info.async_get_system_info() 243 | info.pop('timezone') 244 | _LOGGER.debug(f"SysInfo: {info}") 245 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import random 4 | import re 5 | import string 6 | import time 7 | import uuid 8 | from datetime import datetime 9 | from functools import lru_cache 10 | from typing import List, Optional 11 | 12 | import requests 13 | from aiohttp import web 14 | from homeassistant.components.http import HomeAssistantView 15 | from homeassistant.helpers.device_registry import DeviceRegistry 16 | from homeassistant.helpers.entity_registry import EntityRegistry 17 | from homeassistant.helpers.template import Template 18 | from homeassistant.helpers.typing import HomeAssistantType 19 | from homeassistant.requirements import async_process_requirements 20 | 21 | from .mini_miio import SyncmiIO 22 | from .shell import TelnetShell 23 | from .xiaomi_cloud import MiCloud 24 | 25 | DOMAIN = 'xiaomi_gateway3' 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | def remove_device(hass: HomeAssistantType, did: str): 31 | """Remove device by did from Hass""" 32 | # lumi.1234567890 => 0x1234567890 33 | mac = '0x' + did[5:] 34 | registry: DeviceRegistry = hass.data['device_registry'] 35 | device = registry.async_get_device({('xiaomi_gateway3', mac)}, None) 36 | if device: 37 | registry.async_remove_device(device.id) 38 | 39 | 40 | def migrate_unique_id(hass: HomeAssistantType): 41 | """New unique_id format: `mac_attr`, no leading `0x`, spaces and uppercase. 42 | """ 43 | old_id = re.compile('(^0x|[ A-F])') 44 | 45 | registry: EntityRegistry = hass.data['entity_registry'] 46 | for entity in registry.entities.values(): 47 | if entity.platform != DOMAIN or not old_id.search(entity.unique_id): 48 | continue 49 | 50 | uid = entity.unique_id.replace('0x', '').replace(' ', '_').lower() 51 | registry.async_update_entity(entity.entity_id, new_unique_id=uid) 52 | 53 | 54 | # new miio adds colors to logs 55 | RE_JSON1 = re.compile(b'msg:(.+) length:([0-9]+) bytes') 56 | RE_JSON2 = re.compile(b'{.+}') 57 | 58 | 59 | def extract_jsons(raw) -> List[bytes]: 60 | """There can be multiple concatenated json on one line. And sometimes the 61 | length does not match the message.""" 62 | m = RE_JSON1.search(raw) 63 | if m: 64 | length = int(m[2]) 65 | raw = m[1][:length] 66 | else: 67 | m = RE_JSON2.search(raw) 68 | raw = m[0] 69 | return raw.replace(b'}{', b'}\n{').split(b'\n') 70 | 71 | 72 | def migrate_options(data): 73 | data = dict(data) 74 | options = {k: data.pop(k) for k in ('ble', 'zha') if k in data} 75 | return {'data': data, 'options': options} 76 | 77 | 78 | def check_mgl03(host: str, token: str, telnet_cmd: Optional[str]) \ 79 | -> Optional[str]: 80 | try: 81 | # 1. try connect with telnet (custom firmware)? 82 | shell = TelnetShell(host) 83 | # 1.1. check token with telnet 84 | return None if shell.get_token() == token else 'wrong_token' 85 | except: 86 | if not telnet_cmd: 87 | return 'cant_connect' 88 | 89 | # 2. try connect with miio 90 | miio = SyncmiIO(host, token) 91 | info = miio.info() 92 | # fw 1.4.6_0012 without cloud will respond with a blank string reply 93 | if info is None: 94 | # if device_id not None - device works but not answer on commands 95 | return 'wrong_token' if miio.device_id else 'cant_connect' 96 | 97 | # 3. check if right model 98 | if info and info['model'] != 'lumi.gateway.mgl03': 99 | return 'wrong_model' 100 | 101 | raw = json.loads(telnet_cmd) 102 | # fw 1.4.6_0043+ won't answer on cmd without cloud, so don't check answer 103 | miio.send(raw['method'], raw.get('params')) 104 | 105 | # waiting for telnet to start 106 | time.sleep(1) 107 | 108 | try: 109 | # 4. check if telnet command helps 110 | TelnetShell(host) 111 | except: 112 | return 'wrong_telnet' 113 | 114 | 115 | def get_lan_key(host: str, token: str): 116 | device = SyncmiIO(host, token) 117 | resp = device.send('get_lumi_dpf_aes_key') 118 | if resp is None: 119 | return "Can't connect to gateway" 120 | if len(resp[0]) == 16: 121 | return resp[0] 122 | key = ''.join(random.choice(string.ascii_lowercase + string.digits) 123 | for _ in range(16)) 124 | resp = device.send('set_lumi_dpf_aes_key', [key]) 125 | if resp[0] == 'ok': 126 | return key 127 | return "Can't update gateway key" 128 | 129 | 130 | async def get_room_mapping(cloud: MiCloud, host: str, token: str): 131 | try: 132 | device = SyncmiIO(host, token) 133 | local_rooms = device.send('get_room_mapping') 134 | cloud_rooms = await cloud.get_rooms() 135 | result = '' 136 | for local_id, cloud_id in local_rooms: 137 | cloud_name = next( 138 | (p['name'] for p in cloud_rooms if p['id'] == cloud_id), '-' 139 | ) 140 | result += f"\n- {local_id}: {cloud_name}" 141 | return result 142 | 143 | except: 144 | return "Can't get from cloud" 145 | 146 | 147 | async def get_bindkey(cloud: MiCloud, did: str): 148 | bindkey = await cloud.get_bindkey(did) 149 | if bindkey is None: 150 | return "Can't get from cloud" 151 | if bindkey.endswith('FFFFFFFF'): 152 | return "Not needed" 153 | return bindkey 154 | 155 | 156 | def reverse_mac(s: str): 157 | return f"{s[10:]}{s[8:10]}{s[6:8]}{s[4:6]}{s[2:4]}{s[:2]}" 158 | 159 | 160 | EZSP_URLS = { 161 | 7: 'https://master.dl.sourceforge.net/project/mgl03/zigbee/' 162 | 'ncp-uart-sw_mgl03_6_6_2_stock.gbl?viasf=1', 163 | 8: 'https://master.dl.sourceforge.net/project/mgl03/zigbee/' 164 | 'ncp-uart-sw_mgl03_6_7_8_z2m.gbl?viasf=1', 165 | } 166 | 167 | 168 | def _update_zigbee_firmware(host: str, ezsp_version: int): 169 | shell = TelnetShell(host) 170 | 171 | # stop all utilities without checking if they are running 172 | shell.stop_lumi_zigbee() 173 | shell.stop_zigbee_tcp() 174 | # flash on another port because running ZHA or z2m can breake process 175 | shell.run_zigbee_tcp(port=8889) 176 | time.sleep(.5) 177 | 178 | _LOGGER.debug(f"Try update EZSP to version {ezsp_version}") 179 | 180 | from ..util.elelabs_ezsp_utility import ElelabsUtilities 181 | 182 | config = type('', (), { 183 | 'port': (host, 8889), 184 | 'baudrate': 115200, 185 | 'dlevel': _LOGGER.level 186 | }) 187 | utils = ElelabsUtilities(config, _LOGGER) 188 | 189 | # check current ezsp version 190 | resp = utils.probe() 191 | _LOGGER.debug(f"EZSP before flash: {resp}") 192 | if resp[0] == 0 and resp[1] == ezsp_version: 193 | return True 194 | 195 | url = EZSP_URLS[ezsp_version] 196 | r = requests.get(url) 197 | 198 | resp = utils.flash(r.content) 199 | _LOGGER.debug(f"EZSP after flash: {resp}") 200 | return resp[0] == 0 and resp[1] == ezsp_version 201 | 202 | 203 | async def update_zigbee_firmware(hass: HomeAssistantType, host: str, 204 | ezsp_version: int): 205 | await async_process_requirements(hass, DOMAIN, ['xmodem==0.4.6']) 206 | 207 | return await hass.async_add_executor_job( 208 | _update_zigbee_firmware, host, ezsp_version 209 | ) 210 | 211 | 212 | @lru_cache(maxsize=0) 213 | def attributes_template(hass: HomeAssistantType) -> Template: 214 | template = hass.data[DOMAIN]['attributes_template'] 215 | template.hass = hass 216 | return template 217 | 218 | 219 | TITLE = "Xiaomi Gateway 3 Debug" 220 | NOTIFY_TEXT = 'Open Log' 221 | HTML = (f'{TITLE}' 222 | '' 223 | '
%s
') 224 | 225 | 226 | class XiaomiGateway3Debug(logging.Handler, HomeAssistantView): 227 | name = "xiaomi_debug" 228 | requires_auth = False 229 | 230 | # https://waymoot.org/home/python_string/ 231 | text = [] 232 | 233 | def __init__(self, hass: HomeAssistantType): 234 | super().__init__() 235 | 236 | # random url because without authorization!!! 237 | self.url = f"/{uuid.uuid4()}" 238 | 239 | hass.http.register_view(self) 240 | hass.components.persistent_notification.async_create( 241 | NOTIFY_TEXT % self.url, title=TITLE) 242 | 243 | def handle(self, rec: logging.LogRecord) -> None: 244 | dt = datetime.fromtimestamp(rec.created).strftime("%Y-%m-%d %H:%M:%S") 245 | module = 'main' if rec.module == '__init__' else rec.module 246 | self.text.append(f"{dt} {rec.levelname:7} {module:12} {rec.msg}") 247 | 248 | async def get(self, request: web.Request): 249 | try: 250 | if 'c' in request.query: 251 | self.text.clear() 252 | 253 | if 'q' in request.query or 't' in request.query: 254 | lines = self.text 255 | 256 | if 'q' in request.query: 257 | reg = re.compile(fr"({request.query['q']})", re.IGNORECASE) 258 | lines = [p for p in self.text if reg.search(p)] 259 | 260 | if 't' in request.query: 261 | tail = int(request.query['t']) 262 | lines = lines[-tail:] 263 | 264 | body = '\n'.join(lines) 265 | else: 266 | body = '\n'.join(self.text[:10000]) 267 | 268 | reload = request.query.get('r', '') 269 | return web.Response(text=HTML % (reload, body), 270 | content_type="text/html") 271 | 272 | except: 273 | return web.Response(status=500) 274 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/light.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | 4 | from homeassistant.components.light import LightEntity, SUPPORT_BRIGHTNESS, \ 5 | ATTR_BRIGHTNESS, SUPPORT_COLOR_TEMP, ATTR_COLOR_TEMP, ATTR_TRANSITION 6 | from homeassistant.config import DATA_CUSTOMIZE 7 | from homeassistant.util import color 8 | 9 | from . import DOMAIN 10 | from .core.gateway3 import Gateway3 11 | from .core.helpers import XiaomiEntity 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | CONF_DEFAULT_TRANSITION = 'default_transition' 16 | 17 | 18 | async def async_setup_entry(hass, config_entry, async_add_entities): 19 | def setup(gateway: Gateway3, device: dict, attr: str): 20 | if device['type'] == 'zigbee': 21 | async_add_entities([XiaomiZigbeeLight(gateway, device, attr)]) 22 | elif 'childs' in device: 23 | async_add_entities([XiaomiMeshGroup(gateway, device, attr)]) 24 | else: 25 | async_add_entities([XiaomiMeshLight(gateway, device, attr)], True) 26 | 27 | gw: Gateway3 = hass.data[DOMAIN][config_entry.entry_id] 28 | gw.add_setup('light', setup) 29 | 30 | 31 | class XiaomiZigbeeLight(XiaomiEntity, LightEntity): 32 | _brightness = None 33 | _color_temp = None 34 | 35 | @property 36 | def is_on(self) -> bool: 37 | return self._state 38 | 39 | @property 40 | def brightness(self): 41 | """Return the brightness of this light between 0..255.""" 42 | return self._brightness 43 | 44 | @property 45 | def color_temp(self): 46 | return self._color_temp 47 | 48 | @property 49 | def supported_features(self): 50 | return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP 51 | 52 | def update(self, data: dict = None): 53 | if self.attr in data: 54 | self._state = bool(data[self.attr]) 55 | # sometimes brightness and color_temp stored as string in Xiaomi DB 56 | if 'brightness' in data: 57 | self._brightness = int(data['brightness']) / 100.0 * 255.0 58 | if 'color_temp' in data: 59 | self._color_temp = int(data['color_temp']) 60 | 61 | self.schedule_update_ha_state() 62 | 63 | def turn_on(self, **kwargs): 64 | if ATTR_TRANSITION not in kwargs: 65 | custom = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) 66 | if CONF_DEFAULT_TRANSITION in custom: 67 | kwargs[ATTR_TRANSITION] = custom[CONF_DEFAULT_TRANSITION] 68 | 69 | # transition works only with raw zigbee commands 70 | # nwk empty for new device, it reloads only after restart integration 71 | if ATTR_TRANSITION in kwargs and 'nwk' in self.device: 72 | # is the amount of time, in tenths of a second 73 | tr = int(kwargs[ATTR_TRANSITION] * 10.0) 74 | commands = [] 75 | 76 | # if only turn_on with transition restore last brightness 77 | if ATTR_BRIGHTNESS not in kwargs and ATTR_COLOR_TEMP not in kwargs: 78 | kwargs[ATTR_BRIGHTNESS] = self.brightness or 255 79 | 80 | if ATTR_BRIGHTNESS in kwargs: 81 | br = int(kwargs[ATTR_BRIGHTNESS]) 82 | commands += [ 83 | f"zcl level-control o-mv-to-level {br} {tr}", 84 | f"send 0x{self.device['nwk']} 1 1" 85 | ] 86 | 87 | if ATTR_COLOR_TEMP in kwargs: 88 | ct = int(kwargs[ATTR_COLOR_TEMP]) 89 | commands += [ 90 | f"zcl color-control movetocolortemp {ct} {tr} 0 0", 91 | f"send 0x{self.device['nwk']} 1 1" 92 | ] 93 | 94 | self.gw.send_zigbee_cli(commands) 95 | return 96 | 97 | payload = {} 98 | 99 | if ATTR_BRIGHTNESS in kwargs: 100 | payload['brightness'] = \ 101 | int(kwargs[ATTR_BRIGHTNESS] / 255.0 * 100.0) 102 | 103 | if ATTR_COLOR_TEMP in kwargs: 104 | payload['color_temp'] = kwargs[ATTR_COLOR_TEMP] 105 | 106 | if not payload: 107 | payload[self.attr] = 1 108 | 109 | self.gw.send(self.device, payload) 110 | 111 | def turn_off(self, **kwargs): 112 | if ATTR_TRANSITION not in kwargs: 113 | custom = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) 114 | if CONF_DEFAULT_TRANSITION in custom: 115 | kwargs[ATTR_TRANSITION] = custom[CONF_DEFAULT_TRANSITION] 116 | 117 | # transition works only with raw zigbee commands 118 | if ATTR_TRANSITION in kwargs and 'nwk' in self.device: 119 | # is the amount of time, in tenths of a second 120 | tr = int(kwargs[ATTR_TRANSITION] * 10.0) 121 | commands = [ 122 | f"zcl level-control o-mv-to-level 0 {tr}", 123 | f"send 0x{self.device['nwk']} 1 1" 124 | ] 125 | self.gw.send_zigbee_cli(commands) 126 | return 127 | 128 | self.gw.send(self.device, {self.attr: 0}) 129 | 130 | 131 | class XiaomiMeshLight(XiaomiEntity, LightEntity): 132 | _brightness = None 133 | _max_brightness = 65535 134 | _color_temp = None 135 | _min_mireds = int(1000000 / 6500) 136 | _max_mireds = int(1000000 / 2700) 137 | 138 | @property 139 | def should_poll(self) -> bool: 140 | return False 141 | 142 | @property 143 | def is_on(self) -> bool: 144 | return self._state 145 | 146 | @property 147 | def brightness(self): 148 | """Return the brightness of this light between 0..255.""" 149 | return self._brightness 150 | 151 | @property 152 | def color_temp(self): 153 | return self._color_temp 154 | 155 | @property 156 | def min_mireds(self): 157 | return self._min_mireds 158 | 159 | @property 160 | def max_mireds(self): 161 | return self._max_mireds 162 | 163 | @property 164 | def supported_features(self): 165 | return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP 166 | 167 | async def async_added_to_hass(self): 168 | await super().async_added_to_hass() 169 | 170 | color_temp = self.device.get('color_temp') 171 | if color_temp: 172 | self._min_mireds = math.floor(1000000 / color_temp[1]) 173 | self._max_mireds = math.ceil(1000000 / color_temp[0]) 174 | max_brightness = self.device.get('max_brightness') 175 | if max_brightness: 176 | self._max_brightness = max_brightness 177 | 178 | def update(self, data: dict = None): 179 | if data is None: 180 | self.gw.mesh_force_update() 181 | return 182 | 183 | if self.attr in data: 184 | # handle main attribute as online state 185 | if data[self.attr] is not None: 186 | self._state = bool(data[self.attr]) 187 | self.device['online'] = True 188 | else: 189 | self.device['online'] = False 190 | 191 | if 'brightness' in data and data['brightness'] is not None: 192 | # 0...65535 193 | self._brightness = \ 194 | data['brightness'] * 255.0 / self._max_brightness 195 | if 'color_temp' in data and data['color_temp']: 196 | # 2700..6500 => 370..153 197 | self._color_temp = \ 198 | color.color_temperature_kelvin_to_mired(data['color_temp']) 199 | 200 | self.schedule_update_ha_state() 201 | 202 | def turn_on(self, **kwargs): 203 | # instantly change the HA state, and after 2 seconds check the actual 204 | # state of the lamp (optimistic change state) 205 | payload = {} 206 | 207 | if ATTR_BRIGHTNESS in kwargs: 208 | self._brightness = kwargs[ATTR_BRIGHTNESS] 209 | payload['brightness'] = \ 210 | int(self._brightness / 255.0 * self._max_brightness) 211 | 212 | if ATTR_COLOR_TEMP in kwargs: 213 | self._color_temp = kwargs[ATTR_COLOR_TEMP] 214 | if self._color_temp < self._min_mireds: 215 | self._color_temp = self._min_mireds 216 | if self._color_temp > self._max_mireds: 217 | self._color_temp = self._max_mireds 218 | payload['color_temp'] = \ 219 | color.color_temperature_mired_to_kelvin(self._color_temp) 220 | 221 | if not payload: 222 | payload[self.attr] = True 223 | 224 | self._state = True 225 | 226 | self.gw.send_mesh(self.device, payload) 227 | 228 | self.schedule_update_ha_state() 229 | 230 | def turn_off(self): 231 | # instantly change the HA state, and after 2 seconds check the actual 232 | # state of the lamp (optimistic change state) 233 | self._state = False 234 | 235 | self.gw.send_mesh(self.device, {self.attr: False}) 236 | 237 | self.schedule_update_ha_state() 238 | 239 | 240 | class XiaomiMeshGroup(XiaomiMeshLight): 241 | async def async_added_to_hass(self): 242 | await super().async_added_to_hass() 243 | 244 | if 'childs' in self.device: 245 | # add group to child bulb entities for processing update 246 | for did in self.device['childs']: 247 | self.gw.devices[did]['entities']['group'] = self 248 | 249 | async def async_will_remove_from_hass(self) -> None: 250 | await super().async_will_remove_from_hass() 251 | 252 | if 'childs' in self.device: 253 | for did in self.device['childs']: 254 | self.gw.devices[did]['entities'].pop('group') 255 | 256 | @property 257 | def should_poll(self): 258 | return False 259 | 260 | @property 261 | def icon(self): 262 | return 'mdi:lightbulb-group' 263 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from datetime import timedelta 4 | 5 | from homeassistant.const import * 6 | from homeassistant.util.dt import now 7 | 8 | from . import DOMAIN 9 | from .core import zigbee, utils 10 | from .core.gateway3 import Gateway3 11 | from .core.helpers import XiaomiEntity 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | UNITS = { 16 | DEVICE_CLASS_BATTERY: '%', 17 | DEVICE_CLASS_HUMIDITY: '%', 18 | DEVICE_CLASS_ILLUMINANCE: 'lx', # zb light and motion and ble flower - lux 19 | DEVICE_CLASS_POWER: POWER_WATT, 20 | DEVICE_CLASS_PRESSURE: 'hPa', 21 | DEVICE_CLASS_TEMPERATURE: TEMP_CELSIUS, 22 | 'conductivity': "µS/cm", 23 | 'consumption': ENERGY_WATT_HOUR, 24 | 'gas density': '% LEL', 25 | 'supply': '%', 26 | 'smoke density': '% obs/ft', 27 | 'moisture': '%', 28 | 'tvoc': CONCENTRATION_PARTS_PER_BILLION, 29 | # 'link_quality': 'lqi', 30 | # 'rssi': 'dBm', 31 | # 'msg_received': 'msg', 32 | # 'msg_missed': 'msg', 33 | # 'unresponsive': 'times' 34 | } 35 | 36 | ICONS = { 37 | 'conductivity': 'mdi:flower', 38 | 'consumption': 'mdi:flash', 39 | 'gas density': 'mdi:google-circles-communities', 40 | 'moisture': 'mdi:water-percent', 41 | 'smoke density': 'mdi:google-circles-communities', 42 | 'gateway': 'mdi:router-wireless', 43 | 'zigbee': 'mdi:zigbee', 44 | 'ble': 'mdi:bluetooth', 45 | 'tvoc': 'mdi:cloud', 46 | } 47 | 48 | INFO = ['ieee', 'nwk', 'msg_received', 'msg_missed', 'unresponsive', 49 | 'link_quality', 'rssi', 'last_seen'] 50 | 51 | 52 | async def async_setup_entry(hass, entry, add_entities): 53 | def setup(gateway: Gateway3, device: dict, attr: str): 54 | if attr == 'action': 55 | add_entities([XiaomiAction(gateway, device, attr)]) 56 | elif attr == 'gateway': 57 | add_entities([GatewayStats(gateway, device, attr)]) 58 | elif attr == 'zigbee': 59 | add_entities([ZigbeeStats(gateway, device, attr)]) 60 | elif attr == 'ble': 61 | add_entities([BLEStats(gateway, device, attr)]) 62 | else: 63 | add_entities([XiaomiSensor(gateway, device, attr)]) 64 | 65 | gw: Gateway3 = hass.data[DOMAIN][entry.entry_id] 66 | gw.add_setup('sensor', setup) 67 | 68 | 69 | class XiaomiSensor(XiaomiEntity): 70 | @property 71 | def state(self): 72 | return self._state 73 | 74 | @property 75 | def device_class(self): 76 | return self.attr 77 | 78 | @property 79 | def unit_of_measurement(self): 80 | return UNITS.get(self.attr) 81 | 82 | @property 83 | def icon(self): 84 | return ICONS.get(self.attr) 85 | 86 | def update(self, data: dict = None): 87 | if self.attr in data: 88 | self._state = data[self.attr] 89 | self.schedule_update_ha_state() 90 | 91 | 92 | class GatewayStats(XiaomiSensor): 93 | @property 94 | def device_class(self): 95 | # don't use const to support older Hass version 96 | return 'timestamp' 97 | 98 | @property 99 | def available(self): 100 | return True 101 | 102 | async def async_added_to_hass(self): 103 | self.gw.set_stats(self.device['did'], self) 104 | # update available when added to Hass 105 | self.update() 106 | 107 | async def async_will_remove_from_hass(self) -> None: 108 | self.gw.remove_stats(self.device['did'], self) 109 | 110 | def update(self, data: dict = None): 111 | # empty data - update state to available time 112 | if not data: 113 | self._state = now().isoformat(timespec='seconds') \ 114 | if self.gw.available else None 115 | else: 116 | self._attrs.update(data) 117 | 118 | self.schedule_update_ha_state() 119 | 120 | 121 | class ZigbeeStats(XiaomiSensor): 122 | last_seq1 = None 123 | last_seq2 = None 124 | 125 | @property 126 | def device_class(self): 127 | # don't use const to support older Hass version 128 | return 'timestamp' 129 | 130 | @property 131 | def available(self): 132 | return True 133 | 134 | async def async_added_to_hass(self): 135 | if not self._attrs: 136 | ieee = '0x' + self.device['did'][5:].rjust(16, '0').upper() 137 | self._attrs = { 138 | 'ieee': ieee, 139 | 'nwk': None, 140 | 'msg_received': 0, 141 | 'msg_missed': 0, 142 | 'unresponsive': 0, 143 | 'last_missed': 0, 144 | } 145 | self.render_attributes_template() 146 | 147 | self.gw.set_stats(self._attrs['ieee'], self) 148 | 149 | async def async_will_remove_from_hass(self) -> None: 150 | self.gw.remove_stats(self._attrs['ieee'], self) 151 | 152 | def update(self, data: dict = None): 153 | if 'sourceAddress' in data: 154 | self._attrs['nwk'] = data['sourceAddress'] 155 | self._attrs['link_quality'] = data['linkQuality'] 156 | self._attrs['rssi'] = data['rssi'] 157 | 158 | cid = int(data['clusterId'], 0) 159 | self._attrs['last_msg'] = cluster = zigbee.CLUSTERS.get(cid, cid) 160 | 161 | self._attrs['msg_received'] += 1 162 | 163 | # For some devices better works APSCounter, for other - sequence 164 | # number in payload. Sometimes broken messages arrived. 165 | try: 166 | new_seq1 = int(data['APSCounter'], 0) 167 | raw = data['APSPlayload'] 168 | manufact_spec = int(raw[2:4], 16) & 4 169 | new_seq2 = int(raw[8:10] if manufact_spec else raw[4:6], 16) 170 | if self.last_seq1 is not None: 171 | miss = min( 172 | (new_seq1 - self.last_seq1 - 1) & 0xFF, 173 | (new_seq2 - self.last_seq2 - 1) & 0xFF 174 | ) 175 | self._attrs['msg_missed'] += miss 176 | self._attrs['last_missed'] = miss 177 | if miss: 178 | self.debug( 179 | f"Msg missed: {self.last_seq1} => {new_seq1}, " 180 | f"{self.last_seq2} => {new_seq2}, {cluster}" 181 | ) 182 | self.last_seq1 = new_seq1 183 | self.last_seq2 = new_seq2 184 | 185 | except: 186 | pass 187 | 188 | self._state = now().isoformat(timespec='seconds') 189 | 190 | elif 'parent' in data: 191 | ago = timedelta(seconds=data.pop('ago')) 192 | self._state = (now() - ago).isoformat(timespec='seconds') 193 | self._attrs.update(data) 194 | 195 | elif data.get('deviceState') == 17: 196 | self._attrs['unresponsive'] += 1 197 | 198 | self.schedule_update_ha_state() 199 | 200 | 201 | class BLEStats(XiaomiSensor): 202 | @property 203 | def device_class(self): 204 | # don't use const to support older Hass version 205 | return 'timestamp' 206 | 207 | @property 208 | def available(self): 209 | return True 210 | 211 | async def async_added_to_hass(self): 212 | if not self._attrs: 213 | self._attrs = { 214 | 'mac': self.device['mac'], 215 | 'msg_received': 0, 216 | } 217 | self.render_attributes_template() 218 | 219 | self.gw.set_stats(self.device['mac'], self) 220 | 221 | async def async_will_remove_from_hass(self) -> None: 222 | self.gw.remove_stats(self.device['mac'], self) 223 | 224 | def update(self, data: dict = None): 225 | self._attrs['msg_received'] += 1 226 | self._state = now().isoformat(timespec='seconds') 227 | self.schedule_update_ha_state() 228 | 229 | 230 | # https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/converters/fromZigbee.js#L4738 231 | BUTTON = { 232 | 1: 'single', 233 | 2: 'double', 234 | 3: 'triple', 235 | 4: 'quadruple', 236 | 5: 'quintuple', # only Yeelight Dimmer 237 | 16: 'hold', 238 | 17: 'release', 239 | 18: 'shake', 240 | 128: 'many', 241 | } 242 | 243 | BUTTON_BOTH = { 244 | 4: 'single', 245 | 5: 'double', 246 | 6: 'triple', 247 | 16: 'hold', 248 | 17: 'release', 249 | } 250 | 251 | VIBRATION = { 252 | 1: 'vibration', 253 | 2: 'tilt', 254 | 3: 'drop', 255 | } 256 | 257 | 258 | class XiaomiAction(XiaomiEntity): 259 | _state = '' 260 | _action_attrs = None 261 | 262 | @property 263 | def state(self): 264 | return self._state 265 | 266 | @property 267 | def icon(self): 268 | return 'mdi:bell' 269 | 270 | @property 271 | def device_state_attributes(self): 272 | return self._action_attrs or self._attrs 273 | 274 | def update(self, data: dict = None): 275 | for k, v in data.items(): 276 | if k == 'button': 277 | # fix 1.4.7_0115 heartbeat error (has button in heartbeat) 278 | if 'battery' in data: 279 | return 280 | data[self.attr] = BUTTON.get(v, 'unknown') 281 | break 282 | elif k.startswith('button_both'): 283 | data[self.attr] = k + '_' + BUTTON_BOTH.get(v, 'unknown') 284 | break 285 | elif k.startswith('button'): 286 | data[self.attr] = k + '_' + BUTTON.get(v, 'unknown') 287 | break 288 | elif k == 'vibration' and v != 2: # skip tilt and wait tilt_angle 289 | data[self.attr] = VIBRATION.get(v, 'unknown') 290 | break 291 | elif k == 'tilt_angle': 292 | data = {'vibration': 2, 'angle': v, self.attr: 'tilt'} 293 | break 294 | 295 | if self.attr in data: 296 | self._action_attrs = {**self._attrs, **data} 297 | self._state = data[self.attr] 298 | self.schedule_update_ha_state() 299 | 300 | # repeat event from Aqara integration 301 | self.hass.bus.fire('xiaomi_aqara.click', { 302 | 'entity_id': self.entity_id, 'click_type': self._state 303 | }) 304 | 305 | time.sleep(.3) 306 | 307 | self._state = '' 308 | 309 | self.schedule_update_ha_state() 310 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/shell.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import re 4 | import time 5 | from socket import socket, AF_INET, SOCK_DGRAM 6 | from telnetlib import Telnet 7 | from typing import Union 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | # We should use HTTP-link because wget don't support HTTPS and curl removed in 12 | # lastest fw. But it's not a problem because we check md5 13 | 14 | # original link http://pkg.musl.cc/socat/mipsel-linux-musln32/bin/socat 15 | # original link https://busybox.net/downloads/binaries/1.21.1/busybox-mipsel 16 | WGET = "(wget http://master.dl.sourceforge.net/project/mgl03/{0}?viasf=1 " \ 17 | "-O /data/{1} && chmod +x /data/{1})" 18 | 19 | RUN_ZIGBEE_TCP = "/data/socat tcp-l:%d,reuseaddr,fork,keepalive,nodelay," \ 20 | "keepidle=1,keepintvl=1,keepcnt=5 /dev/ttyS2,raw,echo=0 &" 21 | 22 | LOCK_FIRMWARE = "/data/busybox chattr +i " 23 | UNLOCK_FIRMWARE = "/data/busybox chattr -i " 24 | RUN_FTP = "/data/busybox tcpsvd -E 0.0.0.0 21 /data/busybox ftpd -w &" 25 | 26 | # use awk because buffer 27 | MIIO_147 = "miio_client -l 0 -o FILE_STORE -n 128 -d /data/miio" 28 | MIIO_146 = "miio_client -l 4 -d /data/miio" 29 | MIIO2MQTT = " | awk '/%s/{print $0;fflush()}' | mosquitto_pub -t log/miio -l &" 30 | 31 | RE_VERSION = re.compile(r'version=([0-9._]+)') 32 | 33 | FIRMWARE_PATHS = ('/data/firmware.bin', '/data/firmware/firmware_ota.bin') 34 | 35 | TAR_DATA = b"tar -czOC /data basic_app basic_gw conf factory miio " \ 36 | b"mijia_automation silicon_zigbee_host zigbee zigbee_gw " \ 37 | b"ble_info miioconfig.db 2>/dev/null | base64\n" 38 | 39 | MD5_BT = { 40 | '1.4.6_0012': '367bf0045d00c28f6bff8d4132b883de', 41 | '1.4.6_0043': 'c4fa99797438f21d0ae4a6c855b720d2', 42 | '1.4.7_0115': 'be4724fbc5223fcde60aff7f58ffea28', 43 | '1.4.7_0160': '9290241cd9f1892d2ba84074f07391d4', 44 | '1.5.0_0026': '9290241cd9f1892d2ba84074f07391d4', 45 | } 46 | MD5_BUSYBOX = '099137899ece96f311ac5ab554ea6fec' 47 | # MD5_GW3 = 'c81b91816d4b9ad9bb271a5567e36ce9' # alpha 48 | MD5_SOCAT = '92b77e1a93c4f4377b4b751a5390d979' 49 | 50 | 51 | class TelnetShell(Telnet): 52 | def __init__(self, host: str): 53 | super().__init__(host, timeout=3) 54 | 55 | self.read_until(b"login: ", timeout=3) 56 | # some users have problems with \r\n symbols in login 57 | self.write(b"admin\n") 58 | 59 | raw = self.read_until(b"\r\n# ", timeout=3) 60 | if b'Password:' in raw: 61 | raise Exception("Telnet with password don't supported") 62 | 63 | self.ver = self.get_version() 64 | 65 | def exec(self, command: str, as_bytes=False) -> Union[str, bytes]: 66 | """Run command and return it result.""" 67 | self.write(command.encode() + b"\n") 68 | raw = self.read_until(b"\r\n# ", timeout=10) 69 | return raw if as_bytes else raw.decode() 70 | 71 | def check_bin(self, filename: str, md5: str, url=None) -> bool: 72 | """Check binary md5 and download it if needed.""" 73 | if md5 in self.exec("md5sum /data/" + filename): 74 | return True 75 | elif url: 76 | self.exec(WGET.format(url, filename)) 77 | return self.check_bin(filename, md5) 78 | else: 79 | return False 80 | 81 | # def check_gw3(self): 82 | # return self.check_bin('gw3', MD5_GW3) 83 | 84 | # def run_gw3(self, params=''): 85 | # if self.check_bin('gw3', MD5_GW3, 'gw3/' + MD5_GW3): 86 | # self.exec(f"/data/gw3 {params}&") 87 | 88 | # def stop_gw3(self): 89 | # self.exec(f"killall gw3") 90 | 91 | def run_zigbee_tcp(self, port=8888): 92 | if self.check_bin('socat', MD5_SOCAT, 'bin/socat'): 93 | self.exec(RUN_ZIGBEE_TCP % port) 94 | 95 | def stop_zigbee_tcp(self): 96 | # stop both 8888 and 8889 97 | self.exec("pkill -f 'tcp-l:888'") 98 | 99 | def run_lumi_zigbee(self): 100 | self.exec("daemon_app.sh &") 101 | 102 | def stop_lumi_zigbee(self): 103 | self.exec("killall daemon_app.sh") 104 | self.exec("killall Lumi_Z3GatewayHost_MQTT") 105 | 106 | def check_firmware_lock(self) -> bool: 107 | """Check if firmware update locked. And create empty file if needed.""" 108 | self.exec("mkdir -p /data/firmware") 109 | locked = [ 110 | "Permission denied" in self.exec("touch " + path) 111 | for path in FIRMWARE_PATHS 112 | ] 113 | return all(locked) 114 | 115 | def lock_firmware(self, enable: bool): 116 | if self.check_bin('busybox', MD5_BUSYBOX, 'bin/busybox'): 117 | command = LOCK_FIRMWARE if enable else UNLOCK_FIRMWARE 118 | for path in FIRMWARE_PATHS: 119 | self.exec(command + path) 120 | 121 | def run_ftp(self): 122 | if self.check_bin('busybox', MD5_BUSYBOX, 'bin/busybox'): 123 | self.exec(RUN_FTP) 124 | 125 | def check_bt(self) -> bool: 126 | md5 = MD5_BT.get(self.ver) 127 | if not md5: 128 | return False 129 | # we use same name for bt utis so gw can kill it in case of update etc. 130 | return self.check_bin('silabs_ncp_bt', md5, md5 + '/silabs_ncp_bt') 131 | 132 | def run_bt(self): 133 | self.exec( 134 | "killall silabs_ncp_bt; pkill -f log/ble; " 135 | "/data/silabs_ncp_bt /dev/ttyS1 1 2>&1 >/dev/null | " 136 | "mosquitto_pub -t log/ble -l &" 137 | ) 138 | 139 | def sniff_bluetooth(self): 140 | """Deprecated""" 141 | self.write(b"killall silabs_ncp_bt; silabs_ncp_bt /dev/ttyS1 1\n") 142 | 143 | def run_public_mosquitto(self): 144 | self.exec("killall mosquitto") 145 | time.sleep(.5) 146 | self.exec("mosquitto -d") 147 | time.sleep(.5) 148 | # fix CPU 90% full time bug 149 | self.exec("killall zigbee_gw") 150 | 151 | def run_ntpd(self): 152 | self.exec("ntpd -l") 153 | 154 | def get_running_ps(self) -> str: 155 | return self.exec("ps -w") 156 | 157 | def redirect_miio2mqtt(self, pattern: str): 158 | self.exec("killall daemon_miio.sh") 159 | self.exec("miio_client; pkill -f log/miio") 160 | time.sleep(.5) 161 | cmd = MIIO_147 if self.ver >= '1.4.7_0063' else MIIO_146 162 | self.exec(cmd + MIIO2MQTT % pattern) 163 | self.exec("daemon_miio.sh &") 164 | 165 | def run_public_zb_console(self): 166 | # Z3 starts with tail on old fw and without it on new fw from 1.4.7 167 | self.exec("killall daemon_app.sh") 168 | self.exec("tail Lumi_Z3GatewayHost_MQTT") 169 | 170 | # run Gateway with open console port (`-v` param) 171 | arg = " -r 'c'" if self.ver >= '1.4.7_0063' else '' 172 | 173 | # use `tail` because input for Z3 is required; 174 | # add `-l 0` to disable all output, we'll enable it later with 175 | # `debugprint on 1` command 176 | self.exec( 177 | "nohup tail -f /dev/null 2>&1 | " 178 | "nohup Lumi_Z3GatewayHost_MQTT -n 1 -b 115200 -l 0 " 179 | f"-p '/dev/ttyS2' -d '/data/silicon_zigbee_host/'{arg} 2>&1 | " 180 | "mosquitto_pub -t log/z3 -l &" 181 | ) 182 | 183 | self.exec("daemon_app.sh &") 184 | 185 | def read_file(self, filename: str, as_base64=False): 186 | if as_base64: 187 | self.write(f"cat {filename} | base64\n".encode()) 188 | self.read_until(b"\r\n", timeout=3) # skip command 189 | raw = self.read_until(b"# ", timeout=10) 190 | # b"cat: can't open ..." 191 | return base64.b64decode(raw) 192 | else: 193 | self.write(f"cat {filename}\n".encode()) 194 | self.read_until(b"\r\n", timeout=3) # skip command 195 | return self.read_until(b"# ", timeout=10)[:-2] 196 | 197 | def tar_data(self): 198 | self.write(TAR_DATA) 199 | self.read_until(b"base64\r\n", timeout=3) # skip command 200 | raw = self.read_until(b"# ", timeout=30) 201 | return base64.b64decode(raw) 202 | 203 | def run_buzzer(self): 204 | self.exec("kill $(ps | grep dummy:basic_gw | awk '{print $1}')") 205 | 206 | def stop_buzzer(self): 207 | self.exec("killall daemon_miio.sh; killall -9 basic_gw") 208 | # run dummy process with same str in it 209 | self.exec("sh -c 'sleep 999d' dummy:basic_gw &") 210 | self.exec("daemon_miio.sh &") 211 | 212 | def get_version(self): 213 | raw = self.read_file('/etc/rootfs_fw_info') 214 | m = RE_VERSION.search(raw.decode()) 215 | return m[1] 216 | 217 | def get_token(self): 218 | return self.read_file('/data/miio/device.token').rstrip().hex() 219 | 220 | def get_did(self): 221 | raw = self.read_file('/data/miio/device.conf').decode() 222 | m = re.search(r'did=(\d+)', raw) 223 | return m[1] 224 | 225 | def get_wlan_mac(self) -> str: 226 | raw = self.read_file('/sys/class/net/wlan0/address') 227 | 228 | return raw.decode().rstrip().upper() 229 | 230 | @property 231 | def mesh_group_table(self) -> str: 232 | if self.ver >= '1.4.7_0160': 233 | return 'mesh_group_v3' 234 | elif self.ver >= '1.4.6_0043': 235 | return 'mesh_group_v1' 236 | else: 237 | return 'mesh_group' 238 | 239 | @property 240 | def mesh_device_table(self) -> str: 241 | if self.ver >= '1.4.7_0160': 242 | return 'mesh_device_v3' 243 | else: 244 | return 'mesh_device' 245 | 246 | @property 247 | def zigbee_db(self) -> str: 248 | # https://github.com/AlexxIT/XiaomiGateway3/issues/14 249 | # fw 1.4.6_0012 and below have one zigbee_gw.db file 250 | # fw 1.4.6_0030 have many json files in this folder 251 | return '/data/zigbee_gw/*.json' if self.ver >= '1.4.6_0030' \ 252 | else '/data/zigbee_gw/zigbee_gw.db' 253 | 254 | 255 | NTP_DELTA = 2208988800 # 1970-01-01 00:00:00 256 | NTP_QUERY = b'\x1b' + 47 * b'\0' 257 | 258 | 259 | def ntp_time(host: str) -> float: 260 | """Return server send time""" 261 | try: 262 | sock = socket(AF_INET, SOCK_DGRAM) 263 | sock.settimeout(2) 264 | 265 | sock.sendto(NTP_QUERY, (host, 123)) 266 | raw = sock.recv(1024) 267 | 268 | integ = int.from_bytes(raw[-8:-4], 'big') 269 | fract = int.from_bytes(raw[-4:], 'big') 270 | return integ + float(fract) / 2 ** 32 - NTP_DELTA 271 | 272 | except: 273 | return 0 274 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/mini_miio.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import hashlib 3 | import json 4 | import logging 5 | import random 6 | import socket 7 | import time 8 | from asyncio.protocols import BaseProtocol 9 | from asyncio.transports import DatagramTransport 10 | from typing import Union, Optional 11 | 12 | from cryptography.hazmat.backends import default_backend 13 | from cryptography.hazmat.primitives import padding 14 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | HELLO = bytes.fromhex( 19 | "21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" 20 | ) 21 | 22 | 23 | class BasemiIO: 24 | """A simple class that implements the miIO protocol.""" 25 | device_id = None 26 | delta_ts = None 27 | 28 | def __init__(self, host: str, token: str): 29 | self.addr = (host, 54321) 30 | self.token = bytes.fromhex(token) 31 | 32 | key = hashlib.md5(self.token).digest() 33 | iv = hashlib.md5(key + self.token).digest() 34 | self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv), 35 | backend=default_backend()) 36 | 37 | def _encrypt(self, plaintext: bytes): 38 | padder = padding.PKCS7(128).padder() 39 | padded_plaintext = padder.update(plaintext) + padder.finalize() 40 | 41 | encryptor = self.cipher.encryptor() 42 | return encryptor.update(padded_plaintext) + encryptor.finalize() 43 | 44 | def _decrypt(self, ciphertext: bytes): 45 | decryptor = self.cipher.decryptor() 46 | padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize() 47 | 48 | unpadder = padding.PKCS7(128).unpadder() 49 | return unpadder.update(padded_plaintext) + unpadder.finalize() 50 | 51 | def _pack_raw(self, msg_id: int, method: str, 52 | params: Union[dict, list] = None): 53 | # latest zero unnecessary 54 | payload = json.dumps({ 55 | 'id': msg_id, 56 | 'method': method, 'params': params or [] 57 | }, separators=(',', ':')).encode() + b'\x00' 58 | 59 | data = self._encrypt(payload) 60 | 61 | raw = b'\x21\x31' 62 | raw += (32 + len(data)).to_bytes(2, 'big') # total length 63 | raw += b'\x00\x00\x00\x00' # unknow 64 | raw += self.device_id.to_bytes(4, 'big') 65 | raw += int(time.time() - self.delta_ts).to_bytes(4, 'big') 66 | 67 | raw += hashlib.md5(raw + self.token + data).digest() 68 | raw += data 69 | 70 | assert len(raw) < 1024, "Exceeded message size" 71 | 72 | return raw 73 | 74 | def _unpack_raw(self, raw: bytes): 75 | assert raw[:2] == b'\x21\x31' 76 | # length = int.from_bytes(raw[2:4], 'big') 77 | # unknown = raw[4:8] 78 | # device_id = int.from_bytes(raw[8:12], 'big') 79 | # ts = int.from_bytes(raw[12:16], 'big') 80 | # checksum = raw[16:32] 81 | return self._decrypt(raw[32:]) 82 | 83 | 84 | class SyncmiIO(BasemiIO): 85 | """Synchronous miIO protocol.""" 86 | 87 | def __init__(self, host: str, token: str, timeout: float = 3): 88 | super().__init__(host, token) 89 | self.debug = False 90 | self.timeout = timeout 91 | 92 | def ping(self, sock: socket) -> bool: 93 | """Returns `true` if the connection to the miio device is working. The 94 | token is not verified at this stage. 95 | """ 96 | try: 97 | sock.sendto(HELLO, self.addr) 98 | raw = sock.recv(1024) 99 | if raw[:2] == b'\x21\x31': 100 | self.device_id = int.from_bytes(raw[8:12], 'big') 101 | self.delta_ts = time.time() - int.from_bytes(raw[12:16], 'big') 102 | return True 103 | except: 104 | pass 105 | return False 106 | 107 | def send(self, method: str, params: Union[dict, list] = None): 108 | """Send command to miIO device and get result from it. Params can be 109 | dict or list depend on command. 110 | """ 111 | pings = 0 112 | for times in range(1, 4): 113 | try: 114 | # create socket every time for reset connection, because we can 115 | # reseive answer on previous request or request from another 116 | # thread 117 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 118 | sock.settimeout(self.timeout) 119 | 120 | # need device_id for send command, can get it from ping cmd 121 | if self.delta_ts is None and not self.ping(sock): 122 | pings += 1 123 | continue 124 | 125 | # pack each time for new message id 126 | msg_id = random.randint(100000000, 999999999) 127 | raw_send = self._pack_raw(msg_id, method, params) 128 | t = time.monotonic() 129 | sock.sendto(raw_send, self.addr) 130 | # can receive more than 1024 bytes (1056 approximate maximum) 131 | raw_recv = sock.recv(10240) 132 | t = time.monotonic() - t 133 | data = self._unpack_raw(raw_recv).rstrip(b'\x00') 134 | 135 | if data == b'': 136 | # mgl03 fw 1.4.6_0012 without Internet respond on miIO.info 137 | # command with empty answer 138 | data = {'result': ''} 139 | break 140 | 141 | data = json.loads(data) 142 | # check if we received response for our cmd 143 | if data['id'] == msg_id: 144 | break 145 | 146 | _LOGGER.debug(f"{self.addr[0]} | wrong ID") 147 | 148 | except socket.timeout: 149 | _LOGGER.debug(f"{self.addr[0]} | timeout {times}") 150 | except Exception as e: 151 | _LOGGER.debug(f"{self.addr[0]} | exception {e}") 152 | 153 | # init ping again 154 | self.delta_ts = None 155 | 156 | else: 157 | _LOGGER.warning( 158 | f"{self.addr[0]} | Device offline" 159 | if pings >= 2 else 160 | f"{self.addr[0]} | Can't send {method} {params}" 161 | ) 162 | return None 163 | 164 | if self.debug: 165 | _LOGGER.debug( 166 | f"{self.addr[0]} | Send {method} {len(raw_send)}B, " 167 | f"recv {len(raw_recv)}B in {t:.1f} sec and {times} try" 168 | ) 169 | 170 | if 'result' in data: 171 | return data['result'] 172 | else: 173 | _LOGGER.debug(f"{self.addr[0]} | {data}") 174 | return None 175 | 176 | def send_bulk(self, method: str, params: list): 177 | """Sends a command with a large number of parameters. Splits into 178 | multiple requests when the size of one request is exceeded. 179 | """ 180 | try: 181 | result = [] 182 | # Chunk of 15 is seems like the best size. Because request should 183 | # be lower than 1024 and response should be not more than 1056. 184 | # {'did':'1234567890','siid': 2,'piid': 1,'value': False,'code': 0} 185 | for i in range(0, len(params), 15): 186 | result += self.send(method, params[i:i + 15]) 187 | return result 188 | except: 189 | return None 190 | 191 | def info(self) -> Union[dict, str, None]: 192 | """Get info about miIO device. 193 | 194 | Response dict - device ok, token ok 195 | Response empty string - device ok, token ok (mgl03 on fw 1.4.6_0012 196 | without cloud connection) 197 | Response None, device_id not None - device ok, token wrong 198 | Response None, device_id None - device offline 199 | """ 200 | return self.send('miIO.info') 201 | 202 | 203 | # noinspection PyMethodMayBeStatic,PyTypeChecker 204 | class AsyncmiIO(BasemiIO, BaseProtocol): 205 | response = None 206 | sock: Optional[DatagramTransport] = None 207 | 208 | def datagram_received(self, data: bytes, addr): 209 | # hello message 210 | if len(data) == 32: 211 | if data[:2] == b'\x21\x31': 212 | self.device_id = int.from_bytes(data[8:12], 'big') 213 | ts = int.from_bytes(data[12:16], 'big') 214 | self.delta_ts = time.time() - ts 215 | result = True 216 | else: 217 | result = False 218 | else: 219 | result = self._unpack_raw(data) 220 | 221 | self.response.set_result(result) 222 | 223 | def error_received(self, exc): 224 | print('Error received:', exc) 225 | 226 | def connection_lost(self, exc): 227 | print("Connection closed") 228 | self.sock = None 229 | 230 | async def send_raw(self, data: bytes): 231 | loop = asyncio.get_running_loop() 232 | if not self.sock: 233 | self.sock, _ = await loop.create_datagram_endpoint( 234 | lambda: self, remote_addr=self.addr) 235 | 236 | self.response = loop.create_future() 237 | # this method does not block 238 | self.sock.sendto(data) 239 | return await self.response 240 | 241 | async def ping(self): 242 | return await self.send_raw(HELLO) 243 | 244 | async def send(self, method: str, params: Union[dict, list] = None): 245 | """Send command to miIO device and get result from it. Params can be 246 | dict or list depend on command. 247 | """ 248 | if not self.device_id and not await self.ping(): 249 | return None 250 | 251 | try: 252 | raw = self._pack_raw(method, params) 253 | data = await self.send_raw(raw) 254 | return json.loads(data.rstrip(b'\x00'))['result'] 255 | except Exception as e: 256 | _LOGGER.warning(f"Can't send: {e}") 257 | return None 258 | 259 | async def send_bulk(self, method: str, params: list): 260 | """Sends a command with a large number of parameters. Splits into 261 | multiple requests when the size of one request is exceeded. 262 | """ 263 | try: 264 | result = [] 265 | for i in range(0, len(params), 15): 266 | result += await self.send(method, params[i:i + 15]) 267 | return result 268 | except: 269 | return None 270 | 271 | async def info(self): 272 | """Get info about miIO device.""" 273 | return await self.send('miIO.info') 274 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import homeassistant.helpers.config_validation as cv 4 | import voluptuous as vol 5 | from homeassistant.config_entries import ConfigFlow, OptionsFlow, ConfigEntry 6 | from homeassistant.core import callback 7 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 8 | 9 | from . import DOMAIN 10 | from .core import utils 11 | from .core.gateway3 import TELNET_CMD 12 | from .core.xiaomi_cloud import MiCloud 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | ACTIONS = { 17 | 'cloud': "Add Mi Cloud Account", 18 | 'token': "Add Gateway using Token" 19 | } 20 | 21 | SERVERS = { 22 | 'cn': "China", 23 | 'de': "Europe", 24 | 'i2': "India", 25 | 'ru': "Russia", 26 | 'sg': "Singapore", 27 | 'us': "United States" 28 | } 29 | 30 | OPT_DEBUG = { 31 | 'true': "Basic logs", 32 | 'miio': "miIO logs", 33 | 'mqtt': "MQTT logs" 34 | } 35 | OPT_PARENT = { 36 | -1: "Disabled", 0: "Manually", 60: "Hourly" 37 | } 38 | OPT_MODE = { 39 | False: "Mi Home", 40 | True: "Zigbee Home Automation (ZHA)", 41 | 'z2m': "zigbee2mqtt" 42 | } 43 | 44 | 45 | class XiaomiGateway3FlowHandler(ConfigFlow, domain=DOMAIN): 46 | cloud = None 47 | 48 | async def async_step_user(self, user_input=None): 49 | if user_input is not None: 50 | if user_input['action'] == 'cloud': 51 | return await self.async_step_cloud() 52 | elif user_input['action'] == 'token': 53 | return await self.async_step_token() 54 | else: 55 | device = next(d for d in self.hass.data[DOMAIN]['devices'] 56 | if d['did'] == user_input['action']) 57 | return self.async_show_form( 58 | step_id='token', 59 | data_schema=vol.Schema({ 60 | vol.Required('host', default=device['localip']): str, 61 | vol.Required('token', default=device['token']): str, 62 | vol.Required('telnet_cmd', default=TELNET_CMD): str, 63 | }), 64 | ) 65 | 66 | if DOMAIN in self.hass.data and 'devices' in self.hass.data[DOMAIN]: 67 | for device in self.hass.data[DOMAIN]['devices']: 68 | if (device['model'] == 'lumi.gateway.mgl03' and 69 | device['did'] not in ACTIONS): 70 | name = f"Add {device['name']} ({device['localip']})" 71 | ACTIONS[device['did']] = name 72 | 73 | return self.async_show_form( 74 | step_id='user', 75 | data_schema=vol.Schema({ 76 | vol.Required('action', default='cloud'): vol.In(ACTIONS) 77 | }) 78 | ) 79 | 80 | async def async_step_cloud(self, user_input=None, error=None): 81 | if user_input: 82 | if not user_input['servers']: 83 | return await self.async_step_cloud(error='no_servers') 84 | 85 | session = async_create_clientsession(self.hass) 86 | cloud = MiCloud(session) 87 | if await cloud.login(user_input['username'], 88 | user_input['password']): 89 | user_input.update(cloud.auth) 90 | return self.async_create_entry(title=user_input['username'], 91 | data=user_input) 92 | 93 | else: 94 | return await self.async_step_cloud(error='cant_login') 95 | 96 | return self.async_show_form( 97 | step_id='cloud', 98 | data_schema=vol.Schema({ 99 | vol.Required('username'): str, 100 | vol.Required('password'): str, 101 | vol.Required('servers', default=['cn']): 102 | cv.multi_select(SERVERS) 103 | }), 104 | errors={'base': error} if error else None 105 | ) 106 | 107 | async def async_step_token(self, user_input=None, error=None): 108 | """GUI > Configuration > Integrations > Plus > Xiaomi Gateway 3""" 109 | if user_input is not None: 110 | error = utils.check_mgl03(**user_input) 111 | if error: 112 | return await self.async_step_token(error=error) 113 | 114 | return self.async_create_entry(title=user_input['host'], 115 | data=user_input) 116 | 117 | return self.async_show_form( 118 | step_id='token', 119 | data_schema=vol.Schema({ 120 | vol.Required('host'): str, 121 | vol.Required('token'): str, 122 | vol.Required('telnet_cmd', default=TELNET_CMD): str, 123 | }), 124 | errors={'base': error} if error else None 125 | ) 126 | 127 | @staticmethod 128 | @callback 129 | def async_get_options_flow(entry: ConfigEntry): 130 | return OptionsFlowHandler(entry) 131 | 132 | 133 | TITLE = "Xiaomi Gateway 3" 134 | 135 | ZHA_NOTIFICATION = """Please create manually 136 | 137 | Integration: **Zigbee Home Automation** 138 | Radio Type: **EZSP** 139 | Path: `socket://%s:8888` 140 | Speed: `115200`""" 141 | 142 | Z2M_NOTIFICATION = """Add to your zigbee2mqtt config 143 | 144 | ``` 145 | serial: 146 | port: 'tcp://%s:8888' 147 | adapter: ezsp 148 | ``` 149 | """ 150 | 151 | 152 | class OptionsFlowHandler(OptionsFlow): 153 | def __init__(self, entry: ConfigEntry): 154 | self.entry = entry 155 | 156 | async def async_step_init(self, user_input=None): 157 | if 'servers' in self.entry.data: 158 | return await self.async_step_cloud() 159 | else: 160 | return await self.async_step_user() 161 | 162 | async def async_step_cloud(self, user_input=None): 163 | if user_input is not None: 164 | did = user_input['did'] 165 | device = next(d for d in self.hass.data[DOMAIN]['devices'] 166 | if d['did'] == did) 167 | 168 | if device['pid'] != '6': 169 | device_info = ( 170 | f"Name: {device['name']}\n" 171 | f"Model: {device['model']}\n" 172 | f"IP: {device['localip']}\n" 173 | f"MAC: {device['mac']}\n" 174 | f"Token: {device['token']}" 175 | ) 176 | else: 177 | bindkey = await utils.get_bindkey( 178 | self.hass.data[DOMAIN]['cloud'], did 179 | ) 180 | device_info = ( 181 | f"Name: {device['name']}\n" 182 | f"Model: {device['model']}\n" 183 | f"MAC: {device['mac']}\n" 184 | f"Bindkey: {bindkey}\n" 185 | ) 186 | 187 | if device['model'] == 'lumi.gateway.v3': 188 | device_info += "\nLAN key: " + utils.get_lan_key( 189 | device['localip'], device['token'] 190 | ) 191 | elif '.vacuum.' in device['model']: 192 | device_info += "\nRooms: " + await utils.get_room_mapping( 193 | self.hass.data[DOMAIN]['cloud'], 194 | device['localip'], device['token'], 195 | ) 196 | 197 | elif not self.hass.data[DOMAIN].get('devices'): 198 | device_info = "No devices in account" 199 | else: 200 | device_info = "Choose a device from the list" 201 | 202 | devices = {} 203 | for device in self.hass.data[DOMAIN].get('devices', []): 204 | # 0 - wifi, 6 - ble, 8 - wifi+ble 205 | if device['pid'] in ('0', '8'): 206 | info = device['localip'] 207 | elif device['pid'] == '6': 208 | info = 'BLE' 209 | else: 210 | continue 211 | devices[device['did']] = f"{device['name']} ({info})" 212 | 213 | return self.async_show_form( 214 | step_id="cloud", 215 | data_schema=vol.Schema({ 216 | vol.Required('did'): vol.In(devices) 217 | }), 218 | description_placeholders={ 219 | 'device_info': device_info 220 | } 221 | ) 222 | 223 | async def async_step_user(self, user_input=None): 224 | if user_input: 225 | old_mode = self.entry.options.get('zha', False) 226 | new_mode = user_input['zha'] 227 | if new_mode != old_mode: 228 | host = user_input['host'] 229 | 230 | # change zigbee firmware if needed 231 | if new_mode in (False, 'z2m'): 232 | ezsp_version = 8 if new_mode else 7 233 | if not await utils.update_zigbee_firmware( 234 | self.hass, host, ezsp_version 235 | ): 236 | raise Exception("Can't update zigbee firmware") 237 | 238 | if new_mode is True: 239 | self.hass.components.persistent_notification.async_create( 240 | ZHA_NOTIFICATION % host, TITLE 241 | ) 242 | elif new_mode == 'z2m': 243 | self.hass.components.persistent_notification.async_create( 244 | Z2M_NOTIFICATION % host, TITLE 245 | ) 246 | 247 | return self.async_create_entry(title='', data=user_input) 248 | 249 | host = self.entry.options['host'] 250 | token = self.entry.options['token'] 251 | telnet_cmd = self.entry.options.get('telnet_cmd', '') 252 | ble = self.entry.options.get('ble', True) 253 | stats = self.entry.options.get('stats', False) 254 | debug = self.entry.options.get('debug', []) 255 | buzzer = self.entry.options.get('buzzer', False) 256 | parent = self.entry.options.get('parent', -1) 257 | zha = self.entry.options.get('zha', False) 258 | 259 | return self.async_show_form( 260 | step_id="user", 261 | data_schema=vol.Schema({ 262 | vol.Required('host', default=host): str, 263 | vol.Required('token', default=token): str, 264 | vol.Optional('telnet_cmd', default=telnet_cmd): str, 265 | vol.Required('ble', default=ble): bool, 266 | vol.Required('stats', default=stats): bool, 267 | vol.Optional('debug', default=debug): cv.multi_select( 268 | OPT_DEBUG 269 | ), 270 | vol.Optional('buzzer', default=buzzer): bool, 271 | vol.Optional('parent', default=parent): vol.In(OPT_PARENT), 272 | vol.Required('zha', default=zha): vol.In(OPT_MODE), 273 | }), 274 | ) 275 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/bluetooth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | # Bluetooth Model: [Manufacturer, Device Name, Device Model] 5 | # params: [siid, piid, hass attr name, hass domain] 6 | DEVICES = [{ 7 | # MiBeacon from official support 8 | 152: ["Xiaomi", "Flower Care", "HHCCJCY01"], 9 | 349: ["Xiaomi", "Flower Pot", "HHCCPOT002"], 10 | 426: ["Xiaomi", "TH Sensor", "LYWSDCGQ/01ZM"], 11 | 794: ["Xiaomi", "Door Lock", "MJZNMS02LM"], 12 | 839: ["Xiaomi", "Qingping TH Sensor", "CGG1"], 13 | 903: ["Xiaomi", "ZenMeasure TH", "MHO-C401"], 14 | 982: ["Xiaomi", "Qingping Door Sensor", "CGH1"], 15 | 1034: ["Xiaomi", "Mosquito Repellent", "WX08ZM"], 16 | 1115: ["Xiaomi", "TH Clock", "LYWSD02MMC"], 17 | 1161: ["Xiaomi", "Toothbrush T500", "MES601"], 18 | 1249: ["Xiaomi", "Magic Cube", "XMMF01JQD"], 19 | 1371: ["Xiaomi", "TH Sensor 2", "LYWSD03MMC"], 20 | 1398: ["Xiaomi", "Alarm Clock", "CGD1"], 21 | 1694: ["Aqara", "Door Lock N100", "ZNMS16LM"], 22 | 1695: ["Aqara", "Door Lock N200", "ZNMS17LM"], 23 | 1747: ["Xiaomi", "ZenMeasure Clock", "MHO-C303"], 24 | 1983: ["Yeelight", "Button S1", "YLAI003"], 25 | 2038: ["Xiaomi", "Night Light 2", "MJYD02YL-A"], # 15,4103,4106,4119,4120 26 | 2147: ["Xiaomi", "Water Leak Sensor", "SJWS01LM"], 27 | 2443: ["Xiaomi", "Door Sensor 2", "MCCGQ02HL"], 28 | 2444: ["Xiaomi", "Door Lock", "XMZNMST02YD"], 29 | 2480: ["Xiaomi", "Safe Box", "BGX-5/X1-3001"], 30 | 2691: ["Xiaomi", "Qingping Motion Sensor", "CGPR1"], 31 | # logs: https://github.com/AlexxIT/XiaomiGateway3/issues/180 32 | 2701: ["Xiaomi", "Motion Sensor 2", "RTCGQ02LM"], # 15,4119,4120 33 | 2888: ["Xiaomi", "Qingping TH Sensor", "CGG1"], # same model as 839?! 34 | }, { 35 | # Mesh Light 36 | 0: ["Xiaomi", "Mesh Group", "Mesh Group"], 37 | 948: ["Yeelight", "Mesh Downlight", "YLSD01YL"], 38 | 995: ["Yeelight", "Mesh Bulb E14", "YLDP09YL"], 39 | 996: ["Yeelight", "Mesh Bulb E27", "YLDP10YL"], 40 | 997: ["Yeelight", "Mesh Spotlight", "YLSD04YL"], 41 | 1771: ["Xiaomi", "Mesh Bulb", "MJDP09YL"], 42 | 1772: ["Xiaomi", "Mesh Downlight", "MJTS01YL/MJTS003"], 43 | 2076: ["Yeelight", "Mesh Downlight M2", "YLTS02YL/YLTS04YL"], 44 | 2293: ["Unknown", "Mesh Lightstrip (RF ready)", "2293"], 45 | 2342: ["Yeelight", "Mesh Bulb M2", "YLDP25YL/YLDP26YL"], 46 | 2584: ["XinGuang", "XinGuang Smart Light", "LIBMDA09X"], 47 | 3164: ["Unknown", "Mesh Downlight (RF ready)", "3164"], 48 | 3416: ["Unknown", "Mesh Downlight (Yeelight compatible)", "3416"], 49 | 'miot_spec': [ 50 | [2, 1, 'light', 'light'], 51 | [2, 2, 'brightness', None], 52 | [2, 3, 'color_temp', None], 53 | ] 54 | }, { 55 | # Mesh Switches 56 | 1946: ["Xiaomi", "Mesh Wall Double Switch", "DHKG02ZM"], 57 | 'miot_spec': [ 58 | [2, 1, 'left_switch', 'switch'], 59 | [3, 1, 'right_switch', 'switch'], 60 | ] 61 | }, { 62 | 1945: ["Xiaomi", "Mesh Wall Switch", "DHKG01ZM"], 63 | 2007: ["Unknown", "Mesh Switch Controller"], 64 | 'miot_spec': [ 65 | [2, 1, 'switch', 'switch'] 66 | ], 67 | }, { 68 | 2093: ["PTX", "Mesh Wall Triple Switch", "PTX-TK3/M"], 69 | 3878: ["PTX", "Mesh Wall Triple Switch", "PTX-SK3M"], 70 | 'miot_spec': [ 71 | [2, 1, 'left_switch', 'switch'], 72 | [3, 1, 'middle_switch', 'switch'], 73 | [4, 1, 'right_switch', 'switch'], 74 | [8, 1, 'backlight', 'switch'], 75 | [8, 2, 'left_smart', 'switch'], 76 | [8, 3, 'middle_smart', 'switch'], 77 | [8, 4, 'right_smart', 'switch'] 78 | ] 79 | }, { 80 | 2257: ["PTX", "Mesh Wall Double Switch", "PTX-SK2M"], 81 | 'miot_spec': [ 82 | [2, 1, 'left_switch', 'switch'], 83 | [3, 1, 'right_switch', 'switch'], 84 | [8, 1, 'backlight', 'switch'], 85 | [8, 2, 'left_smart', 'switch'], 86 | [8, 3, 'right_smart', 'switch'], 87 | ] 88 | }, { 89 | 2258: ["PTX", "Mesh Wall Single Switch", "PTX-SK1M"], 90 | 'miot_spec': [ 91 | [2, 1, 'switch', 'switch'], 92 | [8, 1, 'backlight', 'switch'], 93 | [8, 2, 'smart', 'switch'], 94 | ] 95 | }, { 96 | 2717: ["Xiaomi", "Mesh Wall Triple Switch", "ZNKG03HL/ISA-KG03HL"], 97 | 'miot_spec': [ 98 | [2, 1, 'left_switch', 'switch'], 99 | [3, 1, 'middle_switch', 'switch'], 100 | [4, 1, 'right_switch', 'switch'], 101 | [6, 1, 'humidity', 'sensor'], 102 | [6, 7, 'temperature', 'sensor'], 103 | ] 104 | }, { 105 | 3083: ["Xiaomi", "Mi Smart Electrical Outlet", "ZNCZ01ZM"], 106 | 'miot_spec': [ 107 | [2, 1, 'outlet', 'switch'], 108 | [3, 1, 'power', 'sensor'], 109 | [4, 1, 'backlight', 'switch'], 110 | ] 111 | }] 112 | 113 | # if color temp not default 2700..6500 114 | COLOR_TEMP = { 115 | 2584: [3000, 6400], 116 | } 117 | # if max brightness not default 65535 118 | MAX_BRIGHTNESS = { 119 | 2293: 100, 120 | 2584: 100, 121 | 3164: 100, 122 | 3416: 100, 123 | } 124 | 125 | BLE_FINGERPRINT_ACTION = [ 126 | "Match successful", "Match failed", "Timeout", "Low quality", 127 | "Insufficient area", "Skin is too dry", "Skin is too wet" 128 | ] 129 | BLE_DOOR_ACTION = [ 130 | "Door is open", "Door is closed", "Timeout is not closed", 131 | "Knock on the door", "Breaking the door", "Door is stuck" 132 | ] 133 | BLE_LOCK_ACTION = { 134 | 0b0000: "Unlock outside the door", 135 | 0b0001: "Lock", 136 | 0b0010: "Turn on anti-lock", 137 | 0b0011: "Turn off anti-lock", 138 | 0b0100: "Unlock inside the door", 139 | 0b0101: "Lock inside the door", 140 | 0b0110: "Turn on child lock", 141 | 0b0111: "Turn off child lock", 142 | 0b1111: None 143 | } 144 | BLE_LOCK_METHOD = { 145 | 0b0000: "bluetooth", 146 | 0b0001: "password", 147 | 0b0010: "biological", 148 | 0b0011: "key", 149 | 0b0100: "turntable", 150 | 0b0101: "nfc", 151 | 0b0110: "one-time password", 152 | 0b0111: "two-step verification", 153 | 0b1000: "coercion", 154 | 0b1010: "manual", 155 | 0b1011: "automatic", 156 | 0b1111: None 157 | } 158 | BLE_LOCK_ERROR = { 159 | 0xC0DE0000: "Frequent unlocking with incorrect password", 160 | 0xC0DE0001: "Frequent unlocking with wrong fingerprints", 161 | 0xC0DE0002: "Operation timeout (password input timeout)", 162 | 0xC0DE0003: "Lock picking", 163 | 0xC0DE0004: "Reset button is pressed", 164 | 0xC0DE0005: "The wrong key is frequently unlocked", 165 | 0xC0DE0006: "Foreign body in the keyhole", 166 | 0xC0DE0007: "The key has not been taken out", 167 | 0xC0DE0008: "Error NFC frequently unlocks", 168 | 0xC0DE0009: "Timeout is not locked as required", 169 | 0xC0DE000A: "Failure to unlock frequently in multiple ways", 170 | 0xC0DE000B: "Unlocking the face frequently fails", 171 | 0xC0DE000C: "Failure to unlock the vein frequently", 172 | 0xC0DE000D: "Hijacking alarm", 173 | 0xC0DE000E: "Unlock inside the door after arming", 174 | 0xC0DE000F: "Palmprints frequently fail to unlock", 175 | 0xC0DE0010: "The safe was moved", 176 | 0xC0DE1000: "The battery level is less than 10%", 177 | 0xC0DE1001: "The battery is less than 5%", 178 | 0xC0DE1002: "The fingerprint sensor is abnormal", 179 | 0xC0DE1003: "The accessory battery is low", 180 | 0xC0DE1004: "Mechanical failure", 181 | } 182 | 183 | ACTIONS = { 184 | 1249: {0: 'right', 1: 'left'}, 185 | 1983: {0: 'single', 0x010000: 'double', 0x020000: 'hold'}, 186 | 2147: {0: 'single'}, 187 | } 188 | 189 | 190 | def get_ble_domain(param: str) -> Optional[str]: 191 | if param in ( 192 | 'sleep', 'lock', 'opening', 'water_leak', 'smoke', 'gas', 'light', 193 | 'contact', 'motion', 'power'): 194 | return 'binary_sensor' 195 | 196 | elif param in ( 197 | 'action', 'rssi', 'temperature', 'humidity', 'illuminance', 198 | 'moisture', 'conductivity', 'battery', 'formaldehyde', 199 | 'supply', 'idle_time'): 200 | return 'sensor' 201 | 202 | return None 203 | 204 | 205 | def parse_xiaomi_ble(event: dict, pdid: int) -> Optional[dict]: 206 | """Parse Xiaomi BLE Data 207 | https://iot.mi.com/new/doc/embedded-development/ble/object-definition 208 | """ 209 | eid = event['eid'] 210 | data = bytes.fromhex(event['edata']) 211 | length = len(data) 212 | 213 | if eid == 0x1001 and length == 3: # 4097 214 | value = int.from_bytes(data, 'little') 215 | return { 216 | 'action': ACTIONS[pdid][value] 217 | if pdid in ACTIONS and value in ACTIONS[pdid] 218 | else value 219 | } 220 | 221 | elif eid == 0x1002 and length == 1: # 4098 222 | # No sleep (0x00), falling asleep (0x01) 223 | return {'sleep': data[0]} # 1 => true 224 | 225 | elif eid == 0x1003 and length == 1: # 4099 226 | # Signal strength value 227 | return {'rssi': data[0]} 228 | 229 | elif eid == 0x1004 and length == 2: # 4100 230 | return { 231 | 'temperature': int.from_bytes(data, 'little', signed=True) / 10.0 232 | } 233 | 234 | elif eid == 0x1005 and length == 2: # 4101 235 | # Kettle, thanks https://github.com/custom-components/ble_monitor/ 236 | return {'power': data[0], 'temperature': data[1]} 237 | 238 | elif eid == 0x1006 and length == 2: # 4102 239 | # Humidity percentage, ranging from 0-1000 240 | value = int.from_bytes(data, 'little') / 10.0 241 | if pdid in (903, 1371): 242 | # two models has bug, they increase humidity on each data by 0.1 243 | value = int(value) 244 | return {'humidity': value} 245 | 246 | elif eid == 0x1007 and length == 3: # 4103 247 | value = int.from_bytes(data, 'little') 248 | 249 | if pdid == 2038: 250 | # Night Light 2: 1 - no light, 100 - light 251 | return {'light': int(value >= 100)} 252 | 253 | # Range: 0-120000, lux 254 | return {'illuminance': value} 255 | 256 | elif eid == 0x1008 and length == 1: # 4104 257 | # Humidity percentage, range: 0-100 258 | return {'moisture': data[0]} 259 | 260 | elif eid == 0x1009 and length == 2: # 4105 261 | # Soil EC value, Unit us/cm, range: 0-5000 262 | return {'conductivity': int.from_bytes(data, 'little')} 263 | 264 | elif eid == 0x100A: # 4106 265 | # TODO: lock timestamp 266 | return {'battery': data[0]} 267 | 268 | elif eid == 0x100D and length == 4: # 4109 269 | return { 270 | 'temperature': int.from_bytes(data[:2], 'little', 271 | signed=True) / 10.0, 272 | 'humidity': int.from_bytes(data[2:], 'little') / 10.0 273 | } 274 | 275 | elif eid == 0x100E and length == 1: # 4110 276 | # 1 => true => on => unlocked 277 | # 0x00: unlock state (all bolts retracted) 278 | # TODO: other values 279 | return {'lock': 1 if data[0] == 0 else 0} 280 | 281 | elif eid == 0x100F and length == 1: # 4111 282 | # 1 => true => on => dooor opening 283 | return {'opening': 1 if data[0] == 0 else 0} 284 | 285 | elif eid == 0x1010 and length == 2: # 4112 286 | return {'formaldehyde': int.from_bytes(data, 'little') / 100.0} 287 | 288 | elif eid == 0x1012 and length == 1: # 4114 289 | return {'opening': data[0]} # 1 => true => open 290 | 291 | elif eid == 0x1013 and length == 1: # 4115 292 | # Remaining percentage, range 0~100 293 | return {'supply': data[0]} 294 | 295 | elif eid == 0x1014 and length == 1: # 4116 296 | return {'water_leak': data[0]} # 1 => on => wet 297 | 298 | elif eid == 0x1015 and length == 1: # 4117 299 | # TODO: equipment failure (0x02) 300 | return {'smoke': data[0]} # 1 => on => alarm 301 | 302 | elif eid == 0x1016 and length == 1: # 4118 303 | return {'gas': data[0]} # 1 => on => alarm 304 | 305 | elif eid == 0x1017 and length == 4: # 4119 306 | # The duration of the unmanned state, in seconds 307 | return {'idle_time': int.from_bytes(data, 'little')} 308 | 309 | elif eid == 0x1018 and length == 1: # 4120 310 | # Door Sensor 2: 0 - dark, 1 - light 311 | return {'light': 1 if data[0] else 0} 312 | 313 | elif eid == 0x1019 and length == 1: # 4121 314 | # 0x00: open the door, 0x01: close the door, 315 | # 0x02: not closed after timeout, 0x03: device reset 316 | # 1 => true => open 317 | if data[0] == 0: 318 | return {'contact': 1} 319 | elif data[0] == 1: 320 | return {'contact': 0} 321 | else: 322 | return {} 323 | 324 | elif eid == 0x0006 and len(data) == 5: 325 | action = int.from_bytes(data[4:], 'little') 326 | # status, action, state 327 | return { 328 | 'action': 'fingerprint', 329 | 'action_id': action, 330 | 'key_id': hex(int.from_bytes(data[:4], 'little')), 331 | 'message': BLE_FINGERPRINT_ACTION[action] 332 | } 333 | 334 | elif eid == 0x0007: 335 | # TODO: lock timestamp 336 | return { 337 | 'action': 'door', 338 | 'action_id': data[0], 339 | 'message': BLE_DOOR_ACTION[data[0]] 340 | } 341 | 342 | elif eid == 0x0008: 343 | # TODO: lock timestamp 344 | return { 345 | 'action': 'armed', 346 | 'state': bool(data[0]) 347 | } 348 | 349 | elif eid == 0x000B: # 11 350 | action = data[0] & 0x0F 351 | method = data[0] >> 4 352 | key_id = int.from_bytes(data[1:5], 'little') 353 | error = BLE_LOCK_ERROR.get(key_id) 354 | 355 | # all keys except Bluetooth have only 65536 values 356 | if error is None and method > 0: 357 | key_id &= 0xFFFF 358 | elif error: 359 | key_id = hex(key_id) 360 | 361 | timestamp = int.from_bytes(data[5:], 'little') 362 | timestamp = datetime.fromtimestamp(timestamp).isoformat() 363 | 364 | return { 365 | 'action': 'lock', 366 | 'action_id': action, 367 | 'method_id': method, 368 | 'message': BLE_LOCK_ACTION[action], 369 | 'method': BLE_LOCK_METHOD[method], 370 | 'key_id': key_id, 371 | 'error': error, 372 | 'timestamp': timestamp 373 | } 374 | 375 | elif eid == 0x0F: # 15 376 | # Night Light 2: 1 - moving no light, 100 - moving with light 377 | # Motion Sensor 2: 0 - moving no light, 256 - moving with light 378 | # Qingping Motion Sensor - moving with illuminance data 379 | value = int.from_bytes(data, 'little') 380 | return ( 381 | {'motion': 1, 'illuminance': value} 382 | if pdid == 2691 else 383 | {'motion': 1, 'light': int(value >= 100)} 384 | ) 385 | 386 | elif eid == 0x10 and len(data) == 2: # 16 387 | # Toothbrush Т500 388 | if data[0] == 0: 389 | return {'action': 'start', 'counter': data[1]} 390 | else: 391 | return {'action': 'finish', 'score': data[1]} 392 | 393 | return None 394 | 395 | 396 | def get_device(pdid: int, default_name: str) -> Optional[dict]: 397 | for device in DEVICES: 398 | if pdid in device: 399 | desc = device[pdid] 400 | return { 401 | 'device_manufacturer': desc[0], 402 | 'device_name': desc[0] + ' ' + desc[1], 403 | 'device_model': desc[2] if len(desc) > 2 else pdid, 404 | 'lumi_spec': None, 405 | 'miot_spec': device.get('miot_spec'), 406 | # if color temp not default 2700..6500 407 | 'color_temp': COLOR_TEMP.get(pdid), 408 | 'max_brightness': MAX_BRIGHTNESS.get(pdid) 409 | } 410 | 411 | return { 412 | 'device_name': default_name, 413 | 'device_model': pdid, 414 | 'lumi_spec': None, 415 | # default Mesh device will be Bulb 416 | 'miot_spec': [ 417 | [2, 1, 'light', 'light'], 418 | [2, 2, 'brightness', None], 419 | [2, 3, 'color_temp', None], 420 | ] 421 | } if default_name == 'Mesh' else { 422 | 'device_name': default_name, 423 | 'device_model': pdid, 424 | } 425 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/util/elelabs_ezsp_utility.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Elelabs International Limited 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import binascii 16 | import io 17 | import socket 18 | import time 19 | 20 | from xmodem import XMODEM 21 | 22 | 23 | # Maximum untouched utility with fix only serial class from pyserial to TCP 24 | # https://github.com/Elelabs/elelabs-zigbee-ezsp-utility 25 | class serial: 26 | PARITY_NONE = None 27 | STOPBITS_ONE = None 28 | 29 | class Serial: 30 | def __init__(self, port, **kwargs): 31 | self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 32 | self.s.settimeout(5) 33 | self.s.connect(port) 34 | 35 | def flushInput(self): 36 | pass 37 | 38 | def read(self, size: int = 1): 39 | try: 40 | return self.s.recv(size) 41 | except: 42 | return b'' 43 | 44 | def readline(self): 45 | raw = b'' 46 | while True: 47 | c = self.read() 48 | raw += c 49 | if c == b'\n' or c == b'': 50 | break 51 | return raw 52 | 53 | def write(self, data: bytes): 54 | self.s.send(data) 55 | 56 | def close(self): 57 | self.s.close() 58 | 59 | 60 | class AdapterModeProbeStatus: 61 | NORMAL = 0 62 | BOOTLOADER = 1 63 | ERROR = 2 64 | 65 | 66 | class SerialInterface: 67 | def __init__(self, port, baudrate): 68 | self.port = port 69 | self.baudrate = baudrate 70 | 71 | def open(self): 72 | try: 73 | self.serial = serial.Serial(port=self.port, 74 | baudrate=self.baudrate, 75 | parity=serial.PARITY_NONE, 76 | stopbits=serial.STOPBITS_ONE, 77 | xonxoff=True, 78 | timeout=3) 79 | except Exception as e: 80 | raise Exception("PORT ERROR: %s" % str(e)) 81 | 82 | def close(self): 83 | self.serial.close() 84 | 85 | 86 | class AshProtocolInterface: 87 | FLAG_BYTE = b'\x7E' 88 | RANDOMIZE_START = 0x42 89 | RANDOMIZE_SEQ = 0xB8 90 | RSTACK_FRAME_CMD = b'\x1A\xC0\x38\xBC\x7E' 91 | RSTACK_FRAME_ACK = b'\x1A\xC1\x02\x0B\x0A\x52\x7E' 92 | 93 | def __init__(self, serial, config, logger): 94 | self.logger = logger 95 | self.config = config 96 | self.serial = serial 97 | 98 | self.ackNum = 0 99 | self.frmNum = 0 100 | 101 | def dataRandomize(self, frame): 102 | rand = self.RANDOMIZE_START 103 | out = bytearray() 104 | for x in frame: 105 | out += bytearray([x ^ rand]) 106 | if rand % 2: 107 | rand = (rand >> 1) ^ self.RANDOMIZE_SEQ 108 | else: 109 | rand = rand >> 1 110 | return out 111 | 112 | def ashFrameBuilder(self, ezsp_frame): 113 | ash_frame = bytearray() 114 | # Control byte 115 | ash_frame += bytearray([(((self.ackNum << 0) & 0xFF) | ( 116 | ((self.frmNum % 8) << 4) & 0xFF)) & 0xFF]) 117 | self.ackNum = (self.ackNum + 1) % 8 118 | self.frmNum = (self.frmNum + 1) % 8 119 | ash_frame += self.dataRandomize(ezsp_frame) 120 | crc = binascii.crc_hqx(ash_frame, 0xFFFF) 121 | ash_frame += bytearray([crc >> 8, crc & 0xFF]) 122 | ash_frame = self.replaceReservedBytes(ash_frame) 123 | ash_frame += self.FLAG_BYTE 124 | if self.config.dlevel == 'ASH': 125 | self.logger.debug('[ ASH REQUEST ] ' + ' '.join( 126 | format(x, '02x') for x in ash_frame)) 127 | return ash_frame 128 | 129 | def revertEscapedBytes(self, msg): 130 | msg = msg.replace(b'\x7d\x5d', b'\x7d') 131 | msg = msg.replace(b'\x7d\x5e', b'\x7e') 132 | msg = msg.replace(b'\x7d\x31', b'\x11') 133 | msg = msg.replace(b'\x7d\x33', b'\x13') 134 | msg = msg.replace(b'\x7d\x38', b'\x18') 135 | msg = msg.replace(b'\x7d\x3a', b'\x1a') 136 | return msg 137 | 138 | def replaceReservedBytes(self, msg): 139 | msg = msg.replace(b'\x7d', b'\x7d\x5d') 140 | msg = msg.replace(b'\x7e', b'\x7d\x5e') 141 | msg = msg.replace(b'\x11', b'\x7d\x31') 142 | msg = msg.replace(b'\x13', b'\x7d\x33') 143 | msg = msg.replace(b'\x18', b'\x7d\x38') 144 | msg = msg.replace(b'\x1a', b'\x7d\x3a') 145 | return msg 146 | 147 | def getResponse(self, applyRandomize=False): 148 | timeout = time.time() + 3 149 | msg = bytearray() 150 | 151 | receivedbyte = None 152 | 153 | while (time.time() < timeout) and (receivedbyte != self.FLAG_BYTE): 154 | receivedbyte = self.serial.read() 155 | msg += receivedbyte 156 | 157 | if len(msg) == 0: 158 | return -1, None, None 159 | 160 | msg = self.revertEscapedBytes(msg) 161 | 162 | if self.config.dlevel == 'ASH': 163 | self.logger.debug( 164 | '[ ASH RESPONSE ] ' + ' '.join(format(x, '02x') for x in msg)) 165 | 166 | if applyRandomize: 167 | msg_parsed = self.dataRandomize(bytearray(msg[1:-3])) 168 | if self.config.dlevel == 'ASH' or self.config.dlevel == 'EZSP': 169 | self.logger.debug('[ EZSP RESPONSE ] ' + ' '.join( 170 | format(x, '02x') for x in msg_parsed)) 171 | return 0, msg, msg_parsed 172 | else: 173 | return 0, msg 174 | 175 | def sendResetFrame(self): 176 | self.serial.flushInput() 177 | self.logger.debug('RESET FRAME') 178 | if self.config.dlevel == 'ASH': 179 | self.logger.debug('[ ASH REQUEST ] ' + ' '.join( 180 | format(x, '02x') for x in self.RSTACK_FRAME_CMD)) 181 | self.serial.write(self.RSTACK_FRAME_CMD) 182 | status, response = self.getResponse() 183 | 184 | if status: 185 | return status 186 | 187 | if not (self.RSTACK_FRAME_ACK in response): 188 | return -1 189 | 190 | return 0 191 | 192 | def sendAck(self, ackNum): 193 | ack = bytearray([ackNum & 0x07 | 0x80]) 194 | crc = binascii.crc_hqx(ack, 0xFFFF) 195 | ack += bytearray([crc >> 8, crc & 0xFF]) 196 | ack = self.replaceReservedBytes(ack) 197 | ack += self.FLAG_BYTE 198 | 199 | if self.config.dlevel == 'ASH': 200 | self.logger.debug( 201 | '[ ASH ACK ] ' + ' '.join(format(x, '02x') for x in ack)) 202 | self.serial.write(ack) 203 | 204 | def sendAshCommand(self, ezspFrame): 205 | ash_frame = self.ashFrameBuilder(ezspFrame) 206 | self.serial.flushInput() 207 | self.serial.write(ash_frame) 208 | status, ash_response, ezsp_response = self.getResponse(True) 209 | if status: 210 | return status, None 211 | 212 | self.sendAck(ash_response[0]) 213 | return 0, ezsp_response 214 | 215 | 216 | class EzspProtocolInterface: 217 | def __init__(self, serial, config, logger): 218 | self.logger = logger 219 | self.config = config 220 | 221 | self.INITIAL_EZSP_VERSION = 4 222 | 223 | self.VERSION = b'\x00' 224 | self.GET_VALUE = b'\xAA' 225 | self.GET_MFG_TOKEN = b'\x0B' 226 | self.LAUNCH_STANDALONE_BOOTLOADER = b'\x8F' 227 | 228 | self.EZSP_VALUE_VERSION_INFO = 0x11 229 | self.EZSP_MFG_STRING = 0x01 230 | self.EZSP_MFG_BOARD_NAME = 0x02 231 | self.STANDALONE_BOOTLOADER_NORMAL_MODE = 1 232 | 233 | self.ezspVersion = self.INITIAL_EZSP_VERSION 234 | self.sequenceNum = 0 235 | self.ash = AshProtocolInterface(serial, config, logger) 236 | 237 | def ezspFrameBuilder(self, command): 238 | ezsp_frame = bytearray() 239 | 240 | # Sequence byte 241 | ezsp_frame += bytearray([self.sequenceNum]) 242 | self.sequenceNum = (self.sequenceNum + 1) % 255 243 | ezsp_frame += b'\x00' 244 | if self.ezspVersion >= 5: 245 | # Legacy frame ID - always 0xFF 246 | ezsp_frame += b'\xFF' 247 | # Extended frame control 248 | ezsp_frame += b'\x00' 249 | 250 | ezsp_frame = ezsp_frame + command 251 | 252 | if self.ezspVersion >= 8: 253 | ezsp_frame[2] = 0x01 254 | ezsp_frame[3] = command[0] & 0xFF # LSB 255 | ezsp_frame[4] = command[0] >> 8 # MSB 256 | 257 | if self.config.dlevel == 'ASH' or self.config.dlevel == 'EZSP': 258 | self.logger.debug('[ EZSP REQUEST ] ' + ' '.join( 259 | format(x, '02x') for x in ezsp_frame)) 260 | return ezsp_frame 261 | 262 | def sendEzspCommand(self, commandData, commandName=''): 263 | self.logger.debug(commandName) 264 | status, response = self.ash.sendAshCommand( 265 | self.ezspFrameBuilder(commandData)) 266 | if status: 267 | raise Exception("sendAshCommand status error: %d" % status) 268 | 269 | return response 270 | 271 | def sendVersion(self, desiredProtocolVersion): 272 | resp = self.sendEzspCommand( 273 | self.VERSION + bytearray([desiredProtocolVersion]), 274 | 'sendVersion: V%d' % desiredProtocolVersion) 275 | return resp[3] # protocolVersion 276 | 277 | def getValue(self, valueId, valueIdName): 278 | resp = self.sendEzspCommand(self.GET_VALUE + bytearray([valueId]), 279 | 'getValue: %s' % valueIdName) 280 | status = resp[5] 281 | valueLength = resp[6] 282 | valueArray = resp[7:] 283 | return status, valueLength, valueArray 284 | 285 | def getMfgToken(self, tokenId, tokenIdName): 286 | resp = self.sendEzspCommand(self.GET_MFG_TOKEN + bytearray([tokenId]), 287 | 'getMfgToken: %s' % tokenIdName) 288 | tokenDataLength = resp[5] 289 | tokenData = resp[6:] 290 | return tokenDataLength, tokenData 291 | 292 | def launchStandaloneBootloader(self, mode, modeName): 293 | resp = self.sendEzspCommand( 294 | self.LAUNCH_STANDALONE_BOOTLOADER + bytearray([mode]), 295 | 'launchStandaloneBootloader: %s' % modeName) 296 | status = resp[5] 297 | return status 298 | 299 | def initEzspProtocol(self): 300 | ash_status = self.ash.sendResetFrame() 301 | if ash_status: 302 | return ash_status 303 | 304 | self.ezspVersion = self.sendVersion(self.INITIAL_EZSP_VERSION) 305 | self.logger.debug("EZSP v%d detected" % self.ezspVersion) 306 | if (self.ezspVersion != self.INITIAL_EZSP_VERSION): 307 | self.sendVersion(self.ezspVersion) 308 | 309 | return 0 310 | 311 | 312 | class ElelabsUtilities: 313 | def __init__(self, config, logger): 314 | self.logger = logger 315 | self.config = config 316 | 317 | def probe(self): 318 | serialInterface = SerialInterface(self.config.port, 319 | self.config.baudrate) 320 | serialInterface.open() 321 | 322 | ezsp = EzspProtocolInterface(serialInterface.serial, self.config, 323 | self.logger) 324 | ezsp_status = ezsp.initEzspProtocol() 325 | if ezsp_status == 0: 326 | status, value_length, value_array = ezsp.getValue( 327 | ezsp.EZSP_VALUE_VERSION_INFO, "EZSP_VALUE_VERSION_INFO") 328 | if (status == 0): 329 | firmware_version = str(value_array[2]) + '.' + str( 330 | value_array[3]) + '.' + str(value_array[4]) + '-' + str( 331 | value_array[0]) 332 | else: 333 | self.logger.info('EZSP status returned %d' % status) 334 | 335 | token_data_length, token_data = ezsp.getMfgToken( 336 | ezsp.EZSP_MFG_STRING, "EZSP_MFG_STRING") 337 | if token_data.decode("ascii", "ignore") == "Elelabs": 338 | token_data_length, token_data = ezsp.getMfgToken( 339 | ezsp.EZSP_MFG_BOARD_NAME, "EZSP_MFG_BOARD_NAME") 340 | adapter_name = token_data.decode("ascii", "ignore") 341 | 342 | self.logger.info("Elelabs adapter detected:") 343 | self.logger.info("Adapter: %s" % adapter_name) 344 | else: 345 | adapter_name = None 346 | self.logger.info("Generic EZSP adapter detected:") 347 | 348 | self.logger.info("Firmware: %s" % firmware_version) 349 | self.logger.info("EZSP v%d" % ezsp.ezspVersion) 350 | 351 | serialInterface.close() 352 | return AdapterModeProbeStatus.NORMAL, ezsp.ezspVersion, firmware_version, adapter_name 353 | else: 354 | if self.config.baudrate != 115200: 355 | serialInterface.close() 356 | time.sleep(1) 357 | serialInterface = SerialInterface(self.config.port, 115200) 358 | serialInterface.open() 359 | 360 | # check if allready in bootloader mode 361 | serialInterface.serial.write(b'\x0A') 362 | first_line = serialInterface.serial.readline() # read blank line 363 | if len(first_line) == 0: 364 | # timeout 365 | serialInterface.close() 366 | self.logger.info( 367 | "Couldn't communicate with the adapter in normal or in bootloader modes") 368 | return AdapterModeProbeStatus.ERROR, None, None, None 369 | 370 | btl_info = serialInterface.serial.readline() # read Gecko BTL version or blank line 371 | 372 | self.logger.info("EZSP adapter in bootloader mode detected:") 373 | self.logger.info(btl_info.decode("ascii", "ignore")[ 374 | :-2]) # show Bootloader version 375 | serialInterface.close() 376 | return AdapterModeProbeStatus.BOOTLOADER, None, None, None 377 | 378 | def restart(self, mode): 379 | adapter_status, ezsp_version, firmware_version, adapter_name = self.probe() 380 | if adapter_status == AdapterModeProbeStatus.NORMAL: 381 | if mode == 'btl': 382 | serialInterface = SerialInterface(self.config.port, 383 | self.config.baudrate) 384 | serialInterface.open() 385 | 386 | self.logger.info("Launch in bootloader mode") 387 | ezsp = EzspProtocolInterface(serialInterface.serial, 388 | self.config, self.logger) 389 | ezsp_status = ezsp.initEzspProtocol() 390 | status = ezsp.launchStandaloneBootloader( 391 | ezsp.STANDALONE_BOOTLOADER_NORMAL_MODE, 392 | "STANDALONE_BOOTLOADER_NORMAL_MODE") 393 | if status: 394 | serialInterface.close() 395 | self.logger.critical( 396 | "Error launching the adapter in bootloader mode") 397 | return -1 398 | 399 | serialInterface.close() 400 | # wait for reboot 401 | time.sleep(2) 402 | 403 | adapter_status, ezsp_version, firmware_version, adapter_name = self.probe() 404 | if adapter_status == AdapterModeProbeStatus.BOOTLOADER: 405 | return 0 406 | else: 407 | return -1 408 | else: 409 | self.logger.info( 410 | "Allready in EZSP normal mode. No need to restart") 411 | return 0 412 | elif adapter_status == AdapterModeProbeStatus.BOOTLOADER: 413 | if mode == 'btl': 414 | self.logger.info( 415 | "Allready in bootloader mode. No need to restart") 416 | return 0 417 | else: 418 | serialInterface = SerialInterface(self.config.port, 115200) 419 | serialInterface.open() 420 | 421 | self.logger.info("Launch in EZSP normal mode") 422 | 423 | # Send Reboot 424 | serialInterface.serial.write(b'2') 425 | serialInterface.close() 426 | 427 | # wait for reboot 428 | time.sleep(2) 429 | 430 | adapter_status, ezsp_version, firmware_version, adapter_name = self.probe() 431 | if adapter_status == AdapterModeProbeStatus.NORMAL: 432 | return 0 433 | else: 434 | return -1 435 | 436 | def flash(self, filename): 437 | # STATIC FUNCTIONS 438 | def getc(size, timeout=1): 439 | read_data = self.serialInterface.serial.read(size) 440 | return read_data 441 | 442 | def putc(data, timeout=1): 443 | self.currentPacket += 1 444 | if (self.currentPacket % 20) == 0: 445 | print('.', end='') 446 | if (self.currentPacket % 100) == 0: 447 | print('') 448 | self.serialInterface.serial.write(data) 449 | time.sleep(0.001) 450 | 451 | # if not (".gbl" in filename) and not (".ebl" in filename): 452 | # self.logger.critical( 453 | # 'Aborted! Gecko bootloader accepts .gbl or .ebl images only.') 454 | # return 455 | 456 | if self.restart("btl"): 457 | self.logger.critical( 458 | "EZSP adapter not in the bootloader mode. Can't perform update procedure") 459 | 460 | self.serialInterface = SerialInterface(self.config.port, 115200) 461 | self.serialInterface.open() 462 | # Enter '1' to initialize X-MODEM mode 463 | self.serialInterface.serial.write(b'\x0A') 464 | self.serialInterface.serial.write(b'1') 465 | time.sleep(1) 466 | self.serialInterface.serial.readline() # BL > 1 467 | self.serialInterface.serial.readline() # begin upload 468 | 469 | self.logger.info( 470 | 'Successfully restarted into X-MODEM mode! Starting upload of the new firmware... DO NOT INTERRUPT(!)') 471 | 472 | self.currentPacket = 0 473 | # Wait for char 'C' 474 | success = False 475 | start_time = time.time() 476 | while time.time() - start_time < 10: 477 | if self.serialInterface.serial.read() == b'C': 478 | success = True 479 | if time.time() - start_time > 5: 480 | break 481 | if not success: 482 | self.logger.info( 483 | 'Failed to restart into bootloader mode. Please see users guide.') 484 | return 485 | 486 | # Start XMODEM transaction 487 | modem = XMODEM(getc, putc) 488 | # stream = open(filename, 'rb') 489 | stream = io.BytesIO(filename) 490 | sentcheck = modem.send(stream) 491 | 492 | print('') 493 | if sentcheck: 494 | self.logger.info('Firmware upload complete') 495 | else: 496 | self.logger.critical( 497 | 'Firmware upload failed. Please try a correct firmware image or restart in normal mode.') 498 | return 499 | self.logger.info('Rebooting NCP...') 500 | # Wait for restart 501 | time.sleep(4) 502 | # Send Reboot into App-Code command 503 | self.serialInterface.serial.write(b'2') 504 | self.serialInterface.close() 505 | time.sleep(2) 506 | return self.probe() 507 | 508 | def ele_update(self, new_version): 509 | adapter_status, ezsp_version, firmware_version, adapter_name = self.probe() 510 | if adapter_status == AdapterModeProbeStatus.NORMAL: 511 | if adapter_name == None: 512 | self.logger.critical( 513 | "No Elelabs product detected.\r\nUse 'flash' utility for generic EZSP products.\r\nContact info@elelabs.com if you see this meesage for original Elelabs product") 514 | return 515 | 516 | if new_version == 'v6' and ezsp_version == 6: 517 | self.logger.info( 518 | "Elelabs product is operating EZSP protocol v%d. No need to update to %s" % ( 519 | ezsp_version, new_version)) 520 | return 521 | 522 | if new_version == 'v8' and ezsp_version == 8: 523 | self.logger.info( 524 | "Elelabs product is operating EZSP protocol v%d. No need to update to %s" % ( 525 | ezsp_version, new_version)) 526 | return 527 | 528 | if adapter_name == "ELR023" or adapter_name == "ELU013": 529 | if new_version == 'v6': 530 | self.flash("data/ELX0X3_MG13_6.0.3_ezsp_v6.gbl") 531 | elif new_version == 'v8': 532 | self.flash("data/ELX0X3_MG13_6.7.0_ezsp_v8.gbl") 533 | else: 534 | self.logger.critical("Unknown EZSP version") 535 | elif adapter_name == "ELR022" or adapter_name == "ELU012": 536 | self.logger.critical( 537 | "TODO!. Contact Elelabs at info@elelabs.com") 538 | elif adapter_name == "EZBPIS" or adapter_name == "EZBUSBA": 539 | self.logger.critical( 540 | "TODO!. Contact Elelabs at info@elelabs.com") 541 | else: 542 | self.logger.critical( 543 | "Unknown Elelabs product %s detected.\r\nContact info@elelabs.com if you see this meesage for original Elelabs product" % adapter_name) 544 | elif adapter_status == AdapterModeProbeStatus.BOOTLOADER: 545 | self.logger.critical( 546 | "The product not in the normal EZSP mode.\r\n'restart' into normal mode or use 'flash' utility instead") 547 | else: 548 | self.logger.critical("No upgradable device found") 549 | -------------------------------------------------------------------------------- /custom_components/xiaomi_gateway3/core/zigbee.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | # https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/devices.js#L390 5 | # https://slsys.io/action/devicelists.html 6 | # All lumi models: 7 | # https://github.com/rytilahti/python-miio/issues/699#issuecomment-643208618 8 | # Zigbee Model: [Manufacturer, Device Name, Device Model] 9 | # params: [lumi res name, xiaomi prop name, hass attr name, hass domain] 10 | # old devices uses lumi_spec, new devices uses miot_spec 11 | # for miot_spec events you can use yaml in attr name field 12 | DEVICES = [{ 13 | 'lumi.gateway.mgl03': ["Xiaomi", "Gateway 3", "ZNDMWG03LM"], 14 | 'lumi_spec': [ 15 | ['8.0.2012', None, 'power_tx', None], 16 | ['8.0.2024', None, 'channel', None], 17 | ['8.0.2081', None, 'pairing_stop', None], 18 | ['8.0.2082', None, 'removed_did', None], 19 | ['8.0.2084', None, 'added_device', None], # new devices added (info) 20 | ['8.0.2103', None, 'device_model', None], # new device model 21 | ['8.0.2109', None, 'pairing_start', None], 22 | ['8.0.2110', None, 'discovered_mac', None], # new device discovered 23 | ['8.0.2111', None, 'pair_command', None], # add new device 24 | ['8.0.2155', None, 'cloud', None], # {"cloud_link":0} 25 | [None, None, 'pair', 'remote'], 26 | [None, None, 'firmware lock', 'switch'], # firmware lock 27 | ], 28 | 'miot_spec': [ 29 | # different format from bluetooth Mesh Device :( 30 | [3, 1, 'alarm', 'alarm_control_panel'], 31 | [3, 22, 'alarm_trigger', None], 32 | ] 33 | }, { 34 | # on/off, power measurement 35 | 'lumi.plug': ["Xiaomi", "Plug", "ZNCZ02LM"], # tested 36 | 'lumi.plug.mitw01': ["Xiaomi", "Plug TW", "ZNCZ03LM"], 37 | 'lumi.plug.mmeu01': ["Xiaomi", "Plug EU", "ZNCZ04LM"], 38 | 'lumi.plug.maus01': ["Xiaomi", "Plug US", "ZNCZ12LM"], 39 | 'lumi.ctrl_86plug': ["Aqara", "Socket", "QBCZ11LM"], 40 | # 'lumi.plug.maeu01': ["Aqara", "Plug EU", "SP-EUC01"], 41 | 'lumi_spec': [ 42 | ['0.12.85', 'load_power', 'power', 'sensor'], 43 | ['0.13.85', None, 'consumption', 'sensor'], 44 | ['4.1.85', 'neutral_0', 'switch', 'switch'], # or channel_0? 45 | ] 46 | }, { 47 | 'lumi.ctrl_86plug.aq1': ["Aqara", "Socket", "QBCZ11LM"], 48 | 'lumi_spec': [ 49 | ['0.12.85', 'load_power', 'power', 'sensor'], 50 | ['0.13.85', None, 'consumption', 'sensor'], 51 | ['4.1.85', 'channel_0', 'switch', 'switch'], # @to4ko 52 | ] 53 | }, { 54 | 'lumi.ctrl_ln1': ["Aqara", "Single Wall Switch", "QBKG11LM"], 55 | 'lumi.ctrl_ln1.aq1': ["Aqara", "Single Wall Switch", "QBKG11LM"], 56 | 'lumi.switch.b1nacn02': ["Aqara", "Single Wall Switch D1", "QBKG23LM"], 57 | 'lumi_spec': [ 58 | ['0.12.85', 'load_power', 'power', 'sensor'], 59 | ['0.13.85', None, 'consumption', 'sensor'], 60 | ['4.1.85', 'neutral_0', 'switch', 'switch'], # or channel_0? 61 | ['13.1.85', None, 'button', None], 62 | [None, None, 'action', 'sensor'], 63 | ] 64 | }, { 65 | # dual channel on/off, power measurement 66 | 'lumi.relay.c2acn01': ["Aqara", "Relay", "LLKZMK11LM"], # tested 67 | 'lumi.ctrl_ln2': ["Aqara", "Double Wall Switch", "QBKG12LM"], 68 | 'lumi.ctrl_ln2.aq1': ["Aqara", "Double Wall Switch", "QBKG12LM"], 69 | 'lumi.switch.b2nacn02': ["Aqara", "Double Wall Switch D1", "QBKG24LM"], 70 | 'lumi_spec': [ 71 | # ['0.11.85', 'load_voltage', 'power', 'sensor'], # 0 72 | ['0.12.85', 'load_power', 'power', 'sensor'], 73 | ['0.13.85', None, 'consumption', 'sensor'], 74 | # ['0.14.85', None, '?', 'sensor'], # 5.01, 6.13 75 | ['4.1.85', 'channel_0', 'channel 1', 'switch'], 76 | ['4.2.85', 'channel_1', 'channel 2', 'switch'], 77 | # [?, 'enable_motor_mode', 'interlock', None] 78 | ['13.1.85', None, 'button_1', None], 79 | ['13.2.85', None, 'button_2', None], 80 | ['13.5.85', None, 'button_both', None], 81 | [None, None, 'action', 'sensor'], 82 | ] 83 | }, { 84 | 'lumi.ctrl_neutral1': ["Aqara", "Single Wall Switch", "QBKG04LM"], 85 | 'lumi_spec': [ 86 | ['4.1.85', 'neutral_0', 'switch', 'switch'], # @vturekhanov 87 | ['13.1.85', None, 'button', None], 88 | [None, None, 'action', 'sensor'], 89 | ] 90 | }, { 91 | # on/off 92 | 'lumi.switch.b1lacn02': ["Aqara", "Single Wall Switch D1", "QBKG21LM"], 93 | 'lumi_spec': [ 94 | ['4.1.85', 'channel_0', 'switch', 'switch'], # or neutral_0? 95 | ['13.1.85', None, 'button', None], 96 | [None, None, 'action', 'sensor'], 97 | ] 98 | }, { 99 | # dual channel on/off 100 | 'lumi.ctrl_neutral2': ["Aqara", "Double Wall Switch", "QBKG03LM"], 101 | 'lumi_spec': [ 102 | ['4.1.85', 'neutral_0', 'channel 1', 'switch'], # @to4ko 103 | ['4.2.85', 'neutral_1', 'channel 2', 'switch'], # @to4ko 104 | ['13.1.85', None, 'button_1', None], 105 | ['13.2.85', None, 'button_2', None], 106 | ['13.5.85', None, 'button_both', None], 107 | [None, None, 'action', 'sensor'], 108 | ] 109 | }, { 110 | 'lumi.switch.b2lacn02': ["Aqara", "Double Wall Switch D1", "QBKG22LM"], 111 | 'lumi_spec': [ 112 | ['4.1.85', 'channel_0', 'channel 1', 'switch'], 113 | ['4.2.85', 'channel_1', 'channel 2', 'switch'], 114 | ['13.1.85', None, 'button_1', None], 115 | ['13.2.85', None, 'button_2', None], 116 | ['13.5.85', None, 'button_both', None], 117 | [None, None, 'action', 'sensor'], 118 | ] 119 | }, { 120 | # triple channel on/off, no neutral wire 121 | 'lumi.switch.l3acn3': ["Aqara", "Triple Wall Switch D1", "QBKG25LM"], 122 | 'lumi_spec': [ 123 | ['4.1.85', 'neutral_0', 'channel 1', 'switch'], # @to4ko 124 | ['4.2.85', 'neutral_1', 'channel 2', 'switch'], # @to4ko 125 | ['4.3.85', 'neutral_2', 'channel 3', 'switch'], # @to4ko 126 | ['13.1.85', None, 'button_1', None], 127 | ['13.2.85', None, 'button_2', None], 128 | ['13.3.85', None, 'button_3', None], 129 | ['13.5.85', None, 'button_both_12', None], 130 | ['13.6.85', None, 'button_both_13', None], 131 | ['13.7.85', None, 'button_both_23', None], 132 | [None, None, 'action', 'sensor'], 133 | ] 134 | }, { 135 | # with neutral wire, thanks @Mantoui 136 | 'lumi.switch.n3acn3': ["Aqara", "Triple Wall Switch D1", "QBKG26LM"], 137 | 'lumi_spec': [ 138 | ['0.12.85', 'load_power', 'power', 'sensor'], 139 | ['0.13.85', None, 'consumption', 'sensor'], 140 | ['4.1.85', 'channel_0', 'channel 1', 'switch'], 141 | ['4.2.85', 'channel_1', 'channel 2', 'switch'], 142 | ['4.3.85', 'channel_2', 'channel 3', 'switch'], 143 | ['13.1.85', None, 'button_1', None], 144 | ['13.2.85', None, 'button_2', None], 145 | ['13.3.85', None, 'button_3', None], 146 | ['13.5.85', None, 'button_both_12', None], 147 | ['13.6.85', None, 'button_both_13', None], 148 | ['13.7.85', None, 'button_both_23', None], 149 | [None, None, 'action', 'sensor'], 150 | ] 151 | }, { 152 | # cube action, no retain 153 | 'lumi.sensor_cube': ["Aqara", "Cube", "MFKZQ01LM"], 154 | 'lumi.sensor_cube.aqgl01': ["Aqara", "Cube", "MFKZQ01LM"], # tested 155 | 'lumi_spec': [ 156 | ['0.2.85', None, 'duration', None], 157 | ['0.3.85', None, 'angle', None], 158 | ['13.1.85', None, 'action', 'sensor'], 159 | ['8.0.2001', 'battery', 'battery', 'sensor'], 160 | ] 161 | }, { 162 | # light with brightness and color temp 163 | 'lumi.light.aqcn02': ["Aqara", "Bulb", "ZNLDP12LM"], 164 | 'lumi.light.cwopcn02': ["Aqara", "Opple MX650", "XDD12LM"], 165 | 'lumi.light.cwopcn03': ["Aqara", "Opple MX480", "XDD13LM"], 166 | 'ikea.light.led1545g12': ["IKEA", "Bulb E27 980 lm", "LED1545G12"], 167 | 'ikea.light.led1546g12': ["IKEA", "Bulb E27 950 lm", "LED1546G12"], 168 | 'ikea.light.led1536g5': ["IKEA", "Bulb E14 400 lm", "LED1536G5"], 169 | 'ikea.light.led1537r6': ["IKEA", "Bulb GU10 400 lm", "LED1537R6"], 170 | 'lumi_spec': [ 171 | ['4.1.85', 'power_status', 'light', 'light'], 172 | ['14.1.85', 'light_level', 'brightness', None], 173 | ['14.2.85', 'colour_temperature', 'color_temp', None], 174 | ] 175 | }, { 176 | # light with brightness 177 | 'ikea.light.led1623g12': ["IKEA", "Bulb E27 1000 lm", "LED1623G12"], 178 | 'ikea.light.led1650r5': ["IKEA", "Bulb GU10 400 lm", "LED1650R5"], 179 | 'ikea.light.led1649c5': ["IKEA", "Bulb E14", "LED1649C5"], # tested 180 | 'lumi_spec': [ 181 | ['4.1.85', 'power_status', 'light', 'light'], 182 | ['14.1.85', 'light_level', 'brightness', None], 183 | ] 184 | }, { 185 | # button action, no retain 186 | 'lumi.sensor_switch': ["Xiaomi", "Button", "WXKG01LM"], 187 | 'lumi.sensor_switch.aq2': ["Aqara", "Button", "WXKG11LM"], 188 | 'lumi.remote.b1acn01': ["Aqara", "Button", "WXKG11LM"], 189 | 'lumi.sensor_switch.aq3': ["Aqara", "Shake Button", "WXKG12LM"], 190 | 'lumi.sensor_86sw1': ["Aqara", "Single Wall Button", "WXKG03LM"], 191 | 'lumi.remote.b186acn01': ["Aqara", "Single Wall Button", "WXKG03LM"], 192 | 'lumi.remote.b186acn02': ["Aqara", "Single Wall Button D1", "WXKG06LM"], 193 | 'lumi_spec': [ 194 | ['13.1.85', None, 'button', None], 195 | [None, None, 'action', 'sensor'], 196 | ['8.0.2001', 'battery', 'battery', 'sensor'], 197 | ] 198 | }, { 199 | # multi button action, no retain 200 | 'lumi.sensor_86sw2': ["Aqara", "Double Wall Button", "WXKG02LM"], 201 | 'lumi.remote.b286acn01': ["Aqara", "Double Wall Button", "WXKG02LM"], 202 | 'lumi.sensor_86sw2.es1': ["Aqara", "Double Wall Button", "WXKG02LM"], 203 | 'lumi.remote.b286acn02': ["Aqara", "Double Wall Button D1", "WXKG07LM"], 204 | 'lumi.remote.b286opcn01': ["Aqara", "Opple Two Button", "WXCJKG11LM"], 205 | 'lumi.remote.b486opcn01': ["Aqara", "Opple Four Button", "WXCJKG12LM"], 206 | 'lumi.remote.b686opcn01': ["Aqara", "Opple Six Button", "WXCJKG13LM"], 207 | 'lumi_spec': [ 208 | ['13.1.85', None, 'button_1', None], 209 | ['13.2.85', None, 'button_2', None], 210 | ['13.3.85', None, 'button_3', None], 211 | ['13.4.85', None, 'button_4', None], 212 | ['13.6.85', None, 'button_5', None], 213 | ['13.7.85', None, 'button_6', None], 214 | ['13.5.85', None, 'button_both', None], 215 | [None, None, 'action', 'sensor'], 216 | ['8.0.2001', 'battery', 'battery', 'sensor'], 217 | ] 218 | }, { 219 | # temperature and humidity sensor 220 | 'lumi.sensor_ht': ["Xiaomi", "TH Sensor", "WSDCGQ01LM"], 221 | 'lumi_spec': [ 222 | ['0.1.85', 'temperature', 'temperature', 'sensor'], 223 | ['0.2.85', 'humidity', 'humidity', 'sensor'], 224 | ['8.0.2001', 'battery', 'battery', 'sensor'], 225 | ] 226 | }, { 227 | # temperature, humidity and pressure sensor 228 | 'lumi.weather': ["Aqara", "TH Sensor", "WSDCGQ11LM"], 229 | 'lumi.sensor_ht.agl02': ["Aqara", "TH Sensor", "WSDCGQ12LM"], 230 | 'lumi_spec': [ 231 | ['0.1.85', 'temperature', 'temperature', 'sensor'], 232 | ['0.2.85', 'humidity', 'humidity', 'sensor'], 233 | ['0.3.85', 'pressure', 'pressure', 'sensor'], 234 | ['8.0.2001', 'battery', 'battery', 'sensor'], 235 | ] 236 | }, { 237 | # door window sensor 238 | 'lumi.sensor_magnet': ["Xiaomi", "Door Sensor", "MCCGQ01LM"], 239 | 'lumi.sensor_magnet.aq2': ["Aqara", "Door Sensor", "MCCGQ11LM"], 240 | 'lumi_spec': [ 241 | ['3.1.85', 'status', 'contact', 'binary_sensor'], 242 | ['8.0.2001', 'battery', 'battery', 'sensor'], 243 | ] 244 | }, { 245 | # motion sensor 246 | 'lumi.sensor_motion': ["Xiaomi", "Motion Sensor", "RTCGQ01LM"], 247 | 'lumi_spec': [ 248 | ['3.1.85', None, 'motion', 'binary_sensor'], 249 | ['8.0.2001', 'battery', 'battery', 'sensor'], 250 | ] 251 | }, { 252 | # motion sensor with illuminance 253 | 'lumi.sensor_motion.aq2': ["Aqara", "Motion Sensor", "RTCGQ11LM"], 254 | 'lumi_spec': [ 255 | ['0.3.85', 'lux', 'illuminance_lux', None], 256 | ['0.4.85', 'illumination', 'illuminance', 'sensor'], 257 | ['3.1.85', None, 'motion', 'binary_sensor'], 258 | ['8.0.2001', 'battery', 'battery', 'sensor'], 259 | ] 260 | }, { 261 | # water leak sensor 262 | 'lumi.sensor_wleak.aq1': ["Aqara", "Water Leak Sensor", "SJCGQ11LM"], 263 | 'lumi_spec': [ 264 | ['3.1.85', 'alarm', 'moisture', 'binary_sensor'], 265 | ['8.0.2001', 'battery', 'battery', 'sensor'], 266 | ] 267 | }, { 268 | # vibration sensor 269 | 'lumi.vibration.aq1': ["Aqara", "Vibration Sensor", "DJT11LM"], 270 | 'lumi_spec': [ 271 | ['0.1.85', None, 'bed_activity', None], 272 | ['0.2.85', None, 'tilt_angle', None], 273 | ['0.3.85', None, 'vibrate_intensity', None], 274 | ['13.1.85', None, 'vibration', None], 275 | ['14.1.85', None, 'vibration_level', None], 276 | ['8.0.2001', 'battery', 'battery', 'sensor'], 277 | [None, None, 'action', 'sensor'] 278 | ] 279 | }, { 280 | 'lumi.sen_ill.mgl01': ["Xiaomi", "Light Sensor", "GZCGQ01LM"], 281 | 'miot_spec': [ 282 | ['2.1', '2.1', 'illuminance', 'sensor'], 283 | ['3.1', '3.1', 'battery', 'sensor'], 284 | ] 285 | }, { 286 | 'lumi.sensor_smoke': ["Honeywell", "Smoke Sensor", "JTYJ-GD-01LM/BW"], 287 | 'lumi_spec': [ 288 | ['0.1.85', 'density', 'smoke density', 'sensor'], 289 | ['13.1.85', 'alarm', 'smoke', 'binary_sensor'], 290 | ['8.0.2001', 'battery', 'battery', 'sensor'], 291 | ] 292 | }, { 293 | 'lumi.sensor_natgas': ["Honeywell", "Gas Sensor", "JTQJ-BF-01LM/BW"], 294 | 'lumi_spec': [ 295 | ['0.1.85', 'density', 'gas density', 'sensor'], 296 | ['13.1.85', 'alarm', 'gas', 'binary_sensor'], 297 | ] 298 | }, { 299 | 'lumi.curtain': ["Aqara", "Curtain", "ZNCLDJ11LM"], 300 | 'lumi.curtain.aq2': ["Aqara", "Roller Shade", "ZNGZDJ11LM"], 301 | 'lumi_spec': [ 302 | ['1.1.85', 'curtain_level', 'position', None], 303 | ['14.2.85', None, 'motor', 'cover'], 304 | ['14.3.85', 'cfg_param', 'cfg_param', None], 305 | ['14.4.85', 'run_state', 'run_state', None], 306 | ] 307 | }, { 308 | 'lumi.curtain.hagl04': ["Aqara", "Curtain B1", "ZNCLDJ12LM"], 309 | 'lumi_spec': [ 310 | ['1.1.85', 'curtain_level', 'position', None], 311 | ['14.2.85', None, 'motor', 'cover'], 312 | ['14.3.85', 'cfg_param', 'cfg_param', None], 313 | ['14.4.85', 'run_state', 'run_state', None], 314 | ['8.0.2001', 'battery', 'battery', 'sensor'], 315 | ] 316 | }, { 317 | 'lumi.lock.aq1': ["Aqara", "Door Lock S1", "ZNMS11LM"], 318 | 'lumi.lock.acn02': ["Aqara", "Door Lock S2", "ZNMS12LM"], 319 | 'lumi.lock.acn03': ["Aqara", "Door Lock S2 Pro", "ZNMS12LM"], 320 | 'lumi_spec': [ 321 | ['13.1.85', None, 'key_id', 'sensor'], 322 | ['13.20.85', 'lock_state', 'lock', 'binary_sensor'], 323 | ['8.0.2001', 'battery', 'battery', 'sensor'], 324 | ] 325 | }, { 326 | # https://github.com/AlexxIT/XiaomiGateway3/issues/101 327 | 'lumi.airrtc.tcpecn02': ["Aqara", "Thermostat S2", "KTWKQ03ES"], 328 | 'lumi_spec': [ 329 | ['3.1.85', 'power_status', 'power', None], 330 | ['3.2.85', None, 'current_temperature', None], 331 | ['14.2.85', 'ac_state', 'climate', 'climate'], 332 | ['14.8.85', None, 'mode', None], 333 | ['14.9.85', None, 'target_temperature', None], 334 | ['14.10.85', None, 'fan_mode', None], 335 | ] 336 | }, { 337 | 'lumi.airrtc.vrfegl01': ["Xiaomi", "VRF Air Conditioning"], 338 | 'lumi_spec': [ 339 | ['13.1.85', None, 'channels', 'sensor'] 340 | ] 341 | }, { 342 | # no N, https://www.aqara.com/en/single_switch_T1_no-neutral.html 343 | 'lumi.switch.l0agl1': ["Aqara", "Relay T1", "SSM-U02"], 344 | 'miot_spec': [ 345 | ['2.1', '2.1', 'switch', 'switch'], 346 | ] 347 | }, { 348 | # with N, https://www.aqara.com/en/single_switch_T1_with-neutral.html 349 | 'lumi.switch.n0agl1': ["Aqara", "Relay T1", "SSM-U01"], 350 | 'lumi.plug.maeu01': ["Aqara", "Plug", "SP-EUC01"], 351 | 'miot_spec': [ 352 | ['2.1', '2.1', 'switch', 'switch'], 353 | ['3.1', '3.1', 'consumption', 'sensor'], 354 | ['3.2', '3.2', 'power', 'sensor'], 355 | # ['5.7', '5.7', 'voltage', 'sensor'], 356 | ] 357 | }, { 358 | 'lumi.motion.agl04': ["Aqara", "Precision Motion Sensor", "RTCGQ13LM"], 359 | 'miot_spec': [ 360 | [None, None, 'motion', 'binary_sensor'], 361 | ['3.1', '3.1', 'battery', 'sensor'], 362 | ['4.1', None, 'motion: 1', None], 363 | ] 364 | }, { 365 | 'lumi.airmonitor.acn01': ["Aqara", "TVOC Air Quality Monitor", 366 | "VOCKQJK11LM"], 367 | 'miot_spec': [ 368 | ['3.1', '3.1', 'temperature', 'sensor'], 369 | ['3.2', '3.2', 'humidity', 'sensor'], 370 | ['3.3', '3.3', 'tvoc', 'sensor'], 371 | ['4.1', '4.1', 'tvoc_level', 'binary_sensor'], 372 | ['4.2', '4.2', 'battery', 'sensor'], 373 | ] 374 | }, { 375 | 'lumi.switch.b1lc04': ["Aqara", "Single Wall Switch E1", "QBKG38LM"], 376 | 'miot_spec': [ 377 | ['2.1', '2.1', 'switch', 'switch'], 378 | ['6.1', None, 'button: 1', None], 379 | ['6.2', None, 'button: 2', None], 380 | [None, None, 'action', 'sensor'], 381 | ] 382 | }, { 383 | 'lumi.switch.b2lc04': ["Aqara", "Double Wall Switch E1", "QBKG39LM"], 384 | 'miot_spec': [ 385 | ['2.1', '2.1', 'channel 1', 'switch'], 386 | ['3.1', '3.1', 'channel 2', 'switch'], 387 | ['7.1', None, 'button_1: 1', None], 388 | ['7.2', None, 'button_1: 2', None], 389 | ['8.1', None, 'button_2: 1', None], 390 | ['8.2', None, 'button_2: 2', None], 391 | ['9.1', None, 'button_both: 4', None], 392 | [None, None, 'action', 'sensor'], 393 | ] 394 | }, { 395 | # with neutral wire 396 | 'lumi.switch.b1nc01': ["Aqara", "Single Wall Switch E1", "QBKG40LM"], 397 | 'miot_spec': [ 398 | ['2.1', '2.1', 'switch', 'switch'], 399 | ['7.1', None, 'button: 1', None], 400 | ['7.2', None, 'button: 2', None], 401 | [None, None, 'action', 'sensor'], 402 | ] 403 | }, { 404 | # with neutral wire 405 | 'lumi.switch.b2nc01': ["Aqara", "Double Wall Switch E1", "QBKG41LM"], 406 | 'miot_spec': [ 407 | ['2.1', '2.1', 'channel 1', 'switch'], 408 | ['3.1', '3.1', 'channel 2', 'switch'], 409 | ['8.1', None, 'button_1: 1', None], 410 | ['8.2', None, 'button_1: 2', None], 411 | ['9.1', None, 'button_2: 1', None], 412 | ['9.2', None, 'button_2: 2', None], 413 | ['10.1', None, 'button_both: 4', None], 414 | [None, None, 'action', 'sensor'], 415 | ] 416 | }, { 417 | # required switch firmware 0.0.0_0030 418 | 'lumi.switch.b2naus01': ["Aqara", "Double Wall Switch US", "WS-USC04"], 419 | 'miot_spec': [ 420 | ['2.1', '2.1', 'channel 1', 'switch'], 421 | ['3.1', '3.1', 'channel 2', 'switch'], 422 | ['4.1', None, 'consumption', None], 423 | ['4.2', 'load_power', 'power', 'sensor'], 424 | ['7.1', None, 'button_1: 1', None], 425 | ['7.2', None, 'button_1: 2', None], 426 | ['8.1', None, 'button_2: 1', None], 427 | ['8.2', None, 'button_2: 2', None], 428 | ['9.1', None, 'button_both: 4', None], 429 | [None, None, 'action', 'sensor'], 430 | ] 431 | }, { 432 | # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:curtain:0000A00C:lumi-acn002:1 433 | 'lumi.curtain.acn002': ["Aqara", "Roller Shade E1", "ZNJLBL01LM"], 434 | 'miot_spec': [ 435 | # ['2.1', '2.1', 'fault', None], 436 | ['2.2', None, 'motor', 'cover'], 437 | # ['2.4', '2.4', 'target_position', None], 438 | ['2.5', '2.5', 'position', None], 439 | ['2.6', '2.6', 'run_state', None], 440 | ['3.4', '3.4', 'battery', 'sensor'], 441 | ] 442 | }] 443 | 444 | GLOBAL_PROP = { 445 | '8.0.2001': 'battery', 446 | '8.0.2002': 'reset_cnt', 447 | '8.0.2003': 'send_all_cnt', 448 | '8.0.2004': 'send_fail_cnt', 449 | '8.0.2005': 'send_retry_cnt', 450 | '8.0.2006': 'chip_temperature', 451 | '8.0.2007': 'lqi', 452 | '8.0.2008': 'voltage', 453 | '8.0.2009': 'pv_state', 454 | '8.0.2010': 'cur_state', 455 | '8.0.2011': 'pre_state', 456 | '8.0.2013': 'CCA', 457 | '8.0.2014': 'protect', 458 | '8.0.2015': 'power', 459 | '8.0.2022': 'fw_ver', 460 | '8.0.2023': 'hw_ver', 461 | '8.0.2030': 'poweroff_memory', 462 | '8.0.2031': 'charge_protect', 463 | '8.0.2032': 'en_night_tip_light', 464 | '8.0.2034': 'load_s0', # ctrl_dualchn 465 | '8.0.2035': 'load_s1', # ctrl_dualchn 466 | '8.0.2036': 'parent', 467 | '8.0.2041': 'model', 468 | '8.0.2042': 'max_power', 469 | '8.0.2044': 'plug_detection', 470 | '8.0.2101': 'nl_invert', # ctrl_86plug 471 | '8.0.2102': 'alive', 472 | '8.0.2157': 'network_pan_id', 473 | '8.0.9001': 'battery_end_of_life' 474 | } 475 | 476 | CLUSTERS = { 477 | 0x0000: 'Basic', 478 | 0x0001: 'PowerCfg', 479 | 0x0003: 'Identify', 480 | 0x0006: 'OnOff', 481 | 0x0008: 'LevelCtrl', 482 | 0x000A: 'Time', 483 | 0x000C: 'AnalogInput', # cube, gas sensor 484 | 0x0012: 'Multistate', 485 | 0x0019: 'OTA', # illuminance sensor 486 | 0x0101: 'DoorLock', 487 | 0x0400: 'Illuminance', # motion sensor 488 | 0x0402: 'Temperature', 489 | 0x0403: 'Pressure', 490 | 0x0405: 'Humidity', 491 | 0x0406: 'Occupancy', # motion sensor 492 | 0x0500: 'IasZone', # gas sensor 493 | 0x0B04: 'ElectrMeasur', 494 | 0xFCC0: 'Xiaomi' 495 | } 496 | 497 | RE_ZIGBEE_MODEL_TAIL = re.compile(r'\.v\d$') 498 | 499 | 500 | def get_device(zigbee_model: str) -> Optional[dict]: 501 | # the model has an extra tail when added (v1, v2, v3) 502 | if RE_ZIGBEE_MODEL_TAIL.search(zigbee_model): 503 | zigbee_model = zigbee_model[:-3] 504 | 505 | for device in DEVICES: 506 | if zigbee_model in device: 507 | desc = device[zigbee_model] 508 | return { 509 | # 'model': zigbee_model, 510 | 'device_manufacturer': desc[0], 511 | 'device_name': desc[0] + ' ' + desc[1], 512 | 'device_model': ( 513 | zigbee_model + ' ' + desc[2] 514 | if len(desc) > 2 else zigbee_model 515 | ), 516 | 'lumi_spec': device.get('lumi_spec'), 517 | 'miot_spec': device.get('miot_spec') 518 | } 519 | 520 | return { 521 | 'device_name': 'Zigbee', 522 | 'device_mode': zigbee_model, 523 | 'lumi_spec': [], 524 | 'miot_spec': [] 525 | } 526 | 527 | 528 | def fix_xiaomi_props(model, params) -> dict: 529 | for k, v in params.items(): 530 | if k in ('temperature', 'humidity', 'pressure'): 531 | if model != 'lumi.airmonitor.acn01': 532 | params[k] = v / 100.0 533 | elif v in ('on', 'open'): 534 | params[k] = 1 535 | elif v in ('off', 'close'): 536 | params[k] = 0 537 | elif k == 'battery' and v and v > 1000: 538 | params[k] = round((min(v, 3200) - 2500) / 7) 539 | elif k == 'run_state': 540 | # https://github.com/AlexxIT/XiaomiGateway3/issues/139 541 | if v == 'offing': 542 | params[k] = 0 543 | elif v == 'oning': 544 | params[k] = 1 545 | else: 546 | params[k] = 2 547 | 548 | return params 549 | 550 | 551 | def get_buttons(model: str): 552 | model, _ = model.split(' ', 1) 553 | for device in DEVICES: 554 | if model in device: 555 | return [ 556 | param[2] for param in device['lumi_spec'] 557 | if param[2].startswith('button') 558 | ] 559 | return None 560 | --------------------------------------------------------------------------------