The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .github
    ├── ISSUE_TEMPLATE
    │   └── bug-report-or-feature-request.md
    └── workflows
    │   └── hacs.yml
├── .gitignore
├── DEVICES.md
├── LICENSE.md
├── README.md
├── assets
    ├── bluetooth_lock.png
    ├── cloud_tokens.png
    ├── integrations.png
    ├── logo.png
    ├── occupancy_timeout.png
    └── zigbee_table.png
├── custom_components
    └── xiaomi_gateway3
    │   ├── __init__.py
    │   ├── alarm_control_panel.py
    │   ├── binary_sensor.py
    │   ├── button.py
    │   ├── climate.py
    │   ├── config_flow.py
    │   ├── core
    │       ├── const.py
    │       ├── converters
    │       │   ├── base.py
    │       │   ├── const.py
    │       │   ├── lumi.py
    │       │   ├── mesh.py
    │       │   ├── mibeacon.py
    │       │   ├── silabs.py
    │       │   └── zigbee.py
    │       ├── core_utils.py
    │       ├── device.py
    │       ├── devices.py
    │       ├── ezsp.py
    │       ├── gate
    │       │   ├── base.py
    │       │   ├── ble.py
    │       │   ├── lumi.py
    │       │   ├── matter.py
    │       │   ├── mesh.py
    │       │   ├── miot.py
    │       │   ├── openmiio.py
    │       │   └── silabs.py
    │       ├── gateway.py
    │       ├── logger.py
    │       ├── mini_miio.py
    │       ├── mini_mqtt.py
    │       ├── shell
    │       │   ├── base.py
    │       │   ├── const.py
    │       │   ├── session.py
    │       │   ├── shell_e1.py
    │       │   ├── shell_mgw.py
    │       │   └── shell_mgw2.py
    │       ├── unqlite.py
    │       └── xiaomi_cloud.py
    │   ├── cover.py
    │   ├── device_trigger.py
    │   ├── diagnostics.py
    │   ├── hass
    │       ├── add_entitites.py
    │       ├── entity.py
    │       ├── entity_description.py
    │       └── hass_utils.py
    │   ├── light.py
    │   ├── manifest.json
    │   ├── number.py
    │   ├── select.py
    │   ├── sensor.py
    │   ├── switch.py
    │   ├── text.py
    │   └── translations
    │       ├── en.json
    │       ├── hu.json
    │       ├── pl.json
    │       ├── pt-BR.json
    │       ├── ro.json
    │       ├── ru.json
    │       ├── ua.json
    │       ├── zh-Hans.json
    │       └── zh-Hant.json
├── hacs.json
├── print_models.py
└── tests
    ├── test_automations.py
    ├── test_backward.py
    ├── test_conv_ble.py
    ├── test_conv_lumi.py
    ├── test_conv_mesh.py
    ├── test_conv_multiple.py
    ├── test_conv_silabs.py
    ├── test_conv_zigbee.py
    ├── test_migrate.py
    ├── test_misc.py
    └── test_zigpy_quirks.py


/.github/ISSUE_TEMPLATE/bug-report-or-feature-request.md:
--------------------------------------------------------------------------------
 1 | ---
 2 | name: Bug report or Feature request
 3 | about: Default issue
 4 | title: ''
 5 | labels: ''
 6 | assignees: ''
 7 | 
 8 | ---
 9 | 
10 | <!--
11 | - Write issue ONLY in English
12 | - Search if similar issue already exist, also check closed issues, do not create duplicates
13 | - Check Integration errors in Hass logs (Configuration > Logs), maybe answer there
14 | - Check Integration debug (Configuration > Integrations > Xiaomi Gateway 3 > Configure > Debug) for something useful
15 | - Read the readme carefully, maybe the answer is there
16 | - Check if you using supported gateway firmware
17 | -->
18 | 


--------------------------------------------------------------------------------
/.github/workflows/hacs.yml:
--------------------------------------------------------------------------------
 1 | name: HACS validation
 2 | 
 3 | on:
 4 |   push:
 5 |   pull_request:
 6 | 
 7 | jobs:
 8 |   hacs:
 9 |     runs-on: "ubuntu-latest"
10 |     steps:
11 |       - uses: "actions/checkout@v2"
12 |       - uses: "hacs/action@main"
13 |         with: { category: "integration" }
14 |   hassfest:
15 |     runs-on: "ubuntu-latest"
16 |     steps:
17 |       - uses: "actions/checkout@v3"
18 |       - uses: "home-assistant/actions/hassfest@master"
19 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | .idea/
3 | .homeassistant/
4 | 
5 | tests/test_0.py
6 | 


--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2020 AlexxIT
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/assets/bluetooth_lock.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexxIT/XiaomiGateway3/c642604d0308a28ae24fbda1192bb9931e81988b/assets/bluetooth_lock.png


--------------------------------------------------------------------------------
/assets/cloud_tokens.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexxIT/XiaomiGateway3/c642604d0308a28ae24fbda1192bb9931e81988b/assets/cloud_tokens.png


--------------------------------------------------------------------------------
/assets/integrations.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexxIT/XiaomiGateway3/c642604d0308a28ae24fbda1192bb9931e81988b/assets/integrations.png


--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexxIT/XiaomiGateway3/c642604d0308a28ae24fbda1192bb9931e81988b/assets/logo.png


--------------------------------------------------------------------------------
/assets/occupancy_timeout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexxIT/XiaomiGateway3/c642604d0308a28ae24fbda1192bb9931e81988b/assets/occupancy_timeout.png


--------------------------------------------------------------------------------
/assets/zigbee_table.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexxIT/XiaomiGateway3/c642604d0308a28ae24fbda1192bb9931e81988b/assets/zigbee_table.png


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/__init__.py:
--------------------------------------------------------------------------------
  1 | import logging
  2 | 
  3 | import voluptuous as vol
  4 | from homeassistant.config_entries import ConfigEntry
  5 | from homeassistant.const import EVENT_HOMEASSISTANT_STOP
  6 | from homeassistant.core import HomeAssistant
  7 | from homeassistant.helpers import config_validation as cv, device_registry
  8 | from homeassistant.helpers.device_registry import DeviceEntry
  9 | from homeassistant.helpers.typing import ConfigType
 10 | 
 11 | from .core import logger
 12 | from .core.const import DOMAIN
 13 | from .core.device import XDevice
 14 | from .core.gate.base import EVENT_MQTT_CONNECT
 15 | from .core.gateway import MultiGateway
 16 | from .hass import hass_utils
 17 | from .hass.add_entitites import handle_add_entities
 18 | from .hass.entity import XEntity
 19 | 
 20 | _LOGGER = logging.getLogger(__name__)
 21 | 
 22 | PLATFORMS = [
 23 |     "alarm_control_panel",
 24 |     "binary_sensor",
 25 |     "button",
 26 |     "climate",
 27 |     "cover",
 28 |     "light",
 29 |     "number",
 30 |     "select",
 31 |     "sensor",
 32 |     "switch",
 33 |     "text",
 34 | ]
 35 | 
 36 | CONF_DEVICES = "devices"
 37 | CONF_ATTRIBUTES_TEMPLATE = "attributes_template"
 38 | CONF_OPENMIIO = "openmiio"
 39 | CONF_LOGGER = "logger"
 40 | 
 41 | CONFIG_SCHEMA = vol.Schema(
 42 |     {
 43 |         DOMAIN: vol.Schema(
 44 |             {
 45 |                 CONF_LOGGER: logger.CONFIG_SCHEMA,
 46 |                 vol.Optional(CONF_ATTRIBUTES_TEMPLATE): cv.template,
 47 |             },
 48 |             extra=vol.ALLOW_EXTRA,
 49 |         ),
 50 |     },
 51 |     extra=vol.ALLOW_EXTRA,
 52 | )
 53 | 
 54 | 
 55 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
 56 |     if config := config.get(DOMAIN):
 57 |         if devices_config := config.get(CONF_DEVICES):
 58 |             XDevice.configs = hass_utils.fix_yaml_devices_config(devices_config)
 59 | 
 60 |         if logger_config := config.get(CONF_LOGGER):
 61 |             _ = hass.async_add_executor_job(
 62 |                 logger.init, __name__, logger_config, hass.config.config_dir
 63 |             )
 64 | 
 65 |         if template := config.get(CONF_ATTRIBUTES_TEMPLATE):
 66 |             template.hass = hass
 67 |             XEntity.attributes_template = template
 68 | 
 69 |     hass.data[DOMAIN] = {}
 70 | 
 71 |     await hass_utils.store_devices(hass)
 72 | 
 73 |     return True
 74 | 
 75 | 
 76 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 77 |     if entry.data:
 78 |         return await hass_utils.setup_cloud(hass, entry)
 79 | 
 80 |     await hass_utils.store_gateway_key(hass, entry)
 81 | 
 82 |     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 83 | 
 84 |     gw = MultiGateway(**entry.options)
 85 |     handle_add_entities(hass, entry, gw)
 86 |     gw.start()
 87 | 
 88 |     hass.data[DOMAIN][entry.entry_id] = gw
 89 | 
 90 |     entry.async_on_unload(entry.add_update_listener(async_reload_entry))
 91 | 
 92 |     async def hass_stop(event):
 93 |         await gw.stop()
 94 | 
 95 |     entry.async_on_unload(
 96 |         hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_stop)
 97 |     )
 98 | 
 99 |     return True
100 | 
101 | 
102 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
103 |     if entry.data:
104 |         return True  # skip unload for cloud config entry
105 | 
106 |     # remove all stats entities if disable stats
107 |     hass_utils.remove_stats_entities(hass, entry)
108 | 
109 |     # important to remove entities before stop gateway
110 |     ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
111 | 
112 |     gw: MultiGateway = hass.data[DOMAIN][entry.entry_id]
113 |     await gw.stop()
114 |     gw.remove_all_devices()
115 | 
116 |     return ok
117 | 
118 | 
119 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry):
120 |     await hass.config_entries.async_reload(entry.entry_id)
121 | 
122 | 
123 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
124 |     if config_entry.version == 1:
125 |         hass_utils.migrate_legacy_devices_unique_id(hass)
126 |         hass_utils.migrate_legacy_entitites_unique_id(hass)
127 |         hass_utils.migrate_devices_store()
128 | 
129 |         try:
130 |             # fix support Hass 2023.12 and earlier - no version arg
131 |             hass.config_entries.async_update_entry(config_entry, version=4)
132 |         except TypeError:
133 |             pass
134 |     return True
135 | 
136 | 
137 | async def async_remove_config_entry_device(
138 |     hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
139 | ) -> bool:
140 |     """Supported from Hass v2022.3"""
141 |     device_registry.async_get(hass).async_remove_device(device_entry.id)
142 |     return True
143 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/alarm_control_panel.py:
--------------------------------------------------------------------------------
 1 | import re
 2 | 
 3 | from homeassistant.components.alarm_control_panel import (
 4 |     AlarmControlPanelEntity,
 5 |     AlarmControlPanelEntityFeature,
 6 | )
 7 | from homeassistant.const import MAJOR_VERSION, MINOR_VERSION
 8 | from homeassistant.helpers.restore_state import RestoreEntity
 9 | 
10 | from .hass.entity import XEntity
11 | 
12 | 
13 | # noinspection PyUnusedLocal
14 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
15 |     XEntity.ADD[entry.entry_id + "alarm_control_panel"] = async_add_entities
16 | 
17 | 
18 | if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 11):
19 |     from homeassistant.components.alarm_control_panel import AlarmControlPanelState
20 | 
21 |     class XAlarmControlBase(XEntity, AlarmControlPanelEntity):
22 |         mode: str = None
23 |         trigger: bool = False
24 | 
25 |         def set_state(self, data: dict):
26 |             if self.attr in data:
27 |                 self.mode = data[self.attr]
28 |             if "alarm_trigger" in data:
29 |                 self.trigger = data["alarm_trigger"]
30 | 
31 |             self._attr_alarm_state = AlarmControlPanelState(
32 |                 "triggered" if self.trigger else self.mode
33 |             )
34 | 
35 |         def get_state(self) -> dict:
36 |             return {self.attr: self._attr_alarm_state}
37 | 
38 | else:
39 |     class XAlarmControlBase(XEntity, AlarmControlPanelEntity):
40 |         mode: str = None
41 |         trigger: bool = False
42 | 
43 |         def set_state(self, data: dict):
44 |             if self.attr in data:
45 |                 self.mode = data[self.attr]
46 |             if "alarm_trigger" in data:
47 |                 self.trigger = data["alarm_trigger"]
48 | 
49 |             self._attr_state = "triggered" if self.trigger else self.mode
50 | 
51 |         def get_state(self) -> dict:
52 |             return {self.attr: self._attr_state}
53 | 
54 | 
55 | class XAlarmControlPanel(XAlarmControlBase, RestoreEntity):
56 |     _attr_code_arm_required = False
57 |     _attr_supported_features = (
58 |         AlarmControlPanelEntityFeature.ARM_HOME
59 |         | AlarmControlPanelEntityFeature.ARM_AWAY
60 |         | AlarmControlPanelEntityFeature.ARM_NIGHT
61 |         | AlarmControlPanelEntityFeature.TRIGGER
62 |     )
63 | 
64 |     def on_init(self):
65 |         # TODO: test alarm disable
66 |         self.listen_attrs.add("alarm_trigger")
67 | 
68 |     async def async_alarm_disarm(self, code=None):
69 |         if self.trigger:
70 |             self.device.write({"alarm_trigger": False})
71 |         else:
72 |             self.device.write({self.attr: "disarmed"})
73 | 
74 |     async def async_alarm_arm_home(self, code=None):
75 |         self.device.write({self.attr: "armed_home"})
76 | 
77 |     async def async_alarm_arm_away(self, code=None):
78 |         self.device.write({self.attr: "armed_away"})
79 | 
80 |     async def async_alarm_arm_night(self, code=None):
81 |         self.device.write({self.attr: "armed_night"})
82 | 
83 |     async def async_alarm_trigger(self, code: str = None):
84 |         """code = `123,1` (duration in seconds + volume = 1-3)."""
85 |         if code is None:
86 |             self.device.write({"alarm_trigger": True})
87 |             return
88 | 
89 |         params = (
90 |             f"start_alarm,{code}" if re.match(r"^\d+,[123]
quot;, code) else "stop_alarm"
91 |         )
92 |         await self.device.send_gateway.mqtt.publish(
93 |             "miio/command", {"_to": 1, "method": "local.status", "params": params}
94 |         )
95 | 
96 | 
97 | XEntity.NEW["alarm_control_panel"] = XAlarmControlPanel
98 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/binary_sensor.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import time
  3 | from datetime import datetime, timezone
  4 | from functools import cached_property
  5 | 
  6 | from homeassistant.components.binary_sensor import BinarySensorEntity
  7 | from homeassistant.components.script import ATTR_LAST_TRIGGERED
  8 | from homeassistant.helpers.restore_state import RestoreEntity
  9 | 
 10 | from .hass.entity import XEntity, XStatsEntity
 11 | 
 12 | 
 13 | # noinspection PyUnusedLocal
 14 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
 15 |     XEntity.ADD[entry.entry_id + "binary_sensor"] = async_add_entities
 16 | 
 17 | 
 18 | class XBinarySensor(XEntity, BinarySensorEntity, RestoreEntity):
 19 |     """Basic binary_sensor with invert_state support."""
 20 | 
 21 |     def set_state(self, data: dict):
 22 |         self._attr_is_on = not data[self.attr] if self.invert else data[self.attr]
 23 | 
 24 |     def get_state(self) -> dict:
 25 |         return {self.attr: not self._attr_is_on if self.invert else self._attr_is_on}
 26 | 
 27 |     @cached_property
 28 |     def invert(self) -> bool:
 29 |         return self.device.extra.get("invert_state", False)
 30 | 
 31 | 
 32 | class XGatewaySensor(XEntity, BinarySensorEntity):
 33 |     """Gateway connection available sensor with useful extra attributes."""
 34 | 
 35 |     def on_init(self):
 36 |         self.listen_attrs.add("available")
 37 |         self._attr_is_on = True
 38 |         self._attr_extra_state_attributes = {}
 39 | 
 40 |     def set_state(self, data: dict):
 41 |         if self.attr in data:
 42 |             self._attr_extra_state_attributes.update(data[self.attr])
 43 |         if "available" in data:
 44 |             self._attr_is_on = data["available"]
 45 | 
 46 |     @property
 47 |     def available(self):
 48 |         return True
 49 | 
 50 | 
 51 | class XBinaryStatsSensor(XStatsEntity, BinarySensorEntity):
 52 |     pass
 53 | 
 54 | 
 55 | class XMotionSensor(XEntity, BinarySensorEntity):
 56 |     """Smart motion sensor with custom occupancy_timeout."""
 57 | 
 58 |     _attr_is_on: bool = False
 59 |     _unrecorded_attributes = {ATTR_LAST_TRIGGERED}
 60 | 
 61 |     clear_task: asyncio.Task = None
 62 |     last_off_ts: float = 0
 63 |     last_on_ts: float = 0
 64 |     next_occupancy_timeout_pos: int = 0
 65 | 
 66 |     def on_init(self):
 67 |         self._attr_extra_state_attributes = {}
 68 | 
 69 |     @cached_property
 70 |     def occupancy_timeout(self) -> list[float] | float:
 71 |         return self.device.extra.get("occupancy_timeout", 90)
 72 | 
 73 |     def set_state(self, data: dict):
 74 |         # fix 1.4.7_0115 heartbeat error (has motion in heartbeat)
 75 |         if "battery" in data:
 76 |             return
 77 | 
 78 |         assert data[self.attr] is True
 79 | 
 80 |         # don't trigger motion right after illumination
 81 |         ts = time.time()
 82 |         if ts - self.last_on_ts < 1:
 83 |             return
 84 | 
 85 |         if self.clear_task:
 86 |             self.clear_task.cancel()
 87 | 
 88 |         utcnow = datetime.fromtimestamp(ts, timezone.utc)
 89 | 
 90 |         self._attr_is_on = True
 91 |         self._attr_extra_state_attributes[ATTR_LAST_TRIGGERED] = utcnow
 92 |         self.last_on_ts = ts
 93 | 
 94 |         if timeout := self.occupancy_timeout:
 95 |             if isinstance(timeout, list):
 96 |                 delay = timeout[self.next_occupancy_timeout_pos]
 97 |                 if self.next_occupancy_timeout_pos + 1 < len(timeout):
 98 |                     self.next_occupancy_timeout_pos += 1
 99 |             else:
100 |                 delay = timeout
101 | 
102 |             if delay < 0 and ts + delay < self.last_off_ts:
103 |                 delay *= 2
104 | 
105 |             self.clear_task = self.hass.loop.create_task(
106 |                 self.async_clear_state(abs(delay))
107 |             )
108 | 
109 |         # repeat event from Aqara integration
110 |         self.hass.bus.async_fire("xiaomi_aqara.motion", {"entity_id": self.entity_id})
111 | 
112 |     async def async_clear_state(self, delay: float):
113 |         await asyncio.sleep(delay)
114 | 
115 |         self.last_off_ts = time.time()
116 |         self.next_occupancy_timeout_pos = 0
117 | 
118 |         self._attr_is_on = False
119 |         self._async_write_ha_state()
120 | 
121 |     async def async_will_remove_from_hass(self):
122 |         if self.clear_task:
123 |             self.clear_task.cancel()
124 | 
125 |         if self._attr_is_on:
126 |             self._attr_is_on = False
127 |             self._async_write_ha_state()
128 | 
129 |         await super().async_will_remove_from_hass()
130 | 
131 | 
132 | XEntity.NEW["binary_sensor"] = XBinarySensor
133 | XEntity.NEW["binary_sensor.attr.gateway"] = XGatewaySensor
134 | XEntity.NEW["binary_sensor.attr.motion"] = XMotionSensor
135 | XEntity.NEW["binary_sensor.attr.ble"] = XBinaryStatsSensor
136 | XEntity.NEW["binary_sensor.attr.matter"] = XBinaryStatsSensor
137 | XEntity.NEW["binary_sensor.attr.mesh"] = XBinaryStatsSensor
138 | XEntity.NEW["binary_sensor.attr.zigbee"] = XBinaryStatsSensor
139 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/button.py:
--------------------------------------------------------------------------------
 1 | from homeassistant.components.button import ButtonEntity
 2 | 
 3 | from .hass.entity import XEntity
 4 | 
 5 | 
 6 | # noinspection PyUnusedLocal
 7 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
 8 |     XEntity.ADD[entry.entry_id + "button"] = async_add_entities
 9 | 
10 | 
11 | class XButton(XEntity, ButtonEntity):
12 |     async def async_press(self):
13 |         self.device.write({self.attr: True})
14 | 
15 | 
16 | XEntity.NEW["button"] = XButton
17 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/climate.py:
--------------------------------------------------------------------------------
  1 | from homeassistant.components.climate import (
  2 |     ClimateEntity,
  3 |     ClimateEntityFeature,
  4 |     FAN_AUTO,
  5 |     FAN_HIGH,
  6 |     FAN_LOW,
  7 |     FAN_MEDIUM,
  8 |     HVACAction,
  9 |     HVACMode,
 10 | )
 11 | from homeassistant.const import (
 12 |     MAJOR_VERSION,
 13 |     MINOR_VERSION,
 14 |     PRECISION_WHOLE,
 15 |     UnitOfTemperature,
 16 | )
 17 | 
 18 | from .hass.entity import XEntity
 19 | 
 20 | 
 21 | # noinspection PyUnusedLocal
 22 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
 23 |     XEntity.ADD[entry.entry_id + "climate"] = async_add_entities
 24 | 
 25 | 
 26 | ACTIONS = {
 27 |     HVACMode.OFF: HVACAction.OFF,
 28 |     HVACMode.COOL: HVACAction.COOLING,
 29 |     HVACMode.HEAT: HVACAction.HEATING,
 30 |     HVACMode.DRY: HVACAction.DRYING,
 31 |     HVACMode.FAN_ONLY: HVACAction.FAN,
 32 | }
 33 | 
 34 | 
 35 | # https://developers.home-assistant.io/blog/2024/01/24/climate-climateentityfeatures-expanded
 36 | ONOFF = (
 37 |     (ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF)
 38 |     if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 2)
 39 |     else 0
 40 | )
 41 | 
 42 | 
 43 | class XAqaraS2(XEntity, ClimateEntity):
 44 |     _attr_fan_mode = None
 45 |     _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO]
 46 |     _attr_hvac_mode = None
 47 |     _attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT]
 48 |     _attr_precision = PRECISION_WHOLE
 49 |     _attr_supported_features = (
 50 |         ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ONOFF
 51 |     )
 52 |     _attr_target_temperature = 0
 53 |     _attr_target_temperature_step = 1
 54 |     _attr_temperature_unit = UnitOfTemperature.CELSIUS
 55 |     # support only KTWKQ03ES for now
 56 |     _attr_max_temp = 30
 57 |     _attr_min_temp = 17
 58 | 
 59 |     _enabled = None
 60 |     _mode = None
 61 | 
 62 |     def on_init(self):
 63 |         self.listen_attrs |= {"power", "current_temp", "hvac_mode", "target_temp"}
 64 | 
 65 |     def set_state(self, data: dict):
 66 |         if "power" in data:
 67 |             self._enabled = data["power"]
 68 |         if "current_temp" in data:
 69 |             self._attr_current_temperature = data["current_temp"]
 70 |         if "fan_mode" in data:
 71 |             self._attr_fan_mode = data["fan_mode"]
 72 |         if "hvac_mode" in data:
 73 |             self._attr_hvac_mode = data["hvac_mode"]
 74 |             self._mode = data["hvac_mode"]
 75 |             # better support HomeKit
 76 |             # https://github.com/AlexxIT/XiaomiGateway3/issues/707#issuecomment-1099109552
 77 |             self._attr_hvac_action = ACTIONS.get(self._attr_hvac_mode)
 78 |         if "target_temp" in data:
 79 |             # fix scenes with turned off climate
 80 |             # https://github.com/AlexxIT/XiaomiGateway3/issues/101#issuecomment-757781988
 81 |             self._attr_target_temperature = data["target_temp"]
 82 | 
 83 |         self._attr_hvac_mode = self._mode if self._enabled else HVACMode.OFF
 84 | 
 85 |     async def async_set_temperature(self, temperature: int, **kwargs) -> None:
 86 |         if temperature:
 87 |             self.device.write({self.attr: {"target_temp": temperature}})
 88 | 
 89 |     async def async_set_fan_mode(self, fan_mode: str) -> None:
 90 |         self.device.write({self.attr: {"fan_mode": fan_mode}})
 91 | 
 92 |     async def async_set_hvac_mode(self, hvac_mode: str) -> None:
 93 |         self.device.write({self.attr: {"hvac_mode": hvac_mode}})
 94 | 
 95 | 
 96 | class XAqaraE1(XEntity, ClimateEntity):
 97 |     _attr_hvac_mode = None
 98 |     _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.AUTO]
 99 |     _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ONOFF
100 |     _attr_temperature_unit = UnitOfTemperature.CELSIUS
101 |     _attr_max_temp = 30
102 |     _attr_min_temp = 5
103 |     _attr_target_temperature_step = 0.5
104 | 
105 |     _enabled = None
106 |     _mode = None
107 | 
108 |     def on_init(self):
109 |         self.listen_attrs = {"climate", "mode", "current_temp", "target_temp"}
110 | 
111 |     def set_state(self, data: dict):
112 |         if "climate" in data:
113 |             self._enabled = data["climate"]
114 |         if "mode" in data:
115 |             self._mode = data["mode"]
116 |         if "current_temp" in data:
117 |             self._attr_current_temperature = data["current_temp"]
118 |         if "target_temp" in data:
119 |             self._attr_target_temperature = data["target_temp"]
120 | 
121 |         if self._enabled is None or self._mode is None:
122 |             return
123 | 
124 |         self._attr_hvac_mode = self._mode if self._enabled else HVACMode.OFF
125 | 
126 |     async def async_set_temperature(self, temperature: int, **kwargs) -> None:
127 |         self.device.write({"target_temp": temperature})
128 | 
129 |     async def async_set_hvac_mode(self, hvac_mode: str) -> None:
130 |         if hvac_mode in (HVACMode.HEAT, HVACMode.AUTO):
131 |             payload = {"mode": hvac_mode}
132 |         elif hvac_mode == HVACMode.OFF:
133 |             payload = {"climate": False}
134 |         else:
135 |             return
136 |         self.device.write(payload)
137 | 
138 | 
139 | class XScdvbHAVC(XEntity, ClimateEntity):
140 |     _attr_fan_mode = None
141 |     _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO]
142 |     _attr_hvac_mode = None
143 |     _attr_hvac_modes = [
144 |         HVACMode.OFF,
145 |         HVACMode.COOL,
146 |         HVACMode.HEAT,
147 |         HVACMode.AUTO,
148 |         HVACMode.DRY,
149 |         HVACMode.FAN_ONLY,
150 |     ]
151 |     _attr_precision = PRECISION_WHOLE
152 |     _attr_supported_features = (
153 |         ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ONOFF
154 |     )
155 |     _attr_temperature_unit = UnitOfTemperature.CELSIUS
156 |     _attr_max_temp = 32
157 |     _attr_min_temp = 16
158 |     _attr_target_temperature_step = 1
159 | 
160 |     _enabled = None
161 |     _mode = None
162 | 
163 |     def on_init(self):
164 |         self.listen_attrs |= {"current_temp", "fan_mode", "hvac_mode", "target_temp"}
165 | 
166 |     def set_state(self, data: dict):
167 |         if "climate" in data:
168 |             self._enabled = data["climate"]
169 |         if "hvac_mode" in data:
170 |             self._mode = data["hvac_mode"]
171 |         if "fan_mode" in data:
172 |             self._attr_fan_mode = data["fan_mode"]
173 |         if "current_temp" in data:
174 |             self._attr_current_temperature = data["current_temp"]
175 |         if "target_temp" in data:
176 |             self._attr_target_temperature = data["target_temp"]
177 | 
178 |         if self._enabled is None or self._mode is None:
179 |             return
180 | 
181 |         self._attr_hvac_mode = self._mode if self._enabled else HVACMode.OFF
182 | 
183 |     async def async_set_temperature(self, temperature: int, **kwargs) -> None:
184 |         if temperature:
185 |             self.device.write({"target_temp": temperature})
186 | 
187 |     async def async_set_fan_mode(self, fan_mode: str) -> None:
188 |         if not self._enabled:
189 |             self.device.write({"climate": True})
190 |             self._attr_hvac_mode = self._mode
191 |         self.device.write({"fan_mode": fan_mode})
192 | 
193 |     async def async_set_hvac_mode(self, hvac_mode: str) -> None:
194 |         if hvac_mode == HVACMode.OFF:
195 |             self.device.write({"climate": False})
196 |         else:
197 |             if not self._enabled:
198 |                 self.device.write({"climate": True})
199 |             # better support HomeKit
200 |             if hvac_mode == HVACMode.AUTO:
201 |                 hvac_mode = self._mode
202 |             self.device.write({"hvac_mode": hvac_mode})
203 | 
204 | 
205 | XEntity.NEW["climate.model.lumi.airrtc.tcpecn02"] = XAqaraS2
206 | XEntity.NEW["climate.model.lumi.airrtc.agl001"] = XAqaraE1
207 | XEntity.NEW["climate.model.14050"] = XScdvbHAVC
208 | XEntity.NEW["climate.model.9507"] = XScdvbHAVC
209 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/const.py:
--------------------------------------------------------------------------------
 1 | DOMAIN = "xiaomi_gateway3"
 2 | 
 3 | GATEWAY = "gateway"
 4 | ZIGBEE = "zigbee"
 5 | BLE = "ble"
 6 | MESH = "mesh"
 7 | GROUP = "group"
 8 | MATTER = "matter"
 9 | 
