├── 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 |
--------------------------------------------------------------------------------