├── .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: ",
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: ",
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: ",
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: ",
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: ",
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": "Введите код из: ",
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: ",
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: ",
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: ",
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 |
--------------------------------------------------------------------------------