10 | SUPPORTED_MODELS = (
11 |     "lumi.gateway.mgl03",
12 |     "lumi.gateway.aqcn02",
13 |     "lumi.gateway.aqcn03",
14 |     "lumi.gateway.mcn001",
15 |     "lumi.gateway.mgl001",
16 | )
17 | 
18 | PID_WIFI = 0
19 | PID_BLE = 6
20 | PID_WIFI_BLE = 8
21 | 
22 | 
23 | def source_hash() -> str:
24 |     if source_hash.__doc__:
25 |         return source_hash.__doc__
26 | 
27 |     try:
28 |         import hashlib
29 |         import os
30 | 
31 |         m = hashlib.md5()
32 |         path = os.path.dirname(os.path.dirname(__file__))
33 |         for root, dirs, files in os.walk(path):
34 |             dirs.sort()
35 |             for file in sorted(files):
36 |                 if not file.endswith(".py"):
37 |                     continue
38 |                 path = os.path.join(root, file)
39 |                 with open(path, "rb") as f:
40 |                     m.update(f.read())
41 | 
42 |         source_hash.__doc__ = m.hexdigest()[:7]
43 |         return source_hash.__doc__
44 | 
45 |     except Exception as e:
46 |         return repr(e)
47 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/converters/base.py:
--------------------------------------------------------------------------------
  1 | from dataclasses import dataclass
  2 | from typing import TYPE_CHECKING
  3 | 
  4 | from ..const import BLE, GATEWAY, MATTER, ZIGBEE
  5 | 
  6 | if TYPE_CHECKING:
  7 |     from ..device import XDevice
  8 | 
  9 | TIME = {"s": 1, "m": 60, "h": 3600, "d": 86400}
 10 | 
 11 | 
 12 | def decode_time(value: str) -> float:
 13 |     """Conver string time to float time (seconds).
 14 |     @type value: 15s or 30m or 24h or 1d
 15 |     """
 16 |     return float(value[:-1]) * TIME[value[-1]]
 17 | 
 18 | 
 19 | def encode_time(value: float) -> str:
 20 |     s = ""
 21 |     if i := int(value / 86400):
 22 |         s += f"{i}d"
 23 |         value %= 86400
 24 |     if i := int(value / 3600):
 25 |         s += f"{i}h"
 26 |         value %= 3600
 27 |     if i := int(value / 60):
 28 |         s += f"{i}m"
 29 |         value %= 60
 30 |     if i := int(value):
 31 |         s += f"{i}s"
 32 |     return s or "0s"
 33 | 
 34 | 
 35 | @dataclass
 36 | class BaseConv:
 37 |     attr: str
 38 |     domain: str = None
 39 |     mi: str | int = None
 40 |     entity: dict = None
 41 | 
 42 |     def decode(self, device: "XDevice", payload: dict, value):
 43 |         payload[self.attr] = value
 44 | 
 45 |     def encode(self, device: "XDevice", payload: dict, value):
 46 |         if not self.mi or device.type == BLE or ".e." in self.mi:
 47 |             return
 48 |         if ".p." in self.mi:
 49 |             s, p = self.mi.split(".p.")
 50 |             if device.type == ZIGBEE:
 51 |                 payload["cmd"] = "write"
 52 |                 payload["did"] = device.did
 53 |                 params = {"siid": int(s), "piid": int(p), "value": value}
 54 |                 payload.setdefault("mi_spec", []).append(params)
 55 |             else:
 56 |                 payload["method"] = "set_properties"
 57 |                 params = {
 58 |                     "did": device.did,
 59 |                     "siid": int(s),
 60 |                     "piid": int(p),
 61 |                     "value": value,
 62 |                 }
 63 |                 payload.setdefault("params", []).append(params)
 64 |         elif ".a." in self.mi:
 65 |             s, a = self.mi.split(".a.")
 66 |             payload["method"] = "action"
 67 |             params = {"did": device.did, "siid": int(s), "aiid": int(a), "in": []}
 68 |             payload["params"] = params
 69 |         elif device.type == MATTER:
 70 |             payload["method"] = "set_properties_v3"
 71 |             params = {"did": device.did, "iid": self.mi, "value": value}
 72 |             payload.setdefault("params", []).append(params)
 73 |         else:
 74 |             payload["cmd"] = "write"
 75 |             payload["did"] = device.did if device.type != GATEWAY else "lumi.0"
 76 |             params = {"res_name": self.mi, "value": value}
 77 |             payload.setdefault("params", []).append(params)
 78 | 
 79 |     def encode_read(self, device: "XDevice", payload: dict):
 80 |         if not self.mi or device.type == BLE or ".e." in self.mi or ".a." in self.mi:
 81 |             return
 82 |         if ".p." in self.mi:
 83 |             s, p = self.mi.split(".p.")
 84 |             if device.type == ZIGBEE:
 85 |                 payload["cmd"] = "read"
 86 |                 payload["did"] = device.did
 87 |                 params = {"siid": int(s), "piid": int(p)}
 88 |                 payload.setdefault("mi_spec", []).append(params)
 89 |             else:
 90 |                 payload["method"] = "get_properties"
 91 |                 params = {"did": device.did, "siid": int(s), "piid": int(p)}
 92 |                 payload.setdefault("params", []).append(params)
 93 |         elif device.type == MATTER:
 94 |             payload["method"] = "get_properties_v3"
 95 |             params = {"did": device.did, "iid": self.mi}
 96 |             payload.setdefault("params", []).append(params)
 97 |         else:
 98 |             payload["cmd"] = "read"
 99 |             payload["did"] = device.did if device.type != GATEWAY else "lumi.0"
100 |             params = {"res_name": self.mi}
101 |             payload.setdefault("params", []).append(params)
102 | 
103 | 
104 | @dataclass
105 | class ConstConv(BaseConv):
106 |     """In any cases set constant value to attribute."""
107 | 
108 |     value: bool | int | str = None
109 | 
110 |     def decode(self, device: "XDevice", payload: dict, value):
111 |         payload[self.attr] = self.value
112 | 
113 | 
114 | class BoolConv(BaseConv):
115 |     """Decode from int to bool, encode from bool to int."""
116 | 
117 |     def decode(self, device: "XDevice", payload: dict, value: int):
118 |         payload[self.attr] = bool(value)
119 | 
120 |     def encode(self, device: "XDevice", payload: dict, value: bool):
121 |         super().encode(device, payload, int(value))
122 | 
123 | 
124 | @dataclass
125 | class MapConv(BaseConv):
126 |     map: dict[int | str, bool | str] = None
127 | 
128 |     def decode(self, device: "XDevice", payload: dict, value: int):
129 |         if value in self.map:
130 |             payload[self.attr] = self.map[value]
131 | 
132 |     def encode(self, device: "XDevice", payload: dict, value):
133 |         value = next(k for k, v in self.map.items() if v == value)
134 |         super().encode(device, payload, value)
135 | 
136 | 
137 | @dataclass
138 | class MathConv(BaseConv):
139 |     max: float = float("inf")
140 |     min: float = -float("inf")
141 |     multiply: float = 1.0
142 |     round: int = None
143 |     step: float = 1.0
144 | 
145 |     def decode(self, device: "XDevice", payload: dict, value: float):
146 |         if self.min <= value <= self.max:
147 |             if self.multiply != 1.0:
148 |                 value *= self.multiply
149 |             if self.round is not None:
150 |                 # convert to int when round is zero
151 |                 value = round(value, self.round or None)
152 |             payload[self.attr] = value
153 | 
154 |     def encode(self, device: "XDevice", payload: dict, value: float):
155 |         if self.multiply != 1.0:
156 |             value /= self.multiply
157 |         super().encode(device, payload, value)
158 | 
159 | 
160 | @dataclass
161 | class MaskConv(BaseConv):
162 |     mask: int = 0
163 | 
164 |     def decode(self, device: "XDevice", payload: dict, value: int):
165 |         # noinspection PyTypedDict
166 |         device.extra[self.attr] = value
167 |         payload[self.attr] = bool(value & self.mask)
168 | 
169 |     def encode(self, device: "XDevice", payload: dict, value: bool):
170 |         # noinspection PyTypedDict
171 |         new_value = device.extra.get(self.attr, 0)
172 |         new_value = new_value | self.mask if value else new_value & ~self.mask
173 |         super().encode(device, payload, new_value)
174 | 
175 | 
176 | @dataclass
177 | class BrightnessConv(BaseConv):
178 |     max: float = 100.0
179 | 
180 |     def decode(self, device: "XDevice", payload: dict, value: int):
181 |         payload[self.attr] = value / self.max * 255.0
182 | 
183 |     def encode(self, device: "XDevice", payload: dict, value: float):
184 |         value = round(value / 255.0 * self.max)
185 |         super().encode(device, payload, int(value))
186 | 
187 | 
188 | @dataclass
189 | class ColorTempKelvin(BaseConv):
190 |     # 2700..6500 => 370..153
191 |     mink: int = 2700
192 |     maxk: int = 6500
193 | 
194 |     def decode(self, device: "XDevice", payload: dict, value: int):
195 |         """Convert degrees kelvin to mired shift."""
196 |         payload[self.attr] = int(1000000.0 / value)
197 | 
198 |     def encode(self, device: "XDevice", payload: dict, value: int):
199 |         value = int(1000000.0 / value)
200 |         if value < self.mink:
201 |             value = self.mink
202 |         if value > self.maxk:
203 |             value = self.maxk
204 |         super().encode(device, payload, value)
205 | 
206 | 
207 | class RGBColor(BaseConv):
208 |     def decode(self, device: "XDevice", payload: dict, value: int):
209 |         payload[self.attr] = ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)
210 | 
211 |     def encode(self, device: "XDevice", payload: dict, value: tuple[int, int, int]):
212 |         value = value[0] << 16 | value[1] << 8 | value[2]
213 |         super().encode(device, payload, value)
214 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/converters/const.py:
--------------------------------------------------------------------------------
 1 | # buttons actions
 2 | UNKNOWN = "unknown"
 3 | BUTTON_SINGLE = "single"
 4 | BUTTON_DOUBLE = "double"
 5 | BUTTON_TRIPLE = "triple"
 6 | BUTTON_QUADRUPLE = "quadruple"
 7 | BUTTON_HOLD = "hold"
 8 | BUTTON_RELEASE = "release"
 9 | 
10 | BUTTON_1_SINGLE = "button_1_single"
11 | BUTTON_2_SINGLE = "button_2_single"
12 | BUTTON_3_SINGLE = "button_3_single"
13 | BUTTON_4_SINGLE = "button_4_single"
14 | 
15 | BUTTON_1_DOUBLE = "button_1_double"
16 | BUTTON_2_DOUBLE = "button_2_double"
17 | BUTTON_3_DOUBLE = "button_3_double"
18 | BUTTON_4_DOUBLE = "button_4_double"
19 | 
20 | BUTTON_1_HOLD = "button_1_hold"
21 | BUTTON_2_HOLD = "button_2_hold"
22 | BUTTON_3_HOLD = "button_3_hold"
23 | BUTTON_4_HOLD = "button_4_hold"
24 | 
25 | BUTTON_BOTH_12 = "button_both_12"
26 | BUTTON_BOTH_13 = "button_both_13"
27 | BUTTON_BOTH_23 = "button_both_23"
28 | 
29 | BUTTON_BOTH_SINGLE = "button_both_single"
30 | BUTTON_BOTH_DOUBLE = "button_both_double"
31 | BUTTON_BOTH_HOLD = "button_both_hold"
32 | 
33 | # https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/converters/fromZigbee.js#L4738
34 | BUTTON = {
35 |     1: BUTTON_SINGLE,
36 |     2: BUTTON_DOUBLE,
37 |     3: BUTTON_TRIPLE,
38 |     4: BUTTON_QUADRUPLE,
39 |     5: "quintuple",  # only Yeelight Dimmer
40 |     16: BUTTON_HOLD,
41 |     17: BUTTON_RELEASE,
42 |     18: "shake",
43 |     128: "many",
44 | }
45 | BUTTON_BOTH = {
46 |     4: BUTTON_SINGLE,
47 |     5: BUTTON_DOUBLE,
48 |     6: BUTTON_TRIPLE,
49 |     16: BUTTON_HOLD,
50 |     17: BUTTON_RELEASE,
51 | }
52 | 
53 | ENTITY_CONFIG = {"category": "config", "enabled": False}
54 | ENTITY_DIAGNOSTIC = {"category": "diagnostic"}
55 | ENTITY_DISABLED = {"enabled": False}
56 | ENTITY_LAZY = {"lazy": True}
57 | 
58 | UNIT_CELSIUS = "°C"
59 | UNIT_SECONDS = "s"
60 | UNIT_MINUTES = "min"
61 | 
62 | UNIT_METERS = "m"
63 | 
64 | # door: On means open, Off means closed
65 | # lock: On means open (unlocked), Off means closed (locked)
66 | STATE_UNLOCK = True
67 | STATE_LOCKED = False
68 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/converters/lumi.py:
--------------------------------------------------------------------------------
  1 | from dataclasses import dataclass
  2 | from typing import TYPE_CHECKING
  3 | 
  4 | from .base import BaseConv
  5 | from .const import BUTTON, BUTTON_BOTH, UNKNOWN
  6 | 
  7 | if TYPE_CHECKING:
  8 |     from ..device import XDevice
  9 | 
 10 | 
 11 | class ResetsConv(BaseConv):
 12 |     # noinspection PyTypedDict
 13 |     def decode(self, device: "XDevice", payload: dict, value: int):
 14 |         if "resets" in device.params and value > device.params["resets"]:
 15 |             device.extra.setdefault("new_resets", 0)
 16 |             device.extra["new_resets"] += value - device.params["resets"]
 17 | 
 18 |         super().decode(device, payload, value)
 19 | 
 20 | 
 21 | class BatVoltConv(BaseConv):
 22 |     childs = {"battery_voltage", "battery_original"}
 23 |     min: int = 2700
 24 |     max: int = 3200
 25 | 
 26 |     def decode(self, device: "XDevice", payload: dict, value: int):
 27 |         payload["battery_voltage"] = value
 28 | 
 29 |         if value <= self.min:
 30 |             payload[self.attr] = 0
 31 |         elif value >= self.max:
 32 |             payload[self.attr] = 100
 33 |         else:
 34 |             payload[self.attr] = int(100.0 * (value - self.min) / (self.max - self.min))
 35 | 
 36 | 
 37 | class ButtonConv(BaseConv):
 38 |     def decode(self, device: "XDevice", payload: dict, value: int):
 39 |         payload[self.attr] = value
 40 |         if self.attr == "button":
 41 |             payload["action"] = BUTTON.get(value, UNKNOWN)
 42 |         elif self.attr.startswith("button_both"):
 43 |             both = BUTTON_BOTH.get(value, UNKNOWN)
 44 |             payload["action"] = self.attr + "_" + both
 45 | 
 46 |         elif self.attr.startswith("button"):
 47 |             payload["action"] = self.attr + "_" + BUTTON.get(value, UNKNOWN)
 48 | 
 49 |     def encode_read(self, device: "XDevice", payload: dict):
 50 |         pass
 51 | 
 52 | 
 53 | class VibrationConv(BaseConv):
 54 |     def decode(self, device: "XDevice", payload: dict, value: int):
 55 |         payload[self.attr] = value
 56 |         # skip tilt and wait tilt_angle
 57 |         if value == 1:
 58 |             payload["action"] = "vibration"
 59 |         elif value == 3:
 60 |             payload["action"] = "drop"
 61 | 
 62 | 
 63 | class TiltAngleConv(BaseConv):
 64 |     def decode(self, device: "XDevice", payload: dict, value: int):
 65 |         payload["action"] = "tilt"
 66 |         payload["angle"] = value
 67 |         payload["vibration"] = 2
 68 | 
 69 | 
 70 | class ClimateConv(BaseConv):
 71 |     hvac = {"off": 0x01, "heat": 0x10, "cool": 0x11}
 72 |     fan = {"low": 0x00, "medium": 0x10, "high": 0x20, "auto": 0x30}
 73 | 
 74 |     def decode(self, device: "XDevice", payload: dict, value: int):
 75 |         # use payload to push data to climate entity
 76 |         # use device extra to pull data on encode
 77 |         # noinspection PyTypedDict
 78 |         payload[self.attr] = device.extra[self.attr] = value
 79 | 
 80 |     def encode(self, device: "XDevice", payload: dict, value: dict):
 81 |         if self.attr not in device.extra:
 82 |             return
 83 |         # noinspection PyTypedDict
 84 |         b = bytearray(device.extra[self.attr].to_bytes(4, "big"))
 85 |         if "hvac_mode" in value:
 86 |             b[0] = self.hvac[value["hvac_mode"]]
 87 |         if "fan_mode" in value:
 88 |             b[1] = self.fan[value["fan_mode"]]
 89 |         if "target_temp" in value:
 90 |             b[2] = int(value["target_temp"])
 91 |         value = int.from_bytes(b, "big")
 92 |         super().encode(device, payload, value)
 93 | 
 94 | 
 95 | class ClimateTempConv(BaseConv):
 96 |     def decode(self, device: "XDevice", payload: dict, value: int):
 97 |         payload[self.attr] = value if value < 255 else 0
 98 | 
 99 | 
100 | # we need get pos with one property and set pos with another
101 | class CurtainPosConv(BaseConv):
102 |     def encode(self, device: "XDevice", payload: dict, value):
103 |         conv = next(c for c in device.converters if c.attr == "target_position")
104 |         conv.encode(device, payload, value)
105 | 
106 | 
107 | @dataclass
108 | class LockActionConv(BaseConv):
109 |     map: dict = None
110 | 
111 |     def decode(self, device: "XDevice", payload: dict, value):
112 |         if self.attr in ("lock_control", "door_state", "lock_state"):
113 |             payload["action"] = "lock"
114 |             payload[self.attr] = self.map.get(value)
115 |         elif self.attr == "key_id":
116 |             payload["action"] = "lock"
117 |             payload[self.attr] = value
118 |         elif self.attr == "alarm":
119 |             v = self.map.get(value)
120 |             if v != "doorbell":
121 |                 payload["action"] = self.attr
122 |                 payload[self.attr] = v
123 |             else:
124 |                 payload["action"] = v
125 |         elif self.attr.endswith("_wrong"):
126 |             payload["action"] = "error"
127 |             payload["error"] = self.attr
128 |             payload[self.attr] = value
129 |         elif self.attr in ("error", "method"):
130 |             payload[self.attr] = self.map.get(value)
131 | 
132 | 
133 | @dataclass
134 | class LockConv(BaseConv):
135 |     mask: int = 0
136 | 
137 |     def decode(self, device: "XDevice", payload: dict, value: int):
138 |         # Hass: On means open (unlocked), Off means closed (locked)
139 |         payload[self.attr] = not bool(value & self.mask)
140 | 
141 | 
142 | class AqaraDNDTimeConv(BaseConv):
143 |     """
144 |     Encoding format:
145 |         Period: <START_HOUR>:<START_MINUTE> - <END_HOUR>:<END_MINUTE>
146 |         Encoded: AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD
147 |             (Each character represents 1 bit)
148 |             AAAAAAAA = binary number of <END_MINUTE>
149 |             BBBBBBBB = binary number of <END_HOUR>
150 |             CCCCCCCC = binary number of <START_MIN>
151 |             DDDDDDDD = binary number of <START_HOUR>
152 | 
153 |     Example:
154 |         Period: 23:59 - 10:44
155 |         Encoded: 00101100 00001010 00111011 00010111
156 |             00101100 = 44 <END_MINUTE>
157 |             00001010 = 10 <END_HOUR>
158 |             00111011 = 59 <START_MIN>
159 |             00010111 = 23 <START_HOUR>
160 |     """
161 | 
162 |     pattern = "^[0-2][0-9]:[0-5][0-9]-[0-2][0-9]:[0-5][0-9]
quot;
163 | 
164 |     def decode(self, device: "XDevice", payload: dict, v: int):
165 |         payload[self.attr] = (
166 |             f"{v & 0xFF:02d}:{(v >> 8) & 0xFF:02d}-"
167 |             f"{(v >> 16) & 0xFF:02d}:{(v >> 24) & 0xFF:02d}"
168 |         )
169 | 
170 |     def encode(self, device: "XDevice", payload: dict, v: str):
171 |         v = int(v[:2]) | int(v[3:5]) << 8 | int(v[6:8]) << 16 | int(v[9:11]) << 24
172 |         super().encode(device, payload, v)
173 | 
174 | 
175 | # global props
176 | LUMI_GLOBALS: dict[str, BaseConv] = {
177 |     "8.0.2001": BaseConv("battery_original"),
178 |     "8.0.2002": ResetsConv("resets"),
179 |     "8.0.2003": BaseConv("send_all_cnt"),
180 |     "8.0.2004": BaseConv("send_fail_cnt"),
181 |     "8.0.2005": BaseConv("send_retry_cnt"),
182 |     "8.0.2006": BaseConv("chip_temperature"),
183 |     "8.0.2007": BaseConv("lqi"),
184 |     "8.0.2008": BaseConv("battery_voltage"),
185 |     "8.0.2022": BaseConv("fw_ver"),
186 |     "8.0.2023": BaseConv("hw_ver"),
187 |     "8.0.2036": BaseConv("parent"),
188 |     "8.0.2041": BaseConv("8.0.2041"),  # =55
189 |     "8.0.2091": BaseConv("8.0.2091"),  # ota_progress?
190 |     "8.0.2156": BaseConv("nwk"),
191 |     "8.0.2228": BaseConv("8.0.2228"),  # =4367
192 |     "8.0.2231": BaseConv("8.0.2231"),  # =0
193 |     # skip online state
194 |     # "8.0.2102": OnlineConv("online", "binary_sensor"),
195 |     # "8.0.2156": Converter("nwk", "sensor"),
196 | }
197 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/converters/mesh.py:
--------------------------------------------------------------------------------
 1 | import re
 2 | from typing import TYPE_CHECKING
 3 | 
 4 | from .base import BaseConv
 5 | 
 6 | if TYPE_CHECKING:
 7 |     from ..device import XDevice
 8 | 
 9 | 
10 | class InductionRange(BaseConv):
11 |     def decode(self, device: "XDevice", payload: dict, value: int):
12 |         mask = "0 0.8 1.5 2.3 3.0 3.8 4.5 5.3 6"
13 |         for i in range(0, 8):
14 |             v = "+" if value & (1 << i) else "_"
15 |             mask = mask.replace(" ", v, 1)
16 |         payload[self.attr] = mask
17 | 
18 |     def encode(self, device: "XDevice", payload: dict, value: str):
19 |         m = re.findall(r"[+xv_-]", value)
20 |         if len(m) != 8:
21 |             return
22 |         mask = 0
23 |         for i, v in enumerate(m):
24 |             if v in "+xv":
25 |                 mask |= 1 << i
26 |         super().encode(device, payload, mask)
27 | 
28 | 
29 | class GiotTimePatternConv(BaseConv):
30 |     """
31 |     Period encoding:
32 |     8-digit number: HHMMhhmm
33 |         HH = start hour
34 |         MM = start minute
35 |         hh = end hour
36 |         mm = end minute
37 |     Example:
38 |         Period: 23:59 - 10:44
39 |         Encoded: 23591044
40 |     """
41 | 
42 |     pattern = "^[0-2][0-9]:[0-5][0-9]-[0-2][0-9]:[0-5][0-9]
quot;
43 | 
44 |     def decode(self, device: "XDevice", payload: dict, value: int):
45 |         value = str(value)
46 |         if len(value) != 8:
47 |             return
48 |         payload[self.attr] = f"{value[:2]}:{value[2:4]}-{value[4:6]}:{value[6:]}"
49 | 
50 |     def encode(self, device: "XDevice", payload: dict, value: str):
51 |         value = value.replace(":", "").replace("-", "")
52 |         if len(value) != 8:
53 |             return
54 |         super().encode(device, payload, int(value))
55 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/core_utils.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import base64
  3 | import hashlib
  4 | import hmac
  5 | import random
  6 | import socket
  7 | 
  8 | from .const import PID_BLE, SUPPORTED_MODELS
  9 | from .mini_miio import AsyncMiIO
 10 | from .shell.session import Session
 11 | from .xiaomi_cloud import MiCloud
 12 | 
 13 | 
 14 | async def check_port(host: str, port: int) -> bool:
 15 |     s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 16 |     s.settimeout(2)
 17 |     try:
 18 |         ok = await asyncio.get_event_loop().run_in_executor(
 19 |             None, s.connect_ex, (host, port)
 20 |         )
 21 |         return ok == 0
 22 |     finally:
 23 |         s.close()
 24 | 
 25 | 
 26 | async def gateway_info(host: str, token: str = None, key: str = None) -> dict | None:
 27 |     # Strategy:
 28 |     # 1. Check open telnet and return host, did, token, key
 29 |     # 2. Try to enable telnet using host, token and (optionaly) key
 30 |     # 3. Check open telnet again
 31 |     # 4. Return error
 32 |     try:
 33 |         async with Session(host) as sh:
 34 |             info = await sh.get_miio_info()
 35 |         info["host"] = host
 36 |         return info
 37 |     except:
 38 |         pass
 39 | 
 40 |     if not token:
 41 |         return None
 42 | 
 43 |     # try to enable telnet and return miio info
 44 |     result = await enable_telnet(host, token, key)
 45 | 
 46 |     # waiting for telnet to start
 47 |     await asyncio.sleep(1)
 48 | 
 49 |     # call with empty token so only telnet will check
 50 |     if info := await gateway_info(host):
 51 |         return info
 52 | 
 53 |     # result ok, but telnet can't be opened
 54 |     return {"error": "wrong_telnet" if result == "ok" else result}
 55 | 
 56 | 
 57 | # universal command for open telnet on all models
 58 | TELNET_CMD = "passwd -d $USER; riu_w 101e 53 3012 || echo enable > /sys/class/tty/tty/enable; telnetd"
 59 | 
 60 | 
 61 | async def enable_telnet(host: str, token: str, key: str = None) -> str:
 62 |     # Strategy:
 63 |     # 1. Get miio info
 64 |     miio = AsyncMiIO(host, token)
 65 |     if miio_info := await miio.info():
 66 |         model: str = miio_info.get("model")
 67 |         fwver: str = miio_info.get("fw_ver")
 68 |         # 2. Send different telnet cmd based on gateway model and firmware
 69 |         if model == "lumi.gateway.mgl03":
 70 |             if fwver < "1.4.6_0043":
 71 |                 methods = ["enable_telnet_service"]
 72 |             elif fwver < "1.5.5":
 73 |                 methods = ["set_ip_info"]
 74 |             else:
 75 |                 methods = ["system_command"]
 76 |         elif model in ("lumi.gateway.aqcn02", "lumi.gateway.aqcn03"):
 77 |             methods = ["set_ip_info" if fwver < "4.0.4" else "system_command"]
 78 |         elif model in ("lumi.gateway.mcn001", "lumi.gateway.mgl001"):
 79 |             methods = ["set_ip_info" if fwver < "1.0.7" else "system_command"]
 80 |         else:
 81 |             return "wrong_model"
 82 | 
 83 |         if "system_command" in methods and len(key or "") != 16:
 84 |             return "no_key"
 85 |     else:
 86 |         # 3. Send all open telnet cmd if we can't get miio info
 87 |         # PS. Can't try `system_command` without `miio_info`
 88 |         methods = ["set_ip_info", "enable_telnet_service"]
 89 | 
 90 |     # 4. Return ok or some error
 91 |     for method in methods:
 92 |         if method == "enable_telnet_service":
 93 |             params = None
 94 |         elif method == "set_ip_info":
 95 |             params = {"ssid": '""', "pswd": "1; " + TELNET_CMD}
 96 |         elif method == "system_command":
 97 |             params = {
 98 |                 "password": miio_password(miio.device_id, miio_info["mac"], key),
 99 |                 "command": TELNET_CMD,
100 |             }
101 |         else:
102 |             raise NotImplementedError(method)
103 | 
104 |         res = await miio.send(method, params, tries=1)
105 |         # set_ip_info: {'result': ['ok']}
106 |         # system_command: {'result': ['ok']}
107 |         # system_command: {'error': {'code': -4004, 'message': 'inner error'}}
108 |         if res and res.get("result") == ["ok"]:
109 |             return "ok"
110 | 
111 |     if miio_info:
112 |         return "wrong_telnet"
113 | 
114 |     if miio_info is not None:
115 |         return "wrong_token"
116 | 
117 |     return "cant_connect"
118 | 
119 | 
120 | def miio_password(did: str, mac: str, key: str) -> str:
121 |     secret = hashlib.sha256(f"{did}{mac}{key}".encode()).hexdigest()
122 |     dig = hmac.new(secret.encode(), msg=key.encode(), digestmod=hashlib.sha256).digest()
123 |     return base64.b64encode(dig)[-16:].decode()
124 | 
125 | 
126 | async def get_device_info(cloud: MiCloud, device: dict) -> dict:
127 |     info = {"Name": device["name"], "Model": device["model"], "MAC": device["mac"]}
128 | 
129 |     if device["pid"] != PID_BLE:
130 |         info["IP"] = device["localip"]
131 |         info["Token"] = device["token"]
132 |     else:
133 |         bindkey = await cloud.get_bindkey(device["did"])
134 |         info["Bindkey"] = bindkey or "Can't get from cloud"
135 | 
136 |     if fw_version := device["extra"].get("fw_version"):
137 |         info["Firmware"] = fw_version
138 | 
139 |     if device["model"] in SUPPORTED_MODELS:
140 |         gw_info = await gateway_info(device["localip"], device["token"])
141 |         if error := gw_info.get("error"):
142 |             info["Telnet"] = error
143 |         else:
144 |             info["Firmware"] = gw_info["version"]
145 |             info["Key"] = gw_info["key"]
146 |             info["Telnet"] = "open"
147 |     elif device["model"] == "lumi.gateway.v3":
148 |         info["LAN Key"] = await get_lan_key(device["localip"], device["token"])
149 |     elif ".vacuum." in device["model"]:
150 |         info["Rooms"] = await get_room_mapping(
151 |             cloud, device["localip"], device["token"]
152 |         )
153 |     elif device["model"] == "yeelink.light.bslamp2":
154 |         info["LAN mode"] = await enable_bslamp2_lan(device["localip"], device["token"])
155 |     elif device["model"].startswith("yeelink.light."):
156 |         info["Remotes"] = await get_ble_remotes(device["localip"], device["token"])
157 | 
158 |     return info
159 | 
160 | 
161 | async def get_lan_key(host: str, token: str):
162 |     device = AsyncMiIO(host, token)
163 |     resp = await device.send("get_lumi_dpf_aes_key")
164 |     if not resp:
165 |         return "Can't connect to gateway"
166 |     if "result" not in resp:
167 |         return f"Wrong response: {resp}"
168 |     resp = resp["result"]
169 |     if len(resp[0]) == 16:
170 |         return resp[0]
171 |     key = "".join(
172 |         random.choice("abcdefghijklmnopqrstuvwxyz01234567890") for _ in range(16)
173 |     )
174 |     resp = await device.send("set_lumi_dpf_aes_key", [key])
175 |     if resp.get("result") == ["ok"]:
176 |         return key
177 |     return "Can't update gateway key"
178 | 
179 | 
180 | async def get_room_mapping(cloud: MiCloud, host: str, token: str):
181 |     try:
182 |         device = AsyncMiIO(host, token)
183 |         local_rooms = await device.send("get_room_mapping")
184 |         cloud_rooms = await cloud.get_rooms()
185 |         result = ""
186 |         for local_id, cloud_id in local_rooms["result"]:
187 |             cloud_name = next(
188 |                 (p["name"] for p in cloud_rooms if p["id"] == cloud_id), "-"
189 |             )
190 |             result += f"\n- {local_id}: {cloud_name}"
191 |         return result
192 | 
193 |     except:
194 |         return "Can't get from cloud"
195 | 
196 | 
197 | async def enable_bslamp2_lan(host: str, token: str):
198 |     device = AsyncMiIO(host, token)
199 |     resp = await device.send("get_prop", ["lan_ctrl"])
200 |     if not resp:
201 |         return "Can't connect to lamp"
202 |     if resp.get("result") == ["1"]:
203 |         return "Already enabled"
204 |     resp = await device.send("set_ps", ["cfg_lan_ctrl", "1"])
205 |     if resp.get("result") == ["ok"]:
206 |         return "Enabled"
207 |     return "Can't enable LAN"
208 | 
209 | 
210 | async def get_ble_remotes(host: str, token: str):
211 |     device = AsyncMiIO(host, token)
212 |     resp = await device.send("ble_dbg_tbl_dump", {"table": "evtRuleTbl"})
213 |     if not resp:
214 |         return "Can't connect to lamp"
215 |     if "result" not in resp:
216 |         return f"Wrong response"
217 |     return "\n".join(
218 |         [f"{p['beaconkey']} ({format_mac(p['mac'])})" for p in resp["result"]]
219 |     )
220 | 
221 | 
222 | def format_mac(s: str) -> str:
223 |     return f"{s[10:]}:{s[8:10]}:{s[6:8]}:{s[4:6]}:{s[2:4]}:{s[:2]}".upper()
224 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/ezsp.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import io
  3 | import logging
  4 | import socket
  5 | import time
  6 | 
  7 | from homeassistant.core import HomeAssistant
  8 | from homeassistant.helpers.aiohttp_client import async_create_clientsession
  9 | from homeassistant.requirements import async_process_requirements
 10 | 
 11 | from .const import DOMAIN
 12 | from .shell.const import OPENMIIO_CMD
 13 | from .shell.session import Session
 14 | 
 15 | _LOGGER = logging.getLogger(__name__)
 16 | 
 17 | 
 18 | FIRMWARES = {
 19 |     "6.6.2.0": "https://github.com/stas336/mgl03_fw/raw/refs/heads/main/zigbee/ncp-uart-sw_mgl03_6_6_2_stock.gbl",
 20 |     "7.5.0.0": "https://github.com/stas336/mgl03_fw/raw/refs/heads/main/zigbee/ncp-uart-sw_mgl03_7_5_0_z2m.gbl",
 21 | }
 22 | 
 23 | 
 24 | async def update_zigbee_firmware(hass: HomeAssistant, host: str, custom: bool):
 25 |     tar_fw = "7.5.0.0" if custom else "6.6.2.0"
 26 | 
 27 |     _LOGGER.info(f"{host} [FWUP] Target zigbee firmware v{tar_fw}")
 28 | 
 29 |     session = Session(host)
 30 | 
 31 |     try:
 32 |         await session.connect()
 33 |         sh = await session.login()
 34 |     except Exception as e:
 35 |         _LOGGER.error("Can't connect to gateway", exc_info=e)
 36 |         await session.close()
 37 |         return False
 38 | 
 39 |     try:
 40 |         await async_process_requirements(
 41 |             hass,
 42 |             DOMAIN,
 43 |             [
 44 |                 "bellows>=0.29.0",
 45 |                 "pyserial>=3.5",
 46 |                 "pyserial-asyncio>=0.5",
 47 |             ],
 48 |         )
 49 | 
 50 |         await sh.exec(
 51 |             "zigbee_inter_bootloader.sh 1; zigbee_reset.sh 0; zigbee_reset.sh 1; "
 52 |             "killall openmiio_agent"
 53 |         )
 54 |         await sh.exec("/data/openmiio_agent --zigbee.tcp=8889 &")
 55 |         await asyncio.sleep(2)
 56 | 
 57 |         # some users have broken firmware, so unknown firmware also OK
 58 |         cur_fw = await read_firmware(host)
 59 |         _LOGGER.info(f"{host} [FWUP] Firmware before update: {cur_fw}")
 60 |         if cur_fw and cur_fw.startswith(tar_fw):
 61 |             _LOGGER.debug(f"{host} [FWUP] No need to update")
 62 |             return True
 63 | 
 64 |         await sh.exec(
 65 |             "zigbee_inter_bootloader.sh 0; zigbee_reset.sh 0; zigbee_reset.sh 1; "
 66 |             "killall openmiio_agent"
 67 |         )
 68 |         await sh.exec("/data/openmiio_agent --zigbee.tcp=8889 --zigbee.baud=115200 &")
 69 | 
 70 |         await async_process_requirements(hass, DOMAIN, ["xmodem==0.4.6"])
 71 | 
 72 |         client = async_create_clientsession(hass)
 73 |         r = await client.get(FIRMWARES[tar_fw])
 74 |         content = await r.read()
 75 | 
 76 |         ok = await hass.async_add_executor_job(flash_firmware, host, content)
 77 |         if not ok:
 78 |             return False
 79 | 
 80 |         await sh.exec(
 81 |             "zigbee_inter_bootloader.sh 1; zigbee_reset.sh 0; zigbee_reset.sh 1; "
 82 |             "killall openmiio_agent"
 83 |         )
 84 |         await sh.exec("/data/openmiio_agent --zigbee.tcp=8889 &")
 85 |         await asyncio.sleep(2)
 86 | 
 87 |         cur_fw = await read_firmware(host)
 88 |         _LOGGER.info(f"{host} [FWUP] Firmware after update: {cur_fw}")
 89 |         return cur_fw and cur_fw.startswith(tar_fw)
 90 | 
 91 |     except Exception as e:
 92 |         _LOGGER.error(f"{host} [FWUP] Can't update firmware", exc_info=e)
 93 | 
 94 |     finally:
 95 |         await sh.exec(
 96 |             "zigbee_inter_bootloader.sh 1; zigbee_reset.sh 0; zigbee_reset.sh 1; "
 97 |             "killall openmiio_agent; " + OPENMIIO_CMD
 98 |         )
 99 |         await sh.close()
100 | 
101 | 
102 | async def read_firmware(host: str) -> str | None:
103 |     version = None
104 |     try:
105 |         from bellows import ezsp
106 | 
107 |         ezsp.NETWORK_COORDINATOR_STARTUP_RESET_WAIT = 3
108 | 
109 |         ezsp = ezsp.EZSP(
110 |             {"path": f"socket://{host}:8889", "baudrate": 0, "flow_control": None}
111 |         )
112 |         await ezsp.connect(use_thread=False)
113 |         _, _, version = await ezsp.get_board_info()
114 |         await ezsp.disconnect()
115 |     except Exception as e:
116 |         _LOGGER.warning(f"{host} [FWUP] Read firmware error: {e}")
117 | 
118 |     _LOGGER.debug(f"{host} [FWUP] Current zigbee firmware v{version}")
119 | 
120 |     return version
121 | 
122 | 
123 | def flash_firmware(host: str, content: bytes) -> bool:
124 |     with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
125 |         sock.settimeout(3)
126 |         sock.connect((host, 8889))
127 | 
128 |         sock.send(b"\x0A")
129 | 
130 |         if b"Gecko Bootloader v1.8.0" not in read(sock):
131 |             _LOGGER.warning(f"{host} [FWUP] Not in boot before flash")
132 |             return False
133 | 
134 |         sock.send(b"1")
135 | 
136 |         if b"CCC" not in read(sock):
137 |             _LOGGER.warning(f"{host} [FWUP] Not in flash mode")
138 |             return False
139 | 
140 |         # STATIC FUNCTIONS
141 |         def getc(size, timeout=1):
142 |             read_data = sock.recv(size)
143 |             return read_data
144 | 
145 |         def putc(data, timeout=1):
146 |             sock.send(data)
147 |             time.sleep(0.001)
148 | 
149 |         # noinspection PyUnresolvedReferences
150 |         from xmodem import XMODEM
151 | 
152 |         modem = XMODEM(getc, putc)
153 |         modem.log = _LOGGER.getChild("xmodem")
154 |         stream = io.BytesIO(content)
155 | 
156 |         if not modem.send(stream):
157 |             _LOGGER.warning(f"{host} [FWUP] Xmodem send firmware fail")
158 |             return False
159 | 
160 |         if b"Serial upload complete" not in read(sock):
161 |             _LOGGER.warning(f"{host} [FWUP] Not in boot after flash")
162 |             return False
163 | 
164 |         return True
165 | 
166 | 
167 | def read(sock: socket) -> bytes:
168 |     raw = b""
169 | 
170 |     t = time.time() + sock.gettimeout()
171 |     while time.time() < t:
172 |         try:
173 |             b = sock.recv(1)
174 |             if b == 0:
175 |                 break
176 |             raw += b
177 |         except socket.timeout:
178 |             break
179 | 
180 |     return raw
181 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gate/base.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import logging
  3 | import time
  4 | from functools import cached_property
  5 | from logging import DEBUG, Logger
  6 | from typing import Callable
  7 | 
  8 | from ..const import GATEWAY
  9 | from ..device import XDevice, XDeviceExtra
 10 | from ..mini_mqtt import MQTTMessage, MiniMQTT
 11 | 
 12 | EVENT_ADD_DEVICE = "add_device"
 13 | EVENT_REMOVE_DEVICE = "remove_device"
 14 | EVENT_MQTT_CONNECT = "mqtt_connect"
 15 | EVENT_MQTT_PUBLISH = "mqtt_publish"
 16 | EVENT_TIMER = "timer"
 17 | 
 18 | 
 19 | class XGateway:
 20 |     devices: dict[str, XDevice] = {}  # key is device.did
 21 | 
 22 |     device: XDevice = None
 23 |     listeners: dict[str, list[Callable]]
 24 |     base_log: Logger = None
 25 |     timer_task: asyncio.Task
 26 | 
 27 |     def __init__(self, host: str, **kwargs):
 28 |         self.host = host
 29 | 
 30 |         self.available = False
 31 |         self.listeners = {}
 32 |         self.mqtt = MiniMQTT()
 33 |         self.options: dict = kwargs
 34 | 
 35 |         # setup smart loggers
 36 |         prefix = __package__[:-10]  # .core.gate
 37 |         self.base_log = logging.getLogger(f"{prefix}.gate.{host}")
 38 |         self.mqtt_log = logging.getLogger(f"{prefix}.mqtt.{host}")
 39 |         self.zigb_log = logging.getLogger(f"{prefix}.zigb.{host}")
 40 | 
 41 |         if debug := self.options.get("debug"):
 42 |             if "true" in debug:
 43 |                 self.base_log.setLevel(DEBUG)
 44 |             if "mqtt" in debug:
 45 |                 self.mqtt_log.setLevel(DEBUG)
 46 |             if "zigbee" in debug:
 47 |                 self.zigb_log.setLevel(DEBUG)
 48 | 
 49 |     @cached_property
 50 |     def stats_domain(self) -> str | None:
 51 |         stats = self.options.get("stats")
 52 |         if isinstance(stats, bool):
 53 |             return "sensor" if stats else None
 54 |         return stats
 55 | 
 56 |     def as_dict(self) -> dict:
 57 |         return {
 58 |             "host": self.host,
 59 |             "mac": self.device.extra["mac"],
 60 |             "name": self.device.human_name,
 61 |             "model": self.device.model,
 62 |             "fw_ver": self.device.extra["fw_ver"],
 63 |         }
 64 | 
 65 |     def debug(self, msg: str, device: XDevice = None, exc_info=None, **kwargs):
 66 |         if not self.base_log.isEnabledFor(DEBUG):
 67 |             return
 68 |         if device:
 69 |             msg = {"uid": device.uid, "did": device.did, "msg": msg}
 70 |         else:
 71 |             msg = {"msg": msg}
 72 |         self.base_log.debug(msg | kwargs, exc_info=exc_info)
 73 | 
 74 |     def warning(self, msg: str, exc_info=None):
 75 |         self.base_log.warning({"msg": msg}, exc_info=exc_info)
 76 | 
 77 |     def error(self, msg: str, exc_info=None):
 78 |         self.base_log.error({"msg": msg}, exc_info=exc_info)
 79 | 
 80 |     def add_event_listener(self, event: str, handler: Callable):
 81 |         listeners = self.listeners.setdefault(event, [])
 82 |         # protection from adding handler two times
 83 |         if any(i == handler for i in listeners):
 84 |             return
 85 |         listeners.append(handler)
 86 | 
 87 |     def dispatch_event(self, event: str, *args, **kwargs):
 88 |         try:
 89 |             if listeners := self.listeners.get(event):
 90 |                 for handler in listeners:
 91 |                     handler(*args, **kwargs)
 92 |         except Exception as e:
 93 |             self.error(f"dispatch_event: {event} {args} {kwargs}", exc_info=e)
 94 | 
 95 |     def remove_all_event_listners(self):
 96 |         self.listeners.clear()
 97 | 
 98 |     def init_device(self, model: str | int | None, **kwargs) -> XDevice:
 99 |         device = XDevice(model, **kwargs)
100 |         self.debug("init_device", device=device, data=device.extra)
101 |         self.devices[device.did] = device
102 |         return device
103 | 
104 |     def add_device(self, device: XDevice):
105 |         if self in device.gateways:
106 |             return
107 | 
108 |         device.restore_last_seen(self)
109 | 
110 |         self.debug("add_device", device=device)
111 |         device.gateways.append(self)
112 |         self.dispatch_event(EVENT_ADD_DEVICE, device)
113 | 
114 |     def remove_device(self, device: XDevice):
115 |         if self not in device.gateways:
116 |             return
117 |         self.debug("remove_device", device=device)
118 |         device.gateways.remove(self)
119 |         self.dispatch_event(EVENT_REMOVE_DEVICE, device)
120 | 
121 |     def remove_all_devices(self):
122 |         for device in self.devices.values():
123 |             self.remove_device(device)
124 | 
125 |     async def base_read_device(self, info: dict[str]):
126 |         self.device = self.devices.get(info["did"])
127 |         if not self.device:
128 |             extra: XDeviceExtra = {
129 |                 "did": info["did"],
130 |                 "type": GATEWAY,
131 |                 "mac": info["mac"].lower(),  # aa:bb:cc:dd:ee:ff
132 |                 "fw_ver": info["version"],
133 |             }
134 |             if "lan_mac" in info:
135 |                 extra["mac2"] = info["lan_mac"]
136 |             self.device = self.init_device(info["model"], **extra)
137 |         self.add_device(self.device)
138 | 
139 |     async def handle_mqtt_messages(self):
140 |         if not await self.mqtt.connect(self.host):
141 |             return
142 | 
143 |         try:
144 |             await self.mqtt.subscribe("#")
145 |             self.on_mqtt_connect()
146 |             async for msg in self.mqtt:
147 |                 self.on_mqtt_message(msg)
148 |         except Exception as e:
149 |             self.debug(f"MQTT processing issue", exc_info=e)
150 |         finally:
151 |             try:
152 |                 await self.mqtt.disconnect()
153 |                 await self.mqtt.close()
154 |                 self.on_mqtt_disconnect()
155 |             except Exception as e:
156 |                 self.debug(f"MQTT clossing issue", exc_info=e)
157 | 
158 |     def on_mqtt_connect(self):
159 |         self.debug("MQTT connected")
160 |         self.available = True
161 |         self.dispatch_event(EVENT_MQTT_CONNECT)
162 |         self.timer_task = asyncio.create_task(self.timer())
163 | 
164 |     def on_mqtt_disconnect(self):
165 |         self.debug("MQTT disconnected")
166 |         self.available = False
167 |         self.timer_task.cancel()
168 |         self.update_devices(int(time.time()))
169 | 
170 |     def on_mqtt_message(self, msg: MQTTMessage):
171 |         if msg.topic == "broker/ping":
172 |             return  # skip spam from broker/ping
173 | 
174 |         if self.mqtt_log.isEnabledFor(DEBUG):
175 |             self.mqtt_log.debug({"topic": msg.topic, "data": msg.payload})
176 | 
177 |         self.dispatch_event(EVENT_MQTT_PUBLISH, msg)
178 | 
179 |     async def timer(self):
180 |         while True:
181 |             ts = time.time()
182 |             self.update_devices(int(ts))
183 |             self.dispatch_event(EVENT_TIMER, ts)
184 |             await asyncio.sleep(30)
185 | 
186 |     def update_devices(self, ts: int):
187 |         for device in self.devices.values():
188 |             if self in device.gateways:
189 |                 device.update(ts)
190 | 
191 |     async def send(self, device: XDevice, data: dict):
192 |         pass
193 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gate/ble.py:
--------------------------------------------------------------------------------
 1 | import time
 2 | 
 3 | from .base import XGateway
 4 | from ..device import BLE
 5 | from ..mini_mqtt import MQTTMessage
 6 | from ..shell.shell_mgw import ShellMGW
 7 | 
 8 | 
 9 | # noinspection PyMethodMayBeStatic,PyUnusedLocal
10 | class BLEGateway(XGateway):
11 |     async def ble_read_devices(self, sh: ShellMGW):
12 |         db = await sh.read_db_bluetooth()
13 |         rows = db.read_table("gateway_authed_table")
14 |         for row in rows:
15 |             did = row[4]
16 |             device = self.devices.get(did)
17 |             if not device:
18 |                 mac = reverse_mac(row[1])  # aa:bb:cc:dd:ee:ff
19 |                 model = row[2]
20 |                 device = self.init_device(model, did=did, type=BLE, mac=mac)
21 |             self.add_device(device)
22 | 
23 |     def ble_on_mqtt_publish(self, msg: MQTTMessage):
24 |         if msg.topic in ("miio/report", "central/report"):
25 |             if b'"_async.ble_event"' in msg.payload:
26 |                 self.ble_process_event(msg.json["params"])
27 |             elif b'"_sync.ble_keep_alive"' in msg.payload:
28 |                 self.ble_process_keepalive(msg.json["params"])
29 | 
30 |     def ble_process_event(self, data: dict):
31 |         """
32 |         {
33 |             'dev': {'did': 'blt.3.xxx', 'mac': 'AA:BB:CC:DD:EE:FF', 'pdid': 2038},
34 |             'evt': [{'eid': 15, 'edata': '010000'}],
35 |             'frmCnt': 36, 'gwts': 1636208932
36 |         }
37 |         """
38 | 
39 |         did = data["dev"]["did"]
40 |         device = self.devices.get(did)
41 |         if not device:
42 |             # https://github.com/AlexxIT/XiaomiGateway3/issues/24
43 |             if "mac" not in data["dev"]:
44 |                 self.debug("Unknown device without mac", data=data)
45 |                 return
46 |             # create device "on the fly"
47 |             model = data["dev"]["pdid"]
48 |             mac = data["dev"]["mac"].lower()
49 |             device = self.init_device(model, type=BLE, mac=mac, did=did)
50 |             device.available = True
51 |             self.add_device(device)
52 | 
53 |         ts = device.on_keep_alive(self)
54 | 
55 |         if data["frmCnt"] == device.extra.get("seq"):
56 |             return
57 |         device.extra["seq"] = data["frmCnt"]
58 | 
59 |         device.on_report(data["evt"], self, ts)
60 |         if self.stats_domain:
61 |             device.dispatch({BLE: ts})
62 | 
63 |     def ble_process_keepalive(self, data: list):
64 |         ts = int(time.time())
65 | 
66 |         for item in data:
67 |             if device := self.devices.get(item["did"]):
68 |                 # noinspection PyTypedDict
69 |                 device.extra["rssi_" + self.device.uid] = item["rssi"]
70 |                 device.on_keep_alive(self, ts)
71 | 
72 | 
73 | def reverse_mac(s: str):
74 |     return f"{s[10:]}:{s[8:10]}:{s[6:8]}:{s[4:6]}:{s[2:4]}:{s[:2]}"
75 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gate/lumi.py:
--------------------------------------------------------------------------------
 1 | import json
 2 | import time
 3 | 
 4 | from .base import XGateway
 5 | from ..const import ZIGBEE
 6 | from ..device import XDevice, XDeviceExtra, hex_to_ieee
 7 | from ..mini_mqtt import MQTTMessage
 8 | from ..shell.shell_mgw import ShellMGW
 9 | from ..shell.shell_mgw2 import ShellMGW2
10 | 
11 | 
12 | class LumiGateway(XGateway):
13 |     async def lumi_read_devices(self, sh: ShellMGW | ShellMGW2):
14 |         raw = await sh.read_file("/data/zigbee/device.info")
15 |         lumi = json.loads(raw)["devInfo"]
16 | 
17 |         xiaomi_did = await sh.read_xiaomi_did()
18 | 
19 |         for item in lumi:
20 |             did = item["did"]
21 |             device = self.devices.get(did)
22 |             if not device:
23 |                 extra: XDeviceExtra = {
24 |                     "did": did,
25 |                     "type": ZIGBEE,
26 |                     "ieee": hex_to_ieee(item["mac"]),
27 |                     "nwk": item["shortId"],
28 |                     "fw_ver": item["appVer"],
29 |                     "hw_ver": item["hardVer"],
30 |                 }
31 |                 if did in xiaomi_did:
32 |                     extra["cloud_did"] = xiaomi_did[did]
33 |                 device = self.init_device(item["model"], **extra)
34 | 
35 |             self.add_device(device)
36 | 
37 |     def lumi_on_mqtt_publish(self, msg: MQTTMessage):
38 |         if msg.topic == "zigbee/send":
39 |             self.lumi_process_lumi(msg.json)
40 | 
41 |     def lumi_process_lumi(self, data: dict):
42 |         cmd: str = data["cmd"]
43 |         if cmd == "heartbeat":
44 |             # {"cmd":"heartbeat","params":[{"did":"lumi","res_list":[{"res_name":"8.0.2006","value":46}]}
45 |             data = data["params"][0]
46 |             items = data.get("res_list")
47 |         elif cmd == "report":
48 |             # {"cmd":"report","did":"lumi","params":[{"res_name":"0.3.85","value":129}],"mi_spec":[{"siid":2,"piid":1,"value":129}]}
49 |             items = join_params(data)
50 |         elif cmd == "read_rsp":
51 |             # {"cmd":"read_rsp","did":"lumi","results":[{"res_name":"8.0.2022","value":68,"error_code":0}]}
52 |             # {"cmd":"read_rsp","did":"lumi","mi_spec":[{"siid":5,"piid":2,"value":0,"code":0}]}
53 |             items = join_params(data)
54 |         elif cmd == "write_rsp" and data["did"] == "lumi.0":
55 |             # process write response only from Gateway
56 |             # {"cmd":"write_rsp","did":"lumi.0","results":[{"res_name":"8.0.2109","value":60,"error_code":0}]}
57 |             items = join_params(data)
58 |         else:
59 |             return
60 | 
61 |         if not items:
62 |             return
63 | 
64 |         did = self.device.did if data["did"] == "lumi.0" else data["did"]
65 |         if device := self.devices.get(did):
66 |             device.on_report(items, self, int(time.time()))
67 | 
68 |     async def lumi_send(self, device: XDevice, payload: dict):
69 |         assert payload["cmd"] in ("write", "read"), payload
70 |         for item in payload.get("params", []):
71 |             data = {"cmd": payload["cmd"], "did": payload["did"], "params": [item]}
72 |             await self.mqtt.publish("zigbee/recv", data)
73 |         for item in payload.get("mi_spec", []):
74 |             data = {"cmd": payload["cmd"], "did": payload["did"], "mi_spec": [item]}
75 |             await self.mqtt.publish("zigbee/recv", data)
76 | 
77 | 
78 | def join_params(data: dict) -> list | None:
79 |     result = None
80 |     for k in ("params", "results", "mi_spec"):
81 |         if k in data:
82 |             result = result + data[k] if result else data[k]
83 |     return result
84 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gate/matter.py:
--------------------------------------------------------------------------------
 1 | import json
 2 | import time
 3 | 
 4 | from .base import XGateway
 5 | from ..const import MATTER
 6 | from ..device import XDevice
 7 | from ..mini_mqtt import MQTTMessage
 8 | from ..shell.shell_mgw2 import ShellMGW2
 9 | 
10 | 
11 | class MatterGateway(XGateway):
12 |     async def matter_read_devices(self, sh: ShellMGW2):
13 |         raw = await sh.read_file("/data/matter/certification/device.json")
14 |         if not raw.startswith(b"["):
15 |             return
16 |         for item in json.loads(raw):
17 |             did = item["did"]
18 |             device = self.devices.get(did)
19 |             if not device:
20 |                 device = self.init_device(
21 |                     item["model"], did=did, type=MATTER, fw_ver=item["fw_ver"]
22 |                 )
23 |             self.add_device(device)
24 | 
25 |     def matter_on_mqtt_publish(self, msg: MQTTMessage):
26 |         if msg.topic == "local/matter/response":
27 |             if b'"properties_changed_v3"' in msg.payload:
28 |                 data = decode(msg.payload)
29 |                 self.matter_process_properties(data["result"][0]["RPC"]["params"])
30 |         elif msg.topic == "local/ot/rpcReq":
31 |             # {"method":"_sync.matter_dev_status","params":{"dev_list":null}}
32 |             if b'"_sync.matter_dev_status"' in msg.payload:
33 |                 data = decode(msg.payload)
34 |                 if dev_list := data["params"].get("dev_list"):
35 |                     self.matter_process_dev_status(dev_list)
36 | 
37 |     def matter_process_properties(self, params: list[dict]):
38 |         devices: dict[str, list] = {}
39 |         for item in params:
40 |             if item["did"] not in self.devices:
41 |                 continue
42 |             devices.setdefault(item["did"], []).append(item)
43 | 
44 |         ts = int(time.time())
45 | 
46 |         for did, params in devices.items():
47 |             device = self.devices[did]
48 |             device.on_keep_alive(self, ts)
49 |             device.on_report(params, self, ts)
50 |             if self.stats_domain:
51 |                 device.dispatch({MATTER: ts})
52 | 
53 |     def matter_process_dev_status(self, data: list[dict]):
54 |         ts = int(time.time())
55 | 
56 |         for item in data:
57 |             if device := self.devices.get(item["did"]):
58 |                 if item["status"] == "online":
59 |                     device.extra["rssi"] = item["rssi"]
60 |                     device.on_keep_alive(self, ts)
61 |                 else:
62 |                     device.last_seen.pop(self.device, None)
63 |                     device.update()
64 | 
65 |     async def matter_send(self, device: XDevice, payload: dict):
66 |         assert payload["method"] in ("set_properties_v3", "get_properties_v3"), payload
67 |         assert "params" in payload, payload
68 |         payload["id"] = id = int(time.time())
69 |         data = json.dumps(payload, separators=(",", ":"))
70 |         data = encode(0, id) + encode(1, "local/ot/rpcResponse") + encode(2, data)
71 |         await self.mqtt.publish("local/ot/rpcDown/" + payload["method"], data)
72 | 
73 | 
74 | def encode(pos: int, value: int | str) -> bytes:
75 |     if isinstance(value, int):
76 |         return b"\x04\x00\x00\x00" + bytes([pos]) + value.to_bytes(4, "little")
77 |     if isinstance(value, str):
78 |         value = value.encode() + b"\x00"
79 |         return len(value).to_bytes(4, "little") + bytes([pos]) + value
80 | 
81 | 
82 | def decode(data: bytes) -> dict:
83 |     i = data.index(b'\x00\x00\x02{"') + 3
84 |     return json.loads(data[i:].rstrip(b"\x00"))
85 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gate/mesh.py:
--------------------------------------------------------------------------------
 1 | import time
 2 | 
 3 | from .base import XGateway
 4 | from ..const import GROUP, MESH
 5 | from ..mini_mqtt import MQTTMessage
 6 | from ..shell.shell_mgw import ShellMGW
 7 | 
 8 | 
 9 | # noinspection PyMethodMayBeStatic,PyUnusedLocal
10 | class MeshGateway(XGateway):
11 |     async def mesh_read_devices(self, sh: ShellMGW):
12 |         try:
13 |             # prevent read database two times
14 |             db = await sh.read_db_bluetooth()
15 | 
16 |             childs = {}
17 | 
18 |             # load Mesh bulbs
19 |             rows = sh.db.read_table("mesh_device_v3")
20 |             for row in rows:
21 |                 did = row[0]
22 |                 device = self.devices.get(did)
23 |                 if not device:
24 |                     mac = row[1].lower()  # aa:bb:cc:dd:ee:ff
25 |                     model = row[2]
26 |                     device = self.init_device(model, did=did, mac=mac, type=MESH)
27 |                 self.add_device(device)
28 | 
29 |                 # add bulb to group address
30 |                 childs.setdefault(row[5], []).append(did)
31 | 
32 |             # load Mesh groups
33 |             rows = sh.db.read_table("mesh_group_v3")
34 |             for row in rows:
35 |                 did = "group." + row[0]
36 |                 device = self.devices.get(did)
37 |                 if not device:
38 |                     model = row[2]
39 |                     device = self.init_device(model, did=did, type=GROUP)
40 |                 # update childs of device
41 |                 device.extra["childs"] = childs.get(row[1])
42 |                 self.add_device(device)
43 | 
44 |         except Exception as e:
45 |             self.debug("Can't read mesh DB", exc_info=e)
46 | 
47 |     def mesh_on_mqtt_publish(self, msg: MQTTMessage):
48 |         if msg.topic == "miio/report":
49 |             if b'"_sync.ble_mesh_keep_alive"' in msg.payload:
50 |                 self.mesh_process_keepalive(msg.json["params"])
51 |             elif b'"_sync.ble_mesh_offline"' in msg.payload:
52 |                 self.mesh_process_offline(msg.json["params"]["list"])
53 |             # elif b'"_sync.ble_mesh_query_dev"' in msg.payload:
54 |             #     self.mesh_process_query_dev(msg.json["params"])
55 | 
56 |     def mesh_process_keepalive(self, data: list):
57 |         # "params":[{"did":"123","rssi":-52,"hops":0,"ts":123}],
58 |         ts = int(time.time())
59 | 
60 |         for item in data:
61 |             if device := self.devices.get(item["did"]):
62 |                 # noinspection PyTypedDict
63 |                 device.extra["rssi_" + self.device.uid] = item["rssi"]
64 |                 device.on_keep_alive(self, ts)
65 | 
66 |     def mesh_process_offline(self, data: list):
67 |         for item in data:
68 |             if device := self.devices.get(item["did"]):
69 |                 self.debug("ble_mesh_offline", device=device)
70 |                 device.last_seen.pop(self.device, None)
71 |                 device.update()
72 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gate/miot.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import time
  3 | 
  4 | from .base import XGateway
  5 | from ..const import BLE, MESH
  6 | from ..device import XDevice
  7 | from ..mini_mqtt import MQTTMessage
  8 | 
  9 | 
 10 | # noinspection PyMethodMayBeStatic,PyUnusedLocal
 11 | class MIoTGateway(XGateway):
 12 |     def miot_on_mqtt_publish(self, msg: MQTTMessage):
 13 |         if msg.topic in ("miio/report", "central/report"):
 14 |             if b'"properties_changed"' in msg.payload:
 15 |                 self.miot_process_properties(msg.json["params"], from_cache=False)
 16 |             elif b'"event_occured"' in msg.payload:
 17 |                 self.miot_process_event(msg.json["params"])
 18 |         elif msg.topic == "miio/command_ack":
 19 |             # check if it is response from `get_properties` command
 20 |             result = msg.json.get("result")
 21 |             if isinstance(result, list) and any(
 22 |                 "did" in i and "siid" in i and "value" in i
 23 |                 for i in result
 24 |                 if isinstance(i, dict)
 25 |             ):
 26 |                 self.miot_process_properties(result, from_cache=True)
 27 | 
 28 |     def miot_process_properties(self, params: list, from_cache: bool):
 29 |         """Can receive multiple properties from multiple devices.
 30 |         data = [{'did':123,'siid':2,'piid':1,'value':True,'tid':158}]
 31 |         """
 32 |         ts = int(time.time())
 33 | 
 34 |         # convert miio response format to multiple responses in lumi format
 35 |         devices: dict[str, list] = {}
 36 |         for item in params:
 37 |             if not (device := self.devices.get(item["did"])):
 38 |                 continue
 39 | 
 40 |             if from_cache:
 41 |                 # won't update last_seen for messages from_cache
 42 |                 # AND skip this messages if device not in last_seen
 43 |                 # but only for devices with available_timeout
 44 |                 if self.device not in device.last_seen and device.type in (BLE, MESH):
 45 |                     continue
 46 |             else:
 47 |                 device.on_keep_alive(self, ts)
 48 | 
 49 |             if (seq := item.get("tid")) is not None:
 50 |                 if seq == device.extra.get("seq"):
 51 |                     continue
 52 |                 device.extra["seq"] = seq
 53 | 
 54 |             devices.setdefault(item["did"], []).append(item)
 55 | 
 56 |         for did, params in devices.items():
 57 |             device = self.devices[did]
 58 |             device.on_report(params, self, ts)
 59 |             if self.stats_domain and device.type in (BLE, MESH):
 60 |                 device.dispatch({device.type: ts})
 61 | 
 62 |     def miot_process_event(self, item: dict):
 63 |         # {"did":"123","siid":8,"eiid":1,"tid":123,"ts":123,"arguments":[]}
 64 |         device = self.devices.get(item["did"])
 65 |         if not device:
 66 |             return
 67 | 
 68 |         ts = device.on_keep_alive(self)
 69 | 
 70 |         if (seq := item.get("tid")) is not None:
 71 |             if seq == device.extra.get("seq"):
 72 |                 return
 73 |             device.extra["seq"] = seq
 74 | 
 75 |         device.on_report(item, self, ts)
 76 |         if self.stats_domain and device.type in (BLE, MESH):
 77 |             device.dispatch({device.type: ts})
 78 | 
 79 |     async def miot_send(self, device: XDevice, payload: dict):
 80 |         assert payload["method"] in (
 81 |             "set_properties",
 82 |             "get_properties",
 83 |             "action",
 84 |         ), payload
 85 | 
 86 |         # check if we can send command via any second gateway
 87 |         gw2 = next((gw for gw in device.gateways if gw != self and gw.available), None)
 88 |         if gw2:
 89 |             await self.mqtt_publish_multiple(device, payload, gw2)
 90 |         else:
 91 |             await self.mqtt.publish("miio/command", payload)
 92 | 
 93 |     async def mqtt_publish_multiple(
 94 |         self, device: XDevice, payload: dict, gw2, delay: float = 1.0
 95 |     ):
 96 |         fut = asyncio.get_event_loop().create_future()
 97 | 
 98 |         def try_set_result(r):
 99 |             if not fut.done():
100 |                 fut.set_result(r)
101 | 
102 |         device.add_listener(try_set_result)
103 |         await self.mqtt.publish("miio/command", payload)
104 |         try:
105 |             async with asyncio.timeout(delay):
106 |                 await fut
107 |         except TimeoutError:
108 |             await gw2.mqtt.publish("miio/command", payload)
109 |         finally:
110 |             device.remove_listener(try_set_result)
111 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gate/openmiio.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import random
  3 | import time
  4 | 
  5 | from .base import XGateway
  6 | from .. import core_utils
  7 | from ..const import GATEWAY
  8 | from ..converters.base import encode_time
  9 | from ..mini_mqtt import MQTTMessage
 10 | from ..shell.session import Session
 11 | from ..shell.shell_mgw import ShellMGW
 12 | from ..shell.shell_mgw2 import ShellMGW2
 13 | 
 14 | 
 15 | class OpenMiioGateway(XGateway):
 16 |     miio_ack: dict[int, asyncio.Future] = {}  # TODO: init in constructor
 17 |     openmiio_last_ts: float = 0
 18 | 
 19 |     async def openmiio_prepare_gateway(self, sh: ShellMGW | ShellMGW2):
 20 |         latest = await sh.check_openmiio()
 21 |         if not latest:
 22 |             self.debug("openmiio: download latest version")
 23 |             await sh.download_openmiio()
 24 | 
 25 |             latest = await sh.check_openmiio()
 26 |             if not latest:
 27 |                 raise Exception("openmiio: can't run latest version")
 28 |         else:
 29 |             self.debug("openmiio: latest version detected")
 30 | 
 31 |         if "openmiio_agent" not in await sh.get_running_ps():
 32 |             self.debug("openmiio: run latest version")
 33 |             await sh.run_openmiio()
 34 | 
 35 |             mqtt_online = await core_utils.check_port(self.host, 1883)
 36 |             if not mqtt_online:
 37 |                 self.debug("openmiio: waiting for MQTT to start")
 38 |                 await asyncio.sleep(2)
 39 | 
 40 |         # let openmiio boot
 41 |         self.openmiio_last_ts = time.time()
 42 | 
 43 |     async def openmiio_send(
 44 |         self, method: str, params: dict | list = None, timeout: int = 5
 45 |     ):
 46 |         fut = asyncio.get_event_loop().create_future()
 47 | 
 48 |         cid = random.randint(1_000_000_000, 2_147_483_647)
 49 |         self.miio_ack[cid] = fut
 50 | 
 51 |         payload = {"id": cid, "method": method, "params": params}
 52 |         await self.mqtt.publish("miio/command", payload)
 53 | 
 54 |         try:
 55 |             await asyncio.wait_for(self.miio_ack[cid], timeout)
 56 |         except asyncio.TimeoutError:
 57 |             return None
 58 |         finally:
 59 |             del self.miio_ack[cid]
 60 | 
 61 |         return fut.result()
 62 | 
 63 |     def openmiio_on_mqtt_publish(self, msg: MQTTMessage):
 64 |         if msg.topic == "openmiio/report":
 65 |             self.openmiio_last_ts = time.time()
 66 |             self.device.dispatch({GATEWAY: msg.json})
 67 | 
 68 |         elif msg.topic == "miio/command_ack":
 69 |             if ack := self.miio_ack.get(msg.json["id"]):
 70 |                 ack.set_result(msg.json)
 71 | 
 72 |         elif msg.topic == "miio/report":
 73 |             if b'"event.gw.heartbeat"' in msg.payload:
 74 |                 self.openmiio_process_gw_heartbeat(msg.json["params"][0])
 75 | 
 76 |     def openmiio_on_timer(self, ts: float):
 77 |         if ts - self.openmiio_last_ts < 60:
 78 |             return
 79 | 
 80 |         self.debug("openmiio: WARNING report timeout")
 81 |         self.device.dispatch({GATEWAY: {"openmiio": {"uptime": None}}})
 82 |         asyncio.create_task(self.openmiio_restart())
 83 | 
 84 |     async def openmiio_restart(self):
 85 |         try:
 86 |             async with Session(self.host) as sh:
 87 |                 if await sh.only_one():
 88 |                     await self.openmiio_prepare_gateway(sh)
 89 |         except Exception as e:
 90 |             self.warning("Can't restart openmiio", exc_info=e)
 91 | 
 92 |     def openmiio_process_gw_heartbeat(self, data: dict):
 93 |         payload = {
 94 |             "free_mem": data["free_mem"],
 95 |             "load_avg": data["load_avg"],
 96 |             "rssi": data["rssi"] if data["rssi"] <= 0 else data["rssi"] - 100,
 97 |             "uptime": encode_time(data["run_time"]),
 98 |         }
 99 |         self.device.extra["rssi"] = payload["rssi"]
100 |         self.device.dispatch({GATEWAY: payload})
101 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/gateway.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | 
  3 | from . import core_utils
  4 | from .const import GATEWAY, GROUP, MATTER, MESH, ZIGBEE
  5 | from .device import XDevice
  6 | from .gate.base import EVENT_MQTT_PUBLISH, EVENT_TIMER
  7 | from .gate.ble import BLEGateway
  8 | from .gate.lumi import LumiGateway
  9 | from .gate.matter import MatterGateway
 10 | from .gate.mesh import MeshGateway
 11 | from .gate.miot import MIoTGateway
 12 | from .gate.openmiio import OpenMiioGateway
 13 | from .gate.silabs import SilabsGateway
 14 | from .shell.session import Session
 15 | 
 16 | 
 17 | class MultiGateway(
 18 |     BLEGateway,
 19 |     LumiGateway,
 20 |     MatterGateway,
 21 |     MeshGateway,
 22 |     MIoTGateway,
 23 |     OpenMiioGateway,
 24 |     SilabsGateway,
 25 | ):
 26 |     main_task: asyncio.Task | None = None
 27 | 
 28 |     def start(self):
 29 |         if self.main_task:
 30 |             return
 31 |         self.debug("start")
 32 |         self.main_task = asyncio.create_task(self.run_forever())
 33 | 
 34 |     async def stop(self):
 35 |         if not self.main_task:
 36 |             return
 37 | 
 38 |         self.debug("stop")
 39 |         # wait main task for finished: mqtt disconnect and gateway available false
 40 |         # updated for all devices
 41 |         while not self.main_task.cancelled():
 42 |             self.main_task.cancel()
 43 |             await asyncio.sleep(0.1)
 44 |         self.main_task = None
 45 | 
 46 |     async def run_forever(self):
 47 |         while True:
 48 |             # check if telnet port OK
 49 |             if not await core_utils.check_port(self.host, 23):
 50 |                 if not await self.enable_telnet():
 51 |                     await asyncio.sleep(30)
 52 |                     continue
 53 | 
 54 |             if not await self.prepare_gateway():
 55 |                 await asyncio.sleep(60)
 56 |                 continue
 57 | 
 58 |             await self.handle_mqtt_messages()
 59 | 
 60 |     async def enable_telnet(self) -> bool:
 61 |         """Enable telnet with miio protocol."""
 62 |         if not (token := self.options.get("token")):
 63 |             return False
 64 |         try:
 65 |             resp = await core_utils.enable_telnet(
 66 |                 self.host, token, self.options.get("key")
 67 |             )
 68 |             self.debug("enable_telnet", data=resp)
 69 |             return resp == "ok"
 70 |         except Exception as e:
 71 |             self.debug("enable_telnet", exc_info=e)
 72 |             return False
 73 | 
 74 |     async def prepare_gateway(self) -> bool:
 75 |         try:
 76 |             async with Session(self.host) as sh:
 77 |                 if not await sh.only_one():
 78 |                     self.debug("Connection from a second Hass detected")
 79 |                     return False
 80 | 
 81 |                 info = await sh.get_miio_info()
 82 |                 model, fw = info["model"], info["version"]
 83 | 
 84 |                 if model == "lumi.gateway.mgl03" and fw < "1.4.7_0160":
 85 |                     self.warning(f"Unsupported firmware: {info}")
 86 | 
 87 |                 support_ble_mesh = model in (
 88 |                     "lumi.gateway.mgl03",
 89 |                     "lumi.gateway.mcn001",
 90 |                     "lumi.gateway.mgl001",
 91 |                 )
 92 |                 support_matter = model == "lumi.gateway.mgl001" and fw >= "1.0.7_0019"
 93 | 
 94 |                 await self.base_read_device(info)
 95 |                 await self.lumi_read_devices(sh)
 96 |                 await self.silabs_read_device(sh)
 97 |                 await self.openmiio_prepare_gateway(sh)
 98 | 
 99 |                 if support_ble_mesh:
100 |                     await self.ble_read_devices(sh)
101 |                     await self.mesh_read_devices(sh)
102 | 
103 |                 if support_matter:
104 |                     await self.matter_read_devices(sh)
105 | 
106 |             self.add_event_listener(EVENT_MQTT_PUBLISH, self.lumi_on_mqtt_publish)
107 |             self.add_event_listener(EVENT_MQTT_PUBLISH, self.miot_on_mqtt_publish)
108 |             self.add_event_listener(EVENT_MQTT_PUBLISH, self.openmiio_on_mqtt_publish)
109 |             self.add_event_listener(EVENT_MQTT_PUBLISH, self.silabs_on_mqtt_publish)
110 | 
111 |             if support_ble_mesh:
112 |                 self.add_event_listener(EVENT_MQTT_PUBLISH, self.ble_on_mqtt_publish)
113 |                 self.add_event_listener(EVENT_MQTT_PUBLISH, self.mesh_on_mqtt_publish)
114 | 
115 |             if support_matter:
116 |                 self.add_event_listener(EVENT_MQTT_PUBLISH, self.matter_on_mqtt_publish)
117 | 
118 |             self.add_event_listener(EVENT_TIMER, self.openmiio_on_timer)
119 |             self.add_event_listener(EVENT_TIMER, self.silabs_on_timer)
120 | 
121 |             return True
122 |         except Exception as e:
123 |             self.debug("Can't prepare gateway", exc_info=e)
124 |             return False
125 | 
126 |     async def send(self, device: XDevice, data: dict):
127 |         if device.type == GATEWAY:
128 |             # support multispec in lumi and miot formats
129 |             if "cmd" in data and "method" in data:
130 |                 lumi_data = {
131 |                     "cmd": data["cmd"],
132 |                     "did": "lumi.0",
133 |                     "params": [i for i in data["params"] if "res_name" in i],
134 |                 }
135 |                 miot_data = {
136 |                     "method": data["method"],
137 |                     "params": [i for i in data["params"] if "siid" in i],
138 |                 }
139 |                 await self.lumi_send(device, lumi_data)
140 |                 await self.miot_send(device, miot_data)
141 |             elif "cmd" in data:
142 |                 await self.lumi_send(device, data)
143 |             elif "method" in data:
144 |                 await self.miot_send(device, data)
145 | 
146 |         elif device.type == ZIGBEE:
147 |             # support multispec in lumi and silabs format
148 |             if "cmd" in data and "commands" in data:
149 |                 lumi_data = {"cmd": data["cmd"], "did": data["did"]}
150 |                 if "params" in data:
151 |                     lumi_data["params"] = data["params"]
152 |                 if "mi_spec" in data:
153 |                     lumi_data["mi_spec"] = data["mi_spec"]
154 |                 silabs_data = {"commands": data["commands"]}
155 |                 await self.lumi_send(device, lumi_data)
156 |                 await self.silabs_send(device, silabs_data)
157 |             elif "cmd" in data:
158 |                 await self.lumi_send(device, data)
159 |             elif "commands" in data:
160 |                 await self.silabs_send(device, data)
161 | 
162 |         elif device.type in (MESH, GROUP):
163 |             await self.miot_send(device, data)
164 |         elif device.type == MATTER:
165 |             await self.matter_send(device, data)
166 | 
167 |     async def telnet_command(self, cmd: str) -> bool | None:
168 |         self.debug("telnet_command", data=cmd)
169 |         try:
170 |             async with Session(self.host) as sh:
171 |                 if cmd == "run_ftp":
172 |                     await sh.run_ftp()
173 |                     return True
174 |                 elif cmd == "reboot":
175 |                     await sh.reboot()
176 |                     return True
177 |                 elif cmd == "openmiio_restart":
178 |                     await sh.exec("killall openmiio_agent")
179 |                     await asyncio.sleep(1)
180 |                     await self.openmiio_prepare_gateway(sh)
181 |                     return True
182 |                 elif cmd == "check_firmware_lock":
183 |                     return await sh.check_firmware_lock()
184 |                 elif cmd == "lock_firmware":
185 |                     await sh.lock_firmware(True)
186 |                     return await sh.check_firmware_lock() is True
187 |                 elif cmd == "unlock_firmware":
188 |                     await sh.lock_firmware(False)
189 |                     return await sh.check_firmware_lock() is False
190 | 
191 |         except Exception as e:
192 |             self.error(f"Can't run telnet command: {cmd}", exc_info=e)
193 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/logger.py:
--------------------------------------------------------------------------------
 1 | """
 2 | Logging can be setup from:
 3 | 
 4 | 1. Hass default config
 5 | 
 6 | ```yaml
 7 | logger:
 8 |  logs:
 9 |    custom_components.xiaomi_gateway3: debug
10 | ```
11 | 
12 | 2. Integration config (YAML)
13 | 
14 | ```yaml
15 | xiaomi_gateway3:
16 |   logger:
17 |     filename: xiaomi_gateway3.log
18 |     propagate: False  # disable log to home-assistant.log and console
19 |     max_bytes: 100000000
20 |     backup_count: 3
21 | ```
22 | 
23 | 3. Integration config (GUI)
24 | 
25 | Configuration > Xiaomi Gateway 3 > Configure > Debug
26 | """
27 | 
28 | import logging
29 | import os
30 | from logging import Formatter
31 | from logging.handlers import RotatingFileHandler
32 | from queue import SimpleQueue
33 | 
34 | import voluptuous as vol
35 | from homeassistant.const import CONF_FILENAME
36 | from homeassistant.helpers import config_validation as cv
37 | from homeassistant.util.logging import HomeAssistantQueueHandler
38 | 
39 | FMT = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
40 | 
41 | CONFIG_SCHEMA = vol.Schema(
42 |     {
43 |         vol.Optional("level", default="debug"): cv.string,
44 |         vol.Optional("propagate", default=True): cv.boolean,
45 |         vol.Optional(CONF_FILENAME): cv.string,
46 |         vol.Optional("mode", default="a"): cv.string,
47 |         vol.Optional("max_bytes", default=0): cv.positive_int,
48 |         vol.Optional("backup_count", default=0): cv.positive_int,
49 |         vol.Optional("format", default=FMT): cv.string,
50 |     },
51 |     extra=vol.ALLOW_EXTRA,
52 | )
53 | 
54 | 
55 | def init(logger_name: str, config: dict, config_dir: str = None):
56 |     level = config["level"].upper()
57 | 
58 |     logger = logging.getLogger(logger_name)
59 |     logger.propagate = config["propagate"]
60 |     logger.setLevel(level)
61 | 
62 |     filename = config.get(CONF_FILENAME)
63 |     if filename:
64 |         if config_dir:
65 |             filename = os.path.join(config_dir, filename)
66 | 
67 |         file_handler = RotatingFileHandler(
68 |             filename,
69 |             config["mode"],
70 |             config["max_bytes"],
71 |             config["backup_count"],
72 |         )
73 | 
74 |         fmt = Formatter(config["format"])
75 |         file_handler.setFormatter(fmt)
76 | 
77 |         # copy logic from homeassistant/utils/logging.py
78 |         queue: SimpleQueue[logging.Handler] = SimpleQueue()
79 |         queue_handler = HomeAssistantQueueHandler(queue)
80 |         queue_handler.listener = logging.handlers.QueueListener(queue, file_handler)
81 |         queue_handler.listener.start()
82 | 
83 |         logger.addHandler(queue_handler)
84 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/shell/base.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | import base64
 3 | 
 4 | import aiohttp
 5 | 
 6 | 
 7 | class ShellBase:
 8 |     def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
 9 |         self.reader = reader
10 |         self.writer = writer
11 | 
12 |     async def close(self):
13 |         if not self.writer:
14 |             return
15 |         self.writer.close()
16 |         await self.writer.wait_closed()
17 | 
18 |     async def write(self, data: bytes, until: bytes, timeout: float) -> bytes:
19 |         self.writer.write(data)
20 |         coro = self.reader.readuntil(until)
21 |         return await asyncio.wait_for(coro, timeout)
22 | 
23 |     async def exec(self, command: str, as_bytes=False, timeout=10) -> str | bytes:
24 |         """Run command and return it result."""
25 |         self.writer.write(command.encode() + b"\n")
26 |         coro = self.reader.readuntil(b"# ")
27 |         raw = await asyncio.wait_for(coro, timeout=timeout)
28 |         return raw[:-2] if as_bytes else raw[:-2].decode()
29 | 
30 |     async def read_file(self, filename: str, as_base64=False, tail=None):
31 |         command = f"tail -c {tail} {filename}" if tail else f"cat {filename}"
32 |         if as_base64:
33 |             command += " | base64"
34 |         try:
35 |             raw = await self.exec(command, as_bytes=True, timeout=60)
36 |             # b"cat: can't open ..."
37 |             return base64.b64decode(raw) if as_base64 else raw
38 |         except:
39 |             return None
40 | 
41 |     async def write_file(self, filename: str, data: bytes):
42 |         # start new file
43 |         await self.exec(f"> {filename}")
44 | 
45 |         size = 700  # total exec cmd should be lower than 1024 symbols
46 |         for i in range(0, len(data), size):
47 |             b = base64.b64encode(data[i : i + size]).decode()
48 |             await self.exec(f"echo -n {b} | base64 -d >> {filename}")
49 | 
50 |     async def only_one(self) -> bool:
51 |         # run shell with dummy option, so we can check if second Hass connected
52 |         # shell will close automatically when disconnected from telnet
53 |         raw = await self.exec("(ps|grep -v grep|grep -q 'sh +o') || sh +o")
54 |         return "set -o errexit" in raw
55 | 
56 |     async def get_running_ps(self) -> str:
57 |         return await self.exec("ps")
58 | 
59 |     async def reboot(self):
60 |         # should not wait for response
61 |         self.writer.write(b"reboot\n")
62 |         await self.writer.drain()
63 |         # have to wait or the magic won't happen
64 |         await asyncio.sleep(1)
65 | 
66 |     @staticmethod
67 |     async def download(url_or_path: str) -> bytes:
68 |         if not url_or_path.startswith("http"):
69 |             with open(url_or_path, "rb") as f:
70 |                 return f.read()
71 | 
72 |         timeout = aiohttp.ClientTimeout(total=60)
73 |         async with aiohttp.ClientSession(timeout=timeout) as session:
74 |             async with session.get(url_or_path) as resp:
75 |                 return await resp.read()
76 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/shell/const.py:
--------------------------------------------------------------------------------
1 | # rewrite log every time to prevent memory overflow
2 | OPENMIIO_CMD = "/data/openmiio_agent miio mqtt cache central z3 --zigbee.tcp=8888 > /var/log/openmiio.log 2>&1 &"
3 | OPENMIIO_BASE = "https://github.com/AlexxIT/openmiio_agent/releases/download/v1.2.1/"
4 | OPENMIIO_MD5_MIPS = "6c3f4dca62647b9d19a81e1ccaa5ccc0"
5 | OPENMIIO_MD5_ARM = "bb0b33b8d71acbfb9668ae9a0600c2d8"
6 | OPENMIIO_URL_MIPS = OPENMIIO_BASE + "openmiio_agent_mips"
7 | OPENMIIO_URL_ARM = OPENMIIO_BASE + "openmiio_agent_arm"
8 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/shell/session.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | 
 3 | from .shell_e1 import ShellE1
 4 | from .shell_mgw import ShellMGW
 5 | from .shell_mgw2 import ShellMGW2
 6 | 
 7 | 
 8 | class Session:
 9 |     reader: asyncio.StreamReader
10 |     writer: asyncio.StreamWriter
11 | 
12 |     def __init__(self, host: str, port=23):
13 |         self.coro = asyncio.open_connection(host, port, limit=1_000_000)
14 | 
15 |     async def __aenter__(self):
16 |         await self.connect()
17 |         return await self.login()
18 | 
19 |     async def __aexit__(self, exc_type, exc, tb):
20 |         await self.close()
21 | 
22 |     async def connect(self):
23 |         self.reader, self.writer = await asyncio.wait_for(self.coro, 5)
24 | 
25 |     async def close(self):
26 |         self.writer.close()
27 |         await self.writer.wait_closed()
28 | 
29 |     async def login(self) -> ShellMGW | ShellE1 | ShellMGW2:
30 |         coro = self.reader.readuntil(b"login: ")
31 |         resp: bytes = await asyncio.wait_for(coro, 3)
32 | 
33 |         if b"rlxlinux" in resp:
34 |             shell = ShellMGW(self.reader, self.writer)
35 |         elif b"Aqara-Hub-E1" in resp or b"Aqara_Hub_E1" in resp:
36 |             shell = ShellE1(self.reader, self.writer)
37 |         elif b"Mijia_Hub_V2" in resp:
38 |             shell = ShellMGW2(self.reader, self.writer)
39 |         else:
40 |             raise Exception(f"Unknown response: {resp}")
41 | 
42 |         await shell.login()
43 |         await shell.prepare()
44 | 
45 |         return shell
46 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/shell/shell_e1.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | import re
 3 | 
 4 | from .base import ShellBase
 5 | from .const import OPENMIIO_CMD, OPENMIIO_MD5_ARM, OPENMIIO_URL_ARM
 6 | 
 7 | 
 8 | class ShellE1(ShellBase):
 9 |     async def login(self):
10 |         self.writer.write(b"root\n")
11 |         await asyncio.sleep(0.1)
12 |         self.writer.write(b"\n")  # empty password
13 | 
14 |         coro = self.reader.readuntil(b" # ")
15 |         await asyncio.wait_for(coro, timeout=3)
16 | 
17 |     async def prepare(self):
18 |         # change bash end symbol to gw3 style
19 |         self.writer.write(b"export PS1='# '\n")
20 |         coro = self.reader.readuntil(b"\r\n# ")
21 |         await asyncio.wait_for(coro, timeout=3)
22 | 
23 |         await self.exec("stty -echo")
24 | 
25 |     async def get_version(self) -> str:
26 |         raw1 = await self.exec("agetprop ro.sys.mi_fw_ver")
27 |         raw2 = await self.exec("agetprop ro.sys.mi_build_num")
28 |         return f"{raw1.rstrip()}_{raw2.rstrip()}"
29 | 
30 |     async def get_miio_info(self) -> dict:
31 |         raw = await self.exec("agetprop | grep persist")
32 | 
33 |         m = re.findall(r"([a-z_]+)]: \[(.+?)]", raw)
34 |         props: dict[str, str] = dict(m)
35 | 
36 |         return {
37 |             "did": props["miio_did"],
38 |             "key": props["miio_key"],
39 |             "mac": props["miio_mac"],
40 |             "model": props["model"],
41 |             "token": props["miio_dtoken"].encode().hex(),
42 |             "lan_mac": props.get("lan_mac"),
43 |             "version": await self.get_version()
44 |         }
45 | 
46 |     async def read_xiaomi_did(self) -> dict[str, str]:
47 |         raw = await self.exec("cat /data/mha_master/*.json|grep xiaomi_did")
48 |         m = re.findall(r"(lumi.[a-f0-9]+).+(\d{9,})", raw)
49 |         return dict(m)
50 | 
51 |     async def check_openmiio(self) -> bool:
52 |         """Check binary exec flag and MD5."""
53 |         cmd = f"[ -x /data/openmiio_agent ] && md5sum /data/openmiio_agent"
54 |         return OPENMIIO_MD5_ARM in await self.exec(cmd)
55 | 
56 |     async def download_openmiio(self):
57 |         """Kill previous binary, download new one, upload it to gw and set exec flag"""
58 |         await self.exec("killall openmiio_agent")
59 | 
60 |         raw = await self.download(OPENMIIO_URL_ARM)
61 |         await self.write_file("/data/openmiio_agent", raw)
62 | 
63 |         await self.exec("chmod +x /data/openmiio_agent")
64 | 
65 |     async def run_openmiio(self):
66 |         await self.exec(OPENMIIO_CMD)
67 | 
68 |     async def prevent_unpair(self):
69 |         await self.exec("killall mha_master")
70 | 
71 |     async def run_ftp(self):
72 |         await self.exec("tcpsvd -E 0.0.0.0 21 ftpd -w &")
73 | 
74 |     async def read_silabs_devices(self) -> bytes:
75 |         return await self.read_file("/data/devices.txt")
76 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/shell/shell_mgw.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import re
  3 | 
  4 | from .base import ShellBase
  5 | from .const import OPENMIIO_CMD, OPENMIIO_MD5_MIPS, OPENMIIO_URL_MIPS
  6 | from ..unqlite import SQLite
  7 | 
  8 | CHECK_FIRMWARE = "/data/busybox lsattr /data/firmware/firmware_ota.bin"
  9 | LOCK_FIRMWARE = "mkdir -p /data/firmware && touch /data/firmware/firmware_ota.bin && /data/busybox chattr +i /data/firmware/firmware_ota.bin"
 10 | UNLOCK_FIRMWARE = "/data/busybox chattr -i /data/firmware/firmware_ota.bin"
 11 | 
 12 | BUSYBOX_URL = "https://busybox.net/downloads/binaries/1.21.1/busybox-mipsel"
 13 | BUSYBOX_MD5 = "099137899ece96f311ac5ab554ea6fec"
 14 | 
 15 | 
 16 | class ShellMGW(ShellBase):
 17 |     async def login(self):
 18 |         self.writer.write(b"admin\n")
 19 |         raw = await asyncio.wait_for(self.reader.readuntil(b"\r\n# "), 3)
 20 |         # OK if gateway without password
 21 |         if b"Password:" not in raw:
 22 |             return
 23 |         # check if gateway has default password
 24 |         self.writer.write(b"admin\n")
 25 |         raw = await asyncio.wait_for(self.reader.readuntil(b"\r\n# "), 3)
 26 |         # can't continue without password
 27 |         if b"Password:" in raw:
 28 |             raise Exception("Telnet with password don't supported")
 29 | 
 30 |     async def prepare(self):
 31 |         await self.exec("stty -echo")
 32 | 
 33 |     async def get_version(self):
 34 |         raw = await self.read_file("/etc/rootfs_fw_info")
 35 |         m = re.search(r"version=([0-9._]+)", raw.decode())
 36 |         return m[1]
 37 | 
 38 |     async def get_token(self) -> str:
 39 |         raw = await self.read_file("/data/miio/device.token")
 40 |         return raw.rstrip().hex()
 41 | 
 42 |     async def get_miio_info(self) -> dict[str, str]:
 43 |         """
 44 |         did=123456789
 45 |         key=abcdefabcdefabcd
 46 |         mac=AA:BB:CC:DD:EE:FF
 47 |         vendor=lumi
 48 |         model=lumi.gateway.mgl03
 49 |         """
 50 |         raw = await self.read_file("/data/miio/device.conf")
 51 |         m = re.findall(r"(did|key|mac|model)=(\S+)", raw.decode())
 52 |         props: dict[str, str] = dict(m)
 53 |         props["token"] = await self.get_token()
 54 |         props["version"] = await self.get_version()
 55 |         return props
 56 | 
 57 |     db: SQLite = None
 58 | 
 59 |     async def read_db_bluetooth(self) -> SQLite:
 60 |         if not self.db:
 61 |             raw = await self.read_file("/data/miio/mible_local.db", as_base64=True)
 62 |             self.db = SQLite(raw)
 63 |         return self.db
 64 | 
 65 |     async def read_xiaomi_did(self) -> dict[str, str]:
 66 |         raw = await self.exec("cat /data/zigbee_gw/*.json|grep xiaomi_did")
 67 |         m = re.findall(r"(lumi.[a-f0-9]+).+(\d{9,})", raw)
 68 |         return dict(m)
 69 | 
 70 |     async def check_openmiio(self) -> bool:
 71 |         """Check binary exec flag and MD5."""
 72 |         cmd = f"[ -x /data/openmiio_agent ] && md5sum /data/openmiio_agent"
 73 |         return OPENMIIO_MD5_MIPS in await self.exec(cmd)
 74 | 
 75 |     async def download_openmiio(self):
 76 |         """Kill previous binary, download new one, upload it to gw and set exec flag"""
 77 |         await self.exec("killall openmiio_agent")
 78 | 
 79 |         raw = await self.download(OPENMIIO_URL_MIPS)
 80 |         await self.write_file("/data/openmiio_agent", raw)
 81 | 
 82 |         await self.exec("chmod +x /data/openmiio_agent")
 83 | 
 84 |     async def run_openmiio(self):
 85 |         await self.exec(OPENMIIO_CMD)
 86 | 
 87 |     async def prevent_unpair(self):
 88 |         await self.exec("killall zigbee_gw")
 89 | 
 90 |     async def check_busybox(self) -> bool:
 91 |         cmd = f"[ -x /data/busybox ] && md5sum /data/busybox"
 92 |         if BUSYBOX_MD5 in await self.exec(cmd):
 93 |             return True
 94 | 
 95 |         raw = await self.download(BUSYBOX_URL)
 96 |         await self.write_file("/data/busybox", raw)
 97 |         await self.exec("chmod +x /data/busybox")
 98 | 
 99 |         return BUSYBOX_MD5 in await self.exec(cmd)
100 | 
101 |     async def run_ftp(self):
102 |         if await self.check_busybox():
103 |             await self.exec("/data/busybox tcpsvd -E 0.0.0.0 21 /data/busybox ftpd -w&")
104 | 
105 |     async def check_firmware_lock(self) -> bool:
106 |         """Check if firmware update locked. And create empty file if needed."""
107 |         resp = await self.exec(CHECK_FIRMWARE)
108 |         return "-i-" in resp
109 | 
110 |     async def lock_firmware(self, enable: bool):
111 |         if await self.check_busybox():
112 |             await self.exec(LOCK_FIRMWARE if enable else UNLOCK_FIRMWARE)
113 | 
114 |     async def read_silabs_devices(self) -> bytes:
115 |         return await self.read_file("/data/silicon_zigbee_host/devices.txt")
116 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/shell/shell_mgw2.py:
--------------------------------------------------------------------------------
 1 | from .shell_e1 import ShellE1
 2 | from ..unqlite import SQLite
 3 | 
 4 | 
 5 | class ShellMGW2(ShellE1):
 6 |     db: SQLite = None
 7 | 
 8 |     async def read_db_bluetooth(self) -> SQLite:
 9 |         if not self.db:
10 |             raw = await self.read_file(
11 |                 "/data/local/miio_bt/mible_local.db", as_base64=True
12 |             )
13 |             self.db = SQLite(raw)
14 |         return self.db
15 | 
16 |     async def read_silabs_devices(self) -> bytes:
17 |         return await self.read_file("/data/zigbee_host/devices.txt")
18 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/core/unqlite.py:
--------------------------------------------------------------------------------
  1 | """Two classes for read Unqlite and SQLite DB files frow raw bytes. Default
  2 | python sqlite3 library can't read DB from memory.
  3 | """
  4 | 
  5 | 
  6 | # noinspection PyUnusedLocal
  7 | class Unqlite:
  8 |     page_size = 0
  9 |     pos = 0
 10 | 
 11 |     def __init__(self, raw: bytes):
 12 |         self.raw = raw
 13 |         self.read_db_header()
 14 | 
 15 |     @property
 16 |     def size(self):
 17 |         return len(self.raw)
 18 | 
 19 |     def read(self, length: int):
 20 |         self.pos += length
 21 |         return self.raw[self.pos - length : self.pos]
 22 | 
 23 |     def read_int(self, length: int):
 24 |         return int.from_bytes(self.read(length), "big")
 25 | 
 26 |     def read_db_header(self):
 27 |         assert self.read(7) == b"unqlite", "Wrong file signature"
 28 |         assert self.read(4) == b"\xDB\x7C\x27\x12", "Wrong DB magic"
 29 |         creation_time = self.read_int(4)
 30 |         sector_size = self.read_int(4)
 31 |         self.page_size = self.read_int(4)
 32 |         assert self.read(6) == b"\x00\x04hash", "Unsupported hash"
 33 | 
 34 |     # def read_header2(self):
 35 |     #     self.pos = self.page_size
 36 |     #     magic_numb = self.read(4)
 37 |     #     hash_func = self.read(4)
 38 |     #     free_pages = self.read_int(8)
 39 |     #     split_bucket = self.read_int(8)
 40 |     #     max_split_bucket = self.read_int(8)
 41 |     #     next_page = self.read_int(8)
 42 |     #     num_rect = self.read_int(4)
 43 |     #     for _ in range(num_rect):
 44 |     #         logic_page = self.read_int(8)
 45 |     #         real_page = self.read_int(8)
 46 | 
 47 |     def read_cell(self):
 48 |         key_hash = self.read(4)
 49 |         key_len = self.read_int(4)
 50 |         data_len = self.read_int(8)
 51 |         next_offset = self.read_int(2)
 52 |         overflow_page = self.read_int(8)
 53 |         if overflow_page:
 54 |             self.pos = overflow_page * 0x1000 + 8
 55 |             data_page = self.read_int(8)
 56 |             data_offset = self.read_int(2)
 57 |             name = self.read(key_len)
 58 |             self.pos = data_page * 0x1000 + data_offset
 59 |             value = self.read(data_len)
 60 |         else:
 61 |             name = self.read(key_len)
 62 |             value = self.read(data_len)
 63 |         return name, value, next_offset
 64 | 
 65 |     def read_all(self) -> dict:
 66 |         result = {}
 67 | 
 68 |         page_offset = 2 * self.page_size
 69 |         while page_offset < self.size:
 70 |             self.pos = page_offset
 71 |             next_offset = self.read_int(2)
 72 |             while next_offset:
 73 |                 self.pos = page_offset + next_offset
 74 |                 k, v, next_offset = self.read_cell()
 75 |                 # data sometimes corrupted: b'lumi.158d0004\xb4f9abb.prop'
 76 |                 result[k.decode(errors="replace")] = v.decode(errors="replace")
 77 |             page_offset += self.page_size
 78 | 
 79 |         return result
 80 | 
 81 | 
 82 | # noinspection PyUnusedLocal
 83 | class SQLite:
 84 |     page_size = 0
 85 |     pos = 0
 86 | 
 87 |     def __init__(self, raw: bytes):
 88 |         self.raw = raw
 89 |         self.read_db_header()
 90 |         self.tables = self.read_page(0)
 91 | 
 92 |     @property
 93 |     def size(self):
 94 |         return len(self.raw)
 95 | 
 96 |     def read(self, length: int):
 97 |         self.pos += length
 98 |         return self.raw[self.pos - length : self.pos]
 99 | 
100 |     def read_int(self, length: int):
101 |         return int.from_bytes(self.read(length), "big")
102 | 
103 |     def read_varint(self):
104 |         result = 0
105 |         while True:
106 |             i = self.read_int(1)
107 |             result += i & 0x7F
108 |             if i < 0x80:
109 |                 break
110 |             result <<= 7
111 | 
112 |         return result
113 | 
114 |     def read_db_header(self):
115 |         assert self.read(16) == b"SQLite format 3\0", "Wrong file signature"
116 |         self.page_size = self.read_int(2)
117 | 
118 |     def read_page(self, page_num: int):
119 |         self.pos = 100 if page_num == 0 else self.page_size * page_num
120 | 
121 |         # B-tree Page Header Format
122 |         page_type = self.read(1)
123 | 
124 |         if page_type == b"\x0D":
125 |             return self._read_leaf_table(page_num)
126 |         elif page_type == b"\x05":
127 |             return self._read_interior_table(page_num)
128 |         else:
129 |             raise NotImplemented
130 | 
131 |     def _read_leaf_table(self, page_num: int):
132 |         first_block = self.read_int(2)
133 |         cells_num = self.read_int(2)
134 |         cells_pos = self.read_int(2)
135 |         fragmented_free_bytes = self.read_int(1)
136 | 
137 |         cells_pos = [self.read_int(2) for _ in range(cells_num)]
138 |         rows = []
139 | 
140 |         for cell_pos in cells_pos:
141 |             self.pos = self.page_size * page_num + cell_pos
142 | 
143 |             payload_len = self.read_varint()
144 |             rowid = self.read_varint()
145 | 
146 |             columns_type = []
147 | 
148 |             payload_pos = self.pos
149 |             header_size = self.read_varint()
150 |             while self.pos < payload_pos + header_size:
151 |                 column_type = self.read_varint()
152 |                 columns_type.append(column_type)
153 | 
154 |             cells = []
155 | 
156 |             for column_type in columns_type:
157 |                 if column_type == 0:
158 |                     data = rowid
159 |                 elif 1 <= column_type <= 4:
160 |                     data = self.read_int(column_type)
161 |                 elif column_type == 5:
162 |                     data = self.read_int(6)
163 |                 elif column_type == 6:
164 |                     data = self.read_int(8)
165 |                 elif column_type == 7:
166 |                     # TODO: float
167 |                     data = self.read(8)
168 |                 elif column_type == 8:
169 |                     data = 0
170 |                 elif column_type == 9:
171 |                     data = 1
172 |                 elif column_type >= 12 and column_type % 2 == 0:
173 |                     length = int((column_type - 12) / 2)
174 |                     data = self.read(length)
175 |                 else:
176 |                     length = int((column_type - 13) / 2)
177 |                     data = self.read(length).decode()
178 | 
179 |                 cells.append(data)
180 | 
181 |             rows.append(cells)
182 | 
183 |         return rows
184 | 
185 |     def _read_interior_table(self, page_num: int):
186 |         first_block = self.read_int(2)
187 |         cells_num = self.read_int(2)
188 |         cells_pos = self.read_int(2)
189 |         fragmented_free_bytes = self.read_int(1)
190 |         last_page_num = self.read_int(4)
191 | 
192 |         cells_pos = [self.read_int(2) for _ in range(cells_num)]
193 |         rows = []
194 | 
195 |         for cell_pos in cells_pos:
196 |             self.pos = self.page_size * page_num + cell_pos
197 |             child_page_num = self.read_int(4)
198 |             rowid = self.read_varint()
199 |             rows += self.read_page(child_page_num - 1)
200 | 
201 |         return rows + self.read_page(last_page_num - 1)
202 | 
203 |     def read_table(self, name: str):
204 |         page = next(t[3] - 1 for t in self.tables if t[1] == name)
205 |         return self.read_page(page)
206 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/cover.py:
--------------------------------------------------------------------------------
 1 | from homeassistant.components.cover import CoverEntity
 2 | from homeassistant.const import STATE_CLOSING, STATE_OPENING
 3 | from homeassistant.helpers.restore_state import RestoreEntity
 4 | 
 5 | from .core.gate.base import XGateway
 6 | from .hass.entity import XEntity
 7 | 
 8 | 
 9 | # noinspection PyUnusedLocal
10 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
11 |     XEntity.ADD[entry.entry_id + "cover"] = async_add_entities
12 | 
13 | 
14 | class XCover(XEntity, CoverEntity, RestoreEntity):
15 |     _attr_is_closed = None
16 | 
17 |     def on_init(self):
18 |         # update state only for this attrs
19 |         self.listen_attrs = {"position", "run_state"}
20 | 
21 |     def set_state(self, data: dict):
22 |         if "position" in data:
23 |             self._attr_current_cover_position = data["position"]
24 |             # https://github.com/AlexxIT/XiaomiGateway3/issues/771
25 |             self._attr_is_closed = self._attr_current_cover_position <= 2
26 | 
27 |         if "run_state" in data:
28 |             self._attr_state = data["run_state"]
29 |             self._attr_is_opening = self._attr_state == STATE_OPENING
30 |             self._attr_is_closing = self._attr_state == STATE_CLOSING
31 | 
32 |     def get_state(self) -> dict:
33 |         return {
34 |             "position": self._attr_current_cover_position,
35 |             "run_state": self._attr_state,
36 |         }
37 | 
38 |     async def async_open_cover(self, **kwargs):
39 |         self.device.write({self.attr: "open"})
40 | 
41 |     async def async_close_cover(self, **kwargs):
42 |         self.device.write({self.attr: "close"})
43 | 
44 |     async def async_stop_cover(self, **kwargs):
45 |         self.device.write({self.attr: "stop"})
46 | 
47 |     async def async_set_cover_position(self, position: int, **kwargs):
48 |         self.device.write({"position": position})
49 | 
50 | 
51 | class XCoverGroup(XCover):
52 |     def childs(self):
53 |         return [
54 |             XGateway.devices[did]
55 |             for did in self.device.extra.get("childs", [])
56 |             if did in XGateway.devices
57 |         ]
58 | 
59 |     async def async_added_to_hass(self) -> None:
60 |         await super().async_added_to_hass()
61 |         for child in self.childs():
62 |             child.add_listener(self.device.dispatch)
63 | 
64 |     async def async_will_remove_from_hass(self) -> None:
65 |         await super().async_will_remove_from_hass()
66 |         for child in self.childs():
67 |             child.remove_listener(self.device.dispatch)
68 | 
69 | 
70 | XEntity.NEW["cover"] = XCover
71 | XEntity.NEW["cover.type.group"] = XCoverGroup
72 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/device_trigger.py:
--------------------------------------------------------------------------------
  1 | import re
  2 | 
  3 | import voluptuous as vol
  4 | from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
  5 | from homeassistant.components.homeassistant.triggers import state as state_trigger
  6 | from homeassistant.const import (
  7 |     CONF_DEVICE,
  8 |     CONF_DEVICE_ID,
  9 |     CONF_DOMAIN,
 10 |     CONF_ENTITY_ID,
 11 |     CONF_PLATFORM,
 12 |     CONF_STATE,
 13 |     CONF_TYPE,
 14 | )
 15 | from homeassistant.core import HomeAssistant
 16 | from homeassistant.helpers import device_registry, entity_registry
 17 | from homeassistant.helpers.device_registry import DeviceEntry
 18 | 
 19 | from .core.const import DOMAIN
 20 | from .core.converters.base import BaseConv, ConstConv, MapConv
 21 | from .core.converters.const import (
 22 |     BUTTON_DOUBLE,
 23 |     BUTTON_HOLD,
 24 |     BUTTON_RELEASE,
 25 |     BUTTON_SINGLE,
 26 |     BUTTON_TRIPLE,
 27 | )
 28 | from .core.converters.mibeacon import BLEMapConv
 29 | from .core.devices import DEVICES
 30 | 
 31 | TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
 32 |     {
 33 |         vol.Required(CONF_TYPE): str,
 34 |         vol.Required(CONF_STATE): str,
 35 |     }
 36 | )
 37 | 
 38 | BUTTONS = [BUTTON_SINGLE, BUTTON_DOUBLE, BUTTON_TRIPLE, BUTTON_HOLD, BUTTON_RELEASE]
 39 | 
 40 | 
 41 | def append(dst: list, src):
 42 |     for i in src:
 43 |         if i not in dst:
 44 |             dst.append(i)
 45 | 
 46 | 
 47 | def get_actions(human_model: str) -> list[str] | None:
 48 |     """Gets a list of actions (buttons) using the device human model."""
 49 |     if m := re.search(": ([^,]+)", human_model):
 50 |         first_model = m[1]
 51 |         for desc in DEVICES:
 52 |             for v in desc.values():
 53 |                 if isinstance(v, list) and first_model in v:
 54 |                     actions = []
 55 | 
 56 |                     converters: list[BaseConv] = desc["spec"]
 57 |                     for conv in converters:
 58 |                         if conv.attr == "action":
 59 |                             if isinstance(conv, ConstConv):
 60 |                                 append(actions, [conv.value])
 61 |                             elif isinstance(conv, MapConv):
 62 |                                 append(actions, conv.map.values())
 63 |                             elif isinstance(conv, BLEMapConv):
 64 |                                 append(actions, conv.map.values())
 65 |                         elif conv.attr == "button":
 66 |                             append(actions, BUTTONS)
 67 |                         elif conv.attr.startswith("button"):
 68 |                             append(actions, [f"{conv.attr}_{i}" for i in BUTTONS])
 69 | 
 70 |                     return actions
 71 | 
 72 | 
 73 | DEVICE_ACTIONS = {}
 74 | 
 75 | 
 76 | async def async_get_triggers(
 77 |     hass: HomeAssistant, device_id: str
 78 | ) -> list[dict[str, str]]:
 79 |     if device_id not in DEVICE_ACTIONS:
 80 |         registry = device_registry.async_get(hass)
 81 |         device_entry: DeviceEntry = registry.async_get(device_id)
 82 |         DEVICE_ACTIONS[device_id] = get_actions(device_entry.model)
 83 | 
 84 |     if not DEVICE_ACTIONS[device_id]:
 85 |         return []
 86 | 
 87 |     return [
 88 |         {
 89 |             CONF_PLATFORM: CONF_DEVICE,
 90 |             CONF_DEVICE_ID: device_id,
 91 |             CONF_DOMAIN: DOMAIN,
 92 |             CONF_TYPE: "action",
 93 |         }
 94 |     ]
 95 | 
 96 | 
 97 | async def async_get_trigger_capabilities(
 98 |     hass: HomeAssistant, config: dict
 99 | ) -> dict[str, vol.Schema]:
100 |     device_id = config[CONF_DEVICE_ID]
101 |     if actions := DEVICE_ACTIONS.get(device_id):
102 |         return {"extra_fields": vol.Schema({vol.Required(CONF_STATE): vol.In(actions)})}
103 |     return {}
104 | 
105 | 
106 | async def async_attach_trigger(hass: HomeAssistant, config: dict, action, trigger_info):
107 |     device_id = config[CONF_DEVICE_ID]
108 | 
109 |     registry = entity_registry.async_get(hass)
110 |     for entry in registry.entities.values():
111 |         if entry.device_id == device_id and entry.unique_id.endswith("action"):
112 |             config = state_trigger.TRIGGER_STATE_SCHEMA(
113 |                 {
114 |                     CONF_PLATFORM: CONF_STATE,
115 |                     CONF_ENTITY_ID: entry.entity_id,
116 |                     state_trigger.CONF_TO: config[CONF_STATE],
117 |                 }
118 |             )
119 |             return await state_trigger.async_attach_trigger(
120 |                 hass, config, action, trigger_info, platform_type=CONF_DEVICE
121 |             )
122 | 
123 |     return None
124 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/diagnostics.py:
--------------------------------------------------------------------------------
 1 | from homeassistant.config_entries import ConfigEntry
 2 | from homeassistant.core import HomeAssistant
 3 | from homeassistant.helpers.device_registry import DeviceEntry
 4 | 
 5 | from .core.const import DOMAIN, source_hash
 6 | from .core.gate.base import XGateway
 7 | 
 8 | 
 9 | async def async_get_config_entry_diagnostics(
10 |     hass: HomeAssistant, config_entry: ConfigEntry
11 | ):
12 |     try:
13 |         devices = {device.uid: device.as_dict() for device in XGateway.devices.values()}
14 |     except Exception as e:
15 |         devices = repr(e)
16 | 
17 |     info = await get_info(hass, config_entry)
18 |     info["devices"] = devices
19 |     return info
20 | 
21 | 
22 | async def async_get_device_diagnostics(
23 |     hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
24 | ):
25 |     try:
26 |         uid = next(i[1] for i in device_entry.identifiers if i[0] == DOMAIN)
27 |         device = next(i for i in XGateway.devices.values() if i.uid == uid)
28 |         device = device.as_dict()
29 |     except Exception as e:
30 |         device = repr(e)
31 | 
32 |     info = await get_info(hass, config_entry)
33 |     info["device"] = device
34 |     return info
35 | 
36 | 
37 | async def get_info(hass: HomeAssistant, config_entry: ConfigEntry) -> dict:
38 |     try:
39 |         errors = [
40 |             entry.to_dict()
41 |             for key, entry in hass.data["system_log"].records.items()
42 |             if DOMAIN in str(key)
43 |         ]
44 |     except Exception as e:
45 |         errors = repr(e)
46 | 
47 |     return {
48 |         "version": await hass.async_add_executor_job(source_hash),
49 |         "options": config_entry.options.copy(),
50 |         "errors": errors,
51 |     }
52 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/hass/add_entitites.py:
--------------------------------------------------------------------------------
  1 | import copy
  2 | 
  3 | from homeassistant.config_entries import ConfigEntry
  4 | from homeassistant.core import HomeAssistant
  5 | from homeassistant.helpers import device_registry, entity_registry
  6 | 
  7 | from .entity import XEntity
  8 | from .. import MultiGateway, XDevice
  9 | from ..core.const import BLE, DOMAIN, GATEWAY, MATTER, MESH, ZIGBEE
 10 | from ..core.converters.base import BaseConv
 11 | from ..core.gate.base import EVENT_ADD_DEVICE, EVENT_REMOVE_DEVICE
 12 | 
 13 | CONFIG_ENTRIES: dict[str, MultiGateway] = {}  # key is device did
 14 | 
 15 | 
 16 | def handle_add_entities(
 17 |     hass: HomeAssistant, config_entry: ConfigEntry, gw: MultiGateway
 18 | ):
 19 |     """Add entities when gateway calls the add_device event."""
 20 |     lazy_listeners: dict = {}
 21 | 
 22 |     def add_device(device: XDevice):
 23 |         if device.extra.get("entities") is False:
 24 |             return
 25 | 
 26 |         if device.did not in CONFIG_ENTRIES:
 27 |             # connect all device entities to this gateway
 28 |             CONFIG_ENTRIES[device.did] = gw
 29 | 
 30 |             # instant setup all entities, except lazy
 31 |             for entity in get_entities(device, gw.stats_domain):
 32 |                 gw.debug("add_entity", device=device, entity=entity.entity_id)
 33 |                 add_entity(hass, config_entry, entity)
 34 | 
 35 |             # add listener for setup lazy entities (if device has them)
 36 |             if remove_listener := handle_lazy_entities(hass, config_entry, device):
 37 |                 lazy_listeners[device.did] = remove_listener
 38 |         else:
 39 |             # device already added to another config entry (gateway)
 40 |             # so we add device to the current config entry
 41 |             device_registry.async_get(hass).async_get_or_create(
 42 |                 config_entry_id=config_entry.entry_id,
 43 |                 identifiers={(DOMAIN, device.uid)},
 44 |             )
 45 | 
 46 |     def remove_device(device: XDevice):
 47 |         # remove device entities connection to this gateway
 48 |         if CONFIG_ENTRIES.get(device.did) == gw:
 49 |             # remove lazy entities listener if device has them
 50 |             if remove_listener := lazy_listeners.get(device.did):
 51 |                 remove_listener()
 52 | 
 53 |             CONFIG_ENTRIES.pop(device.did)
 54 | 
 55 |     gw.add_event_listener(EVENT_ADD_DEVICE, add_device)
 56 |     gw.add_event_listener(EVENT_REMOVE_DEVICE, remove_device)
 57 | 
 58 | 
 59 | def get_entities(device: XDevice, stats_domain: str = None) -> list[XEntity]:
 60 |     converters = [i for i in device.converters if i.domain]
 61 | 
 62 |     # TODO: fixme
 63 |     if device.type == GATEWAY:
 64 |         converters.append(BaseConv(device.type, "binary_sensor"))
 65 |     if device.type != GATEWAY:
 66 |         converters.append(BaseConv("command", "select"))
 67 | 
 68 |     # custom stats sensors
 69 |     if stats_domain and device.type in (BLE, MATTER, MESH, ZIGBEE):
 70 |         converters.append(BaseConv(device.type, stats_domain))
 71 | 
 72 |     # custom entities settings from YAML
 73 |     if entities := device.extra.get("entities"):
 74 |         get_extra_entities(converters, entities)
 75 | 
 76 |     return [
 77 |         create_entity(device, conv)
 78 |         for conv in converters
 79 |         if not (conv.entity and conv.entity.get("lazy"))
 80 |     ]
 81 | 
 82 | 
 83 | def create_entity(device: XDevice, conv: BaseConv) -> XEntity:
 84 |     """Create entity, based on device model/type and conv domain."""
 85 |     cls = (
 86 |         XEntity.NEW.get(f"{conv.domain}.model.{device.model}")
 87 |         or XEntity.NEW.get(f"{conv.domain}.type.{device.type}")
 88 |         or XEntity.NEW.get(f"{conv.domain}.attr.{conv.attr}")
 89 |         or XEntity.NEW.get(conv.domain)
 90 |     )
 91 |     return cls(device, conv)
 92 | 
 93 | 
 94 | def add_entity(hass: HomeAssistant, config_entry: ConfigEntry, entity: XEntity):
 95 |     # if device belong to multiple config entries - disabling one of config entry will
 96 |     # block any other config entry for creation device entities
 97 |     reg = entity_registry.async_get(hass)
 98 |     entity_id = reg.async_get_entity_id(entity.domain, DOMAIN, entity.unique_id)
 99 |     if registry_entry := reg.async_get(entity_id):
100 |         # remove disabled_by flag for entity
101 |         if registry_entry.disabled_by == "config_entry":
102 |             reg.async_update_entity(entity_id=entity_id, disabled_by=None)
103 | 
104 |     async_add_entities = XEntity.ADD[config_entry.entry_id + entity.domain]
105 |     async_add_entities([entity], update_before_add=False)
106 | 
107 | 
108 | def handle_lazy_entities(
109 |     hass: HomeAssistant, config_entry: ConfigEntry, device: XDevice
110 | ):
111 |     """Create entities only when first data arrived."""
112 |     # 1. Check if device has lazy entities
113 |     lazy_attrs = {
114 |         i.attr for i in device.converters if i.entity and i.entity.get("lazy")
115 |     }
116 |     # 2. Exit if none
117 |     if not lazy_attrs:
118 |         return None
119 | 
120 |     def add_lazy_entity(attr: str) -> XEntity:
121 |         lazy_attrs.remove(attr)
122 | 
123 |         # important to check non empty domain for some BLE devices
124 |         conv = next(i for i in device.converters if i.attr == attr and i.domain)
125 |         entity = create_entity(device, conv)
126 | 
127 |         gw = CONFIG_ENTRIES.get(device.did)
128 |         gw.debug("add_lazy_entity", device=device, entity=entity.entity_id)
129 |         add_entity(hass, config_entry, entity)
130 |         return entity
131 | 
132 |     # 3. Restore previous lazy entities from Hass entity registry
133 |     prefix = device.uid + "_"
134 |     reg = entity_registry.async_get(hass)
135 |     for entry in reg.entities.values():
136 |         if entry.platform != DOMAIN or not entry.unique_id.startswith(prefix):
137 |             continue
138 |         _, attr = entry.unique_id.split("_", 1)
139 |         if attr in lazy_attrs:
140 |             add_lazy_entity(attr)
141 | 
142 |     # 4. Exit if none left
143 |     if not lazy_attrs:
144 |         return None
145 | 
146 |     def on_device_update(data: dict):
147 |         for attr in data.keys() & lazy_attrs:
148 |             entity = add_lazy_entity(attr)
149 |             entity.on_device_update(data)
150 | 
151 |             if not lazy_attrs:
152 |                 device.remove_listener(on_device_update)
153 | 
154 |     # 5. Wait for rest lazy entities in every message from the device
155 |     device.add_listener(on_device_update)
156 |     return lambda: device.remove_listener(on_device_update)
157 | 
158 | 
159 | def get_extra_entities(converters: list[BaseConv], entities: dict[str, str]):
160 |     for attr, new_domain in entities.items():
161 |         for i, conv in enumerate(converters):
162 |             if conv.attr == attr:
163 |                 if new_domain:
164 |                     new_conv = copy.copy(conv)
165 |                     new_conv.domain = new_domain
166 |                     converters[i] = new_conv
167 |                 else:
168 |                     converters.pop(i)
169 |                 break
170 |         else:
171 |             converters.append(BaseConv(attr, new_domain))
172 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/hass/entity.py:
--------------------------------------------------------------------------------
  1 | import logging
  2 | from datetime import datetime, timezone
  3 | from functools import cached_property
  4 | from typing import Callable, TYPE_CHECKING
  5 | 
  6 | from homeassistant.helpers.device_registry import (
  7 |     CONNECTION_BLUETOOTH,
  8 |     CONNECTION_NETWORK_MAC,
  9 |     CONNECTION_ZIGBEE,
 10 | )
 11 | from homeassistant.helpers.entity import DeviceInfo, Entity
 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
 13 | from homeassistant.helpers.restore_state import ExtraStoredData, RestoredExtraData
 14 | from homeassistant.helpers.template import Template
 15 | 
 16 | from .entity_description import setup_entity_description
 17 | from ..core.const import BLE, DOMAIN, GATEWAY, MESH, ZIGBEE
 18 | from ..core.converters.base import BaseConv
 19 | 
 20 | if TYPE_CHECKING:
 21 |     from ..core.converters.base import BaseConv
 22 |     from ..core.device import XDevice
 23 | 
 24 | _LOGGER = logging.getLogger(__package__)
 25 | 
 26 | 
 27 | def attr_human_name(attr: str):
 28 |     # this words always uppercase
 29 |     if attr in ("ble", "led", "rssi", "usb"):
 30 |         return attr.upper()
 31 |     return attr.replace("_", " ").title()
 32 | 
 33 | 
 34 | class XEntity(Entity):
 35 |     ADD: dict[str, AddEntitiesCallback] = {}  # key: "config_entry_id+domain"
 36 |     NEW: dict[str, Callable] = {}  # key: "domain.attr" or "domain"
 37 | 
 38 |     def __init__(self, device: "XDevice", conv: "BaseConv"):
 39 |         self.device = device
 40 |         self.attr = conv.attr
 41 | 
 42 |         self.listen_attrs: set = {conv.attr}
 43 | 
 44 |         if device.type == GATEWAY:
 45 |             connections = {(CONNECTION_NETWORK_MAC, device.extra["mac"])}
 46 |             if mac2 := device.extra.get("mac2"):
 47 |                 connections.add((CONNECTION_NETWORK_MAC, mac2))
 48 |         elif device.type == ZIGBEE:
 49 |             connections = {(CONNECTION_ZIGBEE, device.extra["ieee"])}
 50 |         elif device.type in (BLE, MESH):
 51 |             connections = {(CONNECTION_BLUETOOTH, device.extra["mac"])}
 52 |         else:
 53 |             connections = None
 54 | 
 55 |         if device.type != GATEWAY:
 56 |             via_device = (DOMAIN, device.gateways[0].device.uid)
 57 |         else:
 58 |             via_device = None
 59 | 
 60 |         self._attr_available = device.available
 61 |         self._attr_device_info = DeviceInfo(
 62 |             identifiers={(DOMAIN, device.uid)},
 63 |             connections=connections,
 64 |             manufacturer=device.extra.get("market_brand"),
 65 |             name=device.human_name,
 66 |             model=device.human_model,
 67 |             sw_version=device.firmware,
 68 |             hw_version=device.extra.get("hw_ver"),
 69 |             via_device=via_device,
 70 |         )
 71 |         self._attr_has_entity_name = True
 72 |         self._attr_name = attr_human_name(conv.attr)
 73 |         self._attr_should_poll = False
 74 |         self._attr_unique_id = f"{device.uid}_{conv.attr}"
 75 | 
 76 |         setup_entity_description(self, conv)
 77 | 
 78 |         if entity_name := device.extra.get("entity_name"):
 79 |             if entity_name.endswith(conv.attr):
 80 |                 self.entity_id = f"{conv.domain}.{entity_name}"
 81 |             else:
 82 |                 self.entity_id = f"{conv.domain}.{entity_name}_{conv.attr}"
 83 |         else:
 84 |             self.entity_id = f"{conv.domain}.{device.uid}_{conv.attr}"
 85 | 
 86 |         self.on_init()
 87 | 
 88 |     @cached_property
 89 |     def domain(self) -> str:
 90 |         return type(self).__module__.rsplit(".", 1)[1]
 91 | 
 92 |     @property
 93 |     def extra_restore_state_data(self) -> ExtraStoredData | None:
 94 |         if state_data := self.get_state():
 95 |             if state := {k: v for k, v in state_data.items() if v is not None}:
 96 |                 return RestoredExtraData(state)
 97 |         return None
 98 | 
 99 |     def debug(self, msg: str):
100 |         _LOGGER.debug({"msg": msg, "entity": self.entity_id})
101 | 
102 |     async def async_added_to_hass(self) -> None:
103 |         # self.debug("async_added_to_hass")
104 |         self.device.add_listener(self.on_device_update)
105 | 
106 |         if hasattr(self, "attributes_template"):
107 |             self.render_attributes_template()
108 | 
109 |         if hasattr(self, "async_get_last_extra_data"):
110 |             data: RestoredExtraData = await self.async_get_last_extra_data()
111 |             if data and self.listen_attrs & data.as_dict().keys():
112 |                 self.set_state(data.as_dict())
113 | 
114 |     def render_attributes_template(self):
115 |         try:
116 |             template: Template = getattr(self, "attributes_template")
117 |             gw = self.device.gateways[0]
118 |             attrs = template.async_render(
119 |                 {"attr": self.attr, "device": self.device, "gateway": gw.device}
120 |             )
121 |             if not isinstance(attrs, dict):
122 |                 return
123 |             if hasattr(self, "_attr_extra_state_attributes"):
124 |                 self._attr_extra_state_attributes.update(attrs)
125 |             else:
126 |                 self._attr_extra_state_attributes = attrs
127 |         except Exception as e:
128 |             _LOGGER.warning("Can't render attributes", exc_info=e)
129 | 
130 |     async def async_will_remove_from_hass(self) -> None:
131 |         # self.debug("async_will_remove_from_hass")
132 |         self.device.remove_listener(self.on_device_update)
133 | 
134 |     # async def async_removed_from_registry(self) -> None:
135 |     #     self.debug("async_removed_from_registry")
136 | 
137 |     async def async_update(self):
138 |         # for manual update via service `homeassistant.update_entity`
139 |         # or via converter `entity={"poll": True}`
140 |         self.device.read(self.listen_attrs)
141 | 
142 |     def on_init(self):
143 |         """Run on class init."""
144 | 
145 |     def on_device_update(self, data: dict):
146 |         state_change = False
147 | 
148 |         if "available" in data:
149 |             self._attr_available = data["available"]
150 |             state_change = True
151 | 
152 |         if self.listen_attrs & data.keys():
153 |             self.set_state(data)
154 |             state_change = True
155 | 
156 |         if state_change and self.hass:
157 |             # _LOGGER.debug(f"{self.entity_id} | async_write_ha_state")
158 |             self._async_write_ha_state()
159 | 
160 |     def set_state(self, data: dict):
161 |         """Run on data from device."""
162 |         self._attr_state = data[self.attr]
163 | 
164 |     def get_state(self) -> dict:
165 |         """Run before entity remove if entity is subclass from RestoreEntity."""
166 | 
167 | 
168 | class XStatsEntity(XEntity):
169 |     _unrecorded_attributes = {"device", "msg_received", "msg_missed"}
170 | 
171 |     # binary_sensor and sensor support simultaneously
172 |     _attr_is_on: bool
173 |     _attr_native_value: datetime
174 | 
175 |     last_seq: int = None
176 | 
177 |     def on_init(self):
178 |         self._attr_available = True
179 |         self._attr_is_on = self.device.available
180 |         self._attr_extra_state_attributes = {"device": self.device.as_dict()}
181 | 
182 |     def on_device_update(self, data: dict):
183 |         state_change = False
184 | 
185 |         if "available" in data:
186 |             self._attr_is_on = data["available"]
187 |             state_change = True
188 | 
189 |         if ts := data.get(self.attr):
190 |             self._attr_native_value = datetime.fromtimestamp(ts, timezone.utc)
191 | 
192 |             if "msg_received" in self._attr_extra_state_attributes:
193 |                 self._attr_extra_state_attributes["msg_received"] += 1
194 |             else:
195 |                 self._attr_extra_state_attributes["msg_received"] = 1
196 | 
197 |             if (seq := self.device.extra.get("seq")) is not None:
198 |                 if self.last_seq is not None:
199 |                     miss = (seq - self.last_seq - 1) & 0xFF
200 |                     if 0 < miss < 0xF0:
201 |                         self._attr_extra_state_attributes["msg_missed"] += 1
202 |                 else:
203 |                     self._attr_extra_state_attributes["msg_missed"] = 0
204 |                 self.last_seq = seq
205 | 
206 |             state_change = True
207 | 
208 |         elif ts == 0:
209 |             state_change = True
210 | 
211 |         if state_change and self.hass:
212 |             self._attr_extra_state_attributes["device"] = self.device.as_dict()
213 |             self._async_write_ha_state()
214 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/light.py:
--------------------------------------------------------------------------------
  1 | import asyncio
  2 | import math
  3 | import time
  4 | from functools import cached_property
  5 | 
  6 | from homeassistant.components.light import (
  7 |     ATTR_BRIGHTNESS,
  8 |     ATTR_COLOR_MODE,
  9 |     ATTR_EFFECT,
 10 |     ATTR_HS_COLOR,
 11 |     ATTR_RGB_COLOR,
 12 |     ATTR_TRANSITION,
 13 |     ColorMode,
 14 |     LightEntity,
 15 |     LightEntityFeature,
 16 | )
 17 | from homeassistant.helpers.restore_state import RestoreEntity
 18 | 
 19 | from .core.gate.base import XGateway
 20 | from .hass.entity import XEntity
 21 | 
 22 | 
 23 | # noinspection PyUnusedLocal
 24 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
 25 |     XEntity.ADD[entry.entry_id + "light"] = async_add_entities
 26 | 
 27 | 
 28 | ATTR_COLOR_TEMP = "color_temp"
 29 | ATTR_COLOR_TEMP_KELVIN = "color_temp_kelvin"
 30 | 
 31 | 
 32 | def color_temp(value: int | None) -> int | None:
 33 |     if value is None:
 34 |         return None
 35 |     if value == 0:
 36 |         return 0
 37 |     return math.floor(1000000 / value)
 38 | 
 39 | 
 40 | class XLight(XEntity, LightEntity, RestoreEntity):
 41 |     _attr_color_temp_kelvin: int | None = None
 42 |     _attr_max_color_temp_kelvin: int | None = 2700
 43 |     _attr_min_color_temp_kelvin: int | None = 6500
 44 | 
 45 |     def on_init(self):
 46 |         only_mode = ColorMode.ONOFF
 47 |         modes = set()
 48 | 
 49 |         for conv in self.device.converters:
 50 |             if conv.attr == ATTR_BRIGHTNESS:
 51 |                 self.listen_attrs.add(conv.attr)
 52 |                 only_mode = ColorMode.BRIGHTNESS
 53 |             elif conv.attr == ATTR_COLOR_TEMP:
 54 |                 self.listen_attrs.add(conv.attr)
 55 |                 modes.add(ColorMode.COLOR_TEMP)
 56 |                 if hasattr(conv, "minm") and hasattr(conv, "maxm"):
 57 |                     self._attr_max_color_temp_kelvin = color_temp(conv.minm)
 58 |                     self._attr_min_color_temp_kelvin = color_temp(conv.maxm)
 59 |                 elif hasattr(conv, "mink") and hasattr(conv, "maxk"):
 60 |                     self._attr_max_color_temp_kelvin = conv.maxk
 61 |                     self._attr_min_color_temp_kelvin = conv.mink
 62 |             elif conv.attr == ATTR_HS_COLOR:
 63 |                 self.listen_attrs.add(conv.attr)
 64 |                 modes.add(ColorMode.HS)
 65 |             elif conv.attr == ATTR_RGB_COLOR:
 66 |                 self.listen_attrs.add(conv.attr)
 67 |                 modes.add(ColorMode.RGB)
 68 |             elif conv.attr == ATTR_COLOR_MODE:
 69 |                 self.listen_attrs.add(conv.attr)
 70 |             elif conv.attr == ATTR_EFFECT and hasattr(conv, "map"):
 71 |                 self.listen_attrs.add(conv.attr)
 72 |                 self._attr_supported_features |= LightEntityFeature.EFFECT
 73 |                 self._attr_effect_list = list(conv.map.values())
 74 | 
 75 |         if modes:
 76 |             self._attr_color_mode = next(iter(modes))
 77 |             self._attr_supported_color_modes = modes
 78 |         else:
 79 |             self._attr_color_mode = only_mode
 80 |             self._attr_supported_color_modes = {only_mode}
 81 | 
 82 |     def set_state(self, data: dict):
 83 |         if self.attr in data:
 84 |             self._attr_is_on = bool(data[self.attr])
 85 |         if ATTR_BRIGHTNESS in data:
 86 |             self._attr_brightness = data[ATTR_BRIGHTNESS]
 87 |         if ATTR_COLOR_TEMP in data:
 88 |             self._attr_color_temp_kelvin = color_temp(data[ATTR_COLOR_TEMP])
 89 |             self._attr_color_mode = ColorMode.COLOR_TEMP
 90 |         if ATTR_HS_COLOR in data:
 91 |             self._attr_hs_color = data[ATTR_HS_COLOR]
 92 |             self._attr_color_mode = ColorMode.HS
 93 |         if ATTR_RGB_COLOR in data:
 94 |             self._attr_rgb_color = data[ATTR_RGB_COLOR]
 95 |             self._attr_color_mode = ColorMode.RGB
 96 |         if ATTR_COLOR_MODE in data:
 97 |             self._attr_color_mode = ColorMode(data[ATTR_COLOR_MODE])
 98 |         if ATTR_EFFECT in data:
 99 |             self._attr_effect = data[ATTR_EFFECT]
100 | 
101 |     def get_state(self) -> dict:
102 |         return {
103 |             self.attr: self._attr_is_on,
104 |             ATTR_BRIGHTNESS: self._attr_brightness,
105 |             ATTR_COLOR_TEMP: color_temp(self._attr_color_temp_kelvin),
106 |         }
107 | 
108 |     async def async_turn_on(self, **kwargs):
109 |         # https://github.com/AlexxIT/XiaomiGateway3/issues/1459
110 |         if not self._attr_is_on or not kwargs:
111 |             kwargs[self.attr] = True
112 |         if ATTR_COLOR_TEMP_KELVIN in kwargs:
113 |             kwargs[ATTR_COLOR_TEMP] = color_temp(kwargs.pop(ATTR_COLOR_TEMP_KELVIN))
114 |         self.device.write(kwargs)
115 | 
116 |     async def async_turn_off(self, **kwargs):
117 |         self.device.write({self.attr: False})
118 | 
119 | 
120 | class XZigbeeLight(XLight):
121 |     def on_init(self):
122 |         super().on_init()
123 | 
124 |         for conv in self.device.converters:
125 |             if conv.attr == ATTR_TRANSITION:
126 |                 self._attr_supported_features |= LightEntityFeature.TRANSITION
127 | 
128 |     @cached_property
129 |     def default_transition(self) -> float | None:
130 |         return self.device.extra.get("default_transition")
131 | 
132 |     async def async_turn_on(self, transition: int = None, **kwargs):
133 |         if self.default_transition is not None and transition is None:
134 |             transition = self.default_transition
135 | 
136 |         if transition is not None:
137 |             # important to sort args in right order, transition should be first
138 |             kwargs = {ATTR_TRANSITION: transition} | kwargs
139 | 
140 |         if ATTR_COLOR_TEMP_KELVIN in kwargs:
141 |             kwargs[ATTR_COLOR_TEMP] = color_temp(kwargs.pop(ATTR_COLOR_TEMP_KELVIN))
142 | 
143 |         self.device.write(kwargs if kwargs else {self.attr: True})
144 | 
145 |         # fix Philips Hue with polling
146 |         if self._attr_should_poll and (not kwargs or transition):
147 |             await asyncio.sleep(transition or 1)
148 | 
149 |     async def async_turn_off(self, transition: int = None, **kwargs):
150 |         if self.default_transition is not None and transition is None:
151 |             transition = self.default_transition
152 | 
153 |         if transition is not None:
154 |             kwargs.setdefault(ATTR_BRIGHTNESS, 0)
155 |             kwargs = {ATTR_TRANSITION: transition} | kwargs
156 | 
157 |         self.device.write(kwargs if kwargs else {self.attr: False})
158 | 
159 |         # fix Philips Hue with polling
160 |         if self._attr_should_poll and (not kwargs or transition):
161 |             await asyncio.sleep(transition or 1)
162 | 
163 | 
164 | class XLightGroup(XLight):
165 |     wait_update: bool = False
166 | 
167 |     def childs(self):
168 |         return [
169 |             XGateway.devices[did]
170 |             for did in self.device.extra.get("childs", [])
171 |             if did in XGateway.devices
172 |         ]
173 | 
174 |     async def async_added_to_hass(self) -> None:
175 |         await super().async_added_to_hass()
176 |         for child in self.childs():
177 |             child.add_listener(self.forward_child_update)
178 | 
179 |     async def async_will_remove_from_hass(self) -> None:
180 |         await super().async_will_remove_from_hass()
181 |         for child in self.childs():
182 |             child.remove_listener(self.forward_child_update)
183 | 
184 |     def forward_child_update(self, data: dict):
185 |         self.wait_update = False
186 |         self.on_device_update(data)
187 | 
188 |     async def wait_update_with_timeout(self, delay: float):
189 |         # thread safe wait logic, because `forward_child_update` and `async_turn_on`
190 |         # can be called from different threads and we can't use asyncio.Event here
191 |         wait_unil = time.time() + delay
192 |         while self.wait_update:
193 |             await asyncio.sleep(0.5)
194 |             if time.time() > wait_unil:
195 |                 break
196 | 
197 |     async def async_turn_on(self, **kwargs):
198 |         self.wait_update = True
199 |         await super().async_turn_on(**kwargs)
200 |         await self.wait_update_with_timeout(10.0)
201 | 
202 |     async def async_turn_off(self, **kwargs):
203 |         self.wait_update = True
204 |         await super().async_turn_off(**kwargs)
205 |         await self.wait_update_with_timeout(10.0)
206 | 
207 | 
208 | XEntity.NEW["light"] = XLight
209 | XEntity.NEW["light.type.zigbee"] = XZigbeeLight
210 | XEntity.NEW["light.type.group"] = XLightGroup
211 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/manifest.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "domain": "xiaomi_gateway3",
 3 |   "name": "Xiaomi Gateway 3",
 4 |   "codeowners": [
 5 |     "@AlexxIT"
 6 |   ],
 7 |   "config_flow": true,
 8 |   "dependencies": [
 9 |   ],
10 |   "documentation": "https://github.com/AlexxIT/XiaomiGateway3",
11 |   "iot_class": "local_push",
12 |   "issue_tracker": "https://github.com/AlexxIT/XiaomiGateway3/issues",
13 |   "requirements": [
14 |     "zigpy>=0.52.3"
15 |   ],
16 |   "version": "4.1.4"
17 | }


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/number.py:
--------------------------------------------------------------------------------
 1 | from homeassistant.components.number import DEFAULT_STEP, NumberEntity, NumberMode
 2 | from homeassistant.helpers.restore_state import RestoreEntity
 3 | 
 4 | from .hass.entity import XEntity
 5 | 
 6 | 
 7 | # noinspection PyUnusedLocal
 8 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
 9 |     XEntity.ADD[entry.entry_id + "number"] = async_add_entities
10 | 
11 | 
12 | class XNumber(XEntity, NumberEntity, RestoreEntity):
13 |     _attr_mode = NumberMode.BOX
14 | 
15 |     def on_init(self):
16 |         conv = next(i for i in self.device.converters if i.attr == self.attr)
17 | 
18 |         multiply: float = getattr(conv, "multiply", 1)
19 | 
20 |         if hasattr(conv, "min"):
21 |             self._attr_native_min_value = conv.min * multiply
22 |         if hasattr(conv, "max"):
23 |             self._attr_native_max_value = conv.max * multiply
24 |         if hasattr(conv, "step") or hasattr(conv, "multiply"):
25 |             self._attr_native_step = getattr(conv, "step", DEFAULT_STEP) * multiply
26 | 
27 |     def set_state(self, data: dict):
28 |         self._attr_native_value = data[self.attr]
29 | 
30 |     def get_state(self) -> dict:
31 |         return {self.attr: self._attr_native_value}
32 | 
33 |     async def async_set_native_value(self, value: float) -> None:
34 |         self.device.write({self.attr: value})
35 | 
36 | 
37 | XEntity.NEW["number"] = XNumber
38 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/sensor.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | 
 3 | from homeassistant.components.sensor import SensorEntity
 4 | from homeassistant.helpers.restore_state import RestoreEntity
 5 | 
 6 | from .hass.entity import XEntity, XStatsEntity
 7 | 
 8 | 
 9 | # noinspection PyUnusedLocal
10 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
11 |     XEntity.ADD[entry.entry_id + "sensor"] = async_add_entities
12 | 
13 | 
14 | class XSensor(XEntity, SensorEntity, RestoreEntity):
15 |     def set_state(self, data: dict):
16 |         self._attr_native_value = data[self.attr]
17 | 
18 |     def get_state(self) -> dict:
19 |         return {self.attr: self._attr_native_value}
20 | 
21 | 
22 | class XStatsSensor(XStatsEntity, SensorEntity):
23 |     pass
24 | 
25 | 
26 | class XActionSensor(XEntity, SensorEntity):
27 |     _attr_native_value = ""
28 |     native_attrs: dict = None
29 |     clear_task: asyncio.Task = None
30 | 
31 |     def set_state(self, data: dict):
32 |         # fix 1.4.7_0115 heartbeat error (has button in heartbeat)
33 |         if "battery" in data or not self.hass:
34 |             return
35 | 
36 |         if self.clear_task:
37 |             self.clear_task.cancel()
38 | 
39 |         self._attr_native_value = data[self.attr]
40 |         self._attr_extra_state_attributes = data
41 | 
42 |         # repeat event from Aqara integration
43 |         self.hass.bus.async_fire(
44 |             "xiaomi_aqara.click",
45 |             {"entity_id": self.entity_id, "click_type": self._attr_native_value},
46 |         )
47 | 
48 |         self.clear_task = self.hass.loop.create_task(self.clear_state())
49 | 
50 |     async def clear_state(self):
51 |         await asyncio.sleep(0.5)
52 |         self._attr_native_value = ""
53 |         self._async_write_ha_state()
54 | 
55 |     async def async_will_remove_from_hass(self):
56 |         if self.clear_task:
57 |             self.clear_task.cancel()
58 | 
59 |         if self._attr_native_value:
60 |             self._attr_native_value = ""
61 |             self._async_write_ha_state()
62 | 
63 |         await super().async_will_remove_from_hass()
64 | 
65 | 
66 | XEntity.NEW["sensor"] = XSensor
67 | XEntity.NEW["sensor.attr.action"] = XActionSensor
68 | XEntity.NEW["sensor.attr.ble"] = XStatsSensor
69 | XEntity.NEW["sensor.attr.matter"] = XStatsSensor
70 | XEntity.NEW["sensor.attr.mesh"] = XStatsSensor
71 | XEntity.NEW["sensor.attr.zigbee"] = XStatsSensor
72 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/switch.py:
--------------------------------------------------------------------------------
 1 | from homeassistant.components.switch import SwitchEntity
 2 | from homeassistant.helpers.restore_state import RestoreEntity
 3 | 
 4 | from .hass.entity import XEntity
 5 | 
 6 | 
 7 | # noinspection PyUnusedLocal
 8 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
 9 |     XEntity.ADD[entry.entry_id + "switch"] = async_add_entities
10 | 
11 | 
12 | class XSwitch(XEntity, SwitchEntity, RestoreEntity):
13 |     def set_state(self, data: dict):
14 |         self._attr_is_on = bool(data[self.attr])
15 | 
16 |     def get_state(self) -> dict:
17 |         return {self.attr: self._attr_is_on}
18 | 
19 |     async def async_turn_on(self):
20 |         self.device.write({self.attr: True})
21 | 
22 |     async def async_turn_off(self):
23 |         self.device.write({self.attr: False})
24 | 
25 | 
26 | XEntity.NEW["switch"] = XSwitch
27 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/text.py:
--------------------------------------------------------------------------------
 1 | from homeassistant.components.text import TextEntity
 2 | 
 3 | from .hass.entity import XEntity
 4 | 
 5 | 
 6 | # noinspection PyUnusedLocal
 7 | async def async_setup_entry(hass, entry, async_add_entities) -> None:
 8 |     XEntity.ADD[entry.entry_id + "text"] = async_add_entities
 9 | 
10 | 
11 | class XText(XEntity, TextEntity):
12 |     _attr_native_value = None
13 | 
14 |     def set_state(self, data: dict):
15 |         self._attr_native_value = data[self.attr]
16 | 
17 |     def get_state(self) -> dict:
18 |         return {self.attr: self._attr_native_value}
19 | 
20 |     async def async_set_value(self, value: str):
21 |         self.device.write({self.attr: value})
22 | 
23 | 
24 | XEntity.NEW["text"] = XText
25 | 


--------------------------------------------------------------------------------
/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 |       "no_key": "Unsupported gateway firmware without key",
  8 |       "wrong_model": "Unsupported gateway model",
  9 |       "wrong_token": "Wrong Mi Home token",
 10 |       "wrong_telnet": "Wrong open telnet command",
 11 |       "verify": "Click to verify url"
 12 |     },
 13 |     "step": {
 14 |       "user": {
 15 |         "title": "Select Action",
 16 |         "data": {
 17 |           "action": "Action"
 18 |         }
 19 |       },
 20 |       "cloud": {
 21 |         "title": "Add Mi Cloud Account",
 22 |         "description": "Select only those servers where you have devices",
 23 |         "data": {
 24 |           "username": "Email / Mi Account ID",
 25 |           "password": "Password",
 26 |           "servers": "Servers"
 27 |         }
 28 |       },
 29 |       "cloud_captcha": {
 30 |         "title": "Verify account",
 31 |         "description": "Enter code from: ![]({image})",
 32 |         "data": {
 33 |           "code": "Code"
 34 |         }
 35 |       },
 36 |       "cloud_verify": {
 37 |         "title": "Verify account",
 38 |         "description": "Enter code from: {address}",
 39 |         "data": {
 40 |           "code": "Code"
 41 |         }
 42 |       },
 43 |       "token": {
 44 |         "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).",
 45 |         "data": {
 46 |           "host": "Host",
 47 |           "token": "Token",
 48 |           "key": "Key"
 49 |         }
 50 |       }
 51 |     }
 52 |   },
 53 |   "options": {
 54 |     "step": {
 55 |       "cloud": {
 56 |         "title": "MiCloud devices info",
 57 |         "data": {
 58 |           "did": "Device"
 59 |         },
 60 |         "description": "{device_info}"
 61 |       },
 62 |       "user": {
 63 |         "title": "Gateway Config",
 64 |         "data": {
 65 |           "host": "Host",
 66 |           "token": "Token",
 67 |           "key": "Key",
 68 |           "telnet_cmd": "Open Telnet command",
 69 |           "ble": "Support Bluetooth devices",
 70 |           "stats": "Add statistic sensors",
 71 |           "debug": "Debug logs"
 72 |         }
 73 |       }
 74 |     }
 75 |   },
 76 |   "device_automation": {
 77 |     "trigger_type": {
 78 |       "action": "Action"
 79 |     }
 80 |   },
 81 |   "entity": {
 82 |     "select": {
 83 |       "command": {
 84 |         "state": {
 85 |           "info": "Device info",
 86 |           "update": "Device update",
 87 |           "pair": "Zigbee pairing",
 88 |           "force_pair": "Zigbee force pairing",
 89 |           "bind": "Zigbee binding",
 90 |           "ota": "Zigbee OTA",
 91 |           "reconfig": "Zigbee reconfig",
 92 |           "parent_scan": "Zigbee parent scan",
 93 |           "firmware_lock": "Gateway firmware lock",
 94 |           "reboot": "Gateway reboot",
 95 |           "run_ftp": "Gateway run FTP",
 96 |           "flash_ezsp": "Zigbee flash EZSP",
 97 |           "openmiio_restart": "OpenmiIO restart",
 98 |           "disable": "Gateway disable",
 99 |           "enable": "Gateway enable",
100 |           "rejoin": "Zigbee rejoin",
101 |           "remove": "Zigbee delete"
102 |         }
103 |       },
104 |       "data": {
105 |         "state": {
106 |           "enabled": "Enabled",
107 |           "disabled": "Disabled",
108 |           "unknown": "Unknown",
109 |           "ok": "OK",
110 |           "error": "ERROR",
111 |           "permit_join": "Ready to join",
112 |           "stop_join": "Pairing stopped",
113 |           "cancel": "Cancel",
114 |           "key_secure": "Send network key (secure)",
115 |           "key_legacy": "Send network key (legacy)",
116 |           "no_firmware": "No firmware",
117 |           "bind": "Bind",
118 |           "unbind": "Unbind",
119 |           "no_devices": "No devices",
120 |           "original": "Original",
121 |           "custom": "Custom"
122 |         }
123 |       }
124 |     }
125 |   }
126 | }


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/hu.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "config": {
  3 |     "error": {
  4 |       "no_servers": "Kérjük válasszon egy szervert",
  5 |       "cant_login": "Sikertelen bejelentkezés! Ellenőrizze a bejelentkezési adatokat",
  6 |       "cant_connect": "Sikertelen csatlakozás",
  7 |       "wrong_model": "Nem támogatott modell",
  8 |       "wrong_token": "Hibás Mi Home token",
  9 |       "wrong_telnet": "Hibás telnet parancs",
 10 |       "verify": "Kattintson az URL ellenőrzéséhez"
 11 |     },
 12 |     "step": {
 13 |       "user": {
 14 |         "title": "Válasszon feladatot",
 15 |         "data": {
 16 |           "action": "Feladat"
 17 |         }
 18 |       },
 19 |       "cloud": {
 20 |         "title": "Mi Cloud fiók hozzáadása",
 21 |         "description": "Csak azokat a szervereket válassza ki, amelyeken vannak eszközei",
 22 |         "data": {
 23 |           "username": "E-mail cím / Mi Account azonosító",
 24 |           "password": "Jelszó",
 25 |           "servers": "Szerver"
 26 |         }
 27 |       },
 28 |       "cloud_captcha": {
 29 |         "title": "Verify account",
 30 |         "description": "Enter code from: ![]({image})",
 31 |         "data": {
 32 |           "code": "Code"
 33 |         }
 34 |       },
 35 |       "cloud_verify": {
 36 |         "title": "Verify account",
 37 |         "description": "Enter code from: {address}",
 38 |         "data": {
 39 |           "code": "Code"
 40 |         }
 41 |       },
 42 |       "token": {
 43 |         "description": "A Mi Home tokent automatikusan beszerezheti a [Cloud integrációval] (https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token), vagy [manuálisan](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md).",
 44 |         "data": {
 45 |           "host": "Hoszt",
 46 |           "token": "Token"
 47 |         }
 48 |       }
 49 |     }
 50 |   },
 51 |   "options": {
 52 |     "step": {
 53 |       "cloud": {
 54 |         "title": "MiCloud eszköz infó",
 55 |         "data": {
 56 |           "did": "Eszköz"
 57 |         },
 58 |         "description": "{device_info}"
 59 |       },
 60 |       "user": {
 61 |         "title": "Központi egység konfiguráció",
 62 |         "data": {
 63 |           "host": "Hoszt",
 64 |           "token": "Token",
 65 |           "telnet_cmd": "Nyissa meg a Telnet parancshoz",
 66 |           "ble": "Bluetooth eszközök támogatása",
 67 |           "stats": "Statisztikai szenzorok hozzáadása",
 68 |           "debug": "Fejlesztői naplózás"
 69 |         }
 70 |       }
 71 |     }
 72 |   },
 73 |   "device_automation": {
 74 |     "trigger_type": {
 75 |       "button": "Gomb megnyomása",
 76 |       "button_1": "1. gomb",
 77 |       "button_2": "2. gomb",
 78 |       "button_3": "3. gomb",
 79 |       "button_4": "4. gomb",
 80 |       "button_5": "5. gomb",
 81 |       "button_6": "6. gomb",
 82 |       "button_both": "Mindkét gomb megnyomása",
 83 |       "button_both_12": "1. és 2. gomb megnyomása",
 84 |       "button_Open Telnet commandboth_13": "1. és 3. gomb megnyomása",
 85 |       "button_both_23": "2. és 3. gomb megnyomása"
 86 |     }
 87 |   },
 88 |   "entity": {
 89 |     "select": {
 90 |       "command": {
 91 |         "state": {
 92 |           "pair": "Zigbee párosítás",
 93 |           "bind": "Zigbee rögzítés",
 94 |           "ota": "Zigbee OTA",
 95 |           "reconfig": "Zigbee újrakonfigurálása",
 96 |           "parent_scan": "Zigbee szkennelés",
 97 |           "firmware_lock": "Központi egység firmware verziójának rögzítése",
 98 |           "reboot": "Központi egység újraindítása",
 99 |           "ftp": "Központi egység FTP hozzáférés",
100 |           "flashzb": "Zigbee EZSP telepítés"
101 |         }
102 |       },
103 |       "data": {
104 |         "state": {
105 |           "enabled": "Engedélyezés",
106 |           "disabled": "Letiltás",
107 |           "unknown": "Ismeretlen",
108 |           "ok": "OK",
109 |           "error": "HIBA",
110 |           "permit_join": "Készen áll a csatlakozásra",
111 |           "stop_join": "A párosítás leállt",
112 |           "cancel": "Mégsem",
113 |           "key_secure": "Hálózati kulcs küldése (biztonságos)",
114 |           "key_legacy": "Hálózati kulcs küldése (örökölt)",
115 |           "no_firmware": "Nincs firmware",
116 |           "bind": "Kötés",
117 |           "unbind": "Kioldás",
118 |           "no_devices": "Nincs eszköz",
119 |           "original": "Eredeti",
120 |           "custom": "Egyéni"
121 |         }
122 |       }
123 |     }
124 |   }
125 | }


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/pl.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "config": {
 3 |     "error": {
 4 |       "no_servers": "Wybierz co najmniej jeden serwer",
 5 |       "cant_login": "Nie można się zalogować, sprawdź czy wpisałeś poprawny login i hasło",
 6 |       "cant_connect": "Nie można nawiązać połączenia z bramką",
 7 |       "wrong_model": "Niewspierany model bramki",
 8 |       "wrong_token": "Niepoprawny token Mi Home",
 9 |       "wrong_telnet": "Nieprawidłowa komenda otwarcia protokołu telnet",
10 |       "verify": "Click to verify url"
11 |     },
12 |     "step": {
13 |       "user": {
14 |         "title": "Wybierz akcję",
15 |         "data": {
16 |           "action": "Akcja"
17 |         }
18 |       },
19 |       "cloud": {
20 |         "title": "Dodaj konto Xiaomi Home",
21 |         "description": "Wybierz tylko te serwery na której masz urządzenia",
22 |         "data": {
23 |           "username": "Email / ID konta Xiaomi",
24 |           "password": "Hasło",
25 |           "servers": "Serwery"
26 |         }
27 |       },
28 |       "cloud_captcha": {
29 |         "title": "Verify account",
30 |         "description": "Enter code from: ![]({image})",
31 |         "data": {
32 |           "code": "Code"
33 |         }
34 |       },
35 |       "cloud_verify": {
36 |         "title": "Verify account",
37 |         "description": "Enter code from: {address}",
38 |         "data": {
39 |           "code": "Code"
40 |         }
41 |       },
42 |       "token": {
43 |         "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).",
44 |         "data": {
45 |           "host": "Host (Adres IP bramki)",
46 |           "token": "Token"
47 |         }
48 |       }
49 |     }
50 |   },
51 |   "options": {
52 |     "step": {
53 |       "init": {
54 |         "title": "Informacje o urządzeniach z Xiaomi Home",
55 |         "data": {
56 |           "did": "Urządzenie"
57 |         },
58 |         "description": "{device_info}"
59 |       },
60 |       "user": {
61 |         "title": "Ustawienia bramki",
62 |         "data": {
63 |           "host": "Host",
64 |           "token": "Token",
65 |           "telnet_cmd": "Komenda do otwarcia protokołu telnet",
66 |           "ble": "Wsparcie dla urządzeń BLE (Bluetoth Low Energy)",
67 |           "stats": "Dane dotyczące wydajności Zigbee i BLE",
68 |           "debug": "Debugowanie"
69 |         }
70 |       }
71 |     }
72 |   },
73 |   "device_automation": {
74 |     "trigger_type": {
75 |       "button": "Naciśnięcie przycisku",
76 |       "button_1": "Naciśnięcie 1 przycisku",
77 |       "button_2": "Naciśnięcie 2 przycisku",
78 |       "button_3": "Naciśnięcie 3 przycisku",
79 |       "button_4": "Naciśnięcie 4 przycisku",
80 |       "button_5": "Naciśnięcie 5 przycisku",
81 |       "button_6": "Naciśnięcie 6 przycisku",
82 |       "button_both": "Naciśnięcie obydwu przycisków",
83 |       "button_both_12": "Naciśnięcie przycisków 1 i 2",
84 |       "button_both_13": "Naciśnięcie przycisków 1 i 3",
85 |       "button_both_23": "Naciśnięcie przycisków 2 i 3"
86 |     }
87 |   }
88 | }
89 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/pt-BR.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "config": {
 3 |     "error": {
 4 |       "no_servers": "Selecione pelo menos um servidor",
 5 |       "cant_login": "Não consigo fazer login, verifique o nome de usuário e a senha",
 6 |       "cant_connect": "Não é possível conectar ao gateway",
 7 |       "wrong_model": "Modelo de gateway não compatível",
 8 |       "wrong_token": "Token Mi Home errado",
 9 |       "wrong_telnet": "Comando telnet aberto errado",
10 |       "verify": "Click to verify url"
11 |     },
12 |     "step": {
13 |       "user": {
14 |         "title": "Selecionar ação",
15 |         "data": {
16 |           "action": "Ação"
17 |         }
18 |       },
19 |       "cloud": {
20 |         "title": "Adicionar conta Mi Cloud",
21 |         "description": "Selecione apenas os servidores em que você possui dispositivos",
22 |         "data": {
23 |           "username": "E-mail / ID da conta Mi",
24 |           "password": "Senha",
25 |           "servers": "Servidores"
26 |         }
27 |       },
28 |       "cloud_captcha": {
29 |         "title": "Verify account",
30 |         "description": "Enter code from: ![]({image})",
31 |         "data": {
32 |           "code": "Code"
33 |         }
34 |       },
35 |       "cloud_verify": {
36 |         "title": "Verify account",
37 |         "description": "Enter code from: {address}",
38 |         "data": {
39 |           "code": "Code"
40 |         }
41 |       },
42 |       "token": {
43 |         "description": "Você pode obter o token Mi Home automaticamente com [integração na nuvem](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) ou [manualmente](https://github.com/Maxmudjon/ com.xiaomi-miio/blob/master/docs/obtain_token.md).",
44 |         "data": {
45 |           "host": "Host",
46 |           "token": "Token"
47 |         }
48 |       }
49 |     }
50 |   },
51 |   "options": {
52 |     "step": {
53 |       "cloud": {
54 |         "title": "Informações de dispositivos MiCloud",
55 |         "data": {
56 |           "did": "Dispositivo"
57 |         },
58 |         "description": "{device_info}"
59 |       },
60 |       "user": {
61 |         "title": "Configuração do Gateway",
62 |         "data": {
63 |           "host": "Host",
64 |           "token": "Token",
65 |           "telnet_cmd": "Abra o comando Telnet",
66 |           "ble": "Suporta dispositivos Bluetooth",
67 |           "stats": "Adicionar sensores estatísticos",
68 |           "debug": "Debug"
69 |         }
70 |       }
71 |     }
72 |   },
73 |   "device_automation": {
74 |     "trigger_type": {
75 |       "button": "Pressione o botão",
76 |       "button_1": "1º botão pressionado",
77 |       "button_2": "2º botão pressionado",
78 |       "button_3": "3º botão pressionado",
79 |       "button_4": "4º botão pressionado",
80 |       "button_5": "5º botão pressionado",
81 |       "button_6": "6º botão pressionado",
82 |       "button_both": "Ambos os botões pressionados",
83 |       "button_both_12": "1º e 2º botão pressionado",
84 |       "button_both_13": "1º e 3º botão pressionado",
85 |       "button_both_23": "2º e 3º botão pressionado"
86 |     }
87 |   }
88 | }


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/ro.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "config": {
 3 |     "error": {
 4 |       "no_servers": "Te rog sa selectezi macar un server!",
 5 |       "cant_login": "Nu ma pot conecta. Mai verifica o data username-ul sau parola!",
 6 |       "cant_connect": "Nu ma pot conecta la gateway!",
 7 |       "wrong_model": "Modelul tau de gateway nu este suportat!",
 8 |       "wrong_token": "Wrong Mi Home token",
 9 |       "wrong_telnet": "Wrong open telnet command",
10 |       "verify": "Click to verify url"
11 |     },
12 |     "step": {
13 |       "user": {
14 |         "title": "Selecteaza actiunea",
15 |         "data": {
16 |           "action": "Actiune"
17 |         }
18 |       },
19 |       "cloud": {
20 |         "title": "Adauga un cont Mi Cloud",
21 |         "description": "Selecteaza doar serverul unde ai dispozitivele",
22 |         "data": {
23 |           "username": "Email / ID-ul contului Mi",
24 |           "password": "Parola",
25 |           "servers": "Servere"
26 |         }
27 |       },
28 |       "cloud_captcha": {
29 |         "title": "Verify account",
30 |         "description": "Enter code from: ![]({image})",
31 |         "data": {
32 |           "code": "Code"
33 |         }
34 |       },
35 |       "cloud_verify": {
36 |         "title": "Verify account",
37 |         "description": "Enter code from: {address}",
38 |         "data": {
39 |           "code": "Code"
40 |         }
41 |       },
42 |       "token": {
43 |         "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).",
44 |         "data": {
45 |           "host": "IP Gateway",
46 |           "token": "Token"
47 |         }
48 |       }
49 |     }
50 |   },
51 |   "options": {
52 |     "step": {
53 |       "cloud": {
54 |         "title": "Informatii despre dispozitivele din MiCloud",
55 |         "data": {
56 |           "did": "Dispozitiv"
57 |         },
58 |         "description": "{device_info}"
59 |       },
60 |       "user": {
61 |         "title": "Configurare Gateway",
62 |         "data": {
63 |           "host": "IP Gateway",
64 |           "token": "Token",
65 |           "telnet_cmd": "Comanda de deschidere Telnet",
66 |           "ble": "Dispozitive BLE suportate",
67 |           "stats": "Date despre performanta dispozitivelor Zigbee si BLE",
68 |           "debug": "Debug"
69 |         }
70 |       }
71 |     }
72 |   },
73 |   "device_automation": {
74 |     "trigger_type": {
75 |       "button": "Apasare Buton",
76 |       "button_1": "Prima apasare de buton",
77 |       "button_2": "A 2-a apasare de buton",
78 |       "button_3": "A 3-a apasare de buton",
79 |       "button_4": "A 4-a apasare de buton",
80 |       "button_5": "A 5-a apasare de buton",
81 |       "button_6": "A 6-a apasare de buton",
82 |       "button_both": "Ambele butoane apasate",
83 |       "button_both_12": "Primul si al 2-lea 2nd buton apasat",
84 |       "button_both_13": "Primul si al 3-lea buton apasat",
85 |       "button_both_23": "Al 2-lea si al 3-lea buton apasat"
86 |     }
87 |   }
88 | }


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/ru.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "config": {
  3 |     "error": {
  4 |       "no_servers": "Пожалуйста, выберите хотя бы один сервер",
  5 |       "cant_login": "Ошибка, проверьте имя пользователя и пароль",
  6 |       "cant_connect": "Не получается подключиться к шлюзу",
  7 |       "wrong_model": "Модель шлюза не поддерживается",
  8 |       "wrong_token": "Неверный Mi Home токен",
  9 |       "wrong_telnet": "Неверная команда для открытия Telnet",
 10 |       "verify": "Перейдите по verify url"
 11 |     },
 12 |     "step": {
 13 |       "user": {
 14 |         "title": "Выберите действие",
 15 |         "data": {
 16 |           "action": "Действие"
 17 |         }
 18 |       },
 19 |       "cloud": {
 20 |         "title": "Добавить Mi Cloud аккаунт",
 21 |         "description": "Выбирайте только те серверы, на которых у вас есть устройства",
 22 |         "data": {
 23 |           "username": "Email / Mi Account ID",
 24 |           "password": "Пароль",
 25 |           "servers": "Серверы"
 26 |         }
 27 |       },
 28 |       "cloud_captcha": {
 29 |         "title": "Проверка аккаунта",
 30 |         "description": "Введите код из: ![]({image})",
 31 |         "data": {
 32 |           "code": "Код"
 33 |         }
 34 |       },
 35 |       "cloud_verify": {
 36 |         "title": "Проверка аккаунта",
 37 |         "description": "Введите код из: {address}",
 38 |         "data": {
 39 |           "code": "Код"
 40 |         }
 41 |       },
 42 |       "token": {
 43 |         "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).",
 44 |         "data": {
 45 |           "host": "IP-адрес",
 46 |           "token": "Токен",
 47 |           "key": "Ключ"
 48 |         }
 49 |       }
 50 |     }
 51 |   },
 52 |   "options": {
 53 |     "step": {
 54 |       "cloud": {
 55 |         "title": "Информация о устройствах MiCloud",
 56 |         "data": {
 57 |           "did": "Устройство"
 58 |         },
 59 |         "description": "{device_info}"
 60 |       },
 61 |       "user": {
 62 |         "title": "Настройка шлюза",
 63 |         "data": {
 64 |           "host": "IP-адрес",
 65 |           "token": "Токен",
 66 |           "key": "Ключ",
 67 |           "telnet_cmd": "Команда для открытия Telnet",
 68 |           "ble": "Поддержка Bluetooth устройств",
 69 |           "stats": "Добавить сенсоры статистики",
 70 |           "debug": "Логи отладки (debug)"
 71 |         }
 72 |       }
 73 |     }
 74 |   },
 75 |   "device_automation": {
 76 |     "trigger_type": {
 77 |       "action": "Действие"
 78 |     }
 79 |   },
 80 |   "entity": {
 81 |       "select": {
 82 |         "command": {
 83 |           "state": {
 84 |             "info": "Информация об устройстве",
 85 |             "update": "Обновление устройства",
 86 |             "pair": "Сопряжение Zigbee",
 87 |             "force_pair": "Принудительное сопряжение Zigbee",
 88 |             "bind": "Привязка Zigbee",
 89 |             "ota": "Zigbee OTA",
 90 |             "reconfig": "Переконфигурация Zigbee",
 91 |             "parent_scan": "Сканирование родительских устройств Zigbee",
 92 |             "firmware_lock": "Блокировка прошивки шлюза",
 93 |             "reboot": "Перезагрузка шлюза",
 94 |             "run_ftp": "Запуск FTP на шлюзе",
 95 |             "flash_ezsp": "Прошивка EZSP Zigbee",
 96 |             "openmiio_restart": "Перезагрузка OpenmiIO",
 97 |             "disable": "Отключение шлюза",
 98 |             "enable": "Включение шлюза",
 99 |             "rejoin": "Повторное подключение Zigbee",
100 |             "remove": "Удаление устройства"
101 |           }
102 |         },
103 |         "data": {
104 |           "state": {
105 |             "enabled": "Включено",
106 |             "disabled": "Отключено",
107 |             "unknown": "Неизвестно",
108 |             "ok": "OK",
109 |             "error": "ОШИБКА",
110 |             "permit_join": "Готов к подключению",
111 |             "stop_join": "Сопряжение остановлено",
112 |             "cancel": "Отмена",
113 |             "key_secure": "Отправить сетевой ключ (безопасный)",
114 |             "key_legacy": "Отправить сетевой ключ (устаревший)",
115 |             "no_firmware": "Нет прошивки",
116 |             "bind": "Привязать",
117 |             "unbind": "Отвязать",
118 |             "no_devices": "Нет устройств",
119 |             "original": "Оригинальный",
120 |             "custom": "Пользовательский"
121 |           }
122 |         }
123 |       }
124 |     }
125 | }
126 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/ua.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "config": {
 3 |     "error": {
 4 |       "no_servers": "Будь ласка, виберіть хоча б один сервер",
 5 |       "cant_login": "Помилка, перевірте ім'я користувача і пароль",
 6 |       "cant_connect": "Не вдається підключитися до шлюзу",
 7 |       "wrong_model": "Модель шлюзу не підтримується",
 8 |       "wrong_token": "Wrong Mi Home token",
 9 |       "wrong_telnet": "Wrong open telnet command",
10 |       "verify": "Click to verify url"
11 |     },
12 |     "step": {
13 |       "user": {
14 |         "title": "Виберіть дію",
15 |         "data": {
16 |           "action": "Дія"
17 |         }
18 |       },
19 |       "cloud": {
20 |         "title": "Додати Mi Cloud аккаунт",
21 |         "description": "Вибирайте тільки ті сервери, на яких у вас є пристрої",
22 |         "data": {
23 |           "username": "Email / Mi Account ID",
24 |           "password": "Пароль",
25 |           "servers": "сервери"
26 |         }
27 |       },
28 |       "cloud_captcha": {
29 |         "title": "Verify account",
30 |         "description": "Enter code from: ![]({image})",
31 |         "data": {
32 |           "code": "Code"
33 |         }
34 |       },
35 |       "cloud_verify": {
36 |         "title": "Verify account",
37 |         "description": "Enter code from: {address}",
38 |         "data": {
39 |           "code": "Code"
40 |         }
41 |       },
42 |       "token": {
43 |         "description": "[Отримайте](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md) Mi Home токен.",
44 |         "data": {
45 |           "host": "IP-адреса",
46 |           "token": "Токен"
47 |         }
48 |       }
49 |     }
50 |   },
51 |   "options": {
52 |     "step": {
53 |       "cloud": {
54 |         "title": "Інформація про пристрої MiCloud",
55 |         "data": {
56 |           "did": "Пристрій"
57 |         },
58 |         "description": "{device_info}"
59 |       },
60 |       "user": {
61 |         "title": "Налаштування шлюзу",
62 |         "data": {
63 |           "host": "IP-адреса",
64 |           "token": "Токен",
65 |           "ble": "Підтримка BLE пристроїв",
66 |           "stats": "Деталізація роботи Zigbee і BLE",
67 |           "debug": "Відлагодження"
68 |         }
69 |       }
70 |     }
71 |   },
72 |   "device_automation": {
73 |     "trigger_type": {
74 |       "button": "Натискання кнопки",
75 |       "button_1": "Натискання першої кнопки",
76 |       "button_2": "Натискання другої кнопки",
77 |       "button_3": "Натискання третьої кнопки",
78 |       "button_4": "Натискання четвертої кнопки",
79 |       "button_5": "Натискання п'ятої кнопки",
80 |       "button_6": "Натискання шостої кнопки",
81 |       "button_both": "Натискання двох кнопок",
82 |       "button_both_12": "Натискання першої та другої кнопок",
83 |       "button_both_13": "Натискання першої і третьої кнопок",
84 |       "button_both_23": "Натискання другої і третьої кнопок"
85 |     }
86 |   }
87 | }


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/zh-Hans.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "config": {
  3 |     "error": {
  4 |       "no_servers": "请选择至少一个服务器",
  5 |       "cant_login": "无法登录,请检查用户名和密码",
  6 |       "cant_connect": "无法连接到网络",
  7 |       "wrong_model": "网关型号不支持",
  8 |       "wrong_token": "错误的网关Token",
  9 |       "wrong_telnet": "错误的打开telnet命令",
 10 |       "verify": "点击链接去验证"
 11 |     },
 12 |     "step": {
 13 |       "user": {
 14 |         "title": "选择动作",
 15 |         "data": {
 16 |           "action": "动作"
 17 |         }
 18 |       },
 19 |       "cloud": {
 20 |         "title": "添加小米云账号",
 21 |         "description": "选择设备所属的服务器",
 22 |         "data": {
 23 |           "username": "邮箱/小米 ID",
 24 |           "password": "密码",
 25 |           "servers": "服务器"
 26 |         }
 27 |       },
 28 |       "cloud_captcha": {
 29 |         "title": "Verify account",
 30 |         "description": "Enter code from: ![]({image})",
 31 |         "data": {
 32 |           "code": "Code"
 33 |         }
 34 |       },
 35 |       "cloud_verify": {
 36 |         "title": "Verify account",
 37 |         "description": "Enter code from: {address}",
 38 |         "data": {
 39 |           "code": "Code"
 40 |         }
 41 |       },
 42 |       "token": {
 43 |         "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)",
 44 |         "data": {
 45 |           "host": "网关IP",
 46 |           "token": "Token",
 47 |           "key": "Key"
 48 |         }
 49 |       }
 50 |     }
 51 |   },
 52 |   "options": {
 53 |     "step": {
 54 |       "init": {
 55 |         "title": "小米云的设备信息",
 56 |         "data": {
 57 |           "did": "设备"
 58 |         },
 59 |         "description": "{device_info}"
 60 |       },
 61 |       "user": {
 62 |         "title": "网关设定",
 63 |         "data": {
 64 |           "host": "主机",
 65 |           "token": "Token",
 66 |           "ble": "支持 BLE 设备",
 67 |           "stats": "Zigbee 和 BLE 效能数据",
 68 |           "debug": "调试信息"
 69 |         }
 70 |       }
 71 |     }
 72 |   },
 73 |   "device_automation": {
 74 |     "trigger_type": {
 75 |       "button": "按下按键",
 76 |       "button_1": "按下第 1 键",
 77 |       "button_2": "按下第 2 键",
 78 |       "button_3": "按下第 3 键",
 79 |       "button_4": "按下第 4 键",
 80 |       "button_5": "按下第 5 键",
 81 |       "button_6": "按下第 6 键",
 82 |       "button_both": "两键同时按下",
 83 |       "button_both_12": "同时按下第 1、2 键",
 84 |       "button_both_13": "同时按下第 1、3 键",
 85 |       "button_both_23": "同时按下第 2、3 键"
 86 |     }
 87 |   },
 88 |   "entity": {
 89 |     "select": {
 90 |       "command": {
 91 |         "state": {
 92 |           "pair": "Zigbee配对",
 93 |           "bind": "Zigbee绑定",
 94 |           "ota": "Zigbee OTA",
 95 |           "reconfig": "Zigbee重新配置",
 96 |           "parent_scan": "Zigbee父设备扫描",
 97 |           "firmware_lock": "网关固件锁定",
 98 |           "reboot": "网关重启",
 99 |           "ftp": "网关运行FTP",
100 |           "flashzb": "Zigbee刷写EZSP",
101 |           "openmiio_restart": "重载OpenmiIO"
102 |         }
103 |       },
104 |       "data": {
105 |         "state": {
106 |           "enabled": "已启用",
107 |           "disabled": "已禁用",
108 |           "unknown": "未知",
109 |           "ok": "完成",
110 |           "error": "出错",
111 |           "permit_join": "允许加入",
112 |           "stop_join": "停止加入",
113 |           "cancel": "取消",
114 |           "key_secure": "发送网络密钥(安全)",
115 |           "key_legacy": "发送网络密钥(传统)",
116 |           "no_firmware": "无固件",
117 |           "bind": "绑定",
118 |           "unbind": "解除绑定",
119 |           "no_devices": "没有设备",
120 |           "original": "原始固件",
121 |           "custom": "自定义固件"
122 |         }
123 |       }
124 |     }
125 |   }
126 | }
127 | 


--------------------------------------------------------------------------------
/custom_components/xiaomi_gateway3/translations/zh-Hant.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "config": {
  3 |     "error": {
  4 |       "no_servers": "請至少選擇一個伺服器",
  5 |       "cant_login": "無法登入,請確認使用者名稱與密碼",
  6 |       "cant_connect": "無法連線至網關",
  7 | 	    "no_key": "因網關韌體缺少金鑰(Key)而無法支援",
  8 |       "wrong_model": "網關型號不支援",
  9 |       "wrong_token": "網關Token錯誤",
 10 |       "wrong_telnet": "開啟 Telnet 指令錯誤",
 11 |       "verify": "點選確認 URL"
 12 |     },
 13 |     "step": {
 14 |       "user": {
 15 |         "title": "選擇動作",
 16 |         "data": {
 17 |           "action": "動作"
 18 |         }
 19 |       },
 20 |       "cloud": {
 21 |         "title": "新增小米雲服務帳號",
 22 |         "description": "選擇有綁定設備的伺服器",
 23 |         "data": {
 24 |           "username": "Email / 小米帳號 ID",
 25 |           "password": "密碼",
 26 |           "servers": "伺服器"
 27 |         }
 28 |       },
 29 |       "cloud_captcha": {
 30 |         "title": "Verify account",
 31 |         "description": "Enter code from: ![]({image})",
 32 |         "data": {
 33 |           "code": "Code"
 34 |         }
 35 |       },
 36 |       "cloud_verify": {
 37 |         "title": "Verify account",
 38 |         "description": "Enter code from: {address}",
 39 |         "data": {
 40 |           "code": "Code"
 41 |         }
 42 |       },
 43 |       "token": {
 44 |         "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。",
 45 |         "data": {
 46 |           "host": "網關IP",
 47 |           "token": "Token",
 48 |           "key": "Key"
 49 |         }
 50 |       }
 51 |     }
 52 |   },
 53 |   "options": {
 54 |     "step": {
 55 |       "cloud": {
 56 |         "title": "小米雲端裝置資訊",
 57 |         "data": {
 58 |           "did": "裝置"
 59 |         },
 60 |         "description": "{device_info}"
 61 |       },
 62 |       "user": {
 63 |         "title": "網關設定",
 64 |         "data": {
 65 |           "host": "網關IP",
 66 |           "token": "Token",
 67 |           "key": "Key",          
 68 |           "telnet_cmd": "開啟Telnet指令",          
 69 |           "ble": "支援的藍牙裝置",
 70 |           "stats": "新增統計資料感測器",
 71 |           "debug": "偵錯日誌"
 72 |         }
 73 |       }
 74 |     }
 75 |   },
 76 |   "device_automation": {
 77 |     "trigger_type": {
 78 |       "button": "按鈕按下",
 79 |       "button_1": "第 1 個按鈕按下",
 80 |       "button_2": "第 2 個按鈕按下",
 81 |       "button_3": "第 3 個按鈕按下",
 82 |       "button_4": "第 4 個按鈕按下",
 83 |       "button_5": "第 5 個按鈕按下",
 84 |       "button_6": "第 6 個按鈕按下",
 85 |       "button_both": "按鈕同時按下",
 86 |       "button_both_12": "第 1 與第 2 個按鈕同時按下",
 87 |       "button_both_13": "第 1 與第 3 個按鈕同時按下",
 88 |       "button_both_23": "第 2 與第 3 個按鈕同時按下"
 89 |     }
 90 |   },
 91 |   "entity": {
 92 |     "select": {
 93 |       "command": {
 94 |         "state": {
 95 |           "info": "裝置資訊",	
 96 |           "update": "裝置更新",		  
 97 |           "pair": "Zigbee配對",
 98 |           "force_pair": "Zigbee強制配對",		  
 99 |           "bind": "Zigbee绑定",
100 |           "ota": "Zigbee OTA",
101 |           "reconfig": "Zigbee重新設定",
102 |           "parent_scan": "Zigbee parent掃描",
103 |           "firmware_lock": "網關韌體鎖定",
104 |           "reboot": "網關重新啟動",
105 |           "run_ftp": "網關啟動FTP",
106 |           "flash_ezsp": "Zigbee flash EZSP",
107 |           "openmiio_restart": "重新啟動OpenmiIO",
108 |           "disable": "網關停用",
109 |           "enable": "網關啟用",
110 |           "rejoin": "Zigbee重新加入",
111 |           "remove": "Zigbee刪除"		  
112 |         }
113 |       },
114 |       "data": {
115 |         "state": {
116 |           "enabled": "已啟用",
117 |           "disabled": "已停用",
118 |           "unknown": "未知",
119 |           "ok": "OK",
120 |           "error": "錯誤",
121 |           "permit_join": "允許配對",
122 |           "stop_join": "停止配對",
123 |           "cancel": "取消",
124 |           "key_secure": "發送網路密鑰(安全)",
125 |           "key_legacy": "發送網路密鑰(傳統)",
126 |           "no_firmware": "無韌體",
127 |           "bind": "绑定",
128 |           "unbind": "解除绑定",
129 |           "no_devices": "沒有裝置",
130 |           "original": "原始韌體",
131 |           "custom": "客製韌體"
132 |         }
133 |       }
134 |     }
135 |   }
136 | }
137 | 


--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 |   "name": "Xiaomi Gateway 3",
3 |   "homeassistant": "2023.2.0",
4 |   "render_readme": true
5 | }


--------------------------------------------------------------------------------
/print_models.py:
--------------------------------------------------------------------------------
 1 | from custom_components.xiaomi_gateway3.core.devices import DEVICES
 2 | 
 3 | columns = ["Brand", "Name", "Model", "Entities"]
 4 | header = ["---"] * len(columns)
 5 | 
 6 | devices = {}
 7 | models = set()
 8 | 
 9 | is_ble = True
10 | 
11 | for i, device in enumerate(DEVICES):
12 |     # skip devices with bad support
13 |     if device.pop("support", 3) < 3:
14 |         continue
15 | 
16 |     spec = device.pop("spec")
17 | 
18 |     # skip BLE with unknown spec
19 |     if "default" not in device:
20 |         spec = ", ".join([conv.attr for conv in spec if conv.domain])
21 |     else:
22 |         spec = "*"
23 | 
24 |     for model, info in device.items():
25 |         if not isinstance(info, list):
26 |             continue
27 | 
28 |         if model in models:
29 |             print("duplicate:", model)
30 |         else:
31 |             models.add(model)
32 | 
33 |         if isinstance(model, str) and model not in info:
34 |             info.append(model)
35 | 
36 |         market_brand = info[0] or "~"
37 |         market_name = info[1]
38 |         market_model = ", ".join(info[2:]) if len(info) > 2 else ""
39 | 
40 |         if isinstance(model, str):
41 |             if "gateway" in model:
42 |                 kind = "Gateways"
43 |             elif model.startswith(("lumi.", "ikea.")):
44 |                 kind = "Xiaomi Zigbee"
45 |             elif model.startswith("matter."):
46 |                 kind = "Matter"
47 |             else:
48 |                 kind = "Other Zigbee"
49 |         elif isinstance(model, int):
50 |             if is_ble:
51 |                 kind = "Xiaomi BLE"
52 |             else:
53 |                 kind = "Xiaomi Mesh"
54 |         else:
55 |             kind = "Unknown"
56 | 
57 |         devices.setdefault(kind, []).append(
58 |             [market_brand, market_name, market_model, spec]
59 |         )
60 | 
61 |     if device.get("default") == "ble":
62 |         is_ble = False
63 | 
64 | out = f"# Supported devices\n\nTotal devices: {len(models)}\n\n"
65 | for k, v in devices.items():
66 |     out += f"## Supported {k}\n\nTotal devices: {len(v)}\n\n"
67 |     out += "|" + "|".join(columns) + "|\n"
68 |     out += "|" + "|".join(header) + "|\n"
69 |     for line in sorted(v):
70 |         out += "|" + "|".join(line) + "|\n"
71 |     out += "\n"
72 | 
73 | open("DEVICES.md", "w", encoding="utf-8").write(out)
74 | 


--------------------------------------------------------------------------------
/tests/test_automations.py:
--------------------------------------------------------------------------------
 1 | from custom_components.xiaomi_gateway3.core.device import XDevice
 2 | from custom_components.xiaomi_gateway3.device_trigger import get_actions
 3 | 
 4 | 
 5 | def test_buttons():
 6 |     device = XDevice("lumi.sensor_switch")
 7 |     p = get_actions(device.human_model)
 8 |     assert p == ["single", "double", "triple", "hold", "release"]
 9 | 
10 |     device = XDevice("lumi.ctrl_ln2")
11 |     p = get_actions(device.human_model)
12 |     assert p == [
13 |         "button_1_single",
14 |         "button_1_double",
15 |         "button_1_triple",
16 |         "button_1_hold",
17 |         "button_1_release",
18 |         "button_2_single",
19 |         "button_2_double",
20 |         "button_2_triple",
21 |         "button_2_hold",
22 |         "button_2_release",
23 |         "button_both_single",
24 |         "button_both_double",
25 |         "button_both_triple",
26 |         "button_both_hold",
27 |         "button_both_release",
28 |     ]
29 | 
30 |     device = XDevice("lumi.switch.l3acn3")
31 |     p = get_actions(device.human_model)
32 |     assert p == [
33 |         "button_1_single",
34 |         "button_1_double",
35 |         "button_1_triple",
36 |         "button_1_hold",
37 |         "button_1_release",
38 |         "button_2_single",
39 |         "button_2_double",
40 |         "button_2_triple",
41 |         "button_2_hold",
42 |         "button_2_release",
43 |         "button_3_single",
44 |         "button_3_double",
45 |         "button_3_triple",
46 |         "button_3_hold",
47 |         "button_3_release",
48 |         "button_both_12_single",
49 |         "button_both_12_double",
50 |         "button_both_12_triple",
51 |         "button_both_12_hold",
52 |         "button_both_12_release",
53 |         "button_both_13_single",
54 |         "button_both_13_double",
55 |         "button_both_13_triple",
56 |         "button_both_13_hold",
57 |         "button_both_13_release",
58 |         "button_both_23_single",
59 |         "button_both_23_double",
60 |         "button_both_23_triple",
61 |         "button_both_23_hold",
62 |         "button_both_23_release",
63 |     ]
64 | 
65 |     device = XDevice("lumi.remote.acn004")
66 |     p = get_actions(device.human_model)
67 |     assert p == [
68 |         "button_1_single",
69 |         "button_1_double",
70 |         "button_1_hold",
71 |         "button_2_single",
72 |         "button_2_double",
73 |         "button_2_hold",
74 |         "button_both_single",
75 |     ]
76 | 
77 |     device = XDevice(1983)
78 |     p = get_actions(device.human_model)
79 |     assert p == ["single", "double", "hold"]
80 | 
81 |     device = XDevice(1946)
82 |     p = get_actions(device.human_model)
83 |     assert p == ["button_1_single", "button_2_single"]
84 | 
85 | 
86 | def test_buttons_6473():
87 |     device = XDevice(6473)
88 |     p = get_actions(device.human_model)
89 |     assert p == [
90 |         "button_1_single",
91 |         "button_2_single",
92 |         "button_both_single",
93 |         "button_1_double",
94 |         "button_2_double",
95 |         "button_1_hold",
96 |         "button_2_hold",
97 |     ]
98 | 


--------------------------------------------------------------------------------
/tests/test_backward.py:
--------------------------------------------------------------------------------
 1 | from homeassistant.const import REQUIRED_PYTHON_VER
 2 | 
 3 | from custom_components.xiaomi_gateway3 import *
 4 | from custom_components.xiaomi_gateway3.alarm_control_panel import *
 5 | from custom_components.xiaomi_gateway3.binary_sensor import *
 6 | from custom_components.xiaomi_gateway3.climate import *
 7 | from custom_components.xiaomi_gateway3.config_flow import *
 8 | from custom_components.xiaomi_gateway3.cover import *
 9 | from custom_components.xiaomi_gateway3.device_trigger import *
10 | from custom_components.xiaomi_gateway3.diagnostics import *
11 | from custom_components.xiaomi_gateway3.light import *
12 | from custom_components.xiaomi_gateway3.number import *
13 | from custom_components.xiaomi_gateway3.select import *
14 | from custom_components.xiaomi_gateway3.sensor import *
15 | from custom_components.xiaomi_gateway3.switch import *
16 | from custom_components.xiaomi_gateway3.text import *
17 | 
18 | 
19 | def test_backward():
20 |     # https://github.com/home-assistant/core/blob/2023.2.0/homeassistant/const.py
21 |     assert REQUIRED_PYTHON_VER >= (3, 10, 0)
22 | 
23 |     assert async_setup_entry, async_unload_entry
24 |     assert XAlarmControlPanel
25 |     assert XBinarySensor
26 |     assert XAqaraS2
27 |     assert FlowHandler
28 |     assert XCover
29 |     assert async_get_triggers
30 |     assert async_get_config_entry_diagnostics
31 |     assert XLight
32 |     assert XNumber
33 |     assert XSelect
34 |     assert XSensor
35 |     assert XSwitch
36 |     assert XText
37 | 


--------------------------------------------------------------------------------
/tests/test_conv_mesh.py:
--------------------------------------------------------------------------------
 1 | from custom_components.xiaomi_gateway3 import XDevice
 2 | 
 3 | 
 4 | def test_es1():
 5 |     device = XDevice(10441)
 6 | 
 7 |     # p = device.decode({"siid": 3, "eiid": 1, "arguments": [{"piid": 1, "value": 1}]})
 8 |     # assert p == {"approach_away": True, "action": "approach"}
 9 | 
10 |     p = device.decode({"siid": 3, "piid": 2, "value": 5})
11 |     assert p == {"induction_range": "0+0.8_1.5+2.3_3.0_3.8_4.5_5.3_6"}
12 | 
13 |     p = device.encode({"induction_range": "0+0.8+1.5+2.3+3.0_3.8_4.5_5.3_6"})
14 |     assert p["params"] == [{"did": None, "piid": 2, "siid": 3, "value": 15}]
15 | 
16 | 
17 | def test_11724():
18 |     device = XDevice(11724)
19 | 
20 |     p = device.decode({"did": "123", "siid": 3, "piid": 16, "value": 23591044})
21 |     assert p == {"night_light_time": "23:59-10:44"}
22 | 
23 |     p = device.encode({"night_light_time": "23:59-10:44"})
24 |     assert p["params"] == [{"did": None, "siid": 3, "piid": 16, "value": 23591044}]
25 | 
26 | 
27 | def test_17573():
28 |     device = XDevice(17573)
29 | 
30 |     p = device.decode({"did": "123", "siid": 2, "piid": 1, "value": True})
31 |     assert p == {"light": True}
32 | 
33 |     p = device.decode({"did": "123", "siid": 2, "piid": 4, "value": 16711680})
34 |     assert p == {"rgb_color": (255, 0, 0)}
35 | 
36 |     p = device.decode({"did": "123", "siid": 2, "piid": 4, "value": 65280})
37 |     assert p == {"rgb_color": (0, 255, 0)}
38 | 
39 |     p = device.decode({"did": "123", "siid": 2, "piid": 4, "value": 255})
40 |     assert p == {"rgb_color": (0, 0, 255)}
41 | 
42 |     p = device.encode({"rgb_color": (255, 255, 0)})
43 |     assert p["params"] == [{"did": None, "piid": 4, "siid": 2, "value": 16776960}]
44 | 
45 | 
46 | def test_3129():
47 |     device = XDevice(3129, did=123)
48 | 
49 |     p = device.encode({"identify": True})
50 |     assert p == {
51 |         "method": "action",
52 |         "params": {"aiid": 1, "did": 123, "in": [], "siid": 4},
53 |     }
54 | 


--------------------------------------------------------------------------------
/tests/test_conv_multiple.py:
--------------------------------------------------------------------------------
 1 | from custom_components.xiaomi_gateway3.core.device import XDevice
 2 | 
 3 | 
 4 | def test_gateway_read():
 5 |     device = XDevice("lumi.gateway.mgl03")
 6 |     attrs = {i.attr for i in device.converters}
 7 |     p = device.encode_read(attrs)
 8 |     assert p == {
 9 |         "cmd": "read",
10 |         "did": None,
11 |         "params": [
12 |             {"res_name": "8.0.2109"},
13 |             {"res_name": "8.0.2110"},
14 |             {"res_name": "8.0.2111"},
15 |             {"res_name": "8.0.2084"},
16 |             {"res_name": "8.0.2082"},
17 |             {"res_name": "8.0.2080"},
18 |             {"res_name": "8.0.2166"},
19 |             {"res_name": "8.0.2091"},
20 |             {"res_name": "8.0.2012"},
21 |             {"res_name": "8.0.2024"},
22 |             {"did": None, "siid": 3, "piid": 1},
23 |             {"did": None, "siid": 3, "piid": 22},
24 |             {"did": None, "siid": 6, "piid": 6},
25 |         ],
26 |         "method": "get_properties",
27 |     }
28 | 
29 | 
30 | def test_gas_read():
31 |     device = XDevice("lumi.sensor_natgas")
32 |     attrs = {i.attr for i in device.converters}
33 |     p = device.encode_read(attrs)
34 |     assert p == {
35 |         "cmd": "read",
36 |         "commands": [
37 |             {"commandcli": "zcl mfg-code 4447"},
38 |             {"commandcli": "zcl global read 1280 65520"},
39 |             {"commandcli": "send 0x0000 1 1"},
40 |         ],
41 |         "did": None,
42 |         "params": [{"res_name": "0.1.85"}, {"res_name": "13.1.85"}],
43 |     }
44 | 


--------------------------------------------------------------------------------
/tests/test_conv_silabs.py:
--------------------------------------------------------------------------------
  1 | from zigpy.zcl.clusters.general import OnOff
  2 | 
  3 | from custom_components.xiaomi_gateway3.core.converters import silabs
  4 | from custom_components.xiaomi_gateway3.core.converters.silabs import zcl_write
  5 | 
  6 | 
  7 | def test_silabs_decode():
  8 |     p = silabs.decode({"clusterId": "0x0006", "APSPlayload": "0x18000A00001001"})
  9 |     assert p == {"cluster": "on_off", "general_command_id": 10, 0: 1}
 10 | 
 11 |     p = silabs.decode({"clusterId": "0x0006", "APSPlayload": "0x08080A04803001"})
 12 |     assert p == {"cluster": "on_off", "general_command_id": 10, 32772: 1}
 13 | 
 14 |     p = silabs.decode({"clusterId": "0x0006", "APSPlayload": "0x010AFD02"})
 15 |     assert p == {"cluster": "on_off", "cluster_command_id": 253, "value": b"\x02"}
 16 | 
 17 |     p = silabs.decode({"clusterId": "0x0500", "APSPlayload": "0x096700210000000000"})
 18 |     assert p == {
 19 |         "cluster": "ias_zone",
 20 |         "cluster_command_id": 0,
 21 |         "value": {"delay": 0, "extended_status": 0, "zone_id": 0, "zone_status": 33},
 22 |     }
 23 | 
 24 |     p = silabs.decode({"clusterId": "0x000A", "APSPlayload": "0x102D000000"})
 25 |     assert p == {"attribute_ids": [0], "cluster": "time", "general_command_id": 0}
 26 | 
 27 |     p = silabs.decode({"clusterId": "0x0400", "APSPlayload": "0x18E30A0000212200"})
 28 |     assert p == {"cluster": "illuminance", "general_command_id": 10, 0: 34}
 29 | 
 30 |     p = silabs.decode({"clusterId": "0x0402", "APSPlayload": "0x18DC0A0000291F08"})
 31 |     assert p == {"cluster": "temperature", "general_command_id": 10, 0: 2079}
 32 | 
 33 |     p = silabs.decode(
 34 |         {"clusterId": "0x0403", "APSPlayload": "0x18DE0A000029E003140028FF100029C526"}
 35 |     )
 36 |     assert p == {
 37 |         "cluster": "pressure",
 38 |         "general_command_id": 10,
 39 |         0: 992,
 40 |         20: -1,
 41 |         16: 9925,
 42 |     }
 43 | 
 44 |     p = silabs.decode({"clusterId": "0x0405", "APSPlayload": "0x18DD0A000021480D"})
 45 |     assert p == {"cluster": "humidity", "general_command_id": 10, 0: 3400}
 46 | 
 47 |     p = silabs.decode({"clusterId": "0x0406", "APSPlayload": "0x18E40A00001801"})
 48 |     assert p == {"cluster": "occupancy", "general_command_id": 10, 0: 1}
 49 | 
 50 |     p = silabs.decode({"clusterId": "0x0102", "APSPlayload": "0x08680A08002000"})
 51 |     assert p == {"cluster": "window_covering", "general_command_id": 10, 8: 0}
 52 | 
 53 |     p = silabs.decode({"clusterId": "0x0001", "APSPlayload": "0x08690A200020FF"})
 54 |     assert p == {"cluster": "power", "general_command_id": 10, 32: 255}
 55 | 
 56 |     p = silabs.decode(
 57 |         {"clusterId": "0xFCC0", "APSPlayload": "0x1D6E12B003080401010401000000"}
 58 |     )
 59 |     assert p == {
 60 |         "cluster": "manufacturer_specific",
 61 |         "cluster_command_id": 3,
 62 |         "value": b"\x08\x04\x01\x01\x04\x01\x00\x00\x00",
 63 |     }
 64 | 
 65 | 
 66 | def test_aqara_gas():
 67 |     p = silabs.decode(
 68 |         {
 69 |             "clusterId": "0x0500",
 70 |             "APSPlayload": "0x1C5F111A01F0FF00270800013800000102",
 71 |         }
 72 |     )
 73 |     assert p == {
 74 |         "cluster": "ias_zone",
 75 |         "general_command_id": 1,
 76 |         65520: 0x0201000038010008,
 77 |     }
 78 | 
 79 |     p = silabs.decode(
 80 |         {
 81 |             "clusterId": "0x0500",
 82 |             "APSPlayload": "0x1C5F113301F0FF00270800013800000202",
 83 |         }
 84 |     )
 85 |     assert p == {
 86 |         "cluster": "ias_zone",
 87 |         "general_command_id": 1,
 88 |         65520: 0x0202000038010008,
 89 |     }
 90 | 
 91 |     p = silabs.decode(
 92 |         {
 93 |             "clusterId": "0x0500",
 94 |             "APSPlayload": "0x1C5F113501F0FF00270800013800000302",
 95 |         }
 96 |     )
 97 |     assert p == {
 98 |         "cluster": "ias_zone",
 99 |         "general_command_id": 1,
100 |         65520: 0x0203000038010008,
101 |     }
102 | 
103 | 
104 | def test_aqara_smoke():
105 |     p = silabs.decode({"clusterId": "0x0001", "APSPlayload": "0x183B01210086"})
106 |     assert p == {"cluster": "power", "general_command_id": 1, 33: None}
107 | 
108 |     p = silabs.decode(
109 |         {
110 |             "clusterId": "0x0500",
111 |             "APSPlayload": "0x1C5F115501F0FF00270200011100000101",
112 |         }
113 |     )
114 |     assert p == {
115 |         "cluster": "ias_zone",
116 |         "general_command_id": 1,
117 |         0xFFF0: 0x101000011010002,
118 |     }
119 | 
120 |     p = silabs.decode(
121 |         {
122 |             "clusterId": "0x0500",
123 |             "APSPlayload": "0x1C5F115701F0FF00270300011100000201",
124 |         }
125 |     )
126 |     assert p == {
127 |         "cluster": "ias_zone",
128 |         "general_command_id": 1,
129 |         0xFFF0: 0x102000011010003,
130 |     }
131 | 
132 |     p = silabs.decode(
133 |         {
134 |             "clusterId": "0x0500",
135 |             "APSPlayload": "0x1C5F115B01F0FF00270200011100000301",
136 |         }
137 |     )
138 |     assert p == {
139 |         "cluster": "ias_zone",
140 |         "general_command_id": 1,
141 |         0xFFF0: 0x103000011010002,
142 |     }
143 | 
144 | 
145 | def test_silabs_decode_zdo():
146 |     p = silabs.decode(
147 |         {
148 |             "clusterId": "0x8000",
149 |             "sourceEndpoint": "0x00",
150 |             "APSPlayload": "0x0200FFEECC03008D15002723",
151 |         }
152 |     )
153 |     assert p == {
154 |         "ieee": "00:15:8d:00:03:cc:ee:ff",
155 |         "nwk": "0x2327",
156 |         "status": "SUCCESS",
157 |         "zdo_command": "NWK_addr_rsp",
158 |     }
159 | 
160 | 
161 | def test_xiaomi_basic():
162 |     p = silabs.decode(
163 |         {
164 |             "clusterId": "0x0000",
165 |             "APSPlayload": "0x1C5F11460A01FF42220121D10B0328190421A8430521090006240100000000082104020A210000641000",
166 |         }
167 |     )
168 |     assert p == {
169 |         "cluster": "basic",
170 |         "general_command_id": 10,
171 |         0xFF01: {1: 3025, 3: 25, 4: 17320, 5: 9, 6: 1, 8: 516, 10: 0, 100: 0},
172 |     }
173 | 
174 |     p = silabs.decode(
175 |         {
176 |             "clusterId": "0x0000",
177 |             "APSPlayload": "0x18370A01FF42280121F90B03281B0421A84305211A00062401000000000A21000008210410642002962300000000",
178 |         }
179 |     )
180 |     assert p == {
181 |         "cluster": "basic",
182 |         "general_command_id": 10,
183 |         0xFF01: {1: 3065, 3: 27, 4: 17320, 5: 26, 6: 1, 8: 4100, 10: 0, 100: 2, 150: 0},
184 |     }
185 | 
186 |     # no leak (100=0)
187 |     p = silabs.decode(
188 |         {
189 |             "clusterId": "0x0000",
190 |             "APSPlayload": "0x1C5F11520A050042156C756D692E73656E736F725F776C65616B2E61713101FF42220121D10B03281C0421A8430521080006240000000000082104020A210000641000",
191 |         }
192 |     )
193 |     assert p == {
194 |         "cluster": "basic",
195 |         "general_command_id": 10,
196 |         5: "lumi.sensor_wleak.aq1",
197 |         0xFF01: {1: 3025, 3: 28, 4: 17320, 5: 8, 6: 0, 8: 516, 10: 0, 100: 0},
198 |     }
199 | 
200 |     # leak detected (100=1)
201 |     p = silabs.decode(
202 |         {
203 |             "clusterId": "0x0000",
204 |             "APSPlayload": "0x1C5F11560A050042156C756D692E73656E736F725F776C65616B2E61713101FF42220121D10B03281C0421A8430521080006240300000000082104020A210000641001",
205 |         }
206 |     )
207 |     assert p == {
208 |         "cluster": "basic",
209 |         "general_command_id": 10,
210 |         5: "lumi.sensor_wleak.aq1",
211 |         65281: {1: 3025, 3: 28, 4: 17320, 5: 8, 6: 3, 8: 516, 10: 0, 100: 1},
212 |     }
213 | 
214 |     p = silabs.decode(
215 |         {
216 |             "clusterId": "0x0000",
217 |             "APSPlayload": "0x1C5F119F0A01FF421B03282D05214B00082108210921020464200B962300000000",
218 |         }
219 |     )
220 |     assert p == {
221 |         "cluster": "basic",
222 |         "general_command_id": 10,
223 |         65281: {3: 45, 5: 75, 8: 8456, 9: 1026, 100: 11, 150: 0},
224 |     }
225 | 
226 | 
227 | def test_zcl_read():
228 |     p = silabs.zcl_read("0x1234", 1, OnOff.cluster_id, OnOff.AttributeDefs.on_off.id)
229 |     assert p == [
230 |         {"commandcli": "zcl global read 6 0"},
231 |         {"commandcli": "send 0x1234 1 1"},
232 |     ]
233 | 
234 |     p = silabs.zcl_write("0x1234", 1, 0xFCC0, 9, 1, type_id=0x20, mfg=0x115F)
235 |     assert p == [
236 |         {"commandcli": "zcl mfg-code 4447"},
237 |         {"commandcli": "zcl global write 64704 9 32 {01}"},
238 |         {"commandcli": "send 0x1234 1 1"},
239 |     ]
240 | 
241 | 
242 | def test_zcl_write():
243 |     p = zcl_write("0x1234", 1, 6, 0, 1)
244 |     assert p == [
245 |         {"commandcli": "zcl global write 6 0 16 {01}"},
246 |         {"commandcli": "send 0x1234 1 1"},
247 |     ]
248 | 
249 | 
250 | def test_zdo():
251 |     # {"commands":[{"commandcli":"zdo ieee 0xe984"}]}
252 |     p = silabs.decode(
253 |         {
254 |             "clusterId": "0x8001",
255 |             "sourceEndpoint": "0x00",
256 |             "APSPlayload": "0x2E00888888881044EF5484E9",
257 |         }
258 |     )
259 |     s = [f"{k}: {v}" for k, v in p.items()]
260 |     assert s == [
261 |         "zdo_command: IEEE_addr_rsp",
262 |         "status: <Status.SUCCESS: 0>",
263 |         "ieee: 54:ef:44:10:88:88:88:88",
264 |         "nwk: 0xE984",
265 |     ]
266 | 
267 | 
268 | def test_general_4():
269 |     p = silabs.decode({"clusterId": "0x0000", "APSPlayload": "0x1C5F11760400"})
270 |     assert p == {"cluster": "basic", "general_command_id": 4, None: 0}
271 | 


--------------------------------------------------------------------------------
/tests/test_conv_zigbee.py:
--------------------------------------------------------------------------------
  1 | from custom_components.xiaomi_gateway3 import XDevice
  2 | from custom_components.xiaomi_gateway3.core.const import ZIGBEE
  3 | from custom_components.xiaomi_gateway3.core.converters import silabs
  4 | 
  5 | 
  6 | def decode(device: XDevice, data: dict) -> dict:
  7 |     data.setdefault("sourceEndpoint", "0x01")
  8 |     return device.decode(data)
  9 | 
 10 | 
 11 | def test_zigbee_plug():
 12 |     device = XDevice("TS0121", nwk="0x1234")
 13 |     p = device.encode_read({"plug"})
 14 |     assert p == {
 15 |         "commands": [
 16 |             {"commandcli": "zcl global read 6 0"},
 17 |             {"commandcli": "send 0x1234 1 1"},
 18 |         ]
 19 |     }
 20 | 
 21 |     p = device.encode_read({conv.attr for conv in device.converters})
 22 |     assert p == {
 23 |         "commands": [
 24 |             {"commandcli": "zcl global read 6 0"},
 25 |             {"commandcli": "send 0x1234 1 1"},
 26 |             {"commandcli": "zcl global read 2820 1285"},
 27 |             {"commandcli": "send 0x1234 1 1"},
 28 |             {"commandcli": "zcl global read 2820 1288"},
 29 |             {"commandcli": "send 0x1234 1 1"},
 30 |             {"commandcli": "zcl global read 2820 1291"},
 31 |             {"commandcli": "send 0x1234 1 1"},
 32 |             {"commandcli": "zcl global read 1794 0"},
 33 |             {"commandcli": "send 0x1234 1 1"},
 34 |             {"commandcli": "zcl global read 6 32770"},
 35 |             {"commandcli": "send 0x1234 1 1"},
 36 |         ]
 37 |     }
 38 | 
 39 |     assert silabs.optimize_read(p["commands"])
 40 |     assert p == {
 41 |         "commands": [
 42 |             {"commandcli": "raw 6 {10000000000280}"},
 43 |             {"commandcli": "send 0x1234 1 1"},
 44 |             {"commandcli": "raw 2820 {100000050508050b05}"},
 45 |             {"commandcli": "send 0x1234 1 1"},
 46 |             {"commandcli": "zcl global read 1794 0"},
 47 |             {"commandcli": "send 0x1234 1 1"},
 48 |         ]
 49 |     }
 50 | 
 51 | 
 52 | def test_ikea_cover():
 53 |     device = XDevice("FYRTUR block-out roller blind", nwk="0x1234")
 54 |     attrs = {i.attr for i in device.converters}
 55 |     p = device.encode_read(attrs)
 56 |     assert p == {
 57 |         "commands": [
 58 |             {"commandcli": "zcl global read 258 8"},
 59 |             {"commandcli": "send 0x1234 1 1"},
 60 |             {"commandcli": "zcl global read 1 33"},
 61 |             {"commandcli": "send 0x1234 1 1"},
 62 |             {"commandcli": "zcl global read 1 32"},
 63 |             {"commandcli": "send 0x1234 1 1"},
 64 |         ]
 65 |     }
 66 | 
 67 |     p = device.encode({"motor": "close"})
 68 |     assert p == {
 69 |         "commands": [
 70 |             {"commandcli": "raw 258 {110001}"},
 71 |             {"commandcli": "send 0x1234 1 1"},
 72 |         ]
 73 |     }
 74 | 
 75 |     p = device.encode({"position": 23})
 76 |     assert p == {
 77 |         "commands": [
 78 |             {"commandcli": "raw 258 {1100054d}"},
 79 |             {"commandcli": "send 0x1234 1 1"},
 80 |         ]
 81 |     }
 82 | 
 83 | 
 84 | def test_aqara_cube():
 85 |     device = XDevice("lumi.sensor_cube")
 86 |     p = device.decode(
 87 |         {
 88 |             "clusterId": "0x0012",
 89 |             "sourceEndpoint": "0x01",
 90 |             "APSPlayload": "0x18140A5500215900",
 91 |         }
 92 |     )
 93 |     assert p == {"action": "flip90", "from_side": 3, "to_side": 1}
 94 | 
 95 | 
 96 | def test_aqara_gas():
 97 |     device = XDevice("")
 98 |     p = silabs.decode(
 99 |         {
100 |             "clusterId": "0x0500",
101 |             "sourceEndpoint": "0x01",
102 |             "APSPlayload": "0x1C5F113C01F0FF00270200011100000101",
103 |         }
104 |     )
105 |     assert p
106 | 
107 | 
108 | def test_sonoff_motion():
109 |     device = XDevice("MS01", nwk="0x1234")
110 | 
111 |     p = device.decode(
112 |         {
113 |             "clusterId": "0x0001",
114 |             "sourceEndpoint": "0x01",
115 |             "APSPlayload": "0x18AC0A2000201E",
116 |         }
117 |     )
118 |     assert p == {"battery_voltage": 3000}
119 | 
120 |     p = device.decode(
121 |         {
122 |             "clusterId": "0x0001",
123 |             "sourceEndpoint": "0x01",
124 |             "APSPlayload": "0x18AD0A210020C8",
125 |         }
126 |     )
127 |     assert p == {"battery": 100}
128 | 
129 |     p = device.decode(
130 |         {
131 |             "clusterId": "0x0500",
132 |             "sourceEndpoint": "0x01",
133 |             "APSPlayload": "0x190300000000000000",
134 |         }
135 |     )
136 |     assert p == {"occupancy": False}
137 | 
138 |     p = device.decode(
139 |         {
140 |             "clusterId": "0x0500",
141 |             "sourceEndpoint": "0x01",
142 |             "APSPlayload": "0x190400010000000000",
143 |         }
144 |     )
145 |     assert p == {"occupancy": True}
146 | 
147 | 
148 | def test_aqara_bulb():
149 |     device = XDevice("lumi.light.acn014", nwk="0x1234")
150 |     p = device.encode({"transition": 2.5, "brightness": 50})
151 |     assert p == {
152 |         "commands": [
153 |             {"commandcli": "zcl level-control o-mv-to-level 50 25"},
154 |             {"commandcli": "send 0x1234 1 1"},
155 |         ],
156 |         "transition": 2.5,
157 |     }
158 | 
159 | 
160 | def test_unknown_device():
161 |     device = XDevice("dummy", type=ZIGBEE, ieee="aa:aa:aa:aa:aa:aa:aa:aa")
162 |     p = decode(device, {"clusterId": "0x0400", "APSPlayload": "0x18610A0000211F00"})
163 |     assert p == {"illuminance": 31}
164 |     p = decode(device, {"clusterId": "0x0402", "APSPlayload": "0x18020A0000298C07"})
165 |     assert p == {"temperature": 19.32}
166 |     p = decode(device, {"clusterId": "0x0405", "APSPlayload": "0x18030A000021CC0F"})
167 |     assert p == {"humidity": 40.44}
168 |     p = decode(device, {"clusterId": "0x000C", "APSPlayload": "0x18630A5500398FC23541"})
169 |     assert p == {"analog": 11.36}
170 |     p = decode(device, {"clusterId": "0x0001", "APSPlayload": "0x189B0A2000201E"})
171 |     assert p == {"battery_voltage": 3000}
172 |     p = decode(device, {"clusterId": "0x0001", "APSPlayload": "0x08660A200020FF"})
173 |     assert p == {"battery_voltage": 25500}
174 |     p = decode(device, {"clusterId": "0x0406", "APSPlayload": "0x18620A00001801"})
175 |     assert p == {"occupancy": True}
176 |     p = decode(device, {"clusterId": "0x0500", "APSPlayload": "0x195300010000FF0000"})
177 |     assert p == {"binary": True}
178 |     p = decode(device, {"clusterId": "0x0300", "APSPlayload": "0x18280107000021C700"})
179 |     assert p == {"color_temp": 199}
180 | 
181 | 
182 | def test_error():
183 |     payload = {"clusterId": "0x0001", "APSPlayload": "0x182701210086"}
184 |     p = silabs.decode(payload)
185 |     assert p == {"cluster": "power", "general_command_id": 1, 33: None}
186 | 
187 |     device = XDevice("dummy", type=ZIGBEE, ieee="aa:aa:aa:aa:aa:aa:aa:aa")
188 |     p = decode(device, payload)
189 |     assert p == {}
190 | 
191 | 
192 | def test_tuya_button():
193 |     device = XDevice("TS0041", nwk="0x1234")
194 | 
195 |     p = device.decode(
196 |         {
197 |             "clusterId": "0x0006",
198 |             "sourceEndpoint": "0x01",
199 |             "APSPlayload": "0x0135FD00",
200 |         }
201 |     )
202 |     assert p == {"action": "button_single", "button": "single"}
203 | 


--------------------------------------------------------------------------------
/tests/test_migrate.py:
--------------------------------------------------------------------------------
 1 | from custom_components.xiaomi_gateway3.core.const import DOMAIN
 2 | from custom_components.xiaomi_gateway3.hass.hass_utils import migrate_attr, migrate_uid
 3 | 
 4 | 
 5 | def test_migrate_uid():
 6 |     def new_identifiers(identifiers: set) -> set | None:
 7 |         assert any(i[1] != migrate_uid(i[1]) for i in identifiers if i[0] == DOMAIN)
 8 |         return {
 9 |             (DOMAIN, migrate_uid(i[1])) if i[0] == DOMAIN else i for i in identifiers
10 |         }
11 | 
12 |     p = new_identifiers({(DOMAIN, "0x158d000fffffff")})
13 |     assert p == {("xiaomi_gateway3", "0x00158d000fffffff")}
14 | 
15 |     p = new_identifiers({(DOMAIN, "167b9c2aea42f000")})
16 |     assert p == {("xiaomi_gateway3", "1620060199102640128")}
17 | 
18 |     p = new_identifiers(
19 |         {(DOMAIN, "50EC50FFFFFF"), (DOMAIN, "50ec50ffffff"), ("dummy", "id")}
20 |     )
21 |     assert p == {("dummy", "id"), ("xiaomi_gateway3", "50ec50ffffff")}
22 | 
23 | 
24 | def test_migrate_entity_unique_id():
25 |     def new_unique_id(unique_id: str, original_device_class: str = None):
26 |         uid, attr = unique_id.split("_", 1)
27 |         new_uid = migrate_uid(uid)
28 |         new_attr = migrate_attr(attr, original_device_class)
29 |         return f"{new_uid}_{new_attr}"
30 | 
31 |     p = new_unique_id("0x158d000fffffff_gas density")
32 |     assert p == "0x00158d000fffffff_gas_density"
33 | 
34 |     p = new_unique_id("0x158d000fffffff_smoke density")
35 |     assert p == "0x00158d000fffffff_smoke_density"
36 | 
37 |     p = new_unique_id("50EC50FFFFFF_light")
38 |     assert p == "50ec50ffffff_light"
39 | 
40 |     p = new_unique_id("0x158d000fffffff_switch", "plug")
41 |     assert p == "0x00158d000fffffff_plug"
42 | 
43 |     p = new_unique_id("167b9c2aea42f000_light")
44 |     assert p == "1620060199102640128_light"
45 | 
46 |     p = new_unique_id("group1620060199102640128_light")
47 |     assert p == "1620060199102640128_light"
48 | 


--------------------------------------------------------------------------------
/tests/test_misc.py:
--------------------------------------------------------------------------------
 1 | import copy
 2 | from dataclasses import dataclass
 3 | 
 4 | from bellows.uart import Gateway
 5 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass
 6 | 
 7 | from custom_components.xiaomi_gateway3.core.converters.base import BaseConv
 8 | from custom_components.xiaomi_gateway3.core.devices import DEVICES
 9 | 
10 | 
11 | def test_bellows():
12 |     class FakeTransport:
13 |         calls = []
14 | 
15 |         def frame_received(self, data):
16 |             self.calls.append(("frame_received", data))
17 | 
18 |         def write(self, data):
19 |             self.calls.append(("write", data))
20 | 
21 |     fake = FakeTransport()
22 | 
23 |     uart = Gateway(fake)
24 |     uart.connection_made(fake)
25 | 
26 |     uart.data_received(bytes.fromhex("45"))
27 |     uart.data_received(bytes.fromhex("41a157"))
28 |     uart.data_received(bytes.fromhex("547915ac"))
29 |     uart.data_received(bytes.fromhex("4d7e"))
30 | 
31 |     assert fake.calls
32 | 
33 | 
34 | def test_dataclass():
35 |     @dataclass
36 |     class Base:
37 |         attr: str
38 |         domain: str = None
39 | 
40 |     @dataclass
41 |     class First(Base):
42 |         domain: str = "switch"
43 | 
44 |     item = First("plug")
45 |     assert item.attr == "plug"
46 |     assert item.domain == "switch"
47 | 
48 |     item = First("plug", "test")
49 |     assert item.attr == "plug"
50 |     assert item.domain == "test"
51 | 
52 | 
53 | def test_enum():
54 |     assert "battery" in iter(BinarySensorDeviceClass)
55 |     assert "battery2" not in iter(BinarySensorDeviceClass)
56 | 
57 | 
58 | def test_copy():
59 |     conv1 = BaseConv("plug", "sensor")
60 |     conv2 = copy.copy(conv1)
61 |     conv2.domain = "light"
62 |     assert conv1.domain != conv2.domain
63 | 
64 | 
65 | def test_unique_models():
66 |     models = set()
67 |     for device in DEVICES:
68 |         for model in device.keys():
69 |             if model in ("default", "spec", "support", "ttl"):
70 |                 continue
71 |             assert model not in models, model
72 |             models.add(model)
73 | 


--------------------------------------------------------------------------------
/tests/test_zigpy_quirks.py:
--------------------------------------------------------------------------------
 1 | import zigpy.device
 2 | import zigpy.quirks
 3 | from zigpy.const import SIG_ENDPOINTS
 4 | from zigpy.device import Device
 5 | 
 6 | 
 7 | def generate_device(manufacturer: str, model: str) -> Device | None:
 8 |     """Generate device from quirks. Should be called earlier:
 9 |         zhaquirks.setup()
10 | 
11 |     Or direct import:
12 |         from zhaquirks.xiaomi.mija.sensor_switch import MijaButton
13 | 
14 |     Used like a Cluster:
15 |         hdr, value = device.deserialize(<endpoint_id>, <cluster_id>, data)
16 |     """
17 |     quirks = zigpy.quirks.get_quirk_list(manufacturer, model)
18 |     if not quirks:
19 |         return None
20 | 
21 |     # noinspection PyTypeChecker
22 |     device = Device(None, None, 0)
23 |     device.manufacturer = manufacturer
24 |     device.model = model
25 | 
26 |     quirk: zigpy.quirks.CustomDevice = quirks[0]
27 |     if SIG_ENDPOINTS in quirk.replacement:
28 |         for endpoint_id in quirk.replacement[SIG_ENDPOINTS].keys():
29 |             device.add_endpoint(endpoint_id)
30 | 
31 |     return quirks[0](None, None, 0, device)
32 | 


--------------------------------------------------------------------------------