first commit

This commit is contained in:
Your Name
2024-01-11 15:10:51 +03:00
commit eddf07c652
602 changed files with 67154 additions and 0 deletions
@@ -0,0 +1,260 @@
import asyncio
import json
import logging
import time
import voluptuous as vol
from homeassistant.components.system_log import CONF_LOGGER
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, MAJOR_VERSION, MINOR_VERSION
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import (
aiohttp_client as ac, config_validation as cv, device_registry as dr
)
from homeassistant.helpers.storage import Store
from . import system_health
from .core import logger, utils
from .core.const import DOMAIN, TITLE
from .core.entity import XEntity
from .core.gateway import XGateway
from .core.xiaomi_cloud import MiCloud
_LOGGER = logging.getLogger(__name__)
DOMAINS = [
'alarm_control_panel', 'binary_sensor', 'climate', 'cover', 'light',
'number', 'select', 'sensor', 'switch'
]
CONF_DEVICES = 'devices'
CONF_ATTRIBUTES_TEMPLATE = 'attributes_template'
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_DEVICES): {
cv.string: vol.Schema({
vol.Optional('occupancy_timeout'): cv.positive_int,
}, extra=vol.ALLOW_EXTRA),
},
CONF_LOGGER: logger.CONFIG_SCHEMA,
vol.Optional(CONF_ATTRIBUTES_TEMPLATE): cv.template
}, extra=vol.ALLOW_EXTRA),
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass: HomeAssistant, hass_config: dict):
if (MAJOR_VERSION, MINOR_VERSION) < (2021, 12):
_LOGGER.error("Minimum supported Hass version 2021.12")
return False
config = hass_config.get(DOMAIN) or {}
if CONF_LOGGER in config:
logger.init(__name__, config[CONF_LOGGER], hass.config.config_dir)
info = await hass.helpers.system_info.async_get_system_info()
_LOGGER.debug(f"SysInfo: {info}")
# update global debug_mode for all gateways
if 'debug_mode' in config[CONF_LOGGER]:
setattr(XGateway, 'debug_mode', config[CONF_LOGGER]['debug_mode'])
if CONF_ATTRIBUTES_TEMPLATE in config:
XEntity.attributes_template = config[CONF_ATTRIBUTES_TEMPLATE]
XEntity.attributes_template.hass = hass
hass.data[DOMAIN] = {}
await utils.load_devices(hass, config.get(CONF_DEVICES))
_register_send_command(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Support two kind of enties - MiCloud and Gateway."""
# entry for MiCloud login
if 'servers' in entry.data:
return await _setup_micloud_entry(hass, entry)
# migrate data (also after first setup) to options
if entry.data:
hass.config_entries.async_update_entry(entry, data={},
options=entry.data)
entries = hass.config_entries.async_entries(DOMAIN)
if any(e.options.get('debug') for e in entries):
await system_health.setup_debug(hass, _LOGGER)
# add options handler
if not entry.update_listeners:
entry.add_update_listener(async_update_options)
hass.data[DOMAIN][entry.entry_id] = XGateway(**entry.options)
hass.async_create_task(_setup_domains(hass, entry))
return True
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry):
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
# check unload cloud integration
if entry.entry_id not in hass.data[DOMAIN]:
return
# remove all stats entities if disable stats
if not entry.options.get('stats'):
utils.remove_stats(hass, entry.entry_id)
gw: XGateway = hass.data[DOMAIN][entry.entry_id]
await gw.stop()
await asyncio.gather(*[
hass.config_entries.async_forward_entry_unload(entry, domain)
for domain in DOMAINS
])
return True
# noinspection PyUnusedLocal
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry):
return True
async def _setup_domains(hass: HomeAssistant, entry: ConfigEntry):
# init setup for each supported domains
await asyncio.gather(*[
hass.config_entries.async_forward_entry_setup(entry, domain)
for domain in DOMAINS
])
gw: XGateway = hass.data[DOMAIN][entry.entry_id]
gw.start()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw.stop)
)
async def _setup_micloud_entry(hass: HomeAssistant, config_entry):
data: dict = config_entry.data.copy()
# quick fix Hass 2022.8 - parallel integration loading
# so Gateway loads before the Cloud with default devices names
store = Store(hass, 1, f"{DOMAIN}/{data['username']}.json")
devices = await store.async_load()
if devices:
_LOGGER.debug(f"Loaded from cache {len(devices)} devices")
_update_devices(devices)
session = ac.async_create_clientsession(hass)
hass.data[DOMAIN]['cloud'] = cloud = MiCloud(session, data['servers'])
if 'service_token' in data:
# load devices with saved MiCloud auth
cloud.auth = data
devices = await cloud.get_devices()
else:
devices = None
if devices is None:
_LOGGER.debug(f"Login to MiCloud for {config_entry.title}")
if await cloud.login(data['username'], data['password']):
# update MiCloud auth in .storage
data.update(cloud.auth)
hass.config_entries.async_update_entry(config_entry, data=data)
devices = await cloud.get_devices()
if devices is None:
_LOGGER.error("Can't load devices from MiCloud")
else:
_LOGGER.error("Can't login to MiCloud")
if devices is not None:
_LOGGER.debug(f"Loaded from MiCloud {len(devices)} devices")
_update_devices(devices)
await store.async_save(devices)
else:
_LOGGER.debug("No devices in .storage")
return False
# TODO: Think about a bunch of devices
if 'devices' not in hass.data[DOMAIN]:
hass.data[DOMAIN]['devices'] = devices
else:
hass.data[DOMAIN]['devices'] += devices
for device in devices:
# key - mac for BLE, and did for others
did = device['did'] if device['pid'] != 6 else \
device['mac'].replace(':', '').lower()
XGateway.defaults.setdefault(did, {})
# don't override name if exists
XGateway.defaults[did].setdefault('name', device['name'])
return True
def _update_devices(devices: list):
for device in devices:
# key - mac for BLE, and did for others
did = device['did'] if device['pid'] != 6 else \
device['mac'].replace(':', '').lower()
XGateway.defaults.setdefault(did, {})
# don't override name if exists
XGateway.defaults[did].setdefault('name', device['name'])
def _register_send_command(hass: HomeAssistant):
async def send_command(call: ServiceCall):
host = call.data["host"]
gw = next(
gw for gw in hass.data[DOMAIN].values()
if isinstance(gw, XGateway) and gw.host == host
)
cmd = call.data["command"].split(" ")
if cmd[0] == "miio":
raw = json.loads(call.data["data"])
resp = await gw.miio.send(raw['method'], raw.get('params'))
hass.components.persistent_notification.async_create(
str(resp), TITLE
)
elif cmd[0] == "set_state": # for debug purposes
device = gw.devices.get(cmd[1])
raw = json.loads(call.data["data"])
device.available = True
device.decode_ts = time.time()
device.update(raw)
hass.services.async_register(DOMAIN, "send_command", send_command)
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry
) -> bool:
"""Supported from Hass v2022.3"""
dr.async_get(hass).async_remove_device(device.id)
try:
# check if device is zigbee
if any(c[0] == dr.CONNECTION_ZIGBEE for c in device.connections):
unique_id = next(
i[1] for i in device.identifiers if i[0] == DOMAIN
)
await utils.remove_zigbee(unique_id)
return True
except Exception as e:
_LOGGER.error("Can't delete device", exc_info=e)
return False
@@ -0,0 +1,58 @@
from homeassistant.components.alarm_control_panel import \
SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, \
SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntity
from homeassistant.const import STATE_ALARM_TRIGGERED
from homeassistant.core import callback
from . import DOMAIN
from .core.converters import Converter
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
else:
entity = XiaomiAlarm(gateway, device, conv)
async_add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
# noinspection PyAbstractClass
class XiaomiAlarm(XEntity, AlarmControlPanelEntity):
_attr_code_arm_required = False
_attr_supported_features = (
SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY |
SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER
)
@callback
def async_set_state(self, data: dict):
if self.attr in data:
self._attr_state = data[self.attr]
if data.get("alarm_trigger") is True:
self._attr_state = STATE_ALARM_TRIGGERED
async def async_alarm_disarm(self, code=None):
await self.device_send({self.attr: "disarmed"})
async def async_alarm_arm_home(self, code=None):
await self.device_send({self.attr: "armed_home"})
async def async_alarm_arm_away(self, code=None):
await self.device_send({self.attr: "armed_away"})
async def async_alarm_arm_night(self, code=None):
await self.device_send({self.attr: "armed_night"})
async def async_alarm_trigger(self, code=None):
await self.device_send({"alarm_trigger": True})
async def async_update(self):
await self.device_read(self.subscribed_attrs)
@@ -0,0 +1,149 @@
import asyncio
import time
from datetime import timedelta
from homeassistant.components.automation import ATTR_LAST_TRIGGERED
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import STATE_ON
from homeassistant.core import callback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util.dt import now
from . import DOMAIN
from .core.converters import Converter, GATEWAY
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
SCAN_INTERVAL = timedelta(seconds=60)
CONF_INVERT_STATE = "invert_state"
CONF_OCCUPANCY_TIMEOUT = "occupancy_timeout"
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
elif conv.attr == "motion":
entity = XiaomiMotionSensor(gateway, device, conv)
elif conv.attr == GATEWAY:
entity = XiaomiGateway(gateway, device, conv)
else:
entity = XiaomiBinarySensor(gateway, device, conv)
async_add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
class XiaomiBinaryBase(XEntity, BinarySensorEntity):
@callback
def async_set_state(self, data: dict):
if self.attr in data:
# support invert_state for sensor
self._attr_is_on = not data[self.attr] \
if self.customize.get(CONF_INVERT_STATE, False) \
else data[self.attr]
for k, v in data.items():
if k in self.subscribed_attrs and k != self.attr:
self._attr_extra_state_attributes[k] = v
class XiaomiBinarySensor(XiaomiBinaryBase, RestoreEntity):
@callback
def async_restore_last_state(self, state: str, attrs: dict):
self._attr_is_on = state == STATE_ON
for k, v in attrs.items():
if k in self.subscribed_attrs:
self._attr_extra_state_attributes[k] = v
async def async_update(self):
await self.device_read(self.subscribed_attrs)
class XiaomiGateway(XiaomiBinaryBase):
@callback
def async_update_available(self):
# sensor state=connected when whole gateway available
self._attr_is_on = self.gw.available
@property
def available(self):
return True
class XiaomiMotionSensor(XEntity, BinarySensorEntity):
_attr_is_on = False
_default_delay = None
_last_on = 0
_last_off = 0
_timeout_pos = 0
_clear_task: asyncio.Task = None
async def async_clear_state(self, delay: float):
await asyncio.sleep(delay)
self._last_off = time.time()
self._timeout_pos = 0
self._attr_is_on = False
self.async_write_ha_state()
async def async_will_remove_from_hass(self):
if self._clear_task:
self._clear_task.cancel()
if self._attr_is_on:
self._attr_is_on = False
self.async_write_ha_state()
await super().async_will_remove_from_hass()
@callback
def async_set_state(self, data: dict):
# fix 1.4.7_0115 heartbeat error (has motion in heartbeat)
if "battery" in data or not self.hass:
return
assert data[self.attr] is True
# don't trigger motion right after illumination
ts = time.time()
if ts - self._last_on < 1:
return
if self._clear_task:
self._clear_task.cancel()
self._attr_is_on = True
self._attr_extra_state_attributes[ATTR_LAST_TRIGGERED] = \
now().isoformat(timespec="seconds")
self._last_on = ts
# if customize of any entity will be changed from GUI - default value
# for all motion sensors will be erased
timeout = self.customize.get(CONF_OCCUPANCY_TIMEOUT, 90)
if timeout:
if isinstance(timeout, list):
pos = min(self._timeout_pos, len(timeout) - 1)
delay = timeout[pos]
self._timeout_pos += 1
else:
delay = timeout
if delay < 0 and ts + delay < self._last_off:
delay *= 2
self.debug(f"Extend delay: {delay} seconds")
self._clear_task = self.hass.loop.create_task(
self.async_clear_state(abs(delay))
)
# repeat event from Aqara integration
self.hass.bus.async_fire("xiaomi_aqara.motion", {
"entity_id": self.entity_id
})
@@ -0,0 +1,72 @@
from homeassistant.components.climate import *
from homeassistant.components.climate.const import *
from homeassistant.core import callback
from . import DOMAIN
from .core.converters import Converter
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
ACTIONS = {
HVAC_MODE_OFF: CURRENT_HVAC_OFF,
HVAC_MODE_COOL: CURRENT_HVAC_COOL,
HVAC_MODE_HEAT: CURRENT_HVAC_HEAT
}
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
else:
entity = XiaomiClimate(gateway, device, conv)
async_add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
# noinspection PyAbstractClass
class XiaomiClimate(XEntity, ClimateEntity):
_attr_fan_mode = None
_attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO]
_attr_hvac_mode = None
_attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_COOL, HVAC_MODE_HEAT]
_attr_precision = PRECISION_WHOLE
_attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE
_attr_temperature_unit = TEMP_CELSIUS
# support only KTWKQ03ES for now
_attr_max_temp = 30
_attr_min_temp = 17
_attr_target_temperature_step = 1
@callback
def async_set_state(self, data: dict):
self._attr_current_temperature = data.get("current_temp")
self._attr_fan_mode = data.get("fan_mode")
self._attr_hvac_mode = data.get("hvac_mode")
# better support HomeKit
# https://github.com/AlexxIT/XiaomiGateway3/issues/707#issuecomment-1099109552
self._attr_hvac_action = ACTIONS.get(self._attr_hvac_mode)
# fix scenes with turned off climate
# https://github.com/AlexxIT/XiaomiGateway3/issues/101#issuecomment-757781988
self._attr_target_temperature = data.get("target_temp", 0)
async def async_update(self):
await self.device_read(self.subscribed_attrs)
async def async_set_temperature(self, **kwargs) -> None:
if kwargs[ATTR_TEMPERATURE] == 0:
return
payload = {"target_temp": kwargs[ATTR_TEMPERATURE]}
await self.device_send({self.attr: payload})
async def async_set_fan_mode(self, fan_mode: str) -> None:
payload = {"fan_mode": fan_mode}
await self.device_send({self.attr: payload})
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
payload = {"hvac_mode": hvac_mode}
await self.device_send({self.attr: payload})
@@ -0,0 +1,272 @@
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, OptionsFlow, ConfigEntry
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from . import DOMAIN
from .core import utils
from .core.xiaomi_cloud import MiCloud
ACTIONS = {
"cloud": "Add Mi Cloud Account",
"token": "Add Gateway using Token"
}
SERVERS = {
"cn": "China",
"de": "Europe",
"i2": "India",
"ru": "Russia",
"sg": "Singapore",
"us": "United States"
}
OPT_DEBUG = {
"true": "Basic logs",
"mqtt": "MQTT logs",
"zigbee": "Zigbee logs",
}
class FlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
cloud = None
async def async_step_user(self, user_input=None):
if user_input is not None:
if user_input["action"] == "cloud":
return await self.async_step_cloud()
elif user_input["action"] == "token":
return await self.async_step_token()
else:
device = next(
device for device in self.hass.data[DOMAIN]["devices"]
if device["did"] == user_input["action"]
)
return self.async_show_form(
step_id="token",
data_schema=vol.Schema({
vol.Required("host", default=device["localip"]): str,
vol.Required("token", default=device["token"]): str,
vol.Required("telnet_cmd"): str,
}),
)
if DOMAIN in self.hass.data and "devices" in self.hass.data[DOMAIN]:
for device in self.hass.data[DOMAIN]["devices"]:
if (device["model"] in utils.SUPPORTED_MODELS and
device["did"] not in ACTIONS):
name = f"Add {device['name']} ({device['localip']})"
ACTIONS[device["did"]] = name
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("action", default="cloud"): vol.In(ACTIONS)
})
)
async def async_step_cloud(self, user_input=None, error=None):
if user_input:
if not user_input["servers"]:
return await self.async_step_cloud(error="no_servers")
session = async_create_clientsession(self.hass)
cloud = MiCloud(session)
if await cloud.login(
user_input["username"], user_input["password"]
):
user_input.update(cloud.auth)
return self.async_create_entry(
title=user_input["username"], data=user_input
)
else:
return await self.async_step_cloud(error="cant_login")
return self.async_show_form(
step_id="cloud",
data_schema=vol.Schema({
vol.Required("username"): str,
vol.Required("password"): str,
vol.Required("servers", default=["cn"]):
cv.multi_select(SERVERS)
}),
errors={"base": error} if error else None
)
async def async_step_token(self, user_input=None, error=None):
"""GUI > Configuration > Integrations > Plus > Xiaomi Gateway 3"""
if user_input is not None:
error = await utils.check_gateway(**user_input)
if error:
return await self.async_step_token(error=error)
return self.async_create_entry(
title=user_input["host"], data=user_input
)
return self.async_show_form(
step_id="token",
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Required("token"): str,
vol.Required("telnet_cmd"): str,
}),
errors={"base": error} if error else None
)
@staticmethod
@callback
def async_get_options_flow(entry: ConfigEntry):
return OptionsFlowHandler(entry)
TITLE = "Xiaomi Gateway 3"
ZHA_NOTIFICATION = """For **ZHA**, goto:
Configuration > Integrations > Add Integration > Zigbee Home Automation:
- Radio Type: **EZSP**
- Path: `socket://{0}:8888`
For **zigbee2mqtt**, goto:
Supervisor > Zigbee2mqtt > Configuration:
```
serial:
port: 'tcp://{0}:8888'
adapter: ezsp
```
"""
# noinspection PyUnusedLocal
class OptionsFlowHandler(OptionsFlow):
def __init__(self, entry: ConfigEntry):
self.entry = entry
async def async_step_init(self, user_input=None):
if "servers" in self.entry.data:
return await self.async_step_cloud()
else:
return await self.async_step_user()
async def async_step_cloud(self, user_input=None):
if user_input is not None:
did = user_input["did"]
device = next(
device for device in self.hass.data[DOMAIN]["devices"]
if device["did"] == did
)
if device["pid"] != 6:
device_info = (
f"Name: {device['name']}\n"
f"Model: {device['model']}\n"
f"IP: {device['localip']}\n"
f"MAC: {device['mac']}\n"
f"Token: {device['token']}"
)
else:
bindkey = await utils.get_bindkey(
self.hass.data[DOMAIN]["cloud"], did
)
device_info = (
f"Name: {device['name']}\n"
f"Model: {device['model']}\n"
f"MAC: {device['mac']}\n"
f"Bindkey: {bindkey}\n"
)
if device["model"] == "lumi.gateway.v3":
device_info += "\nLAN key: " + await utils.get_lan_key(
device["localip"], device["token"]
)
elif ".vacuum." in device["model"]:
device_info += "\nRooms: " + await utils.get_room_mapping(
self.hass.data[DOMAIN]["cloud"],
device["localip"], device["token"],
)
elif device["model"] == "yeelink.light.bslamp2":
device_info += "\nLAN mode: " + await utils.enable_bslamp2_lan(
device["localip"], device["token"]
)
elif not self.hass.data[DOMAIN].get("devices"):
device_info = "No devices in account"
else:
device_info = "Choose a device from the list"
devices = {}
for device in self.hass.data[DOMAIN].get("devices", []):
# 0 - wifi, 6 - ble, 8 - wifi+ble
if device["pid"] in (0, 8):
info = device["localip"]
elif device["pid"] == 6:
info = "BLE"
else:
continue
devices[device["did"]] = f"{device['name']} ({info})"
return self.async_show_form(
step_id="cloud",
data_schema=vol.Schema({
vol.Required("did"): vol.In(devices)
}),
description_placeholders={
"device_info": device_info
}
)
async def async_step_user(self, user_input=None):
if user_input:
old_mode = self.entry.options.get("zha", False)
new_mode = user_input["zha"]
if new_mode != old_mode:
host = user_input["host"]
# change zigbee firmware if needed
if not await utils.update_zigbee_firmware(
self.hass, host, new_mode
):
raise Exception("Can't update zigbee firmware")
if new_mode is True:
self.hass.components.persistent_notification.async_create(
ZHA_NOTIFICATION.format(host), TITLE
)
return self.async_create_entry(title='', data=user_input)
host = self.entry.options["host"]
token = self.entry.options["token"]
telnet_cmd = self.entry.options.get("telnet_cmd", "")
ble = self.entry.options.get("ble", True)
stats = self.entry.options.get("stats", False)
debug = self.entry.options.get("debug", [])
buzzer = self.entry.options.get("buzzer", False)
memory = self.entry.options.get("memory", False)
zha = self.entry.options.get("zha", False)
# filter only supported items
debug = [k for k in debug if k in OPT_DEBUG]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("host", default=host): str,
vol.Required("token", default=token): str,
vol.Optional("telnet_cmd", default=telnet_cmd): str,
vol.Required("ble", default=ble): bool,
vol.Optional("stats", default=stats): bool,
vol.Optional("debug", default=debug):
cv.multi_select(OPT_DEBUG),
vol.Optional("buzzer", default=buzzer): bool,
vol.Optional("memory", default=memory): bool,
vol.Optional("zha", default=zha): bool,
}),
)
@@ -0,0 +1,28 @@
DOMAIN = 'xiaomi_gateway3'
TITLE = "Xiaomi Gateway 3"
def source_hash() -> str:
if source_hash.__doc__:
return source_hash.__doc__
try:
import hashlib
import os
m = hashlib.md5()
path = os.path.dirname(os.path.dirname(__file__))
for root, dirs, files in os.walk(path):
dirs.sort()
for file in sorted(files):
if not file.endswith(".py"):
continue
path = os.path.join(root, file)
with open(path, "rb") as f:
m.update(f.read())
source_hash.__doc__ = m.hexdigest()[:7]
return source_hash.__doc__
except Exception as e:
return f"{type(e).__name__}: {e}"
@@ -0,0 +1,100 @@
import logging
import re
from dataclasses import dataclass
from typing import List, Optional
from .base import Converter, LUMI_GLOBALS, parse_time
from .const import GATEWAY, ZIGBEE, BLE, MESH, MESH_GROUP_MODEL
from .devices import DEVICES
from .stats import STAT_GLOBALS
try:
# loading external converters
# noinspection PyUnresolvedReferences
from xiaomi_gateway3 import DEVICES
except ModuleNotFoundError:
pass
except Exception as e:
logger = logging.getLogger(__name__)
logger.error("Can't load external converters", exc_info=e)
@dataclass
class XDeviceInfo:
manufacturer: str
model: str
name: str
url: str
spec: List[Converter]
ttl: float
def is_mihome_zigbee(model: str) -> bool:
return model.startswith(("lumi.", "ikea."))
def get_device_info(model: str, type: str) -> Optional[XDeviceInfo]:
"""Type is used to select the default spec if the model didn't match
earlier. Should be the latest spec in the list.
"""
for desc in DEVICES:
if model not in desc and desc.get("default") != type:
continue
info = desc.get(model) or ["Unknown", type.upper(), None]
brand, name, market = info if len(info) == 3 else info + [None]
if type == ZIGBEE and not is_mihome_zigbee(model):
url = "https://www.zigbee2mqtt.io/supported-devices/#s=" + market \
if market else None
else:
url = f"https://home.miot-spec.com/s/{model}"
if market and type == ZIGBEE:
market = f"{type} {market} ({model})"
elif market:
market = f"{type} {market}"
else:
market = f"{type} ({model})"
ttl = desc.get("ttl")
if isinstance(ttl, str):
ttl = parse_time(ttl)
return XDeviceInfo(
manufacturer=brand,
model=market,
name=f"{brand} {name}",
url=url,
spec=desc["spec"],
ttl=ttl
)
raise RuntimeError
RE_INFO_MODEL = re.compile(r"^(zigbee|ble|mesh)(?: ([^ ]+))?(?: \((.+?)\))?$")
def get_buttons(info_model: str) -> Optional[List[str]]:
"""Gets a list of buttons using the device info model."""
m = RE_INFO_MODEL.search(info_model)
if not m:
return None
market = m[2]
model = m[3]
# Yeelight Button S1
if market == "YLAI003":
return ["button"]
for device in DEVICES:
if model in device or any(
info[2] == market for info in device.values()
if isinstance(info, list) and len(info) == 3
):
return sorted(set([
conv.attr for conv in device["spec"]
if conv.attr.startswith("button")
]))
return None
@@ -0,0 +1,470 @@
import json
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Optional, TYPE_CHECKING
from .const import *
if TYPE_CHECKING:
from ..device import XDevice
def parse_time(value: str) -> float:
"""Conver string time to float time (seconds).
@type value: 15s or 30m or 24h or 1d
"""
return float(value[:-1]) * TIME[value[-1]]
###############################################################################
# Base (global) converters
###############################################################################
@dataclass
class Converter:
attr: str # hass attribute
domain: Optional[str] = None # hass domain
# hack for old python
kw_only: bool = None
mi: Optional[str] = None
parent: Optional[str] = None
enabled: Optional[bool] = True # support: True, False, None (lazy setup)
poll: bool = False # hass should_poll
# don't init with dataclass because no type:
childs = None # set or dict? of children attributes
zigbee = None # str or set? with zigbee cluster
def decode(self, device: "XDevice", payload: dict, value: Any):
payload[self.attr] = value
def encode(self, device: "XDevice", payload: dict, value: Any):
if not self.mi:
payload[self.attr] = value
return
if ".p." in self.mi:
siid, piid = self.mi.split(".p.")
cmd = {"siid": int(siid), "piid": int(piid), "value": value}
payload.setdefault("mi_spec", []).append(cmd)
else:
cmd = {"res_name": self.mi, "value": value}
payload.setdefault("params", []).append(cmd)
def read(self, device: "XDevice", payload: dict):
if not self.mi:
return
if ".p." in self.mi:
siid, piid = self.mi.split(".p.")
cmd = {"siid": int(siid), "piid": int(piid)}
payload.setdefault("mi_spec", []).append(cmd)
else:
cmd = {"res_name": self.mi}
payload.setdefault("params", []).append(cmd)
class BoolConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = bool(value)
def encode(self, device: "XDevice", payload: dict, value: bool):
super().encode(device, payload, int(value))
@dataclass
class EventConv(Converter):
value: Any = None
def decode(self, device: "XDevice", payload: dict, value: list):
payload[self.attr] = self.value
if len(value) > 0:
payload.update(device.decode_lumi(value))
@dataclass
class MapConv(Converter):
map: dict = None
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = self.map.get(value)
def encode(self, device: "XDevice", payload: dict, value: Any):
value = next(k for k, v in self.map.items() if v == value)
super().encode(device, payload, value)
@dataclass
class MathConv(Converter):
max: float = float("inf")
min: float = -float("inf")
multiply: float = 0
round: int = None
def decode(self, device: "XDevice", payload: dict, value: float):
if self.min <= value <= self.max:
if self.multiply:
value *= self.multiply
if self.round is not None:
# convert to int when round is zero
value = round(value, self.round or None)
payload[self.attr] = value
def encode(self, device: "XDevice", payload: dict, value: float):
if self.multiply:
value /= self.multiply
super().encode(device, payload, value)
@dataclass
class BrightnessConv(Converter):
max: float = 100.0
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = value / self.max * 255.0
def encode(self, device: "XDevice", payload: dict, value: float):
value = round(value / 255.0 * self.max)
super().encode(device, payload, int(value))
@dataclass
class ColorTempKelvin(Converter):
# 2700..6500 => 370..153
mink: int = 2700
maxk: int = 6500
def decode(self, device: "XDevice", payload: dict, value: int):
"""Convert degrees kelvin to mired shift."""
payload[self.attr] = int(1000000.0 / value)
def encode(self, device: "XDevice", payload: dict, value: int):
value = int(1000000.0 / value)
if value < self.mink:
value = self.mink
if value > self.maxk:
value = self.maxk
super().encode(device, payload, value)
@dataclass
class BatteryConv(Converter):
childs = {"battery_voltage", "battery_original"}
min = 2700
max = 3200
def decode(self, device: "XDevice", payload: dict, value: int):
payload["battery_voltage"] = value
if value <= self.min:
payload[self.attr] = 0
elif value >= self.max:
payload[self.attr] = 100
else:
payload[self.attr] = int(
100.0 * (value - self.min) / (self.max - self.min)
)
class ButtonConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = value
if self.attr == "button":
payload["action"] = BUTTON.get(value, UNKNOWN)
elif self.attr.startswith("button_both"):
both = BUTTON_BOTH.get(value, UNKNOWN)
payload["action"] = self.attr + "_" + both
elif self.attr.startswith("button"):
payload["action"] = self.attr + "_" + BUTTON.get(value, UNKNOWN)
@dataclass
class ButtonMIConv(ButtonConv):
value: int = None
def decode(self, device: "XDevice", payload: dict, value: int):
super().decode(device, payload, self.value)
###############################################################################
# Device converters
###############################################################################
class VibrationConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = value
# skip tilt and wait tilt_angle
if value == 1:
payload["action"] = "vibration"
elif value == 3:
payload["action"] = "drop"
class TiltAngleConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: int):
payload["action"] = "tilt"
payload["angle"] = value
payload["vibration"] = 2
class CloudLinkConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: str):
if isinstance(value, str):
value = json.loads(value)["cloud_link"]
payload[self.attr] = bool(value)
class ResetsConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: int):
if 'resets0' not in device.extra:
device.extra['resets0'] = value
payload['new_resets'] = value - device.extra['resets0']
super().decode(device, payload, value)
class ClimateConv(Converter):
hvac = {"off": 0x01, "heat": 0x10, "cool": 0x11}
fan = {"low": 0x00, "medium": 0x10, "high": 0x20, "auto": 0x30}
def decode(self, device: "XDevice", payload: dict, value: Any):
# use payload to push data to climate entity
# use device extra to pull data on encode
payload[self.attr] = device.extra[self.attr] = value
def encode(self, device: "XDevice", payload: dict, value: dict):
if self.attr not in device.extra:
return
b = bytearray(device.extra[self.attr].to_bytes(4, 'big'))
if "hvac_mode" in value:
b[0] = self.hvac[value["hvac_mode"]]
if "fan_mode" in value:
b[1] = self.fan[value["fan_mode"]]
if "target_temp" in value:
b[3] = int(value["target_temp"])
value = int.from_bytes(b, 'big')
super().encode(device, payload, value)
class ClimateTempConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = value if value < 255 else 0
# we need get pos with one property and set pos with another
class CurtainPosConv(Converter):
def encode(self, device: "XDevice", payload: dict, value: Any):
conv = next(
c for c in device.converters if c.attr == "target_position"
)
conv.encode(device, payload, value)
@dataclass
class LockActionConv(Converter):
map: dict = None
def decode(self, device: "XDevice", payload: dict, value: Any):
if self.attr in ("lock_control", "door_state", "lock_state"):
payload["action"] = "lock"
payload[self.attr] = self.map.get(value)
elif self.attr == "key_id":
payload["action"] = "lock"
payload[self.attr] = value
elif self.attr == "alarm":
v = self.map.get(value)
if v != "doorbell":
payload["action"] = self.attr
payload[self.attr] = v
else:
payload["action"] = v
elif self.attr.endswith("_wrong"):
payload["action"] = "error"
payload["error"] = self.attr
payload[self.attr] = value
elif self.attr in ("error", "method"):
payload[self.attr] = self.map.get(value)
@dataclass
class LockConv(Converter):
mask: int = 0
def decode(self, device: "XDevice", payload: dict, value: int):
# Hass: On means open (unlocked), Off means closed (locked)
payload[self.attr] = not bool(value & self.mask)
# to get natgas sensitivity value - write: {"res_name": "4.1.85", "value": 1}
# then it's report: {"res_name": "14.2.85", "value": <1 to 3>}
class GasSensitivityReadConv(Converter):
map = {1: "low", 2: "medium", 3: "high"}
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = self.map.get(value)
def encode(self, device: "XDevice", payload: dict, value: str):
pass
def read(self, device: "XDevice", payload: dict):
payload["cmd"] = "write"
cmd = {"res_name": "4.1.85", "value": 1} # read spec
payload.setdefault("params", []).append(cmd)
# to write natgas sensitivity value - write: {"res_name": "4.1.85", "value": x}
# then it's report {"res_name": "14.1.85", "value": x}
class GasSensitivityWriteConv(Converter):
map = {0x4010000: "low", 0x4020000: "medium", 0x4030000: "high"}
def decode(self, device: "XDevice", payload: dict, value: int):
payload[self.attr] = self.map.get(value)
def encode(self, device: "XDevice", payload: dict, value: str):
value = next(k for k, v in self.map.items() if v == value)
super().encode(device, payload, value)
def read(self, device: "XDevice", payload: dict):
pass
class ParentConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: str):
try:
# value in did format
device: "XDevice" = device.gateways[0].devices[value]
payload[self.attr] = device.nwk
except Exception:
payload[self.attr] = "-"
class OTAConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: Any):
super().decode(device, payload, value)
# forward update percents to gateway, so it can show it in GUI
try:
# noinspection PyUnresolvedReferences
device.gateways[0].device.update(payload)
except Exception:
pass
class OnlineConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: dict):
dt = value["time"]
new_ts = time.time() - dt
# if the device will be on two gateways (by accident), the online time
# from "wrong" gateway could be wrong
if new_ts < device.decode_ts:
return
device.available = dt < device.available_timeout
device.decode_ts = new_ts
payload[self.attr] = value["status"] == "online"
payload["zigbee"] = datetime.now(timezone.utc) - timedelta(seconds=dt)
class RemoveDIDConv(Converter):
def decode(self, device: "XDevice", payload: dict, value: Any):
# two formats:
# "res_name":"8.0.2082","value":{"did":"lumi.1234567890"}"
# "res_name":"8.0.2082","value":"lumi.1234567890"
if isinstance(value, dict):
value = value["did"]
super().decode(device, payload, value)
###############################################################################
# Final converter classes
###############################################################################
# https://github.com/Koenkk/zigbee2mqtt/issues/798
# https://www.maero.dk/aqara-temperature-humidity-pressure-sensor-teardown/
Temperature = MathConv(
"temperature", "sensor", mi="0.1.85", multiply=0.01, min=-4000, max=12500
)
Humidity = MathConv(
"humidity", "sensor", mi="0.2.85", multiply=0.01, min=0, max=10000
)
# Pressure = MathConv("pressure", "sensor", mi="0.3.85", multiply=0.01)
# Motion = BoolConv("motion", "binary_sensor", mi="3.1.85")
# power measurements
Voltage = MathConv("voltage", "sensor", mi="0.11.85", multiply=0.001, round=2)
Power = MathConv("power", "sensor", mi="0.12.85", round=2)
Energy = MathConv("energy", "sensor", mi="0.13.85", multiply=0.001, round=2)
Current = MathConv("current", "sensor", mi="0.14.85", multiply=0.001, round=2)
ChipTemp = Converter(
"chip_temperature", "sensor", mi="8.0.2006", enabled=False
)
# switches and relays
Outlet = BoolConv("outlet", "switch", mi="4.1.85")
Plug = BoolConv("plug", "switch", mi="4.1.85")
Switch = BoolConv("switch", "switch", mi="4.1.85")
Channel1 = BoolConv("channel_1", "switch", mi="4.1.85")
Channel2 = BoolConv("channel_2", "switch", mi="4.2.85")
Channel3 = BoolConv("channel_3", "switch", mi="4.3.85")
Action = Converter("action", "sensor")
Button = ButtonConv("button", mi="13.1.85")
Button1 = ButtonConv("button_1", mi="13.1.85")
Button2 = ButtonConv("button_2", mi="13.2.85")
Button3 = ButtonConv("button_3", mi="13.3.85")
Button4 = ButtonConv("button_4", mi="13.4.85")
Button5 = ButtonConv("button_5", mi="13.6.85")
Button6 = ButtonConv("button_6", mi="13.7.85")
ButtonBoth = ButtonConv("button_both", mi="13.5.85")
Button12 = ButtonConv("button_both_12", mi="13.5.85")
Button13 = ButtonConv("button_both_13", mi="13.6.85")
Button23 = ButtonConv("button_both_23", mi="13.7.85")
PowerOffMemory = MapConv(
"power_on_state", "switch", mi="8.0.2030", map=POWEROFF_MEMORY,
enabled=False
)
ChargeProtect = BoolConv(
"charge_protect", "switch", mi="8.0.2031", enabled=False
)
Led = BoolConv("led", "switch", mi="8.0.2032", enabled=False)
Wireless = BoolConv("wireless", "switch", mi="4.10.85", enabled=False)
Wireless1 = BoolConv("wireless_1", "switch", mi="4.10.85", enabled=False)
Wireless2 = BoolConv("wireless_2", "switch", mi="4.11.85", enabled=False)
Wireless3 = BoolConv("wireless_3", "switch", mi="4.12.85", enabled=False)
# Light = BoolConv("light", "light", mi="4.1.85")
# Brightness = BrightnessConv("brightness", mi="14.1.85", parent="light")
# ColorTemp = Converter("color_temp", mi="14.2.85", parent="light")
# RunState = MapConv("run_state", mi="14.4.85", map=RUN_STATE)
# converts voltage to percent and shows voltage in attributes
# users can adds separate voltage sensor or original percent sensor
Battery = BatteryConv("battery", "sensor", mi="8.0.2008")
BatteryLow = BoolConv(
"battery_low", "binary_sensor", mi="8.0.9001", enabled=False
)
BatteryOrig = Converter("battery_original", mi="8.0.2001", enabled=False)
# zigbee3 devices
# Switch_MI21 = Converter("switch", "switch", mi="2.p.1")
Channel1_MI21 = Converter("channel_1", "switch", mi="2.p.1")
Channel2_MI31 = Converter("channel_2", "switch", mi="3.p.1")
# global props
LUMI_GLOBALS = {
"8.0.2002": ResetsConv("resets", "sensor"),
"8.0.2022": Converter("fw_ver", "sensor"),
"8.0.2036": ParentConv("parent", "sensor"),
"8.0.2091": OTAConv("ota_progress", "sensor"),
"8.0.2102": OnlineConv("online", "binary_sensor"),
# "8.0.2156": Converter("nwk", "sensor"),
}
@@ -0,0 +1,65 @@
GATEWAY = "gateway"
ZIGBEE = "zigbee"
BLE = "ble"
MESH = "mesh"
MESH_GROUP_MODEL = 1054
TIME = {
"s": 1,
"m": 60,
"h": 3600,
"d": 86400
}
UNKNOWN = "unknown"
SINGLE = "single"
DOUBLE = "double"
TRIPLE = "triple"
QUADRUPLE = "quadruple"
HOLD = "hold"
RELEASE = "release"
# https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/converters/fromZigbee.js#L4738
BUTTON = {
1: SINGLE,
2: DOUBLE,
3: TRIPLE,
4: QUADRUPLE,
5: "quintuple", # only Yeelight Dimmer
16: HOLD,
17: RELEASE,
18: "shake",
128: "many",
}
BUTTON_BOTH = {
4: SINGLE,
5: DOUBLE,
6: TRIPLE,
16: HOLD,
17: RELEASE,
}
VIBRATION = {1: "vibration", 2: "tilt", 3: "drop"}
DOOR_STATE = {0: "open", 1: "close", 2: "ajar"}
LOCK_STATE = {
0: "door_cannot_locked", 1: "door_opened", 2: "door_without_lift",
3: "door_locked", 4: "reverse_locked",
}
LOCK_CONTROL = {
0: "in_unlocked", 1: "out_unlocked", 2: "in_locked", 3: "out_locked",
}
LOCK_ALARM = {
0: "off", 1: "key_open", 4: "unlocked", 8: "hijack", 16: "pry",
32: "normally_open", 256: "less_storage", 500: "low_bat", 512: "doorbell"
}
MOTOR = {0: "close", 1: "open", 2: "stop"}
RUN_STATE = {0: "closing", 1: "opening", 2: "stop"}
GATE_ALARM = {
0: "disarmed", 1: "armed_home", 2: "armed_away", 3: "armed_night"
}
BULB_MEMORY = {0: "on", 1: "previous"}
POWEROFF_MEMORY = {0: "off", 1: "previous"}
# Hass: On means low, Off means normal
BATTERY_LOW = {1: False, 2: True}
SWITCH_MODE = {1: "250 ms", 2: "500 ms", 3: "750 ms", 4: "1 sec"}
INVERSE = {0: True, 1: False}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,394 @@
import struct
from datetime import datetime
from typing import TYPE_CHECKING
from .base import Converter
if TYPE_CHECKING:
from ..device import XDevice
BLE_FINGERPRINT_ACTION = [
"Match successful", "Match failed", "Timeout", "Low quality",
"Insufficient area", "Skin is too dry", "Skin is too wet"
]
BLE_DOOR_ACTION = [
"Door is open", "Door is closed", "Timeout is not closed",
"Knock on the door", "Breaking the door", "Door is stuck"
]
BLE_LOCK_ACTION = {
0b0000: "Unlock outside the door",
0b0001: "Lock",
0b0010: "Turn on anti-lock",
0b0011: "Turn off anti-lock",
0b0100: "Unlock inside the door",
0b0101: "Lock inside the door",
0b0110: "Turn on child lock",
0b0111: "Turn off child lock",
0b1111: None
}
BLE_LOCK_METHOD = {
0b0000: "bluetooth",
0b0001: "password",
0b0010: "biological",
0b0011: "key",
0b0100: "turntable",
0b0101: "nfc",
0b0110: "one-time password",
0b0111: "two-step verification",
0b1000: "coercion",
0b1010: "manual",
0b1011: "automatic",
0b1111: None
}
BLE_LOCK_ERROR = {
0xC0DE0000: "Frequent unlocking with incorrect password",
0xC0DE0001: "Frequent unlocking with wrong fingerprints",
0xC0DE0002: "Operation timeout (password input timeout)",
0xC0DE0003: "Lock picking",
0xC0DE0004: "Reset button is pressed",
0xC0DE0005: "The wrong key is frequently unlocked",
0xC0DE0006: "Foreign body in the keyhole",
0xC0DE0007: "The key has not been taken out",
0xC0DE0008: "Error NFC frequently unlocks",
0xC0DE0009: "Timeout is not locked as required",
0xC0DE000A: "Failure to unlock frequently in multiple ways",
0xC0DE000B: "Unlocking the face frequently fails",
0xC0DE000C: "Failure to unlock the vein frequently",
0xC0DE000D: "Hijacking alarm",
0xC0DE000E: "Unlock inside the door after arming",
0xC0DE000F: "Palmprints frequently fail to unlock",
0xC0DE0010: "The safe was moved",
0xC0DE1000: "The battery level is less than 10%",
0xC0DE1001: "The battery is less than 5%",
0xC0DE1002: "The fingerprint sensor is abnormal",
0xC0DE1003: "The accessory battery is low",
0xC0DE1004: "Mechanical failure",
}
KETTLE = {0: "idle", 1: "heat", 2: "cool_down", 3: "warm_up"}
ACTIONS = {
1249: {0: 'right', 1: 'left'},
1983: {0: 'single', 0x010000: 'double', 0x020000: 'hold'},
2147: {0: 'single'},
}
# https://iot.mi.com/new/doc/embedded-development/ble/object-definition
class MiBeaconConv(Converter):
# TODO: think...
childs = {
"action": "sensor",
"battery": "sensor",
"conductivity": "sensor",
"formaldehyde": "sensor",
"humidity": "sensor",
"idle_time": "sensor",
"moisture": "sensor",
"rssi": "sensor",
"supply": "sensor",
"temperature": "sensor",
"contact": "sensor",
"gas": "binary_sensor",
"light": "binary_sensor",
"lock": "binary_sensor",
"motion": "binary_sensor",
"opening": "binary_sensor",
"power": "binary_sensor",
"sleep": "binary_sensor",
"smoke": "binary_sensor",
"water_leak": "binary_sensor",
}
def decode(self, device: 'XDevice', payload: dict, value: dict):
"""Parse Xiaomi BLE Data."""
eid = value['eid']
data = bytes.fromhex(value['edata'])
if eid == 0x1001 and len(data) == 3: # 4097
# button action
value = int.from_bytes(data, 'little')
payload['action'] = ACTIONS[device.model][value] \
if device.model in ACTIONS and value in ACTIONS[device.model] \
else value
elif eid == 0x1002 and len(data) == 1: # 4098
# No sleep (0x00), falling asleep (0x01)
payload["sleep"] = bool(data[0]) # 1 => true
elif eid == 0x1003 and len(data) == 1: # 4099
# Signal strength value
payload['rssi'] = data[0]
elif eid == 0x1004 and len(data) == 2: # 4100
payload["temperature"] = \
int.from_bytes(data, 'little', signed=True) / 10.0
elif eid == 0x1005 and len(data) == 2: # 4101
# Kettle, thanks https://github.com/custom-components/ble_monitor/
# hass: On means power detected, Off means no power
payload.update({
'power': bool(data[0]),
'state': KETTLE[data[0]],
'temperature': data[1],
})
elif eid == 0x1006 and len(data) == 2: # 4102
# Humidity percentage, ranging from 0-1000
value = int.from_bytes(data, 'little') / 10.0
if device.model in (903, 1371):
# two models has bug, they increase humidity on each msg by 0.1
value = int(value)
payload['humidity'] = value
elif eid == 0x1007 and len(data) == 3: # 4103
value = int.from_bytes(data, 'little')
if device.model == 2038:
# Night Light 2: 1 - no light, 100 - light
# hass: On means light detected, Off means no light
payload['light'] = bool(value >= 100)
else:
# Range: 0-120000, lux
payload['illuminance'] = value
elif eid == 0x1008 and len(data) == 1: # 4104
# Humidity percentage, range: 0-100
payload['moisture'] = data[0]
elif eid == 0x1009 and len(data) == 2: # 4105
# Soil EC value, Unit us/cm, range: 0-5000
payload['conductivity'] = int.from_bytes(data, 'little')
elif eid == 0x100A: # 4106
# TODO: lock timestamp
value = data[0]
if device.model == 2691:
# this sensor sends some kind of counter once an hour instead
# of the battery, so filter out the false values
prev = device.extra.get('battery')
device.extra['battery'] = value
if prev != value:
return
payload['battery'] = value
elif eid == 0x100D and len(data) == 4: # 4109
payload.update({
'temperature': int.from_bytes(data[:2], 'little',
signed=True) / 10.0,
'humidity': int.from_bytes(data[2:], 'little') / 10.0
})
elif eid == 0x100E and len(data) == 1: # 4110
# 0x00: unlock state (all bolts retracted)
# hass: On means open (unlocked), Off means closed (locked)
# TODO: other values
payload['lock'] = bool(data[0] == 0)
elif eid == 0x100F and len(data) == 1: # 4111
# 0x00: open the door, 0x01: Close the door
# 0x02: Timeout is not closed, 0x03: knock on the door
# 0x04: Pry the door, 0x05: The door is stuck
# hass: On means open, Off means closed
payload['opening'] = bool(data[0] == 0)
elif eid == 0x1010 and len(data) == 2: # 4112
payload['formaldehyde'] = int.from_bytes(data, 'little') / 100.0
elif eid == 0x1012 and len(data) == 1: # 4114
# hass: On means open, Off means closed
payload['opening'] = bool(data[0]) # 1 => true => open
elif eid == 0x1013 and len(data) == 1: # 4115
# Remaining percentage, range 0~100
payload['supply'] = data[0]
elif eid == 0x1014 and len(data) == 1: # 4116
# hass: On means wet, Off means dry
payload['water_leak'] = bool(data[0])
elif eid == 0x1015 and len(data) == 1: # 4117
# hass: On means smoke detected, Off means no smoke (clear)
if data[0] <= 1:
payload['smoke'] = bool(data[0]) # 1 => on => alarm
elif data[0] == 2:
payload['action'] = "equipment failure"
elif eid == 0x1016 and len(data) == 1: # 4118
# hass: On means gas detected, Off means no gas (clear)
payload['gas'] = bool(data[0]) # 1 => on => alarm
elif eid == 0x1017 and len(data) == 4: # 4119
# The duration of the unmanned state, in seconds
payload['idle_time'] = int.from_bytes(data, 'little')
elif eid == 0x1018 and len(data) == 1: # 4120
# Door Sensor 2: 0 - dark, 1 - light
# hass: On means light detected, Off means no light
payload['light'] = bool(data[0])
elif eid == 0x1019 and len(data) == 1: # 4121
# 0x00: open the door, 0x01: close the door,
# 0x02: not closed after timeout, 0x03: device reset
# hass: On means open, Off means closed
if data[0] == 0:
payload['contact'] = True
elif data[0] == 1:
payload['contact'] = False
elif data[0] == 2:
payload['action'] = 'timeout'
elif data[0] == 3:
payload['action'] = 'reset'
elif eid == 0x4803:
payload['battery'] = data[0]
elif eid == 0x4c01 and len(data) == 4:
payload['temperature'] = round(struct.unpack('<f', data)[0], 2)
elif eid == 0x4c08 and len(data) == 4:
payload['humidity'] = round(struct.unpack('<f', data)[0], 2)
elif eid == 0x0006 and len(data) == 5:
action = int.from_bytes(data[4:], 'little')
if action >= len(BLE_FINGERPRINT_ACTION):
return
# status, action, state
payload.update({
'action': 'fingerprint',
'action_id': action,
'key_id': hex(int.from_bytes(data[:4], 'little')),
'message': BLE_FINGERPRINT_ACTION[action]
})
elif eid == 0x0007:
# TODO: lock timestamp
if data[0] >= len(BLE_DOOR_ACTION):
return
payload.update({
'action': 'door',
'action_id': data[0],
'message': BLE_DOOR_ACTION[data[0]]
})
elif eid == 0x0008:
# TODO: lock timestamp
payload.update({
'action': 'armed',
'state': data[0]
})
elif eid == 0x000B: # 11
action = data[0] & 0x0F
method = data[0] >> 4
key_id = int.from_bytes(data[1:5], 'little')
error = BLE_LOCK_ERROR.get(key_id)
# all keys except Bluetooth have only 65536 values
if error is None and method > 0:
key_id &= 0xFFFF
elif error:
key_id = hex(key_id)
timestamp = int.from_bytes(data[5:], 'little')
timestamp = datetime.fromtimestamp(timestamp).isoformat()
if action not in BLE_LOCK_ACTION or method not in BLE_LOCK_METHOD:
return
payload.update({
'action': 'lock',
'action_id': action,
'method_id': method,
'message': BLE_LOCK_ACTION[action],
'method': BLE_LOCK_METHOD[method],
'key_id': key_id,
'error': error,
'timestamp': timestamp
})
elif eid == 0x000F: # 15
# Night Light 2: 1 - moving no light, 100 - moving with light
# Motion Sensor 2: 0 - moving no light, 256 - moving with light
# Qingping Motion Sensor - moving with illuminance data
value = int.from_bytes(data, 'little')
if device.model == 2691:
payload.update({'motion': True, 'illuminance': value})
else:
payload.update({'motion': True, 'light': bool(value >= 100)})
elif eid == 0x0010 and len(data) == 2: # 16
# Toothbrush Т500
if data[0] == 0:
payload.update({'action': 'start', 'counter': data[1]})
else:
payload.update({'action': 'finish', 'score': data[1]})
elif eid == 0x4E0C: # 19980
# wireless button XMWXKG01YL
value = int.from_bytes(data, 'little')
if value == 1:
payload.update({'action': 'button_1_single'})
if value == 2:
payload.update({'action': 'button_2_single'})
if value == 3:
payload.update({'action': 'button_both_single'})
elif eid == 0x4E0D: # 19981
# wireless button XMWXKG01YL
value = int.from_bytes(data, 'little')
if value == 1:
payload.update({'action': 'button_1_double'})
if value == 2:
payload.update({'action': 'button_2_double'})
elif eid == 0x4E0E: # 19982
# wireless button XMWXKG01YL
value = int.from_bytes(data, 'little')
if value == 1:
payload.update({'action': 'button_1_hold'})
if value == 2:
payload.update({'action': 'button_2_hold'})
elif eid == 0x4818: # 18456
# Linptech motion sensor version 2
payload['idle_time'] = int.from_bytes(data, 'little')
elif eid == 0x4A08: # 18952
# Linptech motion sensor version 2
value = struct.unpack('<f', data)[0]
payload.update({'motion': True, 'illuminance': value})
elif eid == 0x4C03: # 19459
# Linptech motion sensor version 2
payload['battery'] = data[0]
MiBeacon = MiBeaconConv('mibeacon')
BLEAction = Converter("action", "sensor")
BLEAction.childs = {
"action_id", "counter", "error", "key_id", "message", "method",
"method_id", "score", "state", "timestamp"
}
BLEBattery = Converter("battery", "sensor")
BLEConductivity = Converter("conductivity", "sensor")
BLEFormaldehyde = Converter("formaldehyde", "sensor")
BLEHumidity = Converter("humidity", "sensor")
BLEIdleTime = Converter("idle_time", "sensor")
BLEIlluminance = Converter("illuminance", "sensor")
BLEMoisture = Converter("moisture", "sensor")
BLERSSI = Converter("rssi", "sensor")
BLESupply = Converter("supply", "sensor")
BLETemperature = Converter("temperature", "sensor")
BLEContact = Converter("contact", "binary_sensor")
BLEGas = Converter("gas", "binary_sensor")
BLELight = Converter("light", "binary_sensor") # night light, door sensor
BLELock = Converter("lock", "binary_sensor")
BLEMotion = Converter("motion", "binary_sensor")
BLEOpening = Converter("opening", "binary_sensor")
BLEPower = Converter("power", "binary_sensor") # kettle
BLEPower.childs = {"state"}
BLESleep = Converter("sleep", "binary_sensor")
BLESmoke = Converter("smoke", "binary_sensor")
BLEWaterLeak = Converter("water_leak", "binary_sensor")
@@ -0,0 +1,397 @@
import logging
from typing import Union
from zigpy.types import EUI64
from zigpy.zcl import Cluster
from zigpy.zcl.foundation import Command, ZCLHeader, Attribute, \
ReadAttributeRecord, DATA_TYPES
from zigpy.zdo import ZDO
from zigpy.zdo.types import ZDOCmd, SizePrefixedSimpleDescriptor, \
NodeDescriptor
_LOGGER = logging.getLogger(__name__)
CLUSTERS = {}
# noinspection PyTypeChecker
def decode(data: dict):
"""Decode Silabs Z3 GatewayHost MQTT message using zigpy library. Supports
ZDO payload and ZCL payload.
"""
try:
if data["sourceEndpoint"] == "0x00":
# decode ZDO
if "zdo" not in CLUSTERS:
zdo = CLUSTERS["zdo"] = ZDO(None)
else:
zdo = CLUSTERS["zdo"]
cluster_id = int(data['clusterId'], 0)
raw = bytes.fromhex(data['APSPlayload'][2:])
hdr, args = zdo.deserialize(cluster_id, raw)
cmd = ZDOCmd(hdr.command_id).name
if hdr.command_id == ZDOCmd.Active_EP_rsp:
return {
"command": cmd,
"status": str(args[0]),
"endpoints": args[2]
}
elif hdr.command_id == ZDOCmd.Simple_Desc_rsp:
desc: SizePrefixedSimpleDescriptor = args[2]
return {
"command": cmd,
"status": str(args[0]),
"device_type": desc.device_type,
"device_version": desc.device_version,
"endpoint": desc.endpoint,
"input_clusters": desc.input_clusters,
"output_clusters": desc.output_clusters,
"profile": desc.profile
}
elif hdr.command_id == ZDOCmd.Node_Desc_rsp:
desc: NodeDescriptor = args[2]
return {
"command": cmd,
"status": str(args[0]),
"is_mains_powered": desc.is_mains_powered,
"logical_type": str(desc.logical_type),
"manufacturer_code": desc.manufacturer_code,
}
elif hdr.command_id in (ZDOCmd.Bind_rsp, ZDOCmd.Mgmt_Leave_rsp):
return {
"command": cmd,
"status": str(args[0]),
}
elif hdr.command_id in (
ZDOCmd.Node_Desc_req, ZDOCmd.Active_EP_req
):
return {"command": cmd}
elif hdr.command_id == ZDOCmd.Simple_Desc_req:
return {
"command": cmd,
"endpoint": args[0],
}
elif hdr.command_id == ZDOCmd.Bind_req:
return {
"command": cmd,
"src_addr": args[0],
"src_endpoint": args[1],
"cluster": args[2],
"dst_addr": args[3]
}
elif hdr.command_id == ZDOCmd.IEEE_addr_rsp:
return {
"command": cmd,
"status": args[0],
"ieee": args[1],
"nwk": args[2],
}
elif hdr.command_id == ZDOCmd.Mgmt_Leave_req:
return {
"command": cmd,
"ieee": args[0],
}
elif hdr.command_id == ZDOCmd.Mgmt_NWK_Update_rsp:
return {
"command": cmd,
"status": args[0],
"channels": args[1],
"total": args[2],
"failures": args[3],
"energy": args[4],
}
else:
raise NotImplemented
# decode ZCL
cluster_id = int(data['clusterId'], 0)
if cluster_id not in CLUSTERS:
cluster = CLUSTERS[cluster_id] = Cluster.from_id(None, cluster_id)
cluster._log = lambda *_, **__: None
else:
cluster = CLUSTERS[cluster_id]
raw = bytes.fromhex(data['APSPlayload'][2:])
try:
hdr, args = cluster.deserialize(raw)
hdr: ZCLHeader
except ValueError as e:
return {"cluster_id": cluster_id, "error": str(e)}
except KeyError as e:
return {"cluster_id": cluster_id, "error": f"Key error: {e}"}
payload = {
"endpoint": int(data["sourceEndpoint"], 0),
"seq": hdr.tsn,
}
if cluster.ep_attribute:
payload["cluster"] = cluster.ep_attribute
else:
payload["cluster_id"] = cluster_id
if hdr.frame_control.is_general:
payload["command"] = Command(hdr.command_id).name
if (hdr.command_id == Command.Report_Attributes or
hdr.command_id == Command.Write_Attributes):
attrs, = args
for attr in attrs:
assert isinstance(attr, Attribute)
if attr.attrid in cluster.attributes:
name = cluster.attributes[attr.attrid][0]
else:
name = attr.attrid
value = attr.value.value
if isinstance(value, bytes) and value:
payload[name] = "0x" + value.hex()
elif isinstance(value, list) and \
not isinstance(value, EUI64):
payload[name] = [v.value for v in value]
elif isinstance(value, int):
payload[name] = int(value)
else:
payload[name] = value
elif hdr.command_id == Command.Read_Attributes_rsp:
attrs, = args
for attr in attrs:
assert isinstance(attr, ReadAttributeRecord)
if attr.attrid in cluster.attributes:
name = cluster.attributes[attr.attrid][0]
else:
name = attr.attrid
if attr.value is not None:
value = attr.value.value
if isinstance(value, bytes) and value:
payload[name] = "0x" + value.hex()
elif isinstance(value, list):
payload[name] = [v.value for v in value]
elif isinstance(value, int):
payload[name] = int(value)
else:
payload[name] = value
else:
payload[name] = str(attr.status)
elif hdr.command_id == Command.Read_Attributes:
attrs, = args
payload["value"] = attrs
elif hdr.command_id == Command.Configure_Reporting:
attrs, = args
# fix __repr__ bug
for attr in attrs:
if not hasattr(attr, "reportable_change"):
attr.reportable_change = None
payload["value"] = attrs
elif (hdr.command_id == Command.Write_Attributes_rsp or
hdr.command_id == Command.Configure_Reporting_rsp):
resp, = args
payload["status"] = [str(attr.status) for attr in resp]
elif hdr.command_id == Command.Discover_Commands_Received_rsp:
payload["status"] = bool(args[0])
payload["value"] = args[1]
elif hdr.command_id == Command.Default_Response:
payload["value"] = args[0]
payload["status"] = str(args[1])
else:
if isinstance(args, bytes) and args:
args = "0x" + args.hex()
payload["command_id"] = int(hdr.command_id)
payload["value"] = args
elif hdr.frame_control.is_cluster:
# if isinstance(args, bytes) and args:
# args = "0x" + args.hex()
payload["command_id"] = hdr.command_id
if hdr.command_id < len(cluster.commands):
payload["command"] = cluster.commands[hdr.command_id]
if args:
payload["value"] = args
else:
if isinstance(args, bytes) and args:
args = "0x" + args.hex()
payload.update({"command_id": hdr.command_id, "value": args})
return payload
except Exception as e:
_LOGGER.debug("Error while parsing zigbee", exc_info=e)
return None
def get_cluster(cluster: str) -> Cluster:
# noinspection PyProtectedMember
return next(
cls for cls in Cluster._registry.values() if
cls.ep_attribute == cluster
)
def get_attr(attributes: dict, attr) -> int:
if isinstance(attr, int):
return attr
return next(k for k, v in attributes.items() if v[0] == attr)
def get_attr_type(attributes: dict, attr: str) -> (int, int):
attr, attr_type = next(
(k, v[1]) for k, v in attributes.items()
if v[0] == attr
)
type = next(k for k, v in DATA_TYPES.items() if v[1] == attr_type)
return attr, type
# noinspection PyProtectedMember
def get_type_len(type: int) -> int:
t = DATA_TYPES[type][1]
if hasattr(t, "_length"):
return t._length
if hasattr(t, "_size"):
return t._size
raise NotImplementedError
def zcl_on_off(nwk: str, ep: int, value: bool) -> list:
"""Generate Silabs Z3 command (cluster 6)."""
value = "on" if value else "off"
return [
{"commandcli": f"zcl on-off {value}"},
{"commandcli": f"send {nwk} 1 {ep}"}
]
# zcl level-control mv-to-level [level:1] [transitionTime:2] [optionMask:1] [optionOverride:1]
def zcl_level(nwk: str, ep: int, br: int, tr: float) -> list:
"""Generate Silabs Z3 command (cluster 8)."""
tr = int(tr * 10.0) # zcl format - tenths of a seconds
return [
{"commandcli": f"zcl level-control o-mv-to-level {br} {tr}"},
{"commandcli": f"send {nwk} 1 {ep}"}
]
def zcl_color(nwk: str, ep: int, ct: int, tr: float) -> list:
"""Generate Silabs Z3 command (cluster 0x0300)."""
tr = int(tr * 10.0) # zcl format - tenths of a seconds
return [
{"commandcli": f"zcl color-control movetocolortemp {ct} {tr} 0 0"},
{"commandcli": f"send {nwk} 1 {ep}"}
]
# zcl global read [cluster:2] [attributeId:2]
def zcl_read(nwk: str, ep: int, cluster: Union[str, int], *attrs) -> list:
"""Generate Silabs Z3 read attribute command. Support multiple attrs."""
if isinstance(cluster, str):
cluster = get_cluster(cluster)
cid = cluster.cluster_id
# convert List[str] to List[int]
attrs = [get_attr(cluster.attributes, attr) for attr in attrs]
else:
cid = cluster
assert isinstance(cid, int)
for attr in attrs:
assert isinstance(attr, int)
if len(attrs) > 1:
raw = "".join([int(a).to_bytes(2, "little").hex() for a in attrs])
return [
{"commandcli": f"raw {cid} {{100000{raw}}}"},
{"commandcli": f"send {nwk} 1 {ep}"},
]
return [
{"commandcli": f"zcl global read {cid} {attrs[0]}"},
{"commandcli": f"send {nwk} 1 {ep}"}
]
# zcl global write [cluster:2] [attributeId:2] [type:4] [data:-1]
def zcl_write(
nwk: str, ep: int, cluster: Union[str, int], attr: Union[str, int],
data: int, *, mfg: int = None, type: int = None
) -> list:
"""Generate Silabs Z3 write attribute command."""
if isinstance(cluster, str):
cluster = get_cluster(cluster)
if isinstance(attr, str):
attr, type = get_attr_type(cluster.attributes, attr)
cluster = cluster.cluster_id
assert isinstance(cluster, int)
assert isinstance(attr, int) and isinstance(type, int)
type_len = get_type_len(type)
# data always string hex: {FFFF}
data = data.to_bytes(type_len, "little").hex()
pre = [
{"commandcli": f"zcl mfg-code {mfg}"}
] if mfg is not None else []
return pre + [
{"commandcli": f"zcl global write {cluster} {attr} {type} {{{data}}}"},
{"commandcli": f"send {nwk} 1 {ep}"}
]
# zdo bind [destination:2] [source Endpoint:1] [destEndpoint:1] [cluster:2] [remoteEUI64:8] [destEUI64:8]
def zdo_bind(nwk: str, ep: int, cluster: str, src: str, dst: str) -> list:
"""Generate Silabs Z3 bind command."""
cluster = get_cluster(cluster)
cid = cluster.cluster_id
return [{
"commandcli": f"zdo bind {nwk} {ep} 1 {cid} {{{src}}} {{{dst}}}"
}]
# zdo unbind unicast [target:2] [source eui64:8] [source endpoint:1] [clusterID:2] [destinationEUI64:8] [destEndpoint:1]
def zdo_unbind(nwk: str, ep: int, cluster: str, src: str, dst: str) -> list:
"""Generate Silabs Z3 bind command."""
cluster = get_cluster(cluster)
cid = cluster.cluster_id
return [{
"commandcli": f"zdo unbind unicast {nwk} {{{src}}} {ep} {cid} {{{dst}}} 1"
}]
# zcl global send-me-a-report [cluster:2] [attributeId:2] [dataType:1] [minReportTime:2] [maxReportTime:2] [reportableChange:-1]
# minReportTime Minimum number of seconds between reports
# maxReportTime Maximum number of seconds between reports
# reportableChange Amount of change to trigger a report
def zdb_report(
nwk: str, ep: int, cluster: str, attr: str, mint: int, maxt: int,
change: int, type: int = None
):
cluster = get_cluster(cluster)
cid = cluster.cluster_id
if isinstance(attr, str):
attr, type = get_attr_type(cluster.attributes, attr)
type_len = get_type_len(type)
change = change.to_bytes(type_len, "little").hex()
return [{
"commandcli": f"zcl global send-me-a-report {cid} {attr} {type} {mint} {maxt} {{{change}}}"
}, {
"commandcli": f"send {nwk} 1 {ep}"
}]
# zdo leave [target:2] [removeChildren:1] [rejoin:1]
def zdo_leave(nwk: str):
return [{"commandcli": f"zdo leave {nwk} 0 0"}]
@@ -0,0 +1,225 @@
import re
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from .base import Converter
from .const import GATEWAY, ZIGBEE, BLE, MESH
if TYPE_CHECKING:
from ..device import XDevice
RE_SERIAL = re.compile(r'(tx|rx|oe|fe|brk):(\d+)')
ZIGBEE_CLUSTERS = {
0x0000: 'Basic',
0x0001: 'PowerCfg',
0x0003: 'Identify',
0x0006: 'OnOff',
0x0008: 'LevelCtrl',
0x000A: 'Time',
0x000C: 'AnalogInput', # cube, gas sensor
0x0012: 'Multistate',
0x0019: 'OTA', # illuminance sensor
0x0101: 'DoorLock',
0x0300: 'LightColor',
0x0400: 'Illuminance', # motion sensor
0x0402: 'Temperature',
0x0403: 'Pressure',
0x0405: 'Humidity',
0x0406: 'Occupancy', # motion sensor
0x0500: 'IasZone', # gas sensor
0x0B04: 'ElectrMeasur',
0xFCC0: 'Xiaomi'
}
BLE_EVENTS = {
0x0006: "LockFinger",
0x0007: "LockDoor",
0x0008: "LockArmed",
0x000B: "LockAction",
0x000F: "Motion",
0x0010: "Toothbrush",
0x1001: "Action",
0x1002: "Sleep",
0x1003: "RSSI",
0x1004: "Temperature",
0x1005: "Kettle",
0x1006: "Humidity",
0x1007: "Illuminance",
0x1008: "Moisture",
0x1009: "Conductivity",
0x100A: "Battery",
0x100D: "TempHum",
0x100E: "Lock",
0x100F: "Door",
0x1010: "Formaldehyde",
0x1012: "Opening",
0x1013: "Supply",
0x1014: "WaterLeak",
0x1015: "Smoke",
0x1016: "Gas",
0x1017: "IdleTime",
0x1018: "Light",
0x1019: "Contact",
0x4803: "Battery2",
0x4c01: "Temperature2",
0x4c08: "Humidity2",
}
class GatewayStatsConverter(Converter):
childs = {
"network_pan_id", "radio_tx_power", "radio_channel",
"free_mem", "load_avg", "rssi", "uptime",
"bluetooth_tx", "bluetooth_rx", "bluetooth_oe",
"zigbee_tx", "zigbee_rx", "zigbee_oe"
}
def decode(self, device: 'XDevice', payload: dict, value: dict):
if self.attr in value:
payload[self.attr] = value[self.attr]
if 'networkUp' in value:
payload.update({
'network_pan_id': value.get('networkPanId'),
'radio_tx_power': value.get('radioTxPower'),
'radio_channel': value.get('radioChannel'),
})
if 'free_mem' in value:
s = value['run_time']
d = s // (3600 * 24)
h = s % (3600 * 24) // 3600
m = s % 3600 // 60
s = s % 60
payload.update({
'free_mem': value['free_mem'],
'load_avg': value['load_avg'],
'rssi': value['rssi'] - 100,
'uptime': f"{d} days, {h:02}:{m:02}:{s:02}",
})
if 'serial' in value:
lines = value['serial'].split('\n')
for k, v in RE_SERIAL.findall(lines[2]):
payload[f"bluetooth_{k}"] = int(v)
for k, v in RE_SERIAL.findall(lines[3]):
payload[f"zigbee_{k}"] = int(v)
class ZigbeeStatsConverter(Converter):
childs = {
"ieee", "nwk", "msg_received", "msg_missed", "linkquality", "rssi",
"last_msg", "type", "parent", "new_resets"
}
def decode(self, device: 'XDevice', payload: dict, value: dict):
if 'sourceAddress' in value:
cid = int(value['clusterId'], 0)
if 'msg_received' in device.extra:
device.extra['msg_received'] += 1
else:
device.extra.update({'msg_received': 1, 'msg_missed': 0})
# For some devices better works APSCounter, for other - sequence
# number in payload. Sometimes broken messages arrived.
try:
raw = value['APSPlayload']
manufact_spec = int(raw[2:4], 16) & 4
new_seq1 = int(value['APSCounter'], 0)
new_seq2 = int(raw[8:10] if manufact_spec else raw[4:6], 16)
# new_seq2 == 0 -> probably device reset
if 'last_seq1' in device.extra and new_seq2 != 0:
miss = min(
(new_seq1 - device.extra['last_seq1'] - 1) & 0xFF,
(new_seq2 - device.extra['last_seq2'] - 1) & 0xFF
)
# sometimes device repeat message, skip this situation:
# 0xF6 > 0xF7 > 0xF8 > 0xF7 > 0xF8 > 0xF9
if 0 < miss < 254:
device.extra['msg_missed'] += miss
device.extra['last_seq1'] = new_seq1
device.extra['last_seq2'] = new_seq2
except Exception:
pass
payload.update({
ZIGBEE: datetime.now(timezone.utc),
# 'ieee': value['eui64'],
# 'nwk': value['sourceAddress'],
'msg_received': device.extra['msg_received'],
'msg_missed': device.extra['msg_missed'],
'linkquality': value['linkQuality'],
'rssi': value['rssi'],
'last_msg': ZIGBEE_CLUSTERS.get(cid, cid),
})
# if 'ago' in value:
# payload.update({
# ZIGBEE: dt.now() - timedelta(seconds=value['ago']),
# 'type': value['type'],
# })
if 'parent' in value:
payload['parent'] = value['parent']
# if 'resets' in value:
# if 'resets0' not in device.extra:
# device.extra['resets0'] = value['resets']
# payload['new_resets'] = value['resets'] - device.extra['resets0']
class BLEStatsConv(Converter):
childs = {"mac", "msg_received", "last_msg"}
def decode(self, device: 'XDevice', payload: dict, value: dict):
if "msg_received" in device.extra:
device.extra["msg_received"] += 1
else:
device.extra["msg_received"] = 1
eid = value.get("eid")
payload.update({
BLE: datetime.now(timezone.utc),
"mac": device.mac,
"msg_received": device.extra["msg_received"],
"last_msg": BLE_EVENTS.get(eid, eid),
})
class MeshStatsConv(Converter):
childs = {"mac", "msg_received", "last_msg"}
def decode(self, device: 'XDevice', payload: dict, value: list):
if "msg_received" in device.extra:
device.extra["msg_received"] += 1
else:
device.extra["msg_received"] = 1
param = value[0]
if "piid" in param:
prop = f"{param['siid']}.p.{param['piid']}"
elif "eiid" in param:
prop = f"{param['siid']}.e.{param['eiid']}"
else:
raise NotImplementedError
payload.update({
MESH: datetime.now(timezone.utc),
"mac": device.mac,
"msg_received": device.extra["msg_received"],
"last_msg": prop
})
GatewayStats = GatewayStatsConverter(GATEWAY, "binary_sensor")
STAT_GLOBALS = {
# GATEWAY: GatewayStats,
BLE: BLEStatsConv(BLE, "sensor"),
MESH: MeshStatsConv(MESH, "sensor"),
ZIGBEE: ZigbeeStatsConverter(ZIGBEE, "sensor"),
}
@@ -0,0 +1,536 @@
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING, Optional
import zigpy.device
import zigpy.quirks
from zigpy.const import SIG_ENDPOINTS
from zigpy.device import Device
from .base import Converter, parse_time
from .const import *
from .silabs import *
if TYPE_CHECKING:
from .devices import XDevice
def generate_device(manufacturer: str, model: str) -> Optional[Device]:
"""Generate device from quirks. Should be called earlier:
zhaquirks.setup()
Or direct import:
from zhaquirks.xiaomi.mija.sensor_switch import MijaButton
Used like a Cluster:
hdr, value = device.deserialize(<endpoint_id>, <cluster_id>, data)
"""
quirks = zigpy.quirks.get_quirk_list(manufacturer, model)
if not quirks:
return None
device = Device(None, None, 0)
device.manufacturer = manufacturer
device.model = model
quirk: zigpy.quirks.CustomDevice = quirks[0]
if SIG_ENDPOINTS in quirk.replacement:
for endpoint_id in quirk.replacement[SIG_ENDPOINTS].keys():
device.add_endpoint(endpoint_id)
return quirks[0](None, None, 0, device)
###############################################################################
# Base (global) converters
###############################################################################
@dataclass
class ZConverter(Converter):
"""Basic zigbee converter."""
ep: int = 1
zattr = None
bind: bool = None
report: str = None
def decode(self, device: 'XDevice', payload: dict, value: dict):
if value["endpoint"] == self.ep and self.zattr in value:
payload[self.attr] = value[self.zattr]
def read(self, device: "XDevice", payload: dict):
cmd = zcl_read(device.nwk, self.ep, self.zigbee, self.zattr)
payload.setdefault("commands", []).extend(cmd)
def config(self, device: "XDevice", payload: dict, gateway):
if self.bind:
cmd = zdo_bind(
device.nwk, self.ep, self.zigbee, device.mac[2:], gateway.ieee
)
payload.setdefault("commands", []).extend(cmd)
if self.report:
mint, maxt, change = self.report.split(" ")
mint = int(parse_time(mint))
maxt = int(parse_time(maxt))
change = int(change)
cmd = zdb_report(
device.nwk, self.ep, self.zigbee, self.zattr,
mint, maxt, change,
)
payload.setdefault("commands", []).extend(cmd)
class ZBoolConv(ZConverter):
"""Basic zigbee bool converter."""
def decode(self, device: 'XDevice', payload: dict, value: dict):
if value["endpoint"] == self.ep and self.zattr in value:
payload[self.attr] = bool(value[self.zattr])
def encode(self, device: "XDevice", payload: dict, value: bool):
cmd = zcl_write(
device.nwk, self.ep, self.zigbee, self.zattr, int(value), type=0x10
)
payload.setdefault("commands", []).extend(cmd)
class ZMapConv(ZConverter):
map = {}
def decode(self, device: 'XDevice', payload: dict, value: dict):
if self.zattr in value:
payload[self.attr] = self.map.get(value[self.zattr])
def encode(self, device: "XDevice", payload: dict, value: str):
v = next(k for k, v in self.map.items() if v == value)
cmd = zcl_write(
device.nwk, self.ep, self.zigbee, self.zattr, v, type=0x30
)
payload.setdefault("commands", []).extend(cmd)
@dataclass
class ZMathConv(ZConverter):
multiply: float = 1
def decode(self, device: 'XDevice', payload: dict, value: dict):
if value["endpoint"] == self.ep and self.zattr in value:
payload[self.attr] = value[self.zattr] * self.multiply
class ZOnOffConv(ZBoolConv):
zigbee = "on_off"
zattr = "on_off"
def encode(self, device: "XDevice", payload: dict, value: bool):
cmd = zcl_on_off(device.nwk, self.ep, value)
payload.setdefault("commands", []).extend(cmd)
class ZBrightnessConv(ZConverter):
zigbee = "level"
zattr = "current_level"
def encode(self, device: "XDevice", payload: dict, value: Any):
# brightness and transition in seconds
if not isinstance(value, tuple):
value = (value, 1) # default transition
cmd = zcl_level(device.nwk, self.ep, *value)
payload.setdefault("commands", []).extend(cmd)
class ZColorTempConv(ZConverter):
zigbee = "light_color"
zattr = "color_temperature"
min: int = 153
max: int = 500
def encode(self, device: "XDevice", payload: dict, value: Any):
if not isinstance(value, tuple):
value = (value, 1) # default transition
cmd = zcl_color(device.nwk, self.ep, *value)
payload.setdefault("commands", []).extend(cmd)
class ZElectricalConv(ZMathConv):
zigbee = "electrical_measurement"
def read(self, device: "XDevice", payload: dict):
# we can read three attrs from one cluster with single call
cmd = zcl_read(
device.nwk, self.ep, self.zigbee, "rms_voltage", "rms_current",
"active_power"
)
payload.setdefault("commands", []).extend(cmd)
class ZVoltageConv(ZElectricalConv):
zattr = "rms_voltage"
class ZCurrentConv(ZElectricalConv):
zattr = "rms_current"
class ZPowerConv(ZElectricalConv):
zattr = "active_power"
class ZEnergyConv(ZMathConv):
zigbee = "smartenergy_metering"
zattr = "current_summ_delivered"
class ZOccupancyConv(ZBoolConv):
zigbee = "occupancy"
zattr = "occupancy"
@dataclass
class ZOccupancyTimeoutConv(ZConverter):
zigbee = "occupancy"
zattr = "pir_o_to_u_delay"
min: int = 0
max: int = 65535
def encode(self, device: "XDevice", payload: dict, value: int):
cmd = zcl_write(device.nwk, self.ep, self.zigbee, self.zattr, value)
payload.setdefault("commands", []).extend(cmd)
# we need to read new value after write
self.read(device, payload)
# class ZAnalogInput(Converter):
# zigbee = "analog_input"
#
# def decode(self, device: 'XDevice', payload: dict, value: dict):
# if isinstance(value.get("present_value"), float):
# payload[self.attr] = round(value['present_value'], 2)
class ZIASZoneConv(ZConverter):
zigbee = "ias_zone"
def decode(self, device: "XDevice", payload: dict, value: dict):
try:
payload[self.attr] = (value["value"][0] & 1) > 0
except Exception:
pass
def read(self, device: "XDevice", payload: dict):
pass
@dataclass
class ZIlluminanceConv(ZMathConv):
zigbee = "illuminance"
zattr = "measured_value"
multiply: float = 0.01
@dataclass
class ZTemperatureConv(ZMathConv):
zigbee = "temperature"
zattr = "measured_value"
multiply: float = 0.01
@dataclass
class ZHumidityConv(ZMathConv):
zigbee = "humidity"
zattr = "measured_value"
multiply: float = 0.01
class ZBatteryConv(ZConverter):
zigbee = "power"
zattr = "battery_percentage_remaining"
childs = {"battery_voltage"}
def decode(self, device: "XDevice", payload: dict, value: dict):
if isinstance(value.get(self.zattr), int):
payload[self.attr] = int(value[self.zattr] / 2)
elif isinstance(value.get("battery_voltage"), int):
payload["battery_voltage"] = value["battery_voltage"] * 100
def read(self, device: "XDevice", payload: dict):
cmd = zcl_read(
device.nwk, self.ep, self.zigbee, self.zattr, "battery_voltage"
)
payload.setdefault("commands", []).extend(cmd)
# useful for reporting description
# class ZBatteryVoltConv(ZBatteryConv):
# zattr = "battery_voltage"
#
# def decode(self, device: "XDevice", payload: dict, value: dict):
# if isinstance(value.get("battery_voltage"), int):
# payload[self.zattr] = value[self.zattr] * 100
###############################################################################
# Specific defices converters
###############################################################################
class ZTuyaChildModeConv(ZBoolConv):
zigbee = "on_off"
zattr = 0x8000
class ZTuyaLEDModeConv(ZMapConv):
zigbee = "on_off"
zattr = 0x8001
map = {0: "off", 1: "off/on", 2: "on/off", 3: "on"}
# Thanks to:
# https://github.com/Koenkk/zigbee-herdsman/blob/master/src/zcl/definition/cluster.ts
# moesStartUpOnOff: {ID: 0x8002, type: DataType.enum8},
class ZTuyaPowerOnConv(ZMapConv):
zigbee = "on_off"
zattr = 0x8002
map = {0: "off", 1: "on", 2: "previous"}
class ZTuyaButtonModeConv(ZMapConv):
zigbee = "on_off"
zattr = 0x8004
map = {0: "command", 1: "event"}
def config(self, device: "XDevice", payload: dict, gateway):
# set default mode
self.encode(device, payload, "event")
# Thanks to:
# https://github.com/Koenkk/zigbee-herdsman-converters/blob/910271ae8fccb19305752d3f67381b4765853018/converters/fromZigbee.js#L4537
# https://github.com/Koenkk/zigbee-herdsman/blob/068bbe7636f588394f69f82bc25c8b68a4feada7/src/zcl/definition/cluster.ts#L4284
class ZTuyaPlugModeConv(ZMapConv):
zigbee = 0xE001
zattr = 0xD030
map = {0: "toggle", 1: "state", 2: "momentary"}
class ZTuyaButtonConfig(ZConverter):
def config(self, device: "XDevice", payload: dict, gateway):
# some stupid but necessary magic from zigbee2mqtt
cmd = zcl_read(device.nwk, self.ep, "on_off", 0x0004, 0x000, 0x0001,
0x0005, 0x0007, 0xfffe)
cmd += zcl_read(device.nwk, self.ep, 0xE001, 0xD011)
payload.setdefault("commands", []).extend(cmd)
class ZTuyaButtonConv(ZConverter):
zigbee = "on_off"
zattr = "on_off"
map = {0: SINGLE, 1: DOUBLE, 2: HOLD}
def decode(self, device: 'XDevice', payload: dict, value: dict):
# TS004F sends click three times with same seq number
if device.extra.get("seq") == value["seq"] or \
value["endpoint"] != self.ep:
return
device.extra["seq"] = value["seq"]
try:
payload[self.attr] = value = self.map.get(value["value"][0])
payload["action"] = self.attr + "_" + value
except Exception:
pass
class ZAqaraCubeMain(Converter):
zigbee = "multistate_input"
zattr = "present_value"
childs = {"side", "from_side", "to_side"}
def decode(self, device: "XDevice", payload: dict, value: dict):
# Thanks to zigbee2mqtt:
# https://github.com/Koenkk/zigbee-herdsman-converters/blob/4a74caad6361e606e0e995d74e7f9ca2f6bdce3e/converters/fromZigbee.js#L5490
value = value[self.zattr]
if value == 0:
payload["action"] = "shake"
elif value == 2:
payload["action"] = "wakeup"
elif value == 3:
payload["action"] = "fall"
elif value & 0x200:
payload.update({"action": "tap", "side": value & 0b111})
elif value & 0x100:
payload.update({"action": "slide", "side": value & 0b111})
elif value & 0x80:
payload.update({"action": "flip180", "side": value & 0b111})
elif value & 0x40:
payload.update({
"action": "flip90",
"from_side": (value >> 3) & 0b111,
"to_side": value & 0b111,
})
class ZAqaraCubeRotate(Converter):
zigbee = "analog_input"
zattr = "present_value"
childs = {"duration"}
def decode(self, device: "XDevice", payload: dict, value: dict):
payload.update({
"action": "rotate",
"angle": round(value["present_value"]),
"duration": round(value[65285] * 0.001, 2),
})
class ZSonoffButtonConv(ZConverter):
zigbee = "on_off"
zattr = "command_id"
map = {0: "hold", 1: "double", 2: "single"}
def decode(self, device: "XDevice", payload: dict, value: dict):
payload[self.attr] = self.map.get(value[self.zattr])
class ZHueDimmerOnConv(ZConverter):
zigbee = "on_off"
def decode(self, device: "XDevice", payload: dict, value: dict):
if value["command_id"] == 1:
payload[self.attr] = "button_1_single"
elif value["command_id"] == 64:
payload[self.attr] = "button_4_single"
def read(self, device: "XDevice", payload: dict):
pass
def config(self, device: "XDevice", payload: dict, gateway):
super().config(device, payload, gateway)
# Thanks to zigbee2mqtt and ZHA (some unknown magic)
cmd = zcl_write(
device.nwk, 2, cluster="basic", attr=0x0031, data=0x000B,
mfg=0x100B, type=0x19
)
payload.setdefault("commands", []).extend(cmd)
class ZHueDimmerLevelConv(ZConverter):
zigbee = "level"
def decode(self, device: "XDevice", payload: dict, value: dict):
if value["command"] == "step":
if value["value"][0] == 0:
payload[self.attr] = "button_2_single"
elif value["value"][0] == 1:
payload[self.attr] = "button_3_single"
def read(self, device: "XDevice", payload: dict):
pass
class ZXiaomiBrightnessConv(Converter):
"""Converter decode and read data in Lumi format for support heartbeats.
But encode data in Zigbee format for support transition.
"""
def decode(self, device: "XDevice", payload: dict, value: Any):
payload[self.attr] = value / 100.0 * 255.0
def encode(self, device: "XDevice", payload: dict, value: Any):
# brightness and transition in seconds
if not isinstance(value, tuple):
value = (value, 1) # default transition
cmd = zcl_level(device.nwk, 1, *value)
payload.setdefault("commands", []).extend(cmd)
class ZXiaomiColorTempConv(Converter):
"""Converter decode and read data in Lumi format for support heartbeats.
But encode data in Zigbee format for support transition.
"""
minm = 153 # mireds (Aqara Bulb)
maxm = 370 # mireds (Aqara Bulb)
def decode(self, device: "XDevice", payload: dict, value: Any):
if not isinstance(value, dict):
payload[self.attr] = value
def encode(self, device: "XDevice", payload: dict, value: Any):
if not isinstance(value, tuple):
value = (value, 1) # default transition
cmd = zcl_color(device.nwk, 1, *value)
payload.setdefault("commands", []).extend(cmd)
# endpoint 2, cluster 0, attribute 51, type 0x10 (boolean)
class ZHueLed(Converter):
zigbee = "basic"
def decode(self, device: "XDevice", payload: dict, value: dict):
if isinstance(value.get(51), int):
payload[self.attr] = bool(value[51])
def encode(self, device: "XDevice", payload: dict, value: bool):
cmd = zcl_write(device.nwk, 2, self.zigbee, 51, int(value), type=0x10)
payload.setdefault("commands", []).extend(cmd)
def read(self, device: "XDevice", payload: dict):
cmd = zcl_read(device.nwk, 2, self.zigbee, 51)
payload.setdefault("commands", []).extend(cmd)
class IKEARemoteConv1(ZConverter):
zigbee = "on_off"
def decode(self, device: "XDevice", payload: dict, value: dict):
if value.get("command_id") == 2:
payload["button"] = "toggle"
class IKEARemoteConv2(ZConverter):
zigbee = "level"
map = {
1: "brightness_down_hold",
2: "brightness_down_click",
3: "brightness_down_release",
4: "toggle_hold",
5: "brightness_up_hold",
6: "brightness_up_click",
7: "brightness_up_release",
}
def decode(self, device: "XDevice", payload: dict, value: dict):
if "command_id" in value:
payload["button"] = self.map.get(value["command_id"])
class ZAqaraOppleMode(ZConverter):
zigbee = 0xFCC0
map = {0: "binding", 1: "multiclick"}
def encode(self, device: "XDevice", payload: dict, value: Any):
value = next(k for k, v in self.map.items() if v == value)
cmd = zcl_write(
device.nwk, self.ep, self.zigbee, 9, value, type=0x20,
mfg=0x115f
)
payload.setdefault("commands", []).extend(cmd)
def read(self, device: "XDevice", payload: dict):
pass
###############################################################################
# Final converter classes
###############################################################################
ZSwitch = ZOnOffConv("switch", "switch")
ZLight = ZOnOffConv("light", "light")
ZBrightness = ZBrightnessConv("brightness", parent="light")
ZColorTemp = ZColorTempConv("color_temp", parent="light")
ZTuyaPowerOn = ZTuyaPowerOnConv("power_on_state", "select", enabled=False)
# ZTuyaMode = ZTuyaModeConv("mode", "select", enabled=False)
@@ -0,0 +1,474 @@
import logging
import re
import time
from collections import deque
from datetime import datetime
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from . import converters
from .converters import Converter, LUMI_GLOBALS, GATEWAY, ZIGBEE, \
BLE, MESH, MESH_GROUP_MODEL
from .converters.stats import STAT_GLOBALS
if TYPE_CHECKING:
from .entity import XEntity
from .gateway.base import GatewayBase
_LOGGER = logging.getLogger(__name__)
RE_DID = re.compile(r"^lumi.[1-9a-f][0-9a-f]{,15}$")
RE_ZIGBEE_MAC = re.compile(r"^0x[0-9a-f]{16}$")
RE_NETWORK_MAC = re.compile(r"^[0-9a-f]{12}$")
RE_NWK = re.compile(r"^0x[0-9a-z]{4}$")
BATTERY_AVAILABLE = 3 * 60 * 60 # 3 hours
POWER_AVAILABLE = 20 * 60 # 20 minutes
POWER_POLL = 9 * 60 # 9 minutes
# all legacy names for backward compatibility with 1st version
LEGACY_ATTR_ID = {
"channel_1": "channel 1",
"channel_2": "channel 2",
"channel_3": "channel 3",
"gas_density": "gas density",
"group": "light",
"outlet": "switch",
"plug": "switch",
"smoke_density": "smoke density",
}
class XDevice:
converters: List[Converter] = None
available_timeout: float = 0
poll_timeout: float = 0
decode_ts: float = 0
encode_ts: float = 0
_available: bool = None
def __init__(self, type: str, model: Union[str, int, None], did: str,
mac: str, nwk: str = None):
"""Base class to handle device of any type."""
assert type in (GATEWAY, ZIGBEE, BLE, MESH)
if type == ZIGBEE:
assert isinstance(model, str) or model is None
assert RE_DID.match(did)
assert RE_ZIGBEE_MAC.match(mac)
assert RE_NWK.match(nwk)
elif type == BLE:
assert isinstance(model, int)
assert did.startswith("blt.") or did.isdecimal()
assert RE_NETWORK_MAC.match(mac)
elif model == MESH_GROUP_MODEL:
assert did.startswith("group.")
elif type == MESH:
assert isinstance(model, int)
assert did.isdecimal()
assert RE_NETWORK_MAC.match(mac), mac
elif type == GATEWAY:
assert isinstance(model, str)
assert did.isdecimal()
assert RE_NETWORK_MAC.match(mac), mac
# TODO: assert mac
self.type = type
self.model = model
self.did = did
self.mac = mac
self.nwk = nwk
# device brand, model, name and converters
self.info = converters.get_device_info(model, type) if model else None
# all device entities
self.entities: Dict[str, 'XEntity'] = {}
# device gateways (one for GW and Zigbee), multiple for BLE and Mesh
self.gateways: List['GatewayBase'] = []
# internal device storage from any useful data
self.extra: Dict[str, Any] = {}
self.lazy_setup = set()
def as_dict(self, ts: float) -> dict:
resp = {
k: getattr(self, k) for k in (
"type", "model", "fw_ver", "available"
)
}
if self.decode_ts:
resp["decode_time"] = round(ts - self.decode_ts)
if self.encode_ts:
resp["encode_time"] = round(ts - self.encode_ts)
resp["entities"] = {
attr: entity.hass_state for attr, entity in self.entities.items()
}
resp["gateways"] = [gw.device.unique_id for gw in self.gateways]
if self.type in self.entities:
resp["stats"] = self.entities[self.type].extra_state_attributes
return resp
@property
def available(self):
return self._available
@available.setter
def available(self, value: bool):
if self._available == value:
return
self._available = value
if self.entities:
self.update_available()
@property
def unique_id(self) -> str:
return self.extra.get("unique_id", self.mac)
@property
def name(self) -> str:
return self.info.name if self.info else "Unknown"
@property
def fw_ver(self) -> Any:
return self.extra.get("fw_ver")
@property
def ieee(self) -> str:
"""For Hass device connections."""
return ":".join([self.mac[i:i + 2] for i in range(2, 18, 2)])
@property
def has_zigbee_conv(self) -> bool:
if not self.converters:
return False
return any(True for conv in self.converters if conv.zigbee)
def has_support(self, feature: str) -> bool:
if feature == "zigbee":
return self.type == ZIGBEE
if feature == "zigbee+ble":
return self.type in (ZIGBEE, BLE)
if not self.model:
return False
if feature == "bind_from":
# Aqara Opple support binding from
if self.type == ZIGBEE and self.model.endswith("86opcn01"):
return True
conv = self.converters[0]
return conv.zigbee == "on_off" and conv.domain == "sensor" and \
getattr(conv, "bind", False)
if feature == "bind_to":
conv = self.converters[0]
return self.type == ZIGBEE and conv.domain in ("switch", "light")
def update_model(self, value: str):
# xiaomi soft adds tail to some models: .v1 or .v2 or .v3
self.model = value[:-3] if value[-3:-1] == ".v" else value
self.info = converters.get_device_info(self.model, self.type)
def attr_unique_id(self, attr: str):
return f"{self.unique_id}_{LEGACY_ATTR_ID.get(attr, attr)}"
def attr_name(self, attr: str):
# this words always uppercase
if attr in ("ble", "led", "rssi", "usb"):
return self.info.name + " " + attr.upper()
attr = attr.replace("_", " ").title()
# skip second attr in name if exists
if attr in self.info.name:
return self.info.name
return self.info.name + " " + attr
def entity_id(self, conv: Converter):
name = self.extra.get("entity_name", self.mac)
return f"{conv.domain}.{name}_{conv.attr}"
# TODO: rename
def subscribe_attrs(self, conv: Converter):
attrs = {conv.attr}
if conv.childs:
attrs |= set(conv.childs)
attrs.update(c.attr for c in self.converters if c.parent == conv.attr)
return attrs
def __str__(self):
s = f"XDevice({self.type}, {self.model}, {self.mac}"
s += f", {self.nwk})" if self.nwk else ")"
return s
def setup_entitites(self, gateway: 'GatewayBase', stats: bool = False):
"""
xiaomi_gateway3:
devices:
0x001234567890: # match device by IEEE
entities:
plug: light # change entity domain (switch to light)
power: # disable default entity
zigbee: sensor # adds stat entity only for this device
parent: sensor # adds entity from attribute value
lqi: sensor # adds entity from attribute value
model: lumi.plug.mitw01 # overwrite model
name: Kitchen Refrigerator # overwrite device name
entity_name: kitchen_refrigerator # overwrite entity name
System kwargs:
decode_ts - aka "last_seen" from device (stored in config folder)
unique_id - ID legacy format from 1st version
restore_entities - skip lazy status for exist entities
"""
kwargs: Dict[str, Any] = {}
if stats:
kwargs["entities"] = {self.type: "sensor"}
for key in (self.type, self.model, self.mac, self.did):
if key in gateway.defaults:
update(kwargs, gateway.defaults[key])
if "decode_ts" in kwargs and self.decode_ts == 0:
self.decode_ts = kwargs["decode_ts"]
if "model" in kwargs:
# support change device model in config
self.update_model(kwargs["model"])
if "name" in kwargs:
# support set device name in config
self.info.name = kwargs["name"]
for k in ("entity_name", "unique_id"):
if k in kwargs:
self.extra[k] = kwargs[k]
entities = kwargs.get("entities") or {}
restore_entities = kwargs.get("restore_entities") or []
self.setup_converters(entities)
# update available before create entities
self.setup_available()
for conv in self.converters:
# support change attribute domain in config
domain = kwargs.get(conv.attr, conv.domain)
if domain is None:
continue
if conv.enabled is None and conv.attr not in restore_entities:
self.lazy_setup.add(conv.attr)
continue
gateway.setup_entity(domain, self, conv)
def setup_converters(self, entities: dict = None):
"""If no entities - use only required converters. Otherwise search for
converters in:
- STAT_GLOBALS list
- converters childs list (always sensor)
"""
if entities is None:
self.converters = self.info.spec
return
self.converters = self.info.spec.copy()
for attr, domain in entities.items():
if not isinstance(domain, str):
continue
if attr in STAT_GLOBALS:
self.converters.append(STAT_GLOBALS[attr])
continue
for conv in self.converters:
if conv.childs and attr in conv.childs:
conv = Converter(attr, domain)
self.converters.append(conv)
def setup_available(self):
# TODO: change to better logic?
if self.type == GATEWAY or self.model == MESH_GROUP_MODEL:
self.available = True
return
# TODO: change to better logic?
if any(True for c in self.converters if c.attr == "battery"):
self.available_timeout = self.info.ttl or BATTERY_AVAILABLE
else:
self.available_timeout = self.info.ttl or POWER_AVAILABLE
self.poll_timeout = POWER_POLL
self.available = \
(time.time() - self.decode_ts) < self.available_timeout
def decode(self, attr_name: str, value: Any) -> Optional[dict]:
"""Find converter by attr_name and decode value."""
for conv in self.converters:
if conv.attr == attr_name:
self.available = True
self.decode_ts = time.time()
payload = {}
conv.decode(self, payload, value)
return payload
return None
def decode_lumi(self, value: list) -> dict:
"""Decode value from Zigbee Lumi/MIoT spec."""
payload = {}
for param in value:
# Lumi spec has `error_code`, MIoT spec has `code`
if param.get("error_code", 0) != 0 or param.get("code", 0) != 0:
continue
v = param["value"] if "value" in param else param["arguments"]
# res_name is Lumi format
if "res_name" in param:
prop = param["res_name"]
conv: Converter = LUMI_GLOBALS.get(prop)
if conv:
conv.decode(self, payload, v)
if conv.attr == "online":
return payload
# piid or eiid is MIoT format
elif "piid" in param:
prop = f"{param['siid']}.p.{param['piid']}"
elif "eiid" in param:
prop = f"{param['siid']}.e.{param['eiid']}"
else:
raise RuntimeError
self.available = True
self.decode_ts = time.time()
for conv in self.converters:
if conv.mi == prop:
conv.decode(self, payload, v)
return payload
def decode_miot(self, value: list):
"""Decode value from Mesh MIoT spec."""
if MESH in self.entities:
self.update(self.decode(MESH, value))
return self.decode_lumi(value)
def decode_zigbee(self, value: dict) -> Optional[dict]:
"""Decode value from Zigbee spec."""
self.available = True
self.decode_ts = time.time()
payload = {}
for conv in self.converters:
if conv.zigbee == value["cluster"]:
conv.decode(self, payload, value)
return payload
def encode(self, value: dict) -> dict:
"""Encode payload to supported spec, depends on attrs.
@param value: dict with {attr: value} pairs
@return: dict with `params` (lumi spec), `mi_spec` (miot spec),
`commands` (zigbee spec)
"""
self.encode_ts = time.time()
payload = {}
for k, v in value.items():
for conv in self.converters:
if conv.attr == k:
conv.encode(self, payload, v)
return payload
def encode_read(self, attrs: set) -> dict:
self.encode_ts = time.time()
payload = {}
for conv in self.converters:
if conv.attr in attrs:
conv.read(self, payload)
return payload
@property
def powered(self) -> bool:
return "sensor" not in self.converters[0].domain
def update(self, value: dict):
"""Push new state to Hass entities."""
if not value:
return
attrs = value.keys()
if self.lazy_setup:
for attr in self.lazy_setup & attrs:
self.lazy_setup.remove(attr)
conv = next(c for c in self.converters if c.attr == attr)
gateway = self.gateways[0]
gateway.setups[conv.domain](gateway, self, conv)
for entity in self.entities.values():
if entity.subscribed_attrs & attrs:
entity.async_set_state(value)
# noinspection PyProtectedMember
if entity.added:
entity.async_write_ha_state()
def update_available(self):
for entity in self.entities.values():
entity.async_update_available()
# noinspection PyProtectedMember
if entity.added:
entity.async_write_ha_state()
def update(orig_dict: dict, new_dict: dict):
for k, v in new_dict.items():
if isinstance(v, dict):
orig_dict[k] = update(orig_dict.get(k, {}), v)
elif isinstance(v, list):
orig_dict[k] = orig_dict.get(k, []) + v
else:
orig_dict[k] = new_dict[k]
return orig_dict
def logger_wrapper(func, log: deque, name: str = None):
def wrap(*args):
if not (name is None and args[0] == "ble"):
ts = datetime.now().isoformat(timespec="milliseconds")
log.append(
{"ts": ts, "type": name, "value": args[0]}
if name else
{"ts": ts, "type": "decode_" + args[0], "value": args[1]}
)
return func(*args)
return wrap
def logger(device: XDevice) -> Optional[list]:
if "logger" not in device.extra:
device.extra["logger"] = log = deque(maxlen=100)
device.decode = logger_wrapper(device.decode, log)
device.decode_lumi = logger_wrapper(
device.decode_lumi, log, "decode_lumi"
)
device.decode_zigbee = logger_wrapper(
device.decode_zigbee, log, "decode_silabs"
)
device.encode = logger_wrapper(device.encode, log, "encode")
device.encode_read = logger_wrapper(
device.encode_read, log, "encode_read"
)
return None
return list(device.extra["logger"])
@@ -0,0 +1,292 @@
import asyncio
import logging
from datetime import timedelta
from typing import TYPE_CHECKING
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config import DATA_CUSTOMIZE
from homeassistant.const import *
from homeassistant.core import callback, State
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, \
CONNECTION_ZIGBEE
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory
from homeassistant.helpers.template import Template
from .const import DOMAIN
from .converters import Converter, GATEWAY, ZIGBEE, BLE, MESH, MESH_GROUP_MODEL
from .device import XDevice
if TYPE_CHECKING:
from .gateway import XGateway
_LOGGER = logging.getLogger(__name__)
DEVICE_CLASSES = {
BLE: SensorDeviceClass.TIMESTAMP,
GATEWAY: BinarySensorDeviceClass.CONNECTIVITY,
MESH: SensorDeviceClass.TIMESTAMP,
ZIGBEE: SensorDeviceClass.TIMESTAMP,
"cloud_link": BinarySensorDeviceClass.CONNECTIVITY,
"contact": BinarySensorDeviceClass.DOOR,
"latch": BinarySensorDeviceClass.LOCK,
"reverse": BinarySensorDeviceClass.LOCK,
"square": BinarySensorDeviceClass.LOCK,
"water_leak": BinarySensorDeviceClass.MOISTURE,
}
ICONS = {
BLE: "mdi:bluetooth",
GATEWAY: "mdi:router-wireless",
MESH: "mdi:bluetooth",
ZIGBEE: "mdi:zigbee",
"action": "mdi:bell",
"child_mode": "mdi:baby-carriage",
"conductivity": "mdi:flower",
"gas_density": "mdi:google-circles-communities",
"group": "mdi:lightbulb-group",
"idle_time": "mdi:timer",
"led": "mdi:led-off",
"outlet": "mdi:power-socket-us",
"pair": "mdi:zigbee",
"plug": "mdi:power-plug",
"smoke_density": "mdi:google-circles-communities",
"supply": "mdi:gauge",
"switch": "mdi:light-switch",
"tvoc": "mdi:cloud",
}
ENTITY_CATEGORIES = {
BLE: EntityCategory.DIAGNOSTIC,
GATEWAY: EntityCategory.DIAGNOSTIC,
MESH: EntityCategory.DIAGNOSTIC,
ZIGBEE: EntityCategory.DIAGNOSTIC,
"battery": EntityCategory.DIAGNOSTIC,
"battery_charging": EntityCategory.DIAGNOSTIC,
"battery_low": EntityCategory.DIAGNOSTIC,
"battery_percent": EntityCategory.DIAGNOSTIC,
"battery_voltage": EntityCategory.DIAGNOSTIC,
"blind_time": EntityCategory.CONFIG,
"charge_protect": EntityCategory.CONFIG,
"child_mode": EntityCategory.CONFIG,
"chip_temperature": EntityCategory.DIAGNOSTIC,
"cloud_link": EntityCategory.DIAGNOSTIC,
"display_unit": EntityCategory.CONFIG,
"fault": EntityCategory.DIAGNOSTIC,
"flex_switch": EntityCategory.CONFIG,
"led": EntityCategory.CONFIG,
"idle_time": EntityCategory.DIAGNOSTIC,
"max_power": EntityCategory.DIAGNOSTIC,
"mode": EntityCategory.CONFIG,
"motor_reverse": EntityCategory.CONFIG,
"motor_speed": EntityCategory.CONFIG,
"occupancy_timeout": EntityCategory.CONFIG,
"power_on_state": EntityCategory.CONFIG,
"sensitivity": EntityCategory.CONFIG,
"wireless": EntityCategory.CONFIG,
"wireless_1": EntityCategory.CONFIG,
"wireless_2": EntityCategory.CONFIG,
"wireless_3": EntityCategory.CONFIG,
}
STATE_TIMEOUT = timedelta(minutes=10)
class XEntity(Entity):
# duplicate here because typing problem
_attr_extra_state_attributes: dict = None
added = False
attributes_template: Template = None
def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter):
attr = conv.attr
self.gw = gateway
self.device = device
self.attr = attr
self.subscribed_attrs = device.subscribe_attrs(conv)
# minimum support version: Hass v2021.6
self._attr_available = device.available
self._attr_device_class = DEVICE_CLASSES.get(attr, attr)
self._attr_entity_registry_enabled_default = conv.enabled is not False
self._attr_extra_state_attributes = {}
self._attr_icon = ICONS.get(attr)
self._attr_name = device.attr_name(attr)
self._attr_should_poll = conv.poll
self._attr_unique_id = device.attr_unique_id(attr)
self._attr_entity_category = ENTITY_CATEGORIES.get(attr)
self.entity_id = device.entity_id(conv)
if device.model == MESH_GROUP_MODEL:
connections = None
elif device.type in (GATEWAY, BLE, MESH):
connections = {(CONNECTION_NETWORK_MAC, device.mac)}
else:
connections = {(CONNECTION_ZIGBEE, device.ieee)}
if device.type != GATEWAY and gateway.device:
via_device = (DOMAIN, gateway.device.unique_id)
else:
via_device = None
# https://developers.home-assistant.io/docs/device_registry_index/
self._attr_device_info = DeviceInfo(
connections=connections,
identifiers={(DOMAIN, device.unique_id)},
manufacturer=device.info.manufacturer,
model=device.info.model,
name=device.info.name,
sw_version=device.fw_ver,
via_device=via_device,
)
# fix don't enabled by default entities
device.entities[attr] = self
@property
def customize(self) -> dict:
if not self.hass:
return {}
return self.hass.data[DATA_CUSTOMIZE].get(self.entity_id)
@property
def hass_state(self):
if self.hass:
state = self.hass.states.get(self.entity_id)
hass_state = state.state if state else "NO_STATE"
else:
hass_state = "DISABLED"
entity_state = getattr(self, "native_value", self.state)
if hass_state == entity_state:
return hass_state
return {
"state": hass_state,
"value": entity_state,
}
def debug(self, msg: str, exc_info=None):
self.gw.debug(f"{self.entity_id} | {msg}", exc_info=exc_info)
async def async_added_to_hass(self):
self.added = True
# also run when rename entity_id
self.render_attributes_template()
if hasattr(self, "async_get_last_state"):
state: State = await self.async_get_last_state()
if state:
self.async_restore_last_state(state.state, state.attributes)
return
if hasattr(self, "async_update"):
await self.async_update()
async def async_will_remove_from_hass(self) -> None:
# also run when rename entity_id
self.added = False
@callback
def async_set_state(self, data: dict):
"""Handle state update from gateway."""
self._attr_state = data[self.attr]
@callback
def async_restore_last_state(self, state: str, attrs: dict):
"""Restore previous state."""
self._attr_state = state
@callback
def async_update_available(self):
gw_available = any(gw.available for gw in self.device.gateways)
self._attr_available = gw_available and (
self.device.available or
self.customize.get('ignore_offline', False)
)
@callback
def render_attributes_template(self):
try:
attrs = self.attributes_template.async_render({
"attr": self.attr,
"device": self.device,
"gateway": self.gw.device
})
if isinstance(attrs, dict):
self._attr_extra_state_attributes.update(attrs)
except AttributeError:
pass
except Exception as e:
_LOGGER.error("Can't render attributes", exc_info=e)
###########################################################################
async def device_send(self, value: dict):
# GATEWAY support lumi_send in lumi spec and miot_send in miot spec
# ZIGBEE support lumi_send in lumi and miot spec and silabs_send
# MESH support only miot_send in miot spec
payload = self.device.encode(value)
if not payload:
return
if self.device.type == GATEWAY:
assert "params" in payload or "mi_spec" in payload, payload
if "mi_spec" in payload:
await self.gw.miot_send(self.device, payload)
else:
await self.gw.lumi_send(self.device, payload)
elif self.device.type == ZIGBEE:
if "commands" in payload:
await self.gw.silabs_send(self.device, payload)
else:
await self.gw.lumi_send(self.device, payload)
elif self.device.type == MESH:
assert "mi_spec" in payload, payload
ok = await self.gw.miot_send(self.device, payload)
if not ok or self.attr == "group":
return
payload = self.device.encode_read(self.subscribed_attrs)
for _ in range(10):
await asyncio.sleep(.5)
data = await self.gw.miot_read(self.device, payload)
# check that all read attrs are equal to send attrs
if not data or any(data.get(k) != v for k, v in value.items()):
continue
self.async_set_state(data)
self._async_write_ha_state()
break
async def device_read(self, attrs: set):
payload = self.device.encode_read(attrs)
if not payload:
return
if self.device.type == GATEWAY:
assert "params" in payload or "mi_spec" in payload, payload
if "mi_spec" in payload:
data = await self.gw.miot_read(self.device, payload)
if data:
self.async_set_state(data)
else:
await self.gw.lumi_read(self.device, payload)
elif self.device.type == ZIGBEE:
if "commands" in payload:
await self.gw.silabs_read(self.device, payload)
else:
await self.gw.lumi_read(self.device, payload)
elif self.device.type == MESH:
assert "mi_spec" in payload, payload
data = await self.gw.miot_read(self.device, payload)
if data:
# support instant update state
self.async_set_state(data)
@@ -0,0 +1,141 @@
import io
import logging
import socket
import time
from typing import Optional
from ..util.elelabs_ezsp_utility import EzspProtocolInterface
_LOGGER = logging.getLogger(__name__)
# noinspection PyUnusedLocal
class EzspUtils:
sock: socket = None
version: str = None
# used in EzspProtocolInterface, values: 'ASH' or 'EZSP'
dlevel = None
def __init__(self):
self.ezsp = EzspProtocolInterface(self, self, _LOGGER)
def connect(self, host: str, port):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(5)
self.sock.connect((host, port))
# noinspection PyPep8Naming
def flushInput(self):
pass
def write(self, data: bytes):
self.sock.send(data)
def read(self, size: int = 1):
try:
return self.sock.recv(size)
except Exception:
return b''
def readline(self):
ts = time.time() + self.sock.gettimeout()
c = None
raw = b''
while c != b'\n' and c != b'' and time.time() < ts:
c = self.read()
raw += c
return raw
def close(self):
self.sock.close()
def state(self) -> Optional[str]:
try:
resp = self.ezsp.initEzspProtocol()
except Exception as e:
_LOGGER.debug("NCP init error", exc_info=e)
return None
if resp == 0:
resp, _, v = \
self.ezsp.getValue(self.ezsp.EZSP_VALUE_VERSION_INFO, "VER")
if resp != 0:
_LOGGER.debug("NCP get version error")
return None
self.version = f"NCP v{v[2]}.{v[3]}.{v[4]}-{v[0]}"
return "normal"
else:
# check if allready in bootloader mode
self.write(b'\x0A')
first_line = self.readline() # read blank line
if first_line != b'\r\n':
_LOGGER.debug(f"NCP first line error: {first_line}")
return None
# read b'Gecko Bootloader v1.8.0\r\n'
self.version = self.readline().decode().strip()
return "boot"
def launch_boot(self):
assert self.version.startswith("NCP"), self.version
resp = self.ezsp.launchStandaloneBootloader(
self.ezsp.STANDALONE_BOOTLOADER_NORMAL_MODE, "BOOT"
)
if resp != 0:
_LOGGER.debug("NCP launch boot error")
raise RuntimeError
time.sleep(2)
def reboot_and_close(self):
# we should be in bootloader
assert self.version.startswith("Gecko Bootloader"), self.version
self.write(b'2') # send reboot
self.close() # we need disconnect after reboot
time.sleep(2)
def flash_and_close(self, file: bytes) -> bool:
assert self.version.startswith("Gecko Bootloader"), self.version
# STATIC FUNCTIONS
def getc(size, timeout=1):
read_data = self.read(size)
return read_data
def putc(data, timeout=1):
self.write(data)
time.sleep(0.001)
# Enter '1' to initialize X-MODEM mode
self.write(b'\x0A')
self.write(b'1')
# Wait for char 'C'
success = False
start_time = time.time()
while time.time() - start_time < 10:
if self.read() == b'C':
success = True
if time.time() - start_time > 5:
break
if not success:
return False
# noinspection PyUnresolvedReferences
from xmodem import XMODEM
modem = XMODEM(getc, putc)
modem.log = _LOGGER.getChild('xmodem')
stream = io.BytesIO(file)
if modem.send(stream):
_LOGGER.debug('Firmware upload complete')
else:
return False
_LOGGER.debug('Rebooting NCP...')
self.close() # better to close after flash
time.sleep(4)
return True
@@ -0,0 +1,240 @@
"""All different technologies divided to independent classes (modules):
gw3 and e1:
- GatewayBase - base class for all subclasses
- LumiGateway - process Zigbee devices in Lumi and MIoT spec using MQTT
- SilabsGateway - process Zigbee devices in Zigbee spec using Silabs Z3 MQTT
- Z3Gateway - process Zigbee network tables using Silabs Z3 console output
- XGateway - main class for enable Telnet and process MQTT loop
gw3:
- MIoTGateway - process Gateway and Mesh properties in MIoT spec using miio
- MainGateway - process Gateway device and some global stats using Telent
- BLEGateway - process BLE devices in MiBeacon format using MQTT
- MeshGateway - init Mesh devices but depends on MIoTGateway for control them
"""
import asyncio
import json
import logging
import time
from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_CON, SIGNAL_MQTT_DIS, \
SIGNAL_MQTT_PUB, SIGNAL_TIMER
from .gate_e1 import GateE1
from .gate_gw3 import GateGW3
from .gate_hubv2 import GateHubV2
from .. import shell
from ..converters import GATEWAY
from ..mini_miio import AsyncMiIO
from ..mini_mqtt import MiniMQTT, MQTTMessage
_LOGGER = logging.getLogger(__name__)
class XGateway(GateGW3, GateE1, GateHubV2):
main_task: asyncio.Task = None
timer_task: asyncio.Task = None
def __init__(self, host: str, token: str, **options):
self.log = _LOGGER
self.host = host
self.options = options
self.dispatcher = {}
self.setups = {}
self.tasks = []
self.miio = AsyncMiIO(host, token)
self.mqtt = MiniMQTT()
self.miio.debug = 'true' in self.debug_mode
@property
def telnet_cmd(self):
return self.options.get('telnet_cmd')
def start(self):
self.main_task = asyncio.create_task(self.run_forever())
# noinspection PyUnusedLocal
async def stop(self, *args):
self.debug("Stop all tasks")
self.main_task.cancel()
for device in self.devices.values():
if self in device.gateways:
device.gateways.remove(self)
async def check_port(self, port: int):
"""Check if gateway port open."""
return await asyncio.get_event_loop().run_in_executor(
None, shell.check_port, self.host, port
)
async def enable_telnet(self):
"""Enable telnet with miio protocol."""
raw = json.loads(self.telnet_cmd)
resp = await self.miio.send(raw['method'], raw.get('params'))
if not resp or resp.get('result') != ['ok']:
self.debug(f"Can't enable telnet")
return False
return True
async def run_forever(self):
self.debug("Start main loop")
"""Main thread loop."""
while True:
try:
# if not telnet - enable it
if not await self.check_port(23) and \
not await self.enable_telnet():
await asyncio.sleep(30)
continue
# if not mqtt - enable it (handle Mi Home and ZHA mode)
if not await self.prepare_gateway() or \
not await self.mqtt.connect(self.host):
await asyncio.sleep(60)
continue
await self.mqtt_connect()
try:
async for msg in self.mqtt:
# noinspection PyTypeChecker
asyncio.create_task(self.mqtt_message(msg))
except Exception as e:
self.debug(f"MQTT connection issue", exc_info=e)
finally:
await self.mqtt.disconnect()
await self.mqtt.close()
await self.mqtt_disconnect()
except Exception as e:
self.error("Main loop error", exc_info=e)
self.debug("Stop main loop")
async def timer(self):
while True:
ts = time.time()
self.check_available(ts)
await self.dispatcher_send(SIGNAL_TIMER, ts=ts)
await asyncio.sleep(30)
async def mqtt_connect(self):
self.debug("MQTT connected")
await self.mqtt.subscribe('#')
self.update_available(True)
await self.dispatcher_send(SIGNAL_MQTT_CON)
self.timer_task = asyncio.create_task(self.timer())
async def mqtt_disconnect(self):
self.debug("MQTT disconnected")
self.timer_task.cancel()
self.update_available(False)
await self.dispatcher_send(SIGNAL_MQTT_DIS)
async def mqtt_message(self, msg: MQTTMessage):
# skip spam from broker/ping
if msg.topic == 'broker/ping':
return
if 'mqtt' in self.debug_mode:
self.debug_tag(f"{msg.topic} {msg.payload}", tag="MQTT")
try:
await self.dispatcher_send(SIGNAL_MQTT_PUB, msg=msg)
except Exception as e:
self.error(
f"Processing MQTT: {msg.topic} {msg.payload}", exc_info=e
)
async def prepare_gateway(self) -> bool:
"""Launching the required utilities on the gw, if they are not already
running.
"""
try:
async with shell.Session(self.host) as session:
sh = await session.login()
if not await sh.only_one():
self.debug("Connection from a second Hass detected")
return False
await sh.get_version()
self.debug(f"Prepare gateway {sh.model} with fw {sh.ver}")
if isinstance(sh, shell.ShellGw3):
return await self.gw3_prepare_gateway(sh)
elif isinstance(sh, shell.ShellE1):
return await self.e1_prepare_gateway(sh)
elif isinstance(sh, shell.ShellHubV2):
return await self.hubv2_prepare_gateway(sh)
except Exception as e:
self.error(f"Can't prepare gateway", e)
return False
def update_available(self, value: bool):
self.available = value
for device in self.devices.values():
if self in device.gateways:
device.update_available()
async def telnet_send(self, command: str):
try:
async with shell.Session(self.host) as session:
sh = await session.login()
if command == "ftp":
await sh.run_ftp()
elif command == "tardata":
return await sh.tar_data()
elif command == "reboot":
await sh.reboot()
else:
await sh.exec(command)
return True
except Exception as e:
self.error(f"Can't run telnet command: {command}", exc_info=e)
return False
async def tar_data(self) -> str:
try:
async with shell.Session(self.host) as session:
sh = await session.login()
return await sh.tar_data()
except Exception as e:
return f"{type(e).__name__} {e}"
def check_available(self, ts: float):
for device in list(self.devices.values()):
if self not in device.gateways or device.type == GATEWAY:
continue
if (device.poll_timeout and
ts - device.decode_ts > device.poll_timeout and
ts - device.encode_ts > device.poll_timeout
):
for attr, entity in device.entities.items():
if entity.hass and hasattr(entity, "async_update"):
self.debug_device(device, "poll state", attr)
asyncio.create_task(entity.async_update())
break
if (device.available and device.available_timeout and
ts - device.decode_ts > device.available_timeout
):
self.debug_device(device, "set device offline")
device.available = False
@@ -0,0 +1,152 @@
import asyncio
import re
from logging import Logger
from typing import Callable, Dict, List, Optional
from ..converters import Converter
from ..device import XDevice
from ..mini_miio import AsyncMiIO
from ..mini_mqtt import MiniMQTT
RE_ENTITIES = re.compile(r"[a-z_]+")
SIGNAL_PREPARE_GW = "prepare_gateway"
SIGNAL_MQTT_CON = "mqtt_connect"
SIGNAL_MQTT_DIS = "mqtt_disconnect"
SIGNAL_MQTT_PUB = "mqtt_publish"
SIGNAL_TIMER = "timer"
class GatewayBase:
"""devices and defaults are global between all gateways."""
# keys:
# - Gateway: did, "123456789", X digits
# - Zigbee: did, "lumi.abcdef", 8 byte hex mac without leading zeros
# - BLE: mac, "abcdef", 6 byte hex mac
# - Mesh: did, "123456789", X digits
# - Mesh group: did, "group.123456789"
devices: Dict[str, XDevice] = {}
# key - mac, 6 byte hex for gw and bluetooth, 8 byte hex for zb with "0x"
defaults: Dict[str, dict] = {}
log: Logger = None
host: str = None
options: dict = None
available: bool = None
dispatcher: Dict[str, List[Callable]] = None
setups: Dict[str, Callable] = None
tasks: List[asyncio.Task] = None
mqtt: MiniMQTT = None
miio: AsyncMiIO = None
did: str = None
time_offset = 0
@property
def ble_mode(self):
return self.options.get('ble', True)
@property
def debug_mode(self):
return self.options.get('debug', '')
@property
def zha_mode(self) -> bool:
return self.options.get('zha', False)
@property
def stats_enable(self):
return self.options.get('stats', False)
@property
def device(self) -> Optional[XDevice]:
return self.devices.get(self.did)
def debug(self, msg: str, exc_info=None):
"""Global debug messages. Passed only if default debug enabled."""
if 'true' in self.debug_mode:
self.log.debug(f"{self.host} [BASE] {msg}", exc_info=exc_info)
def warning(self, msg: str, exc_info=None):
self.log.warning(f"{self.host} | {msg}", exc_info=exc_info)
def error(self, msg: str, exc_info=None):
self.log.error(f"{self.host} | {msg}", exc_info=exc_info)
def exception(self, msg: str):
self.log.exception(f"{self.host} | {msg}")
def debug_tag(self, msg: str, tag: str):
"""Debug message with tag. Tag should be in upper case. `debug_mode`
must be checked before calling.
"""
self.log.debug(f"{self.host} [{tag}] {msg}")
def debug_device(self, device: XDevice, msg: str, payload=None,
tag: str = "BASE"):
"""Debug message with device. Passed only if default debug enabled."""
if 'true' in self.debug_mode:
adv = device.nwk if device.nwk else device.model
self.log.debug(
f"{self.host} [{tag}] {device.mac} ({adv}) {msg} {payload}"
if payload else
f"{self.host} [{tag}] {device.mac} ({adv}) {msg}"
)
def dispatcher_connect(self, signal: str, target: Callable):
targets = self.dispatcher.setdefault(signal, [])
if target not in targets:
targets.append(target)
async def dispatcher_send(self, signal: str, **kwargs):
if not self.dispatcher.get(signal):
return
# better not to use asyncio.gather
for handler in self.dispatcher[signal]:
await handler(**kwargs)
def add_setup(self, domain: str, handler):
"""Add hass entity setup funcion."""
if "." in domain:
_, domain = domain.rsplit(".", 1)
self.setups[domain] = handler
def setup_entity(self, domain: str, device: XDevice, conv: Converter):
handler = self.setups.get(domain)
if handler:
handler(self, device, conv)
def add_device(self, did: str, device: XDevice):
if did not in self.devices:
self.devices[did] = device
if self not in device.gateways:
device.gateways.append(self)
# don't setup device with unknown model
if not device.model:
return
# if device.entities:
# # don't setup if device already has setup entities
# self.debug_device(device, "Join to gateway", device.model)
# return
# don't setup device from second gateway
if len(device.gateways) > 1:
return
device.setup_entitites(self, stats=self.stats_enable)
self.debug_device(
device, f"setup {device.info.model}:",
", ".join(device.entities.keys())
)
def filter_devices(self, feature: str) -> List[XDevice]:
return [
device for device in self.devices.values()
if self in device.gateways and device.has_support(feature)
]
@@ -0,0 +1,129 @@
from . import miot
from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB
from .. import shell
from ..device import XDevice, BLE
from ..mini_mqtt import MQTTMessage
# noinspection PyMethodMayBeStatic,PyUnusedLocal
class BLEGateway(GatewayBase):
def ble_init(self):
if not self.ble_mode:
return
self.dispatcher_connect(SIGNAL_PREPARE_GW, self.ble_prepare_gateway)
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.ble_mqtt_publish)
async def ble_read_devices(self, sh: shell.ShellGw3):
try:
# prevent read database two times
db = await sh.read_db_bluetooth()
# load BLE devices
rows = sh.db.read_table('gateway_authed_table')
for row in rows:
# BLE key is mac
mac = reverse_mac(row[1])
device = self.devices.get(mac)
if not device:
device = XDevice(BLE, row[2], row[4], mac)
self.add_device(mac, device)
except Exception:
pass
async def ble_prepare_gateway(self, sh: shell.ShellGw3):
if self.available is None:
await self.ble_read_devices(sh)
ok = await sh.check_bt()
if ok:
self.debug("Patch Bluetooth")
sh.patch_bluetooth_mqtt()
else:
self.error("Can't patch Bluetooth utility")
if not self.options.get('memory'):
return
if ok:
self.debug("Init Bluetooth in memory storage")
sh.patch_memory_bluetooth()
else:
self.debug("Disable Bluetooth")
sh.patch_disable_bluetooth()
async def ble_mqtt_publish(self, msg: MQTTMessage):
if msg.topic == 'log/miio':
for data in miot.decode_miio_json(
msg.payload, b'_async.ble_event'
):
await self.ble_process_event(data["params"])
elif msg.topic == 'log/ble':
await self.ble_process_event_fix(msg.json)
async def ble_process_event(self, data: dict):
# {'dev': {'did': 'blt.3.xxx', 'mac': 'AA:BB:CC:DD:EE:FF', 'pdid': 2038},
# 'evt': [{'eid': 15, 'edata': '010000'}],
# 'frmCnt': 36, 'gwts': 1636208932}
# some devices doesn't send mac, only number did
# https://github.com/AlexxIT/XiaomiGateway3/issues/24
if 'mac' in data['dev']:
mac = data['dev']['mac'].replace(':', '').lower()
device = self.devices.get(mac)
if not device:
device = XDevice(
BLE, data['dev']['pdid'], data['dev']['did'], mac
)
self.add_device(mac, device)
else:
device = next((
d for d in self.devices.values() if d.did == data['dev']['did']
), None)
if not device:
self.debug(f"Unregistered BLEE device {data}")
return
if device.extra.get('seq') == data['frmCnt']:
return
device.extra['seq'] = data['frmCnt']
if isinstance(data['evt'], list):
payload = data['evt'][0]
elif isinstance(data['evt'], dict):
payload = data['evt']
else:
raise NotImplementedError
if BLE in device.entities:
device.update(device.decode(BLE, payload))
payload = device.decode("mibeacon", payload)
device.update(payload)
self.debug_device(device, "recv", payload, "BLEE")
async def ble_process_event_fix(self, payload: dict):
# {'did':'blt.3.xxx','eid':4104,'edata':'0b','pdid':152,'seq':3}
device = next((
d for d in self.devices.values() if d.did == payload['did']
), None)
if not device:
self.debug(f"Unregistered BLEF device {payload}")
return
if device.extra.get('seq') == payload['seq']:
return
device.extra['seq'] = payload['seq']
if BLE in device.entities:
device.update(device.decode(BLE, payload))
payload = device.decode("mibeacon", payload)
device.update(payload)
self.debug_device(device, "recv", payload, "BLEF")
def reverse_mac(s: str):
return f"{s[10:]}{s[8:10]}{s[6:8]}{s[4:6]}{s[2:4]}{s[:2]}"
@@ -0,0 +1,94 @@
from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB, SIGNAL_TIMER
from .lumi import LumiGateway
from .silabs import SilabsGateway
from .z3 import Z3Gateway
from .. import shell
from ..device import XDevice, GATEWAY
from ..mini_mqtt import MQTTMessage
MODEL = "lumi.gateway.aqcn02"
class GateE1(LumiGateway, SilabsGateway, Z3Gateway):
e1_ts = 0
def e1_init(self):
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.e1_mqtt_publish)
self.dispatcher_connect(SIGNAL_TIMER, self.e1_timer)
async def e1_read_device(self, sh: shell.ShellE1):
self.did = await sh.get_did()
mac = await sh.get_wlan_mac()
device = self.devices.get(self.did)
if not device:
device = XDevice(GATEWAY, MODEL, self.did, mac)
device.extra = {"fw_ver": sh.ver}
self.add_device(self.did, device)
async def e1_prepare_gateway(self, sh: shell.ShellE1):
self.e1_init()
self.silabs_init()
self.lumi_init()
self.z3_init()
ps = await sh.get_running_ps()
if "/tmp/mosquitto -d" not in ps:
self.debug("Run public mosquitto")
await sh.run_public_mosquitto()
# if "ntpd" not in ps:
# # run NTPd for sync time
# await sh.run_ntpd()
if self.available is None and self.did is None:
await self.e1_read_device(sh)
sh.patch_miio_mqtt()
await self.dispatcher_send(
SIGNAL_PREPARE_GW, sh=sh
)
n = await sh.apply_patches(ps)
self.debug(f"Applied {n} patches to daemons")
return True
async def e1_mqtt_publish(self, msg: MQTTMessage):
if msg.topic.endswith('/heartbeat'):
payload = self.device.decode(GATEWAY, msg.json)
self.device.update(payload)
async def e1_timer(self, ts: float):
if ts < self.e1_ts:
return
await self.e1_update_stats()
self.e1_ts = ts + 300 # 5 min
async def e1_update_stats(self):
try:
async with shell.Session(self.host) as session:
sh = await session.login()
serial = await sh.read_file(
"/proc/tty/driver/ms_uart | grep -v ^0 | sort -r"
)
free_mem = await sh.read_file(
"/proc/meminfo | grep MemFree: | awk '{print $2}'"
)
load_avg = await sh.read_file("/proc/loadavg | sed 's/ /|/g'")
run_time = await sh.read_file("/proc/uptime | cut -f1 -d.")
rssi = await sh.read_file(
"/proc/net/wireless | grep wlan0 | awk '{print $4}' | cut -f1 -d."
)
payload = self.device.decode(GATEWAY, {
"serial": serial.decode(),
"free_mem": int(free_mem),
"load_avg": load_avg.decode(),
"run_time": int(run_time),
"rssi": int(rssi) + 100
})
self.device.update(payload)
except Exception as e:
self.warning("Can't update gateway stats", e)
@@ -0,0 +1,171 @@
import asyncio
import time
from typing import Optional
from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB, SIGNAL_TIMER
from .ble import BLEGateway
from .lumi import LumiGateway
from .mesh import MeshGateway
from .miot import MIoTGateway, decode_miio_json
from .silabs import SilabsGateway
from .z3 import Z3Gateway
from .. import shell
from ..device import XDevice, GATEWAY
from ..mini_mqtt import MQTTMessage
MODEL = "lumi.gateway.mgl03"
class GateGW3(
MIoTGateway, LumiGateway, MeshGateway, BLEGateway, SilabsGateway, Z3Gateway
):
gw3_ts = 0
def gw3_init(self):
# self.dispatcher_connect(SIGNAL_MQTT_CON, self.gw3_mqtt_connect)
# self.dispatcher_connect(SIGNAL_MQTT_DIS, self.gw3_mqtt_disconnect)
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.gw3_mqtt_publish)
self.dispatcher_connect(SIGNAL_TIMER, self.gw3_timer)
async def gw3_read_device(self, sh: shell.ShellGw3):
self.did = await sh.get_did()
mac = await sh.get_wlan_mac()
device = self.devices.get(self.did)
if not device:
device = XDevice(GATEWAY, MODEL, self.did, mac)
device.extra = {"fw_ver": sh.ver}
self.add_device(self.did, device)
async def gw3_prepare_gateway(self, sh: shell.ShellGw3):
# run all inits from subclasses
self.miot_init() # GW3 and Mesh depends on MIoT
self.gw3_init()
self.silabs_init()
self.lumi_init()
self.mesh_init()
self.ble_init()
self.z3_init()
ps = await sh.get_running_ps()
if "mosquitto -d" not in ps:
self.debug("Run public mosquitto")
await sh.run_public_mosquitto()
if "ntpd" not in ps:
# run NTPd for sync time
await sh.run_ntpd()
if self.available is None and self.did is None:
await self.gw3_read_device(sh)
# global patch from GW3, BLE and Mesh
sh.patch_miio_mqtt()
if not self.zha_mode:
# buzzer problem only in MiHome mode
if self.options.get('buzzer'):
self.debug("Disable Buzzer")
sh.patch_disable_buzzer()
if self.options.get('memory'):
self.debug("Init Zigbee in memory storage")
sh.patch_memory_zigbee()
elif await sh.check_zigbee_tcp():
self.debug("Init ZHA mode")
sh.patch_zigbee_tcp()
else:
self.error("Can't run ZHA mode")
return False
await self.dispatcher_send(SIGNAL_PREPARE_GW, sh=sh)
if sh.ver >= "1.4.7_0000":
n = await sh.apply_patches(ps)
self.debug(f"Applied {n} patches to daemons")
else:
await sh.patch_miio_mqtt_fw146(ps)
self.warning(f"Firmware {sh.ver} support is very limited!")
return True
# async def gw3_mqtt_connect(self):
# # change gateway online state
# self.device.update({GATEWAY: True})
# await self.gw3_update_time_offset()
# async def gw3_mqtt_disconnect(self):
# # change gateway online state
# self.device.update({GATEWAY: False})
async def gw3_mqtt_publish(self, msg: MQTTMessage):
if msg.topic == 'log/miio':
payload = decode_miio_json(msg.payload, b'event.gw.heartbeat')
if payload:
payload = payload[0]['params'][0]
payload = self.device.decode(GATEWAY, payload)
self.device.update(payload)
# time offset may changed right after gw.heartbeat
await self.gw3_update_time_offset()
elif msg.topic.endswith('/heartbeat'):
payload = self.device.decode(GATEWAY, msg.json)
self.device.update(payload)
async def gw3_timer(self, ts: float):
if ts < self.gw3_ts:
return
await self.gw3_update_serial_stats()
self.gw3_ts = ts + 300 # 5 min
def _time_delta(self) -> float:
t = shell.ntp_time(self.host)
return t - time.time() if t else 0
async def gw3_update_time_offset(self):
self.time_offset = await asyncio.get_event_loop().run_in_executor(
None, self._time_delta
)
self.debug(f"Gateway time offset: {self.time_offset}")
async def gw3_update_serial_stats(self):
try:
async with shell.Session(self.host) as session:
sh = await session.login()
serial = await sh.read_file('/proc/tty/driver/serial')
payload = self.device.decode(
GATEWAY, {"serial": serial.decode()}
)
self.device.update(payload)
except Exception as e:
self.warning("Can't update gateway stats", e)
async def gw3_memory_sync(self):
try:
async with shell.Session(self.host) as session:
sh = await session.login()
await sh.memory_sync()
except Exception as e:
self.error(f"Can't memory sync", e)
async def gw3_send_lock(self, enable: bool) -> bool:
try:
async with shell.Session(self.host) as session:
sh = await session.login()
await sh.lock_firmware(enable)
locked = await sh.check_firmware_lock()
return enable == locked
except Exception as e:
self.error(f"Can't set firmware lock", e)
return False
async def gw3_read_lock(self) -> Optional[bool]:
try:
async with shell.Session(self.host) as session:
sh = await session.login()
return await sh.check_firmware_lock()
except Exception as e:
self.error(f"Can't get firmware lock", e)
@@ -0,0 +1,94 @@
from .base import SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB, SIGNAL_TIMER
from .lumi import LumiGateway
from .silabs import SilabsGateway
from .z3 import Z3Gateway
from .. import shell
from ..device import XDevice, GATEWAY
from ..mini_mqtt import MQTTMessage
MODEL = "lumi.gateway.mcn001"
class GateHubV2(LumiGateway, SilabsGateway, Z3Gateway):
hubv2_ts = 0
def hubv2_init(self):
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.hubv2_mqtt_publish)
self.dispatcher_connect(SIGNAL_TIMER, self.hubv2_timer)
async def hubv2_read_device(self, sh: shell.ShellHubV2):
self.did = await sh.get_did()
mac = await sh.get_wlan_mac()
device = self.devices.get(self.did)
if not device:
device = XDevice(GATEWAY, MODEL, self.did, mac)
device.extra = {"fw_ver": sh.ver}
self.add_device(self.did, device)
async def hubv2_prepare_gateway(self, sh: shell.ShellHubV2):
self.hubv2_init()
self.silabs_init()
self.lumi_init()
self.z3_init()
ps = await sh.get_running_ps()
if "/tmp/mosquitto -d" not in ps:
self.debug("Run public mosquitto")
await sh.run_public_mosquitto()
# if "ntpd" not in ps:
# # run NTPd for sync time
# await sh.run_ntpd()
if self.available is None and self.did is None:
await self.hubv2_read_device(sh)
sh.patch_miio_mqtt()
await self.dispatcher_send(
SIGNAL_PREPARE_GW, sh=sh
)
n = await sh.apply_patches(ps)
self.debug(f"Applied {n} patches to daemons")
return True
async def hubv2_mqtt_publish(self, msg: MQTTMessage):
if msg.topic.endswith('/heartbeat'):
payload = self.device.decode(GATEWAY, msg.json)
self.device.update(payload)
async def hubv2_timer(self, ts: float):
if ts < self.hubv2_ts:
return
await self.hubv2_update_stats()
self.hubv2_ts = ts + 300 # 5 min
async def hubv2_update_stats(self):
try:
async with shell.Session(self.host) as session:
sh = await session.login()
serial = await sh.read_file(
"/proc/tty/driver/ms_uart | grep -v ^0 | sort -r"
)
free_mem = await sh.read_file(
"/proc/meminfo | grep MemFree: | awk '{print $2}'"
)
load_avg = await sh.read_file("/proc/loadavg | sed 's/ /|/g'")
run_time = await sh.read_file("/proc/uptime | cut -f1 -d.")
rssi = await sh.read_file(
"/proc/net/wireless | grep wlan0 | awk '{print $4}' | cut -f1 -d."
)
payload = self.device.decode(GATEWAY, {
"serial": serial.decode(),
"free_mem": int(free_mem),
"load_avg": load_avg.decode(),
"run_time": int(run_time),
"rssi": int(rssi) + 100 if len(rssi) >=1 else 0
})
self.device.update(payload)
except Exception as e:
self.warning("Can't update gateway stats", e)
@@ -0,0 +1,115 @@
import json
from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_CON, \
SIGNAL_MQTT_PUB
from .. import shell
from ..converters import GATEWAY
from ..device import XDevice, ZIGBEE
from ..mini_mqtt import MQTTMessage
class LumiGateway(GatewayBase):
did: str = None # filled by MainGateway
zigbee_pair_model = None
pair_payload = None
pair_payload2 = None
def lumi_init(self):
if self.zha_mode:
return
self.dispatcher_connect(SIGNAL_PREPARE_GW, self.lumi_prepare_gateway)
# self.dispatcher_connect(SIGNAL_MQTT_CON, self.lumi_mqtt_connect)
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.lumi_mqtt_publish)
async def lumi_read_devices(self, sh: shell.TelnetShell):
# 2. Read zigbee devices
raw = await sh.read_file('/data/zigbee/device.info')
lumi = json.loads(raw)['devInfo']
for item in lumi:
did = item["did"]
device = self.devices.get(did)
if not device:
# adds leading zeroes to mac
mac = f"0x{item['mac'][2:]:>016s}"
device = XDevice(
ZIGBEE, item['model'], did, mac, item['shortId']
)
device.extra = {'fw_ver': item['appVer']}
# 'hw_ver': item['hardVer'],
# 'mod_ver': item['model_ver'],
self.add_device(did, device)
async def lumi_prepare_gateway(self, sh: shell.TelnetShell):
if self.available is None:
await self.lumi_read_devices(sh)
uptime = await sh.read_file("/proc/uptime | cut -f1 -d.")
if int(uptime) >= 3600:
self.dispatcher_connect(SIGNAL_MQTT_CON, self.lumi_mqtt_connect)
async def lumi_mqtt_connect(self):
payload = {"params": [{"res_name": "8.0.2102"}]}
for device in self.filter_devices("zigbee"):
await self.lumi_read(device, payload)
async def lumi_mqtt_publish(self, msg: MQTTMessage):
if msg.topic == 'zigbee/send':
await self.lumi_process_lumi(msg.json)
async def lumi_send(self, device: XDevice, payload: dict):
assert "params" in payload or "mi_spec" in payload, payload
# self.debug_device(device, "send", payload, tag="LUMI")
did = device.did if device.type != GATEWAY else "lumi.0"
payload.update({"cmd": "write", "did": did})
await self.mqtt.publish('zigbee/recv', payload)
async def lumi_read(self, device: XDevice, payload: dict):
assert "params" in payload or "mi_spec" in payload, payload
# self.debug_device(device, "read", payload, tag="LUMI")
payload["did"] = device.did if device.type != GATEWAY else "lumi.0"
payload.setdefault("cmd", "read")
await self.mqtt.publish('zigbee/recv', payload)
async def lumi_process_lumi(self, data: dict):
# cmd:
# - heartbeat - from power device every 5-10 min, from battery - 55 min
# - report - new state from device
# - read, write - action from Hass, MiHome or Gateway software
# - read_rsp, write_rsp - gateway execute command (device may not
# receive it)
# - write_ack - response from device (device receive command)
if data['cmd'] == 'heartbeat':
data = data['params'][0]
elif data['cmd'] in ("report", "read_rsp"):
pass
elif data['cmd'] == 'write_rsp':
# process write response only from Gateway
if data['did'] != 'lumi.0':
return
else:
return
did = data['did'] if data['did'] != 'lumi.0' else self.did
# skip without callback and without data
if did not in self.devices:
return
device: XDevice = self.devices[did]
# support multiple spec in one response
for k in ("res_list", "params", "results", "mi_spec"):
if k not in data:
continue
payload = device.decode_lumi(data[k])
device.update(payload)
# no time in device add command
# ts = round(time.time() - data['time'] * 0.001 + self.time_offset, 2) \
# if 'time' in data else '?'
# self.debug(f"{device.did} {device.model} <= {payload} [{ts}]")
self.debug_device(device, "recv", payload, tag="LUMI")
@@ -0,0 +1,52 @@
from .base import GatewayBase, SIGNAL_PREPARE_GW
from .. import shell
from ..converters import MESH, MESH_GROUP_MODEL
from ..device import XDevice
# noinspection PyMethodMayBeStatic,PyUnusedLocal
class MeshGateway(GatewayBase):
def mesh_init(self):
if not self.ble_mode:
return
self.dispatcher_connect(SIGNAL_PREPARE_GW, self.mesh_prepare_gateway)
async def mesh_read_devices(self, sh: shell.ShellGw3):
try:
# prevent read database two times
db = await sh.read_db_bluetooth()
childs = {}
# load Mesh bulbs
rows = sh.db.read_table(sh.mesh_device_table)
for row in rows:
did = row[0]
mac = row[1].replace(':', '').lower()
device = self.devices.get(did)
if not device:
device = XDevice(MESH, row[2], did, mac)
self.add_device(did, device)
# add bulb to group address
childs.setdefault(row[5], []).append(did)
# load Mesh groups
rows = sh.db.read_table(sh.mesh_group_table)
for row in rows:
did = 'group.' + row[0]
device = self.devices.get(did)
if not device:
# don't know if 8 bytes enougth
mac = int(row[0]).to_bytes(8, 'big').hex()
device = XDevice(MESH, MESH_GROUP_MODEL, did, mac)
# update childs of device
device.extra["childs"] = childs.get(row[1])
self.add_device(did, device)
except Exception as e:
self.debug("Can't read mesh DB", exc_info=e)
async def mesh_prepare_gateway(self, sh: shell.ShellGw3):
if self.available is None:
await self.mesh_read_devices(sh)
@@ -0,0 +1,91 @@
import json
import re
from typing import Dict, List, Optional
from .base import GatewayBase, SIGNAL_MQTT_PUB
from ..device import XDevice
from ..mini_mqtt import MQTTMessage
# noinspection PyMethodMayBeStatic,PyUnusedLocal
class MIoTGateway(GatewayBase):
def miot_init(self):
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.miot_mqtt_publish)
async def miot_mqtt_publish(self, msg: MQTTMessage):
if msg.topic == 'log/miio':
for data in decode_miio_json(
msg.payload, b'properties_changed'
):
await self.miot_process_properties(data["params"])
for data in decode_miio_json(
msg.payload, b'event_occured'
):
await self.miot_process_event(data["params"])
async def miot_process_properties(self, data: list):
"""Can receive multiple properties from multiple devices.
data = [{'did':123,'siid':2,'piid':1,'value:True}]
"""
# convert miio response format to multiple responses in lumi format
devices: Dict[str, Optional[list]] = {}
for item in data:
if item['did'] not in self.devices:
continue
devices.setdefault(item['did'], []).append(item)
for did, payload in devices.items():
device = self.devices[did]
payload = device.decode_miot(payload)
device.update(payload)
async def miot_process_event(self, data: dict):
# {"did":"123","siid":8,"eiid":1,"tid":123,"ts":123,"arguments":[]}
device = self.devices.get(data["did"])
if not device:
return
payload = device.decode_miot([data])
device.update(payload)
async def miot_send(self, device: XDevice, payload: dict) -> bool:
assert "mi_spec" in payload, payload
self.debug_device(device, "send", payload, tag="MIOT")
for item in payload["mi_spec"]:
item["did"] = device.did
# MIoT properties changes should return in
resp = await self.miio.send("set_properties", payload["mi_spec"])
return resp and "result" in resp
async def miot_read(self, device: XDevice, payload: dict) \
-> Optional[dict]:
assert "mi_spec" in payload, payload
self.debug_device(device, "read", payload, tag="MIOT")
for item in payload["mi_spec"]:
item["did"] = device.did
resp = await self.miio.send("get_properties", payload["mi_spec"])
if resp is None or "result" not in resp:
return None
return device.decode_miot(resp['result'])
# new miio adds colors to logs
RE_JSON1 = re.compile(b'msg:(.+) length:([0-9]+) bytes')
RE_JSON2 = re.compile(b'{.+}')
EMPTY_RESPONSE = []
def decode_miio_json(raw: bytes, search: bytes) -> List[dict]:
"""There can be multiple concatenated json on one line. And sometimes the
length does not match the message."""
if search not in raw:
return EMPTY_RESPONSE
m = RE_JSON1.search(raw)
if m:
length = int(m[2])
raw = m[1][:length]
else:
m = RE_JSON2.search(raw)
raw = m[0]
items = raw.replace(b'}{', b'}\n{').split(b'\n')
return [json.loads(raw) for raw in items if search in raw]
@@ -0,0 +1,196 @@
import json
from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_PUB
from .. import shell
from ..converters import silabs, is_mihome_zigbee
from ..converters.zigbee import ZConverter
from ..device import XDevice, ZIGBEE
from ..mini_mqtt import MQTTMessage
# noinspection PyMethodMayBeStatic,PyUnusedLocal
class SilabsGateway(GatewayBase):
ieee: str = None
silabs_pair_model = None
pair_payload = None
pair_payload2 = None
def silabs_init(self):
if self.zha_mode:
return
self.dispatcher_connect(SIGNAL_PREPARE_GW, self.silabs_prepare_gateway)
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.silabs_mqtt_publish)
async def silabs_prepare_gateway(self, sh: shell.TelnetShell):
if self.ieee is not None:
return
# 1. Read coordinator info
raw = await sh.read_file('/data/zigbee/coordinator.info')
info = json.loads(raw)
self.ieee = info["mac"][2:].upper()
assert len(self.ieee) == 16
async def silabs_mqtt_publish(self, msg: MQTTMessage):
if msg.topic.endswith('/MessageReceived'):
await self.silabs_process_recv(msg.json)
elif msg.topic.endswith("/MessagePreSentCallback"):
await self.silabs_process_send(msg.json)
elif msg.topic == "zigbee/send" and b"8.0.2084" in msg.payload:
data: dict = msg.json["params"][0]["value"]
await self.silabs_process_join(data)
async def silabs_process_recv(self, data: dict):
mac = data['eui64'].lower()
nwk = data["sourceAddress"].lower()
zb_msg = None
# print raw zigbee if enabled in logs
if "zigbee" in self.debug_mode:
zb_msg = silabs.decode(data)
self.debug_tag(f"{mac} ({nwk}) recv {zb_msg}", tag="ZIGB")
if mac == "0x0000000000000000" or nwk == "0x0000":
return
did = 'lumi.' + mac.lstrip('0x')
device: XDevice = self.devices.get(did)
if not device:
# we need to save device to know its NWK in future
device = XDevice(ZIGBEE, None, did, mac, nwk)
self.add_device(did, device)
self.debug_device(device, "new unknown device", tag="SLBS")
return
if not device.model:
# Sonoff Mini has a bug: it hasn't app_ver, so gw can't add it
if data["clusterId"] == "0x0000":
if not zb_msg:
zb_msg = silabs.decode(data)
if zb_msg.get("app_version") == "Status.UNSUPPORTED_ATTRIBUTE":
await self.silabs_send_fake_version(device, data)
return
# process raw zigbee if device supports it
if device.has_zigbee_conv:
if not zb_msg:
zb_msg = silabs.decode(data)
if zb_msg and "cluster" in zb_msg:
payload = device.decode_zigbee(zb_msg)
device.update(payload)
# process device stats if enabled, also works for LumiGateway
if device and ZIGBEE in device.entities:
payload = device.decode(ZIGBEE, data)
device.update(payload)
async def silabs_process_send(self, data: dict):
if "zigbee" not in self.debug_mode:
return
did = "lumi." + data["eui64"].lstrip('0x').lower()
if did not in self.devices:
return
device = self.devices[did]
zb_msg = silabs.decode(data)
self.debug_tag(f"{device.mac} {device.nwk} send {zb_msg}", tag="ZIGB")
async def silabs_process_join(self, data: dict):
if not is_mihome_zigbee(data["model"]):
self.debug("Prevent unpair 3rd party model: " + data["model"])
await self.silabs_prevent_unpair()
device = self.devices.get(data["did"])
if not device.model:
self.debug_device(device, "paired", data)
device.update_model(data["model"])
device.extra["fw_ver"] = parse_version(data["version"])
self.add_device(device.did, device)
else:
self.debug_device(device, "model exist on pairing")
await self.silabs_config(device)
async def silabs_send(self, device: XDevice, payload: dict):
assert "commands" in payload, payload
self.debug_device(device, "send", payload, tag="SLBS")
await self.mqtt.publish(f"gw/{self.ieee}/commands", payload)
async def silabs_read(self, device: XDevice, payload: dict):
assert "commands" in payload, payload
self.debug_device(device, "read", payload, tag="SLBS")
await self.mqtt.publish(f"gw/{self.ieee}/commands", payload)
async def silabs_prevent_unpair(self):
try:
async with shell.Session(self.host) as session:
sh = await session.login()
await sh.prevent_unpair()
except Exception as e:
self.error("Can't prevent unpair", e)
async def silabs_config(self, device: XDevice):
"""Run some config converters if device spec has them. Binds, etc."""
payload = {}
for conv in device.converters:
if isinstance(conv, ZConverter):
conv.config(device, payload, self)
if not payload:
return
self.debug_device(device, "config")
await self.mqtt.publish(f"gw/{self.ieee}/commands", payload)
async def silabs_send_fake_version(self, device: XDevice, data: dict):
self.debug_device(device, "send fake version")
data["APSCounter"] = "0x00"
data["APSPlayload"] = "0x1800010100002000"
await self.mqtt.publish(f"gw/{self.ieee}/MessageReceived", data)
async def silabs_rejoin(self, device: XDevice):
"""Emulate first join of device."""
self.debug_device(device, "rejoin")
payload = {
"nodeId": device.nwk.upper(),
"deviceState": 16,
"deviceType": "0x00FF",
"timeSinceLastMessage": 0,
"deviceEndpoint": {
"eui64": device.mac.upper(),
"endpoint": 0,
"clusterInfo": []
},
"firstjoined": 1
}
await self.mqtt.publish(f"gw/{self.ieee}/devicejoined", payload)
async def silabs_bind(self, bind_from: XDevice, bind_to: XDevice):
cmd = []
for cluster in ["on_off", "level", "light_color"]:
cmd += silabs.zdo_bind(
bind_from.nwk, 1, cluster, bind_from.mac[2:], bind_to.mac[2:]
)
await self.mqtt.publish(f"gw/{self.ieee}/commands", {"commands": cmd})
async def silabs_unbind(self, bind_from: XDevice, bind_to: XDevice):
cmd = []
for cluster in ["on_off", "level", "light_color"]:
cmd += silabs.zdo_unbind(
bind_from.nwk, 1, cluster, bind_from.mac[2:], bind_to.mac[2:]
)
await self.mqtt.publish(f"gw/{self.ieee}/commands", {"commands": cmd})
async def silabs_leave(self, device: XDevice):
cmd = silabs.zdo_leave(device.nwk)
await self.mqtt.publish(f"gw/{self.ieee}/commands", {"commands": cmd})
def parse_version(value: str) -> int:
"""Support version `0.0.0_0017`."""
try:
if "_" in value:
_, value = value.split("_")
return int(value)
except Exception:
return 0
@@ -0,0 +1,157 @@
import re
import time
from .base import GatewayBase, SIGNAL_PREPARE_GW, SIGNAL_MQTT_CON, \
SIGNAL_MQTT_PUB, SIGNAL_TIMER
from .. import shell
from ..device import ZIGBEE, XDevice
from ..mini_mqtt import MQTTMessage
# noinspection PyMethodMayBeStatic,PyUnusedLocal
class Z3Gateway(GatewayBase):
ieee: str = None
z3_parent_scan: float = 0
# collected data from MQTT topic log/z3 (zigbee console)
z3_buffer: dict = None
def z3_init(self):
if self.zha_mode or not self.stats_enable:
return
self.dispatcher_connect(SIGNAL_PREPARE_GW, self.z3_prepare_gateway)
self.dispatcher_connect(SIGNAL_MQTT_CON, self.z3_mqtt_connect)
self.dispatcher_connect(SIGNAL_MQTT_PUB, self.z3_mqtt_publish)
self.dispatcher_connect(SIGNAL_TIMER, self.z3_timer)
async def z3_prepare_gateway(self, sh: shell.TelnetShell):
assert self.ieee, "Z3Gateway depends on SilabsGateway"
self.debug("Init Zigbee parents")
sh.patch_zigbee_parents()
async def z3_mqtt_connect(self):
# delay first scan
self.z3_parent_scan = time.time() + 10
async def z3_mqtt_publish(self, msg: MQTTMessage):
if msg.topic == 'log/z3':
await self.z3_process_log(msg.text)
async def z3_timer(self, ts: float):
if ts >= self.z3_parent_scan:
await self.z3_run_parent_scan()
async def z3_run_parent_scan(self):
self.debug("Run zigbee parent scan process")
# block any auto updates in 10 seconds
self.z3_parent_scan = time.time() + 10
payload = {"commands": [
{"commandcli": "debugprint all_on"},
{"commandcli": "plugin device-table print"},
{"commandcli": "plugin stack-diagnostics child-table"},
{"commandcli": "plugin stack-diagnostics neighbor-table"},
{"commandcli": "plugin concentrator print-table"},
{"commandcli": "debugprint all_off"},
]}
await self.mqtt.publish(f"gw/{self.ieee}/commands", payload)
async def z3_process_log(self, payload: str):
if payload.startswith("CLI command executed"):
cmd = payload[22:-1]
if cmd == "debugprint all_on" or self.z3_buffer is None:
# reset all buffers
self.z3_buffer = {}
else:
self.z3_buffer[cmd] = self.z3_buffer['buffer']
self.z3_buffer['buffer'] = ''
if cmd == "plugin concentrator print-table":
await self.z3_process_parent_scan()
elif self.z3_buffer:
self.z3_buffer['buffer'] += payload
async def z3_process_parent_scan(self):
self.debug("Process zigbee parent scan response")
try:
raw = self.z3_buffer["plugin device-table print"]
dt = re.findall(
r'\d+ ([A-F0-9]{4}): {2}([A-F0-9]{16}) 0 {2}(\w+) (\d+)', raw
)
raw = self.z3_buffer["plugin stack-diagnostics child-table"]
ct = re.findall(r'\(>\)([A-F0-9]{16})', raw)
raw = self.z3_buffer["plugin stack-diagnostics neighbor-table"]
rt = re.findall(r'\(>\)([A-F0-9]{16})', raw)
raw = self.z3_buffer["plugin concentrator print-table"]
pt = re.findall(r': (.+?) \(Me\)', raw)
pt = [i.replace('0x', '').split(' -> ') for i in pt]
pt = {i[0]: i[1:] for i in pt}
self.debug(f"Total zigbee devices: {len(dt)}")
# nwk: FFFF, ieee: FFFFFFFFFFFFFFFF, ago: int
# state: JOINED, LEAVE_SENT (32)
for nwk, ieee, state, ago in dt:
if state == "LEAVE_SENT":
continue
if ieee in ct:
type_ = 'device'
elif ieee in rt:
type_ = 'router'
elif nwk in pt:
type_ = 'device'
else:
type_ = '?'
if nwk in pt:
if len(pt[nwk]) > 1:
parent = '0x' + pt[nwk][0].lower()
else:
parent = '-'
elif ieee in ct:
parent = '-'
else:
parent = '?'
nwk = '0x' + nwk.lower() # 0xffff
payload = {
# 'eui64': '0x' + ieee,
# 'nwk': nwk,
# 'ago': int(ago),
'type': type_,
'parent': parent
}
did = 'lumi.' + ieee.lstrip('0').lower()
device = self.devices.get(did)
if not device:
mac = '0x' + ieee.lower()
device = XDevice(ZIGBEE, None, did, mac, nwk)
self.add_device(did, device)
self.debug_device(device, "new unknown device", tag=" Z3 ")
continue
if ZIGBEE not in device.entities:
continue
# the device remains in the gateway database after
# deletion and may appear on another gw with another nwk
if nwk == device.nwk:
# payload = device.decode(ZIGBEE, payload)
device.update(payload)
else:
self.debug(f"Zigbee device with wrong NWK: {ieee}")
# one hour later
self.z3_parent_scan = time.time() + 3600
except Exception as e:
self.debug(f"Can't update parents", exc_info=e)
@@ -0,0 +1,71 @@
"""
Logging can be setup from:
1. Hass default config
```yaml
logger:
logs:
custom_components.xiaomi_gateway3: debug
```
2. Integration config (YAML)
```yaml
xiaomi_gateway3:
logger:
filename: xiaomi_gateway3.log
propagate: False # disable log to home-assistant.log and console
max_bytes: 100000000
backup_count: 3
```
3. Integration config (GUI)
Configuration > Xiaomi Gateway 3 > Configure > Debug
"""
import logging
import os
from logging import Formatter
from logging.handlers import RotatingFileHandler
import voluptuous as vol
from homeassistant.const import CONF_FILENAME
from homeassistant.helpers import config_validation as cv
FMT = "%(asctime)s %(message)s"
CONFIG_SCHEMA = vol.Schema({
vol.Optional('level', default='debug'): cv.string,
vol.Optional('propagate', default=True): cv.boolean,
vol.Optional(CONF_FILENAME): cv.string,
vol.Optional('mode', default='a'): cv.string,
vol.Optional('max_bytes', default=0): cv.positive_int,
vol.Optional('backup_count', default=0): cv.positive_int,
vol.Optional('format', default=FMT): cv.string,
}, extra=vol.ALLOW_EXTRA)
def init(logger_name: str, config: dict, config_dir: str = None):
level = config['level'].upper()
logger = logging.getLogger(logger_name)
logger.propagate = config['propagate']
logger.setLevel(level)
filename = config.get(CONF_FILENAME)
if filename:
if config_dir:
filename = os.path.join(config_dir, filename)
handler = RotatingFileHandler(
filename,
config['mode'],
config['max_bytes'],
config['backup_count'],
)
fmt = Formatter(config['format'])
handler.setFormatter(fmt)
logger.addHandler(handler)
@@ -0,0 +1,346 @@
import asyncio
import hashlib
import json
import logging
import random
import socket
import time
from asyncio import DatagramProtocol, Future
from asyncio.protocols import BaseProtocol
from asyncio.transports import DatagramTransport
from typing import Union
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_LOGGER = logging.getLogger(__name__)
HELLO = bytes.fromhex(
"21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
)
class BasemiIO:
"""A simple class that implements the miIO protocol."""
device_id = None
delta_ts = None
debug = False
def __init__(self, host: str, token: str, timeout: float = 3):
self.addr = (host, 54321)
self.token = bytes.fromhex(token)
self.timeout = timeout
key = hashlib.md5(self.token).digest()
iv = hashlib.md5(key + self.token).digest()
self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv),
backend=default_backend())
def _encrypt(self, plaintext: bytes):
padder = padding.PKCS7(128).padder()
padded_plaintext = padder.update(plaintext) + padder.finalize()
encryptor = self.cipher.encryptor()
return encryptor.update(padded_plaintext) + encryptor.finalize()
def _decrypt(self, ciphertext: bytes):
decryptor = self.cipher.decryptor()
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded_plaintext) + unpadder.finalize()
def _pack_raw(self, msg_id: int, method: str,
params: Union[dict, list] = None):
# latest zero unnecessary
payload = json.dumps({
'id': msg_id,
'method': method, 'params': params or []
}, separators=(',', ':')).encode() + b'\x00'
data = self._encrypt(payload)
raw = b'\x21\x31'
raw += (32 + len(data)).to_bytes(2, 'big') # total length
raw += b'\x00\x00\x00\x00' # unknow
raw += self.device_id.to_bytes(4, 'big')
raw += int(time.time() - self.delta_ts).to_bytes(4, 'big')
raw += hashlib.md5(raw + self.token + data).digest()
raw += data
assert len(raw) < 1024, "Exceeded message size"
return raw
def _unpack_raw(self, raw: bytes):
assert raw[:2] == b'\x21\x31'
# length = int.from_bytes(raw[2:4], 'big')
# unknown = raw[4:8]
# device_id = int.from_bytes(raw[8:12], 'big')
# ts = int.from_bytes(raw[12:16], 'big')
# checksum = raw[16:32]
return self._decrypt(raw[32:])
class SyncMiIO(BasemiIO):
"""Synchronous miIO protocol."""
def ping(self, sock: socket) -> bool:
"""Returns `true` if the connection to the miio device is working. The
token is not verified at this stage.
"""
try:
sock.sendto(HELLO, self.addr)
raw = sock.recv(1024)
if raw[:2] == b'\x21\x31':
self.device_id = int.from_bytes(raw[8:12], 'big')
self.delta_ts = time.time() - int.from_bytes(raw[12:16], 'big')
return True
except Exception:
pass
return False
def send(self, method: str, params: Union[dict, list] = None):
"""Send command to miIO device and get result from it. Params can be
dict or list depend on command.
"""
pings = 0
for times in range(1, 4):
try:
# create socket every time for reset connection, because we can
# reseive answer on previous request or request from another
# thread
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(self.timeout)
# need device_id for send command, can get it from ping cmd
if self.delta_ts is None and not self.ping(sock):
pings += 1
continue
# pack each time for new message id
msg_id = random.randint(100000000, 999999999)
raw_send = self._pack_raw(msg_id, method, params)
t = time.monotonic()
sock.sendto(raw_send, self.addr)
# can receive more than 1024 bytes (1056 approximate maximum)
raw_recv = sock.recv(10240)
t = time.monotonic() - t
data = self._unpack_raw(raw_recv).rstrip(b'\x00')
if data == b'':
# mgl03 fw 1.4.6_0012 without Internet respond on miIO.info
# command with empty answer
data = {'result': ''}
break
data = json.loads(data)
# check if we received response for our cmd
if data['id'] == msg_id:
break
_LOGGER.debug(f"{self.addr[0]} | wrong ID")
except socket.timeout:
_LOGGER.debug(f"{self.addr[0]} | timeout {times}")
except Exception as e:
_LOGGER.debug(f"{self.addr[0]}", exc_info=e)
# init ping again
self.delta_ts = None
else:
_LOGGER.warning(
f"{self.addr[0]} | Device offline"
if pings >= 2 else
f"{self.addr[0]} | Can't send {method} {params}"
)
return None
if self.debug:
_LOGGER.debug(
f"{self.addr[0]} | Send {method} {len(raw_send)}B, "
f"recv {len(raw_recv)}B in {t:.1f} sec and {times} try"
)
if 'result' in data:
return data['result']
else:
_LOGGER.debug(f"{self.addr[0]} | {data}")
return None
def send_bulk(self, method: str, params: list):
"""Sends a command with a large number of parameters. Splits into
multiple requests when the size of one request is exceeded.
"""
try:
result = []
# Chunk of 15 is seems like the best size. Because request should
# be lower than 1024 and response should be not more than 1056.
# {'did':'1234567890','siid': 2,'piid': 1,'value': False,'code': 0}
for i in range(0, len(params), 15):
result += self.send(method, params[i:i + 15])
return result
except Exception:
return None
def info(self) -> Union[dict, str, None]:
"""Get info about miIO device.
Response dict - device ok, token ok
Response empty string - device ok, token ok (mgl03 on fw 1.4.6_0012
without cloud connection)
Response None, device_id not None - device ok, token wrong
Response None, device_id None - device offline
"""
return self.send('miIO.info')
# noinspection PyUnusedLocal
class AsyncSocket(DatagramProtocol):
timeout = 0
transport: DatagramTransport = None
response: Future = None
def __init__(self):
self.response = asyncio.get_event_loop().create_future()
def connection_made(self, transport: DatagramTransport):
# _LOGGER.debug(f"connection_made {transport}")
self.transport = transport
def datagram_received(self, data: bytes, addr):
# _LOGGER.debug(f"datagram_received {data}")
self.response.set_result(data)
def settimeout(self, value: int):
self.timeout = value
def sendto(self, data: bytes):
self.transport.sendto(data)
def close(self):
if not self.transport:
return
try:
self.transport.close()
except Exception:
_LOGGER.exception("Error when closing async socket")
async def connect(self, addr: tuple):
coro = asyncio.get_event_loop().create_datagram_endpoint(
lambda: self, remote_addr=addr
)
if self.timeout:
await asyncio.wait_for(coro, self.timeout)
else:
await coro
async def recv(self, *args):
self.response = asyncio.get_event_loop().create_future()
if self.timeout:
return await asyncio.wait_for(self.response, self.timeout)
return await self.response
# noinspection PyMethodMayBeStatic,PyTypeChecker
class AsyncMiIO(BasemiIO, BaseProtocol):
async def ping(self, sock: AsyncSocket) -> bool:
"""Returns `true` if the connection to the miio device is working. The
token is not verified at this stage.
"""
try:
sock.sendto(HELLO)
raw = await sock.recv(1024)
if raw[:2] == b'\x21\x31':
self.device_id = int.from_bytes(raw[8:12], 'big')
self.delta_ts = time.time() - int.from_bytes(raw[12:16], 'big')
return True
except Exception:
pass
return False
async def send(self, method: str, params: Union[dict, list] = None):
"""Send command to miIO device and get result from it. Params can be
dict or list depend on command.
Possible results:
- None - device offline
- {} (empty dict) - device online but can't response on cmd
- {'id':123,'result':...} - device answered on cmd with good result
- {'id':123,'error':...}
"""
offline = False
for times in range(1, 4):
sock = AsyncSocket()
sock.settimeout(self.timeout)
try:
# create socket every time for reset connection, because we can
# reseive answer on previous request or request from another
# thread
await sock.connect(self.addr)
# need device_id for send command, can get it from ping cmd
if self.delta_ts is None and not await self.ping(sock):
# device doesn't answered on ping
offline = True
continue
# pack each time for new message id
msg_id = random.randint(100000000, 999999999)
raw_send = self._pack_raw(msg_id, method, params)
sock.sendto(raw_send)
# can receive more than 1024 bytes (1056 approximate maximum)
raw_recv = await sock.recv(10240)
data = self._unpack_raw(raw_recv).rstrip(b'\x00')
if data == b'':
# mgl03 fw 1.4.6_0012 without Internet respond on miIO.info
# command with empty answer
continue
data = json.loads(data)
# check if we received response for our cmd
if data['id'] != msg_id:
_LOGGER.debug(f"{self.addr[0]} | wrong answer ID")
continue
return data
except (asyncio.TimeoutError, OSError):
# OSError: [Errno 101] Network unreachable
pass
except Exception as e:
_LOGGER.debug(f"{self.addr[0]} | {method}", exc_info=e)
finally:
sock.close()
# init ping again
self.delta_ts = None
if offline:
_LOGGER.warning(f"{self.addr[0]} | Device offline")
return None
_LOGGER.info(f"{self.addr[0]} | No answer on {method} {params}")
return {}
async def send_bulk(self, method: str, params: list) -> list:
"""Sends a command with a large number of parameters. Splits into
multiple requests when the size of one request is exceeded.
"""
try:
result = []
for i in range(0, len(params), 15):
resp = await self.send(method, params[i:i + 15])
result += resp['result']
return result
except Exception:
return None
async def info(self):
"""Get info about miIO device."""
resp = await self.send('miIO.info')
return resp.get('result') if resp else resp
@@ -0,0 +1,295 @@
"""
http://stanford-clark.com/MQTT/
"""
import asyncio
import json
import logging
import random
from asyncio import StreamReader, StreamWriter
from typing import Optional
_LOGGER = logging.getLogger(__name__)
CONNECT = 1
CONNACK = 2
PUBLISH = 3
PUBACK = 4
SUBSCRIBE = 8
SUBACK = 9
PINGREQ = 12
PINGRESP = 13
DISCONNECT = 14
class MQTTMessage:
type: int
dup: bool
qos: int
retain: bool
topic: str
payload: bytes
@property
def text(self) -> str:
return self.payload.decode()
@property
def json(self) -> dict:
return json.loads(self.payload)
def __str__(self):
return f"{self.topic} {self.payload.decode()}"
class RawMessage:
def __init__(self, raw=b''):
self.pos = 0
self.raw = raw
@property
def size(self):
return len(self.raw)
def read(self, length: int) -> bytes:
self.pos += length
return self.raw[self.pos - length:self.pos]
def read_int(self, length: int) -> int:
return int.from_bytes(self.read(length), 'big')
def read_str(self) -> str:
slen = self.read_int(2)
return self.read(slen).decode()
def read_all(self) -> bytes:
return self.read(self.size - self.pos)
def write_int(self, value: int, length: int):
self.raw += value.to_bytes(length, 'big')
def write_str(self, value: str):
self.write_int(len(value), 2)
self.raw += value.encode()
def write_len(self):
buf = b''
var = len(self.raw)
for _ in range(4):
if var >= 128:
var, b = divmod(var, 128)
buf += (b | 128).to_bytes(1, 'big')
else:
buf += var.to_bytes(1, 'big')
break
self.raw = buf + self.raw
def write_header(self, msg_type: int, qos=0, retain=False):
self.write_len()
header = (msg_type << 4) | (qos << 1) | int(retain)
self.raw = header.to_bytes(1, 'big') + self.raw
@staticmethod
def read_header(header: int) -> MQTTMessage:
msg = MQTTMessage()
msg.type = (header >> 4) & 0b1111
msg.dup = bool((header >> 3) & 1)
msg.qos = (header >> 1) & 0b11
msg.retain = bool(header & 1)
return msg
@staticmethod
def connect(keep_alive: int = 0):
msg = RawMessage()
msg.write_str('MQIsdp') # protocol name
msg.write_int(3, 1) # protocol version
msg.write_int(0, 1) # flags
msg.write_int(keep_alive, 2) # keep alive
cid = random.randint(1000, 9999)
msg.write_str(f"hass-{cid}") # client ID (should be unique)
msg.write_header(CONNECT, qos=0)
return msg.raw
@staticmethod
def subscribe(msg_id: int, *topics, qos=0):
msg = RawMessage()
msg.write_int(msg_id, 2) # message ID
for topic in topics:
msg.write_str(topic)
msg.write_int(qos, 1) # requested QoS
msg.write_header(SUBSCRIBE, qos=1)
return msg.raw
@staticmethod
def publish(topic: str, payload: bytes, retain=False):
msg = RawMessage()
msg.write_str(topic)
# skip msg_id for QoS 0
msg.raw += payload
msg.write_header(PUBLISH, qos=0, retain=retain)
return msg.raw
@staticmethod
def ping():
# adds zero length after header
return (PINGREQ << 4).to_bytes(2, 'little')
@staticmethod
def disconnect():
# adds zero length after header
return (DISCONNECT << 4).to_bytes(2, 'little')
class MiniMQTT:
msg_id: int = None
reader: StreamReader = None
writer: StreamWriter = None
def __init__(self, keepalive=15, timeout=5):
self.keepalive = keepalive
self.timeout = timeout
self.pub_buffer = []
async def read_varlen(self) -> int:
var = 0
for i in range(4):
b = await self.reader.read(1)
var += (b[0] & 0x7F) << (7 * i)
if (b[0] & 0x80) == 0:
break
return var
async def _connect(self, host: str):
self.reader, self.writer = await asyncio.open_connection(host, 1883)
msg = RawMessage.connect()
self.writer.write(msg)
await self.writer.drain()
self.msg_id = 0
raw = await self.reader.readexactly(4)
assert raw[0] == CONNACK << 4
assert raw[1] == 2
return raw[3] == 0
async def connect(self, host: str):
try:
resp = await asyncio.wait_for(self._connect(host), self.timeout)
if resp and self.pub_buffer:
asyncio.create_task(self.empty_buffer())
return resp
except Exception:
return False
async def disconnect(self):
msg = RawMessage.disconnect()
try:
self.writer.write(msg)
await asyncio.wait_for(self.writer.drain(), self.timeout)
except Exception:
_LOGGER.debug("Can't disconnect from gateway")
async def subscribe(self, topic: str):
self.msg_id += 1
msg = RawMessage.subscribe(self.msg_id, topic)
try:
self.writer.write(msg)
await asyncio.wait_for(self.writer.drain(), self.timeout)
except Exception:
_LOGGER.debug(f"Can't subscribe to {topic}")
async def publish(self, topic: str, payload, retain=False):
if self.writer is None:
self.pub_buffer.append([topic, payload, retain])
return
if isinstance(payload, str):
payload = payload.encode()
elif isinstance(payload, dict):
payload = json.dumps(payload, separators=(',', ':')).encode()
# no response for QoS 0
msg = RawMessage.publish(topic, payload, retain)
try:
self.writer.write(msg)
await asyncio.wait_for(self.writer.drain(), self.timeout)
except Exception:
_LOGGER.debug(f"Can't publish {payload} to {topic}")
async def read(self) -> Optional[MQTTMessage]:
raw = await self.reader.read(1)
if raw == b'':
# disconnected
return None
msg = RawMessage.read_header(raw[0])
if msg.type == PUBLISH:
varlen = await self.read_varlen()
raw = await self.reader.readexactly(varlen)
pr = RawMessage(raw)
msg.topic = pr.read_str()
if msg.qos > 0:
_ = pr.read_int(2) # msg ID
raise NotImplementedError
msg.payload = pr.read_all()
elif msg.type == PINGRESP:
await self.reader.readexactly(1)
elif msg.type == SUBACK:
# 1b header, 1b len, 2b msgID, 1b QOS
varlen = await self.reader.readexactly(1)
await self.reader.readexactly(varlen[0])
else:
raise NotImplementedError
return msg
async def close(self):
if not self.writer:
return
try:
self.writer.close()
await asyncio.wait_for(self.writer.wait_closed(), self.timeout)
except Exception:
_LOGGER.debug("Can't close connection")
async def empty_buffer(self):
for args in self.pub_buffer:
await self.publish(*args)
self.pub_buffer.clear()
def __aiter__(self):
return self
async def __anext__(self) -> MQTTMessage:
wait_pong = False
while True:
try:
msg: MQTTMessage = await asyncio.wait_for(
self.read(), self.keepalive
)
if msg is None:
raise StopAsyncIteration
if msg.type == PUBLISH:
return msg
if msg.type == PINGRESP:
wait_pong = False
except asyncio.TimeoutError:
if wait_pong:
# second ping without pong
raise StopAsyncIteration
self.writer.write(RawMessage.ping())
await asyncio.wait_for(self.writer.drain(), self.timeout)
wait_pong = True
@@ -0,0 +1,80 @@
import asyncio
import socket
from typing import Union
from .base import TelnetShell
from .shell_e1 import ShellE1
from .shell_gw3 import ShellGw3
from .shell_hubv2 import ShellHubV2
class Session:
"""Support automatic closing session in case of trouble. Example of usage:
try:
async with shell.Session(host) as session:
sh = await session.login()
return True
except Exception:
return False
"""
def __init__(self, host: str, port=23):
self.coro = asyncio.open_connection(host, port, limit=1_000_000)
async def __aenter__(self):
self.reader, self.writer = await asyncio.wait_for(self.coro, 5)
return self
async def __aexit__(self, exc_type, exc, tb):
self.writer.close()
await self.writer.wait_closed()
async def login(self) -> Union[TelnetShell, ShellGw3, ShellE1, ShellHubV2]:
coro = self.reader.readuntil(b"login: ")
resp: bytes = await asyncio.wait_for(coro, 3)
if b"rlxlinux" in resp:
shell = ShellGw3(self.reader, self.writer)
elif b"Aqara-Hub-E1" in resp:
shell = ShellE1(self.reader, self.writer)
elif b"Mijia_Hub_V2" in resp:
shell = ShellHubV2(self.reader, self.writer)
else:
raise Exception(f"Unknown response: {resp}")
await shell.login()
await shell.prepare()
return shell
NTP_DELTA = 2208988800 # 1970-01-01 00:00:00
NTP_QUERY = b'\x1b' + 47 * b'\0'
def ntp_time(host: str) -> float:
"""Return server send time"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(2)
try:
sock.sendto(NTP_QUERY, (host, 123))
raw = sock.recv(1024)
integ = int.from_bytes(raw[-8:-4], 'big')
fract = int.from_bytes(raw[-4:], 'big')
return integ + float(fract) / 2 ** 32 - NTP_DELTA
except Exception:
return 0
finally:
sock.close()
def check_port(host: str, port: int):
"""Check if gateway port open."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2)
try:
return s.connect_ex((host, port)) == 0
finally:
s.close()
@@ -0,0 +1,67 @@
import asyncio
import base64
from asyncio import StreamReader, StreamWriter
from dataclasses import dataclass
from typing import Union
@dataclass
class TelnetShell:
reader: StreamReader
writer: StreamWriter
model = None
ver = None
async def close(self):
if not self.writer:
return
self.writer.close()
await self.writer.wait_closed()
async def exec(self, command: str, as_bytes=False, timeout=10) \
-> Union[str, bytes]:
"""Run command and return it result."""
self.writer.write(command.encode() + b"\n")
coro = self.reader.readuntil(b"# ")
raw = await asyncio.wait_for(coro, timeout=timeout)
return raw[:-2] if as_bytes else raw[:-2].decode()
async def read_file(self, filename: str, as_base64=False):
command = f"cat {filename}|base64" if as_base64 else f"cat {filename}"
try:
raw = await self.exec(command, as_bytes=True, timeout=60)
# b"cat: can't open ..."
return base64.b64decode(raw) if as_base64 else raw
except Exception:
return None
async def reboot(self):
# should not wait for response
self.writer.write(b"reboot\n")
await self.writer.drain()
# have to wait or the magic won't happen
await asyncio.sleep(1)
async def only_one(self) -> bool:
# run shell with dummy option, so we can check if second Hass connected
# shell will close automatically when disconnected from telnet
raw = await self.exec("(ps|grep -v grep|grep -q 'sh +o') || sh +o")
return "set -o errexit" in raw
async def get_version(self) -> str:
raise NotImplementedError
async def get_token(self) -> str:
raise NotImplementedError
async def prevent_unpair(self):
raise NotImplementedError
async def run_ftp(self):
raise NotImplementedError
async def tar_data(self):
raise NotImplementedError
def patch_zigbee_parents(self):
raise NotImplementedError
@@ -0,0 +1,153 @@
import asyncio
import base64
import hashlib
from .base import TelnetShell
WGET = "(wget http://master.dl.sourceforge.net/project/aqcn02/{0}?viasf=1 -O /data/{1} && chmod +x /data/{1})"
TAR_DATA = "tar -czOC /data mha_master miio storage zigbee devices.txt gatewayInfoJson.info 2>/dev/null | base64"
MD5_MOSQUITTO_PUB = '7c3883281750e00f717d35d6bdf2d913'
def sed(pattern: str, repl: str):
"""sed with extended regex and edit file in-place"""
repl = repl.replace('$', '\$').replace('&', '\&').replace('=', '\='). \
replace('`', '\`').replace('"', '\\"').replace('\n', '\\n')
return f'sed -r "s={pattern}={repl}=" -i /tmp/app_monitor.sh'
PATCH_MIIO_MQTT = sed(
"^ +miio_client -.+$",
"pkill -f log/miio\nmiio_client -l0 -d /data/miio/ -n128 -o DISABLE_PSM -D | awk '/ot_agent_recv_handler_one.+(properties_changed|heartbeat)|record_offline/{print $0;fflush()}' | /data/mosquitto_pub -t log/miio -l &"
)
PATCH_ZIGBEE_PARENTS = sed(
"^ +(mZ3GatewayHost_MQTT -[^>]+).+$",
"pkill -f log/z3\n\\1-l 0 | /data/mosquitto_pub -t log/z3 -l &"
)
# noinspection PyAbstractClass
class ShellE1(TelnetShell):
model = "e1"
apatches: list = None
async def login(self):
self.writer.write(b"root\n")
await asyncio.sleep(.1)
self.writer.write(b"\n") # empty password
coro = self.reader.readuntil(b"/ # ")
await asyncio.wait_for(coro, timeout=3)
async def prepare(self):
# change bash end symbol to gw3 style
self.writer.write(b"export PS1='# '\n")
coro = self.reader.readuntil(b"\r\n# ")
await asyncio.wait_for(coro, timeout=3)
await self.exec("stty -echo")
self.apatches = []
async def prevent_unpair(self):
await self.exec("killall mha_master")
async def tar_data(self):
raw = await self.exec(TAR_DATA, as_bytes=True)
return base64.b64decode(raw)
async def get_version(self):
raw1 = await self.exec("agetprop ro.sys.mi_fw_ver")
raw2 = await self.exec("agetprop ro.sys.mi_build_num")
self.ver = f"{raw1.rstrip()}_{raw2.rstrip()}"
async def get_token(self) -> str:
raw = await self.exec(
"agetprop persist.app.miio_dtoken", as_bytes=True
)
return raw.rstrip().hex()
async def get_did(self):
raw = await self.exec("agetprop persist.sys.miio_did")
return raw.rstrip()
async def get_wlan_mac(self):
raw = await self.exec("agetprop persist.sys.miio_mac")
return raw.rstrip().replace(":", "").lower()
async def get_running_ps(self) -> str:
return await self.exec("ps")
async def run_public_mosquitto(self):
await self.exec("killall mosquitto")
await asyncio.sleep(.5)
# mosquitto bind to local IP and local interface, need to fix this
await self.exec(
"cp /bin/mosquitto /tmp; sed 's=127.0.0.1=0000.0.0.0=;s=^lo$= =' -i /tmp/mosquitto; /tmp/mosquitto -d"
)
async def run_ntpd(self):
await self.exec("ntpd -l")
async def run_ftp(self):
await self.exec("busybox tcpsvd -E 0.0.0.0 21 busybox ftpd -w &")
async def check_bin(self, filename: str, md5: str, url=None) -> bool:
"""Check binary md5 and download it if needed."""
if md5 in await self.exec("md5sum /data/" + filename):
return True
elif url:
await self.exec(WGET.format(url, filename))
return await self.check_bin(filename, md5)
else:
return False
async def check_mosquitto_pub(self):
return await self.check_bin(
'mosquitto_pub', MD5_MOSQUITTO_PUB, 'bin/mosquitto_pub'
)
###########################################################################
def patch_miio_mqtt(self):
self.apatches.append(PATCH_MIIO_MQTT)
def patch_zigbee_parents(self):
self.apatches.append(PATCH_ZIGBEE_PARENTS)
###########################################################################
@property
def app_ps(self):
if self.apatches:
return hashlib.md5('\n'.join(self.apatches).encode()).hexdigest()
return '/bin/app_monitor.sh'
async def update_app_monitor(self) -> int:
await self.check_mosquitto_pub()
await self.exec("killall app_monitor.sh")
await self.exec(
"killall miio_client mZ3GatewayHost_MQTT ser2net socat; pkill -f log/z3"
)
if not self.apatches:
await self.exec(f"app_monitor.sh &")
return 0
await self.exec("cp /bin/app_monitor.sh /tmp")
for patch in self.apatches:
await self.exec(patch)
await self.exec(f"/tmp/app_monitor.sh {self.app_ps} &")
return len(self.apatches)
async def apply_patches(self, ps: str) -> int:
n = 0
if self.app_ps not in ps:
n += await self.update_app_monitor()
return n
@@ -0,0 +1,369 @@
import asyncio
import hashlib
import re
from .base import TelnetShell
from ..unqlite import SQLite
# We should use HTTP-link because wget don't support HTTPS and curl removed in
# lastest fw. But it's not a problem because we check md5
# original link http://pkg.musl.cc/socat/mipsel-linux-musln32/bin/socat
# original link https://busybox.net/downloads/binaries/1.21.1/busybox-mipsel
WGET = "(wget http://master.dl.sourceforge.net/project/mgl03/{0}?viasf=1 -O /data/{1} && chmod +x /data/{1})"
CHECK_FIRMWARE = "/data/busybox lsattr /data/firmware/firmware_ota.bin"
LOCK_FIRMWARE = "mkdir -p /data/firmware && touch /data/firmware/firmware_ota.bin && /data/busybox chattr +i /data/firmware/firmware_ota.bin"
UNLOCK_FIRMWARE = "/data/busybox chattr -i /data/firmware/firmware_ota.bin"
RUN_FTP = "/data/busybox tcpsvd -E 0.0.0.0 21 /data/busybox ftpd -w &"
# flash on another ports because running ZHA or z2m can breake process
RUN_ZIGBEE_FLASH = "/data/ser2net -C '8115:raw:60:/dev/ttyS2:115200 8DATABITS NONE 1STOPBIT' -C '8038:raw:60:/dev/ttyS2:38400 8DATABITS NONE 1STOPBIT'"
# c create, z gzip, O stdout, C change DIR
TAR_DATA = "tar -czO /data/miio/mible_local.db* /data/silicon_zigbee_host/*.txt /data/zigbee /data/zigbee_gw 2>/dev/null | base64"
MD5_BT = {
# '1.4.6_0012': '367bf0045d00c28f6bff8d4132b883de',
# '1.4.6_0043': 'c4fa99797438f21d0ae4a6c855b720d2',
'1.4.7_0115': 'be4724fbc5223fcde60aff7f58ffea28',
'1.4.7_0160': '9290241cd9f1892d2ba84074f07391d4',
'1.5.0_0026': '9290241cd9f1892d2ba84074f07391d4',
'1.5.0_0102': '9290241cd9f1892d2ba84074f07391d4',
'1.5.1_0032': '9290241cd9f1892d2ba84074f07391d4',
}
MD5_BUSYBOX = '099137899ece96f311ac5ab554ea6fec'
# MD5_GW3 = 'c81b91816d4b9ad9bb271a5567e36ce9' # alpha
MD5_SER2NET = 'f27a481e54f94ea7613b461bda090b0f'
def sed(app: str, pattern: str, repl: str):
"""sed with extended regex and edit file in-place"""
repl = repl.replace('$', '\$').replace('&', '\&').replace('=', '\='). \
replace('`', '\`').replace('"', '\\"').replace('\n', '\\n')
return f'sed -r "s={pattern}={repl}=" -i /tmp/daemon_{app}.sh'
# grep output to cloud and send it to MQTT, use awk because buffer
PATCH_MIIO_MQTT = sed(
"miio", "^ +miio_client .+$",
"pkill -f log/miio\nmiio_client -l 0 -o FILE_STORE -d $MIIO_PATH -n 128 | awk '/ot_agent_recv_handler_one.+(ble_event|properties_changed|event_occured|heartbeat)|ot_agent_recv_handler_one.+?app_url/{print $0;fflush()}' | mosquitto_pub -t log/miio -l &"
)
# use patched silabs_ncp_bt from sourceforge and send stderr to MQTT
PATCH_BLETOOTH_MQTT = sed(
"miio", "^ +silabs_ncp_bt .+$",
"pkill -f log/ble\n/data/silabs_ncp_bt /dev/ttyS1 $RESTORE 2>&1 >/dev/null | mosquitto_pub -t log/ble -l &"
)
PATCH_ZIGBEE_PARENTS = sed(
"app", "^ +(Lumi_Z3GatewayHost_MQTT [^>]+).+$",
"pkill -f log/z3\n\\1-l 0 | mosquitto_pub -t log/z3 -l &"
)
# replace default Z3 to ser2net
PATCH_ZIGBEE_TCP1 = sed(
"app", "grep Lumi_Z3GatewayHost_MQTT", "grep ser2net"
)
PATCH_ZIGBEE_TCP2 = sed(
"app", "^ +Lumi_Z3GatewayHost_MQTT .+$",
"/data/ser2net -C '8888:raw:60:/dev/ttyS2:38400 8DATABITS NONE 1STOPBIT XONXOFF'"
)
# patch silabs_ncp_bt for storing data in tmp (memory)
PATCH_MEMORY_BLUETOOTH1 = "[ -d /tmp/miio ] || (cp -R /data/miio /tmp && cp /data/silabs_ncp_bt /tmp && sed -r 's=/data/=/tmp//=g' -i /tmp/silabs_ncp_bt)"
PATCH_MEMORY_BLUETOOTH2 = sed(
"miio", "^/data/silabs_ncp_bt", "/tmp/silabs_ncp_bt"
)
# every 5 min sync sqlite DB from memory to NAND if changed
PATCH_MEMORY_BLUETOOTH3 = sed(
"miio", "^\tdo$", """\tdo
if [ ${#N} -eq 60 ]; then
if [ "`md5sum /tmp/miio/mible_local.db|cut -d' ' -f1`" != "`md5sum /data/miio/mible_local.db|cut -d' ' -f1`" ]; then
cp /tmp/miio/mible_local.db /data/miio
echo "`date` bluetooth" >> /var/log/storage_sync.log
fi; N=
fi; N=$N.
"""
)
# move zigbee DB to tmp (memory)
PATCH_MEMORY_ZIGBEE1 = "[ -d /tmp/zigbee_gw ] || cp -R /data/zigbee_gw /tmp"
PATCH_MEMORY_ZIGBEE2 = sed(
"app", "^ +zigbee_gw", "zigbee_gw -s /tmp/zigbee_gw/"
)
# every 5 min sync zigbee DB if device list changed
PATCH_MEMORY_ZIGBEE3 = sed(
"app", "^\tdo$", """\tdo
if [ ${#N} -eq 60 ]; then
if [ "`md5sum /tmp/zigbee_gw/device_properties.json|cut -d' ' -f1`" != "`md5sum /data/zigbee_gw/device_properties.json|cut -d' ' -f1`" ]; then
cp /tmp/zigbee_gw/device_properties.json /data/zigbee_gw
false | cp -i /tmp/zigbee_gw/*.json /data/zigbee_gw/ 2>/dev/null
echo "`date` zigbee" >> /var/log/storage_sync.log
fi; N=
fi; N=$N.
"""
)
# just for statistics
SAVE_SERIAL_STATS = "[ -f /tmp/serial ] || cp /proc/tty/driver/serial /tmp"
# don't run bt utility in all cases
# if [ ! -e /tmp/bt_dont_need_startup ]; then
PATCH_DISABLE_BLUETOOTH = sed(
"miio", "^ +if.+bt_dont_need_startup.+$", "if false; then"
)
# patch basic_gw file to not beeps with 5 sec Motion Sensors
PATCH_DISABLE_BUZZER1 = "[ -f /tmp/basic_gw ] || (cp /bin/basic_gw /tmp && sed -r 's=dev_query=xxx_query=' -i /tmp/basic_gw)"
# use patched binary instead of original
PATCH_DISABLE_BUZZER2 = sed("miio", "^ +basic_gw", "/tmp/basic_gw")
# compare md5sum of two files and copy if not equal
SYNC_MEMORY_FILE = """[ "`md5sum /tmp/{0}|cut -d' ' -f1`" != "`md5sum /data/{0}|cut -d' ' -f1`" ] && cp /tmp/{0} /data/{0}"""
# `ls -1t` - sort files by change time, `sed q` - leave only first row
DB_BLUETOOTH = "`ls -1t /data/miio/mible_local.db /tmp/miio/mible_local.db 2>/dev/null | sed q`"
# `sed...` - remove filename and adds "*.json" on its place
DB_ZIGBEE = "`ls -1t /data/zigbee_gw/* /tmp/zigbee_gw/* 2>/dev/null | sed -r 's/[^/]+$/*.json/;q'`"
# limited partial support on old firmwares
MIIO2MQTT_FW146 = "miio_client -l 4 -d /data/miio | awk '/ot_agent_recv_handler_one.+(ble_event|properties_changed|heartbeat)/{print $0;fflush()}' | mosquitto_pub -t log/miio -l &"
class ShellGw3(TelnetShell):
model = "gw3"
apatches: list = None
mpatches: list = None
db: SQLite = None
async def login(self):
self.writer.write(b"admin\n")
coro = self.reader.readuntil(b"\r\n# ")
raw = await asyncio.wait_for(coro, timeout=3)
if b"Password:" in raw:
raise Exception("Telnet with password don't supported")
async def prepare(self):
await self.exec("stty -echo")
self.apatches = []
self.mpatches = []
async def get_running_ps(self) -> str:
return await self.exec("ps -ww | grep -v ' 0 SW'")
async def run_public_mosquitto(self):
await self.exec("killall mosquitto")
await asyncio.sleep(.5)
await self.exec("mosquitto -d")
await asyncio.sleep(.5)
# fix CPU 90% full time bug
await self.exec("killall zigbee_gw")
async def run_ntpd(self):
await self.exec("ntpd -l")
async def run_ftp(self):
if await self.check_bin('busybox', MD5_BUSYBOX, 'bin/busybox'):
await self.exec(RUN_FTP)
async def run_zigbee_flash(self) -> bool:
if not await self.check_zigbee_tcp():
return False
await self.exec("killall daemon_app.sh")
await self.exec("killall Lumi_Z3GatewayHost_MQTT ser2net socat")
await self.exec(RUN_ZIGBEE_FLASH)
return True
async def check_bin(self, filename: str, md5: str, url=None) -> bool:
"""Check binary md5 and if it is executable and download it if needed.
"""
cmd = f"[ -x /data/{filename} ] && md5sum /data/{filename}"
if md5 in await self.exec(cmd):
return True
elif url:
# files from sourceforge.net can take up to 3 minutes to download
# for Chinese users
await self.exec(WGET.format(url, filename), timeout=300)
return await self.check_bin(filename, md5)
else:
return False
async def check_bt(self) -> bool:
md5 = MD5_BT.get(self.ver)
if not md5:
return False
# we use same name for bt utis so gw can kill it in case of update etc.
return await self.check_bin(
'silabs_ncp_bt', md5, md5 + '/silabs_ncp_bt'
)
async def check_zigbee_tcp(self):
return await self.check_bin('ser2net', MD5_SER2NET, 'bin/ser2net')
async def check_firmware_lock(self) -> bool:
"""Check if firmware update locked. And create empty file if needed."""
resp = await self.exec(CHECK_FIRMWARE)
return '-i-' in resp
async def lock_firmware(self, enable: bool):
if await self.check_bin('busybox', MD5_BUSYBOX, 'bin/busybox'):
command = LOCK_FIRMWARE if enable else UNLOCK_FIRMWARE
await self.exec(command)
async def memory_sync(self):
# get exists file list from /tmp
resp = await self.exec(
"ls -1 /tmp/miio/mible_local.db /tmp/zigbee_gw/* 2>/dev/null"
)
for file in resp.split('\r\n'):
# sync file if md5sum not equal
command = SYNC_MEMORY_FILE.format(file[5:])
await self.exec(command)
async def prevent_unpair(self):
await self.exec("killall zigbee_gw")
async def tar_data(self) -> str:
raw = await self.exec(TAR_DATA)
return raw.replace("\r\n", "")
async def get_version(self):
raw = await self.read_file('/etc/rootfs_fw_info')
m = re.search(r'version=([0-9._]+)', raw.decode())
self.ver = m[1]
async def get_token(self) -> str:
raw = await self.read_file('/data/miio/device.token')
return raw.rstrip().hex()
async def get_did(self):
raw = await self.read_file('/data/miio/device.conf')
m = re.search(r'did=(\d+)', raw.decode())
return m[1]
async def get_wlan_mac(self) -> str:
raw = await self.read_file('/sys/class/net/wlan0/address')
return raw.decode().rstrip().replace(":", "")
async def read_db_bluetooth(self) -> SQLite:
if not self.db:
raw = await self.read_file(DB_BLUETOOTH, as_base64=True)
self.db = SQLite(raw)
return self.db
@property
def mesh_group_table(self) -> str:
if self.ver >= '1.4.7_0160':
return 'mesh_group_v3'
elif self.ver >= '1.4.6_0043':
return 'mesh_group_v1'
else:
return 'mesh_group'
@property
def mesh_device_table(self) -> str:
return 'mesh_device_v3' if self.ver >= '1.4.7_0160' else 'mesh_device'
###########################################################################
async def patch_miio_mqtt_fw146(self, ps: str):
assert self.ver < "1.4.7_0000", self.ver
if "-t log/miio" in ps:
return
await self.exec("killall daemon_miio.sh")
await self.exec("killall miio_client; pkill -f log/miio")
await asyncio.sleep(.5)
await self.exec(MIIO2MQTT_FW146)
await self.exec("daemon_miio.sh &")
###########################################################################
def patch_miio_mqtt(self):
self.mpatches.append(PATCH_MIIO_MQTT)
def patch_disable_buzzer(self):
self.mpatches += [PATCH_DISABLE_BUZZER1, PATCH_DISABLE_BUZZER2]
def patch_memory_zigbee(self):
self.apatches += [
PATCH_MEMORY_ZIGBEE1, PATCH_MEMORY_ZIGBEE2, PATCH_MEMORY_ZIGBEE3
]
def patch_zigbee_tcp(self):
self.apatches += [PATCH_ZIGBEE_TCP1, PATCH_ZIGBEE_TCP2]
def patch_bluetooth_mqtt(self):
self.mpatches.append(PATCH_BLETOOTH_MQTT)
def patch_memory_bluetooth(self):
self.mpatches += [PATCH_MEMORY_BLUETOOTH1, PATCH_MEMORY_BLUETOOTH2]
def patch_disable_bluetooth(self):
self.mpatches.append(PATCH_DISABLE_BLUETOOTH)
def patch_zigbee_parents(self):
self.apatches.append(PATCH_ZIGBEE_PARENTS)
###########################################################################
@property
def app_ps(self):
if self.apatches:
return hashlib.md5('\n'.join(self.apatches).encode()).hexdigest()
return '/bin/daemon_app.sh'
async def update_daemon_app(self) -> int:
await self.exec("killall daemon_app.sh")
await self.exec(
"killall Lumi_Z3GatewayHost_MQTT ser2net socat zigbee_gw; pkill -f log/z3"
)
if not self.apatches:
await self.exec(f"daemon_app.sh &")
return 0
await self.exec("cp /bin/daemon_app.sh /tmp")
for patch in self.apatches:
await self.exec(patch)
await self.exec(f"/tmp/daemon_app.sh {self.app_ps} &")
return len(self.apatches)
@property
def miio_ps(self):
if self.mpatches:
return hashlib.md5('\n'.join(self.mpatches).encode()).hexdigest()
return '/bin/daemon_miio.sh'
async def update_daemon_miio(self) -> int:
"""Run default daemon_miio if no patches. Or run patched daemon_miio
with patches hash in process list.
"""
await self.exec("killall daemon_miio.sh")
await self.exec(
"killall miio_client silabs_ncp_bt; killall -9 basic_gw; pkill -f 'log/ble|log/miio'"
)
if not self.mpatches:
await self.exec(f"daemon_miio.sh &")
return 0
await self.exec("cp /bin/daemon_miio.sh /tmp")
for patch in self.mpatches:
await self.exec(patch)
await self.exec(f"/tmp/daemon_miio.sh {self.miio_ps} &")
return len(self.mpatches)
async def apply_patches(self, ps: str) -> int:
n = 0
if self.app_ps not in ps:
n += await self.update_daemon_app()
if self.miio_ps not in ps:
n += await self.update_daemon_miio()
return n
@@ -0,0 +1,153 @@
import asyncio
import base64
import hashlib
from .base import TelnetShell
WGET = "(wget http://master.dl.sourceforge.net/project/aqcn02/{0}?viasf=1 -O /data/{1} && chmod +x /data/{1})"
TAR_DATA = "tar -czOC /data mha_master miio storage zigbee devices.txt gatewayInfoJson.info 2>/dev/null | base64"
MD5_MOSQUITTO_PUB = '7c3883281750e00f717d35d6bdf2d913'
def sed(pattern: str, repl: str):
"""sed with extended regex and edit file in-place"""
repl = repl.replace('$', '\$').replace('&', '\&').replace('=', '\='). \
replace('`', '\`').replace('"', '\\"').replace('\n', '\\n')
return f'sed -r "s={pattern}={repl}=" -i /tmp/app_monitor.sh'
PATCH_MIIO_MQTT = sed(
"^ +miio_client -.+$",
"pkill -f log/miio\nmiio_client -l0 -d /data/miio/ -n128 -o DISABLE_PSM -D | awk '/ot_agent_recv_handler_one.+(properties_changed|heartbeat)|record_offline/{print $0;fflush()}' | /data/mosquitto_pub -t log/miio -l &"
)
PATCH_ZIGBEE_PARENTS = sed(
"^ +(mZ3GatewayHost_MQTT -[^>]+).+$",
"pkill -f log/z3\n\\1-l 0 | /data/mosquitto_pub -t log/z3 -l &"
)
# noinspection PyAbstractClass
class ShellHubV2(TelnetShell):
model = "hubv2"
apatches: list = None
async def login(self):
self.writer.write(b"root\n")
await asyncio.sleep(.1)
self.writer.write(b"\n") # empty password
coro = self.reader.readuntil(b"/ # ")
await asyncio.wait_for(coro, timeout=3)
async def prepare(self):
# change bash end symbol to gw3 style
self.writer.write(b"export PS1='# '\n")
coro = self.reader.readuntil(b"\r\n# ")
await asyncio.wait_for(coro, timeout=3)
await self.exec("stty -echo")
self.apatches = []
async def prevent_unpair(self):
await self.exec("killall mha_master")
async def tar_data(self):
raw = await self.exec(TAR_DATA, as_bytes=True)
return base64.b64decode(raw)
async def get_version(self):
raw1 = await self.exec("agetprop ro.sys.mi_fw_ver")
raw2 = await self.exec("agetprop ro.sys.mi_build_num")
self.ver = f"{raw1.rstrip()}_{raw2.rstrip()}"
async def get_token(self) -> str:
raw = await self.exec(
"agetprop persist.app.miio_dtoken", as_bytes=True
)
return raw.rstrip().hex()
async def get_did(self):
raw = await self.exec("agetprop persist.sys.miio_did")
return raw.rstrip()
async def get_wlan_mac(self):
raw = await self.exec("agetprop persist.sys.miio_mac")
return raw.rstrip().replace(":", "").lower()
async def get_running_ps(self) -> str:
return await self.exec("ps")
async def run_public_mosquitto(self):
await self.exec("killall mosquitto")
await asyncio.sleep(.5)
# mosquitto bind to local IP and local interface, need to fix this
await self.exec(
"cp /bin/mosquitto /tmp; sed 's=127.0.0.1=0000.0.0.0=;s=^lo$= =' -i /tmp/mosquitto; /tmp/mosquitto -d"
)
async def run_ntpd(self):
await self.exec("ntpd -l")
async def run_ftp(self):
await self.exec("busybox tcpsvd -E 0.0.0.0 21 busybox ftpd -w &")
async def check_bin(self, filename: str, md5: str, url=None) -> bool:
"""Check binary md5 and download it if needed."""
if md5 in await self.exec("md5sum /data/" + filename):
return True
elif url:
await self.exec(WGET.format(url, filename))
return await self.check_bin(filename, md5)
else:
return False
async def check_mosquitto_pub(self):
return await self.check_bin(
'mosquitto_pub', MD5_MOSQUITTO_PUB, 'bin/mosquitto_pub'
)
###########################################################################
def patch_miio_mqtt(self):
self.apatches.append(PATCH_MIIO_MQTT)
def patch_zigbee_parents(self):
self.apatches.append(PATCH_ZIGBEE_PARENTS)
###########################################################################
@property
def app_ps(self):
if self.apatches:
return hashlib.md5('\n'.join(self.apatches).encode()).hexdigest()
return '/bin/app_monitor.sh'
async def update_app_monitor(self) -> int:
await self.check_mosquitto_pub()
await self.exec("killall app_monitor.sh")
await self.exec(
"killall miio_client mZ3GatewayHost_MQTT ser2net socat; pkill -f log/z3"
)
if not self.apatches:
await self.exec(f"app_monitor.sh &")
return 0
await self.exec("cp /bin/app_monitor.sh /tmp")
for patch in self.apatches:
await self.exec(patch)
await self.exec(f"/tmp/app_monitor.sh {self.app_ps} &")
return len(self.apatches)
async def apply_patches(self, ps: str) -> int:
n = 0
if self.app_ps not in ps:
n += await self.update_app_monitor()
return n
@@ -0,0 +1,205 @@
"""Two classes for read Unqlite and SQLite DB files frow raw bytes. Default
python sqlite3 library can't read DB from memory.
"""
# noinspection PyUnusedLocal
class Unqlite:
page_size = 0
pos = 0
def __init__(self, raw: bytes):
self.raw = raw
self.read_db_header()
@property
def size(self):
return len(self.raw)
def read(self, length: int):
self.pos += length
return self.raw[self.pos - length:self.pos]
def read_int(self, length: int):
return int.from_bytes(self.read(length), 'big')
def read_db_header(self):
assert self.read(7) == b'unqlite', "Wrong file signature"
assert self.read(4) == b'\xDB\x7C\x27\x12', "Wrong DB magic"
creation_time = self.read_int(4)
sector_size = self.read_int(4)
self.page_size = self.read_int(4)
assert self.read(6) == b'\x00\x04hash', "Unsupported hash"
# def read_header2(self):
# self.pos = self.page_size
# magic_numb = self.read(4)
# hash_func = self.read(4)
# free_pages = self.read_int(8)
# split_bucket = self.read_int(8)
# max_split_bucket = self.read_int(8)
# next_page = self.read_int(8)
# num_rect = self.read_int(4)
# for _ in range(num_rect):
# logic_page = self.read_int(8)
# real_page = self.read_int(8)
def read_cell(self):
key_hash = self.read(4)
key_len = self.read_int(4)
data_len = self.read_int(8)
next_offset = self.read_int(2)
overflow_page = self.read_int(8)
if overflow_page:
self.pos = overflow_page * 0x1000 + 8
data_page = self.read_int(8)
data_offset = self.read_int(2)
name = self.read(key_len)
self.pos = data_page * 0x1000 + data_offset
value = self.read(data_len)
else:
name = self.read(key_len)
value = self.read(data_len)
return name, value, next_offset
def read_all(self) -> dict:
result = {}
page_offset = 2 * self.page_size
while page_offset < self.size:
self.pos = page_offset
next_offset = self.read_int(2)
while next_offset:
self.pos = page_offset + next_offset
k, v, next_offset = self.read_cell()
# data sometimes corrupted: b'lumi.158d0004\xb4f9abb.prop'
result[k.decode(errors='replace')] = v.decode(errors='replace')
page_offset += self.page_size
return result
# noinspection PyUnusedLocal
class SQLite:
page_size = 0
pos = 0
def __init__(self, raw: bytes):
self.raw = raw
self.read_db_header()
self.tables = self.read_page(0)
@property
def size(self):
return len(self.raw)
def read(self, length: int):
self.pos += length
return self.raw[self.pos - length:self.pos]
def read_int(self, length: int):
return int.from_bytes(self.read(length), 'big')
def read_varint(self):
result = 0
while True:
i = self.read_int(1)
result += i & 0x7f
if i < 0x80:
break
result <<= 7
return result
def read_db_header(self):
assert self.read(16) == b'SQLite format 3\0', "Wrong file signature"
self.page_size = self.read_int(2)
def read_page(self, page_num: int):
self.pos = 100 if page_num == 0 else self.page_size * page_num
# B-tree Page Header Format
page_type = self.read(1)
if page_type == b'\x0D':
return self._read_leaf_table(page_num)
elif page_type == b'\x05':
return self._read_interior_table(page_num)
else:
raise NotImplemented
def _read_leaf_table(self, page_num: int):
first_block = self.read_int(2)
cells_num = self.read_int(2)
cells_pos = self.read_int(2)
fragmented_free_bytes = self.read_int(1)
cells_pos = [self.read_int(2) for _ in range(cells_num)]
rows = []
for cell_pos in cells_pos:
self.pos = self.page_size * page_num + cell_pos
payload_len = self.read_varint()
rowid = self.read_varint()
columns_type = []
payload_pos = self.pos
header_size = self.read_varint()
while self.pos < payload_pos + header_size:
column_type = self.read_varint()
columns_type.append(column_type)
cells = []
for column_type in columns_type:
if column_type == 0:
data = rowid
elif 1 <= column_type <= 4:
data = self.read_int(column_type)
elif column_type == 5:
data = self.read_int(6)
elif column_type == 6:
data = self.read_int(8)
elif column_type == 7:
# TODO: float
data = self.read(8)
elif column_type == 8:
data = 0
elif column_type == 9:
data = 1
elif column_type >= 12 and column_type % 2 == 0:
length = int((column_type - 12) / 2)
data = self.read(length)
else:
length = int((column_type - 13) / 2)
data = self.read(length).decode()
cells.append(data)
rows.append(cells)
return rows
def _read_interior_table(self, page_num: int):
first_block = self.read_int(2)
cells_num = self.read_int(2)
cells_pos = self.read_int(2)
fragmented_free_bytes = self.read_int(1)
last_page_num = self.read_int(4)
cells_pos = [self.read_int(2) for _ in range(cells_num)]
rows = []
for cell_pos in cells_pos:
self.pos = self.page_size * page_num + cell_pos
child_page_num = self.read_int(4)
rowid = self.read_varint()
rows += self.read_page(child_page_num - 1)
return rows + self.read_page(last_page_num - 1)
def read_table(self, name: str):
page = next(t[3] - 1 for t in self.tables if t[1] == name)
return self.read_page(page)
@@ -0,0 +1,377 @@
import asyncio
import json
import logging
import random
import string
from typing import Optional
import requests
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.core import callback
from homeassistant.helpers import (
device_registry as dr, entity_registry as er
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import Store
from homeassistant.requirements import async_process_requirements
from . import shell
from .const import DOMAIN
from .converters import STAT_GLOBALS
from .device import XDevice
from .ezsp import EzspUtils
from .gateway import XGateway
from .gateway.lumi import LumiGateway
from .mini_miio import AsyncMiIO
from .xiaomi_cloud import MiCloud
SUPPORTED_MODELS = (
'lumi.gateway.mgl03', 'lumi.gateway.aqcn02', 'lumi.gateway.aqcn03', 'lumi.gateway.mcn001'
)
_LOGGER = logging.getLogger(__name__)
@callback
def remove_device(hass: HomeAssistant, device: XDevice):
"""Remove device by did from Hass"""
registry = dr.async_get(hass)
device = registry.async_get_device({(DOMAIN, device.unique_id)}, None)
if device:
registry.async_remove_device(device.id)
@callback
def remove_stats(hass: HomeAssistant, entry_id: str):
suffix = tuple(STAT_GLOBALS.keys())
registry = er.async_get(hass)
remove = [
entity.entity_id
for entity in list(registry.entities.values())
if (entity.config_entry_id == entry_id and
entity.unique_id.endswith(suffix))
]
for entity_id in remove:
registry.async_remove(entity_id)
async def remove_zigbee(unique_id: str):
try:
device: XDevice = next(
d for d in XGateway.devices.values() if d.unique_id == unique_id
)
except StopIteration:
return
# delete device from all gateways
for gw in device.gateways:
payload = gw.device.encode({"remove_did": device.did})
if isinstance(gw, LumiGateway):
await gw.lumi_send(gw.device, payload)
@callback
def update_device_info(hass: HomeAssistant, did: str, **kwargs):
# lumi.1234567890 => 0x1234567890
mac = '0x' + did[5:]
registry = dr.async_get(hass)
device = registry.async_get_device({('xiaomi_gateway3', mac)}, None)
if device:
registry.async_update_device(device.id, **kwargs)
async def load_devices(hass: HomeAssistant, yaml_devices: dict):
# 1. Load devices settings from YAML
if yaml_devices:
for k, v in yaml_devices.items():
# AA:BB:CC:DD:EE:FF => aabbccddeeff
k = k.replace(':', '').lower()
XGateway.defaults[k] = v
# 2. Load unique_id from entity registry (backward support old format)
registry = er.async_get(hass)
for entity in list(registry.entities.values()):
if entity.platform != DOMAIN:
continue
# split mac and attr in unique id
legacy_id, attr = entity.unique_id.split("_", 1)
if legacy_id.startswith("0x"):
# add leading zeroes to zigbee mac
mac = f"0x{legacy_id[2:]:>016s}"
elif len(legacy_id) == 12:
# make mac lowercase (old Mesh devices)
mac = legacy_id.lower()
else:
mac = legacy_id
device = XGateway.defaults.setdefault(mac, {})
device.setdefault("unique_id", legacy_id)
device.setdefault("restore_entities", []).append(attr)
# 3. Load devices data from .storage
store = Store(hass, 1, f"{DOMAIN}/devices.json")
devices = await store.async_load()
if devices:
for k, v in devices.items():
XGateway.defaults.setdefault(k, {}).update(v)
# noinspection PyUnusedLocal
async def stop(*args):
# save devices data to .storage
data = {
d.mac: {"decode_ts": d.decode_ts}
for d in XGateway.devices.values()
if d.decode_ts
}
await store.async_save(data)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop)
def migrate_options(data):
data = dict(data)
options = {k: data.pop(k) for k in ('ble', 'zha') if k in data}
return {'data': data, 'options': options}
async def check_gateway(host: str, token: str, telnet_cmd: Optional[str]) \
-> Optional[str]:
# 1. try connect with telnet (custom firmware)?
try:
async with shell.Session(host) as session:
sh = await session.login()
if sh.model:
# 1.1. check token with telnet
return None if await sh.get_token() == token else 'wrong_token'
except Exception:
pass
if not telnet_cmd:
return 'cant_connect'
# 2. try connect with miio
miio = AsyncMiIO(host, token)
info = await miio.info()
# if info is None - devise doesn't answer on pings
if info is None:
return 'cant_connect'
# if empty info - device works but not answer on commands
if not info:
return 'wrong_token'
# 3. check if right model
if info['model'] not in SUPPORTED_MODELS:
return 'wrong_model'
raw = json.loads(telnet_cmd)
# fw 1.4.6_0043+ won't answer on cmd without cloud, don't check answer
await miio.send(raw['method'], raw.get('params'))
# waiting for telnet to start
await asyncio.sleep(1)
try:
async with shell.Session(host) as session:
sh = await session.login()
if not sh.model:
return 'wrong_telnet'
except Exception:
return None
async def get_lan_key(host: str, token: str):
device = AsyncMiIO(host, token)
resp = await device.send('get_lumi_dpf_aes_key')
if not resp:
return "Can't connect to gateway"
if 'result' not in resp:
return f"Wrong response: {resp}"
resp = resp['result']
if len(resp[0]) == 16:
return resp[0]
key = ''.join(random.choice(string.ascii_lowercase + string.digits)
for _ in range(16))
resp = await device.send('set_lumi_dpf_aes_key', [key])
if resp.get('result') == ['ok']:
return key
return "Can't update gateway key"
async def get_room_mapping(cloud: MiCloud, host: str, token: str):
try:
device = AsyncMiIO(host, token)
local_rooms = await device.send('get_room_mapping')
cloud_rooms = await cloud.get_rooms()
result = ''
for local_id, cloud_id in local_rooms['result']:
cloud_name = next(
(p['name'] for p in cloud_rooms if p['id'] == cloud_id), '-'
)
result += f"\n- {local_id}: {cloud_name}"
return result
except Exception:
return "Can't get from cloud"
async def get_bindkey(cloud: MiCloud, did: str):
bindkey = await cloud.get_bindkey(did)
if bindkey is None:
return "Can't get from cloud"
if bindkey.endswith('FFFFFFFF'):
return "Not needed"
return bindkey
async def enable_bslamp2_lan(host: str, token: str):
device = AsyncMiIO(host, token)
resp = await device.send("get_prop", ["lan_ctrl"])
if not resp:
return "Can't connect to lamp"
if resp.get("result") == ["1"]:
return "Already enabled"
resp = await device.send("set_ps", ["cfg_lan_ctrl", "1"])
if resp.get("result") == ["ok"]:
return "Enabled"
return "Can't enable LAN"
NCP_URL = "https://master.dl.sourceforge.net/project/mgl03/zigbee/%s?viasf=1"
def flash_zigbee_firmware(host: str, ports: list, fw_url: str, fw_ver: str,
fw_port=0, force=False):
"""
param host: gateway host
param ports: one or multiple ports with different speeds, first port
should be 115200
param fw_url: url to firmware file
param fw_ver: firmware version, checks before and after flash
param fw_port: optional, port with firmware speed if it is not 115200
param second_port: optional, second port if current firmware may have
different speed
param force: skip check firmware version before flash
return: True if NCP firmware version equal to fw_ver
"""
# we can flash NCP only in boot mode on speed 115200
# but NCP can work on another speed, so we need to try both of them
# work with 115200 on port 8115, and with 38400 on port 8038
_LOGGER.debug(f"Try to update Zigbee NCP to version {fw_ver}")
if isinstance(ports, int):
ports = [ports]
utils = EzspUtils()
try:
# try to find right speed from the list
for port in ports:
utils.connect(host, port)
state = utils.state()
if state:
break
utils.close()
else:
raise RuntimeError
if state == "normal":
if fw_ver in utils.version and not force:
_LOGGER.debug("No need to flash")
return True
_LOGGER.debug(f"NCP state: {state}, version: {utils.version}")
utils.launch_boot()
state = utils.state()
_LOGGER.debug(f"NCP state: {state}, version: {utils.version}")
# should be in boot
if state != "boot":
return False
r = requests.get(fw_url)
assert r.status_code == 200, r.status_code
assert utils.flash_and_close(r.content)
utils.connect(host, ports[0])
utils.reboot_and_close()
utils.connect(host, fw_port or ports[0])
state = utils.state()
_LOGGER.debug(f"NCP state: {state}, version: {utils.version}")
return fw_ver in utils.version
except Exception as e:
_LOGGER.debug(f"NCP flash error", exc_info=e)
return False
finally:
utils.close()
async def update_zigbee_firmware(hass: HomeAssistant, host: str, custom: bool):
"""Update zigbee firmware for both ZHA and zigbee2mqtt modes"""
await async_process_requirements(hass, DOMAIN, ['xmodem==0.4.6'])
try:
async with shell.Session(host) as session:
sh = await session.login()
assert await sh.run_zigbee_flash()
except Exception as e:
_LOGGER.error("Can't update zigbee firmware", exc_info=e)
return False
await asyncio.sleep(.5)
args = [
host, [8115, 8038], NCP_URL % 'mgl03_ncp_6_7_10_b38400_sw.gbl',
'v6.7.10', 8038
] if custom else [
host, [8115, 8038], NCP_URL % 'ncp-uart-sw_mgl03_6_6_2_stock.gbl',
'v6.6.2', 8115
]
for _ in range(3):
if await hass.async_add_executor_job(flash_zigbee_firmware, *args):
return True
return False
async def get_ota_link(hass: HomeAssistant, device: "XDevice"):
url = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/"
# Xiaomi Plug should be updated to fw 30 before updating to latest fw
if device.model == 'lumi.plug' and 0 < device.fw_ver < 30:
# waiting pull request https://github.com/Koenkk/zigbee-OTA/pull/49
return url.replace('Koenkk', 'AlexxIT') + \
'images/Xiaomi/LM15_SP_mi_V1.3.30_20170929_v30_withCRC.20180514181348.ota'
r = await async_get_clientsession(hass).get(url + "index.json")
items = await r.json(content_type=None)
for item in items:
if item.get('modelId') == device.model:
return url + item['path']
return None
async def run_zigbee_ota(
hass: HomeAssistant, gateway: "XGateway", device: "XDevice"
) -> Optional[bool]:
url = await get_ota_link(hass, device)
if url:
gateway.debug_device(device, "update", url)
resp = await gateway.miio.send('miIO.subdev_ota', {
'did': device.did,
'subdev_url': url
})
if not resp or resp.get('result') != ['ok']:
_LOGGER.error(f"Can't run update process: {resp}")
return None
return True
else:
return False
@@ -0,0 +1,278 @@
"""
The base logic was taken from project https://github.com/squachen/micloud
I had to rewrite the code to work asynchronously and handle timeouts for
requests to the cloud.
MIT License
Copyright (c) 2020 Sammy Svensson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import asyncio
import base64
import hashlib
import json
import logging
import os
import random
import string
import time
from aiohttp import ClientSession
_LOGGER = logging.getLogger(__name__)
SERVERS = ['cn', 'de', 'i2', 'ru', 'sg', 'us']
UA = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-%s APP/xiaomi.smarthome APPV/62830"
class MiCloud:
auth = None
def __init__(self, session: ClientSession, servers: list = None):
self.session = session
self.servers = servers or ['cn']
self.device_id = get_random_string(16)
async def login(self, username: str, password: str):
try:
payload = await self._login_step1()
data = await self._login_step2(username, password, payload)
if not data['location']:
return False
token = await self._login_step3(data['location'])
self.auth = {
'user_id': data['userId'],
'ssecurity': data['ssecurity'],
'service_token': token
}
return True
except Exception as e:
_LOGGER.exception(f"Can't login to Mi Cloud: {e}")
return False
async def _login_step1(self):
r = await self.session.get(
'https://account.xiaomi.com/pass/serviceLogin',
cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id},
headers={'User-Agent': UA % self.device_id},
params={'sid': 'xiaomiio', '_json': 'true'})
raw = await r.read()
_LOGGER.debug(f"MiCloud step1")
resp: dict = json.loads(raw[11:])
return {k: v for k, v in resp.items()
if k in ('sid', 'qs', 'callback', '_sign')}
async def _login_step2(self, username: str, password: str, payload: dict):
payload['user'] = username
payload['hash'] = hashlib.md5(password.encode()).hexdigest().upper()
r = await self.session.post(
'https://account.xiaomi.com/pass/serviceLoginAuth2',
cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id},
data=payload,
headers={'User-Agent': UA % self.device_id},
params={'_json': 'true'})
raw = await r.read()
_LOGGER.debug(f"MiCloud step2")
resp = json.loads(raw[11:])
return resp
async def _login_step3(self, location):
r = await self.session.get(location, headers={'User-Agent': UA})
service_token = r.cookies['serviceToken'].value
_LOGGER.debug(f"MiCloud step3")
return service_token
async def get_devices(self):
payload = {
"getVirtualModel": True,
"getHuamiDevices": 1,
"get_split_device": False,
"support_smart_home": True
}
total = []
for server in self.servers:
resp = await self.request(
server, '/v2/home/device_list_page', payload
)
if resp is None:
return None
total += resp['list']
return total
async def get_rooms(self):
payload = {'fg': True, 'fetch_share': True, 'limit': 300}
total = []
for server in self.servers:
resp = await self.request(server, '/v2/homeroom/gethome', payload)
if resp is None:
return None
for home in resp['homelist']:
total += home['roomlist']
return total
async def get_bindkey(self, did: str):
payload = {'did': did, 'pdid': 1}
for server in self.servers:
resp = await self.request(server, '/v2/device/blt_get_beaconkey',
payload)
if resp:
return resp['beaconkey']
return None
async def request(self, server: str, url: str, payload: dict):
assert server in SERVERS, "Wrong server: " + server
baseurl = 'https://api.io.mi.com/app' if server == 'cn' \
else f"https://{server}.api.io.mi.com/app"
data = json.dumps(payload, separators=(',', ':'))
nonce = gen_nonce()
signed_nonce = gen_signed_nonce(self.auth['ssecurity'], nonce)
try:
params = {
'data': data
}
params['rc4_hash__'] = gen_signature(url, signed_nonce, params)
params = {
k: encrypt_rc4(signed_nonce, v) for (k, v) in params.items()
}
params.update({
'signature': gen_signature(url, signed_nonce, params),
'ssecurity': self.auth['ssecurity'],
'_nonce': nonce
})
r = await self.session.post(baseurl + url, cookies={
'userId': self.auth['user_id'],
'serviceToken': self.auth['service_token'],
'locale': 'en_US'
}, headers={
'Accept-Encoding': 'identity',
'User-Agent': UA,
'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2',
'MIOT-ENCRYPT-ALGORITHM': 'ENCRYPT-RC4'
}, data=params, timeout=10)
resp = await r.text()
resp = json.loads(decrypt_rc4(signed_nonce, resp))
# _LOGGER.debug(f"Response from MIoT API {url}: {resp}")
assert resp['code'] == 0, resp
return resp['result']
except asyncio.TimeoutError:
_LOGGER.error(f"Timeout while requesting MIoT api {url}")
except Exception as e:
_LOGGER.error(f"Can't request MIoT API {url}", exc_info=e)
return None
class RC4:
_idx = 0
_jdx = 0
_ksa: list
def __init__(self, pwd):
self.init_key(pwd)
def init_key(self, pwd):
cnt = len(pwd)
ksa = list(range(256))
j = 0
for i in range(256):
j = (j + ksa[i] + pwd[i % cnt]) & 255
ksa[i], ksa[j] = ksa[j], ksa[i]
self._ksa = ksa
self._idx = 0
self._jdx = 0
return self
def crypt(self, data):
if isinstance(data, str):
data = data.encode()
ksa = self._ksa
i = self._idx
j = self._jdx
out = []
for byt in data:
i = (i + 1) & 255
j = (j + ksa[i]) & 255
ksa[i], ksa[j] = ksa[j], ksa[i]
out.append(byt ^ ksa[(ksa[i] + ksa[j]) & 255])
self._idx = i
self._jdx = j
self._ksa = ksa
return bytearray(out)
def init1024(self):
self.crypt(bytes(1024))
return self
def get_random_string(length: int):
seq = string.ascii_uppercase + string.digits
return ''.join((random.choice(seq) for _ in range(length)))
def gen_nonce() -> str:
"""Time based nonce."""
nonce = os.urandom(8) + int(time.time() / 60).to_bytes(4, 'big')
return base64.b64encode(nonce).decode()
def gen_signed_nonce(ssecret: str, nonce: str) -> str:
"""Nonce signed with ssecret."""
m = hashlib.sha256()
m.update(base64.b64decode(ssecret))
m.update(base64.b64decode(nonce))
return base64.b64encode(m.digest()).decode()
def gen_signature(url: str, signed_nonce: str, data: dict) -> str:
"""Request signature based on url, signed_nonce, nonce and data."""
sign = '&'.join(
(
['POST', url]
+ [f"{k}={v}" for k, v in data.items()]
+ [signed_nonce]
)
)
signature = hashlib.sha1(sign.encode()).digest()
return base64.b64encode(signature).decode()
def encrypt_rc4(pwd, data):
return base64.b64encode(
RC4(base64.b64decode(pwd.encode())).init1024().crypt(data.encode())
).decode()
def decrypt_rc4(pwd, data):
return RC4(base64.b64decode(pwd.encode())).init1024(). \
crypt(base64.b64decode(data))
@@ -0,0 +1,62 @@
from homeassistant.components.cover import CoverEntity, ATTR_POSITION, \
ATTR_CURRENT_POSITION
from homeassistant.const import STATE_CLOSING, STATE_OPENING
from homeassistant.core import callback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN
from .core.converters import Converter
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
else:
entity = XiaomiCover(gateway, device, conv)
async_add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
# noinspection PyAbstractClass
class XiaomiCover(XEntity, CoverEntity, RestoreEntity):
_attr_current_cover_position = 0
_attr_is_closed = None
@callback
def async_set_state(self, data: dict):
if 'run_state' in data:
self._attr_state = data["run_state"]
self._attr_is_opening = self._attr_state == STATE_OPENING
self._attr_is_closing = self._attr_state == STATE_CLOSING
if 'position' in data:
self._attr_current_cover_position = data['position']
# https://github.com/AlexxIT/XiaomiGateway3/issues/771
self._attr_is_closed = self._attr_current_cover_position <= 2
@callback
def async_restore_last_state(self, state: str, attrs: dict):
if not state:
return
self.async_set_state({
"run_state": state,
"position": attrs[ATTR_CURRENT_POSITION]
})
async def async_open_cover(self, **kwargs):
await self.device_send({self.attr: "open"})
async def async_close_cover(self, **kwargs):
await self.device_send({self.attr: "close"})
async def async_stop_cover(self, **kwargs):
await self.device_send({self.attr: "stop"})
async def async_set_cover_position(self, **kwargs):
await self.device_send({"position": kwargs[ATTR_POSITION]})
@@ -0,0 +1,74 @@
import voluptuous as vol
from homeassistant.components.device_automation import \
DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers import \
state as state_trigger
from homeassistant.const import *
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import (
device_registry as dr, entity_registry as er
)
from . import DOMAIN
from .core import converters
from .core.converters.base import BUTTON
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): cv.string,
vol.Required('action'): cv.string
}
)
async def async_attach_trigger(hass, config, action, automation_info):
entity_registry = er.async_get(hass)
device_id = config[CONF_DEVICE_ID]
entry = next((
entry for entry in entity_registry.entities.values() if
entry.device_id == device_id and entry.unique_id.endswith('action')
), None)
if not entry:
return None
to_state = (
config['action'] if config[CONF_TYPE] == 'button' else
f"{config[CONF_TYPE]}_{config['action']}"
)
state_config = {
CONF_PLATFORM: CONF_STATE,
CONF_ENTITY_ID: entry.entity_id,
state_trigger.CONF_TO: to_state
}
state_config = state_trigger.TRIGGER_STATE_SCHEMA(state_config)
return await state_trigger.async_attach_trigger(
hass, state_config, action, automation_info, platform_type="device"
)
async def async_get_triggers(hass, device_id):
device_registry = dr.async_get(hass)
device: dr.DeviceEntry = device_registry.async_get(device_id)
buttons = converters.get_buttons(device.model)
if not buttons:
return None
return [{
CONF_PLATFORM: CONF_DEVICE,
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: button,
} for button in buttons]
# noinspection PyUnusedLocal
async def async_get_trigger_capabilities(hass, config):
return {
"extra_fields": vol.Schema({
vol.Required('action'): vol.In(BUTTON.values()),
})
}
@@ -0,0 +1,68 @@
import time
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .core.const import DOMAIN, source_hash
from .core.converters import GATEWAY
from .core.device import logger
from .core.gateway import XGateway
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
):
options = {
k: "***" if k in ("host", "token") else v
for k, v in entry.options.items()
}
try:
ts = time.time()
devices = {
device.unique_id: device.as_dict(ts)
for device in XGateway.devices.values()
}
except Exception as e:
devices = f"{type(e).__name__}: {e}"
try:
errors = [
entry.to_dict()
for key, entry in hass.data["system_log"].records.items()
if DOMAIN in key
]
except Exception as e:
errors = f"{type(e).__name__}: {e}"
return {
"version": source_hash(),
"options": options,
"errors": errors,
"devices": devices,
}
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
):
info = await async_get_config_entry_diagnostics(hass, entry)
try:
uid = next(i[1] for i in device.identifiers if i[0] == DOMAIN)
info["device"] = info.pop("devices")[uid]
info["device"]["unique_id"] = uid
if device.model.startswith(GATEWAY):
gw: XGateway = hass.data[DOMAIN][entry.entry_id]
info["data.tar.gz.b64"] = await gw.tar_data()
else:
device = next(
d for d in XGateway.devices.values() if d.unique_id == uid
)
info["logger"] = logger(device)
except Exception as e:
info["error"] = f"{type(e).__name__}: {e}"
return info
@@ -0,0 +1,176 @@
import asyncio
from homeassistant.components.light import *
from homeassistant.const import STATE_ON
from homeassistant.core import callback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN
from .core.converters import ZIGBEE, MESH_GROUP_MODEL, Converter
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
CONF_DEFAULT_TRANSITION = 'default_transition'
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
elif device.type == ZIGBEE:
entity = XiaomiZigbeeLight(gateway, device, conv)
elif device.model == MESH_GROUP_MODEL:
entity = XiaomiMeshGroup(gateway, device, conv)
else:
entity = XiaomiMeshLight(gateway, device, conv)
async_add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
# noinspection PyAbstractClass
class XiaomiLight(XEntity, LightEntity, RestoreEntity):
_attr_is_on = None
def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter):
super().__init__(gateway, device, conv)
for conv in device.converters:
if conv.attr == ATTR_BRIGHTNESS:
self._attr_supported_features |= (
SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
)
elif conv.attr == ATTR_COLOR_TEMP:
self._attr_supported_features |= SUPPORT_COLOR_TEMP
if hasattr(conv, "minm") and hasattr(conv, "maxm"):
self._attr_min_mireds = conv.minm
self._attr_max_mireds = conv.maxm
elif hasattr(conv, "mink") and hasattr(conv, "maxk"):
self._attr_min_mireds = int(1000000 / conv.maxk)
self._attr_max_mireds = int(1000000 / conv.mink)
@callback
def async_set_state(self, data: dict):
if self.attr in data:
self._attr_is_on = data[self.attr]
# sometimes brightness and color_temp stored as string in Xiaomi DB
if ATTR_BRIGHTNESS in data:
self._attr_brightness = data[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP in data:
self._attr_color_temp = data[ATTR_COLOR_TEMP]
@callback
def async_restore_last_state(self, state: str, attrs: dict):
self._attr_is_on = state == STATE_ON
self._attr_brightness = attrs.get(ATTR_BRIGHTNESS)
self._attr_color_temp = attrs.get(ATTR_COLOR_TEMP)
async def async_update(self):
await self.device_read(self.subscribed_attrs)
# noinspection PyAbstractClass
class XiaomiZigbeeLight(XiaomiLight):
async def async_turn_on(self, **kwargs):
if ATTR_TRANSITION in kwargs:
tr = kwargs.pop(ATTR_TRANSITION)
elif CONF_DEFAULT_TRANSITION in self.customize:
tr = self.customize[CONF_DEFAULT_TRANSITION]
else:
tr = None
if tr is not None:
if kwargs:
# For the Aqara bulb, it is important that the brightness
# parameter comes before the color_temp parameter. Only this
# way transition will work. So we use `kwargs.pop` func to set
# the exact order of parameters.
for k in (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP):
if k in kwargs:
kwargs[k] = (kwargs.pop(k), tr)
else:
kwargs[ATTR_BRIGHTNESS] = (255, tr)
if not kwargs:
kwargs[self.attr] = True
await self.device_send(kwargs)
async def async_turn_off(self, **kwargs):
if ATTR_TRANSITION in kwargs:
tr = kwargs[ATTR_TRANSITION]
elif CONF_DEFAULT_TRANSITION in self.customize:
tr = self.customize[CONF_DEFAULT_TRANSITION]
else:
tr = None
if tr is not None:
await self.device_send({ATTR_BRIGHTNESS: (0, tr)})
else:
await self.device_send({self.attr: False})
# noinspection PyAbstractClass
class XiaomiMeshBase(XiaomiLight):
async def async_turn_on(self, **kwargs):
kwargs[self.attr] = True
await self.device_send(kwargs)
async def async_turn_off(self, **kwargs):
kwargs[self.attr] = False
await self.device_send(kwargs)
# noinspection PyAbstractClass
class XiaomiMeshLight(XiaomiMeshBase):
@callback
def async_set_state(self, data: dict):
super().async_set_state(data)
if "group" not in self.device.entities:
return
# convert light attr to group attr
if self.attr in data:
data["group"] = data.pop(self.attr)
group = self.device.entities["group"]
group.async_set_state(data)
group.async_write_ha_state()
# noinspection PyAbstractClass
class XiaomiMeshGroup(XiaomiMeshBase):
def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter):
super().__init__(gateway, device, conv)
if not device.extra["childs"]:
device.available = False
return
for did in device.extra["childs"]:
child = gateway.devices[did]
child.entities[self.attr] = self
async def async_will_remove_from_hass(self) -> None:
await super().async_will_remove_from_hass()
if not self.device.extra["childs"]:
return
for did in self.device.extra["childs"]:
child = self.gw.devices[did]
child.entities.pop(self.attr)
async def async_update(self):
# To update a group - request an update of its children
# update_ha_state for all child light entities
try:
childs = []
for did in self.device.extra["childs"]:
light = self.gw.devices[did].entities.get("light")
childs.append(light.async_update_ha_state(True))
if childs:
await asyncio.gather(*childs)
except Exception as e:
self.debug("Can't update child states", exc_info=e)
@@ -0,0 +1,12 @@
{
"domain": "xiaomi_gateway3",
"name": "Xiaomi Gateway 3",
"config_flow": true,
"documentation": "https://github.com/AlexxIT/XiaomiGateway3",
"issue_tracker": "https://github.com/AlexxIT/XiaomiGateway3/issues",
"codeowners": ["@AlexxIT"],
"dependencies": ["http"],
"requirements": ["zigpy>=0.33.0"],
"version": "2.1.2",
"iot_class": "local_push"
}
@@ -0,0 +1,55 @@
from homeassistant.components.number import NumberEntity
from homeassistant.const import MAJOR_VERSION, MINOR_VERSION
from homeassistant.core import callback
from . import DOMAIN
from .core.converters import Converter
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
else:
entity = XiaomiNumber(gateway, device, conv)
async_add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
# noinspection PyAbstractClass
class XiaomiNumber(XEntity, NumberEntity):
_attr_value: float = None
def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter):
super().__init__(gateway, device, conv)
if hasattr(conv, "min"):
self._attr_min_value = conv.min
if hasattr(conv, "max"):
self._attr_max_value = conv.max
@callback
def async_set_state(self, data: dict):
if self.attr in data:
self._attr_value = data[self.attr]
@callback
def async_restore_last_state(self, state: float, attrs: dict):
self._attr_value = state
async def async_update(self):
await self.device_read(self.subscribed_attrs)
# backward compatibility fix
if (MAJOR_VERSION, MINOR_VERSION) >= (2022, 8):
async def async_set_native_value(self, value: float) -> None:
await self.device_send({self.attr: value})
else:
async def async_set_value(self, value: float) -> None:
await self.device_send({self.attr: value})
@@ -0,0 +1,294 @@
from homeassistant.components.select import SelectEntity
from homeassistant.core import callback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN
from .core import utils
from .core.converters import Converter
from .core.device import XDevice, RE_DID
from .core.entity import XEntity
from .core.gateway import XGateway
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
elif conv.attr == "command":
entity = CommandSelect(gateway, device, conv)
elif conv.attr == "data":
entity = DataSelect(gateway, device, conv)
else:
entity = XiaomiSelect(gateway, device, conv)
async_add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
# noinspection PyAbstractClass
class XiaomiSelectBase(XEntity, SelectEntity):
_attr_current_option: str = None
def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter):
super().__init__(gateway, device, conv)
if hasattr(conv, "map"):
self._attr_options = list(conv.map.values())
@callback
def async_set_state(self, data: dict):
if self.attr in data:
self._attr_current_option = data[self.attr]
# noinspection PyAbstractClass
class XiaomiSelect(XiaomiSelectBase, RestoreEntity):
@callback
def async_restore_last_state(self, state: str, attrs: dict):
self._attr_current_option = state
async def async_select_option(self, option: str):
await self.device_send({self.attr: option})
async def async_update(self):
await self.device_read(self.subscribed_attrs)
# noinspection PyAbstractClass
class CommandSelect(XEntity, SelectEntity):
_attr_current_option = None
_attr_device_class = "command"
def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter):
super().__init__(gateway, device, conv)
if device.model == "lumi.gateway.mgl03":
self._attr_options = [
"pair", "bind", "ota", "config", "parentscan", "firmwarelock",
"reboot", "ftp"
]
elif device.model.startswith("lumi.gateway.aqcn0"):
self._attr_options = [
"pair", "bind", "ota", "config", "parentscan", "reboot", "ftp"
]
elif device.model.startswith("lumi.gateway.mcn001"):
self._attr_options = [
"pair", "bind", "ota", "config", "parentscan", "reboot", "ftp"
]
@callback
def async_set_state(self, data: dict):
if self.attr in data:
self._attr_current_option = data[self.attr]
async def async_select_option(self, option: str) -> None:
# clear select.data
self.device.update({"data": None})
if option == "pair":
await self.device_send({"pair": True})
elif option in ("bind", "config", "ota"):
self.device.update({"command": option})
elif option == "firmwarelock":
lock = await self.gw.gw3_read_lock()
self.device.update({"command": option, "lock": lock})
elif option in ("ftp", "reboot"):
ok = await self.gw.telnet_send(option)
self.device.update({"command": option, "ok": ok})
elif option == "parentscan":
await self.gw.z3_run_parent_scan()
self.device.update({"command": option, "ok": True})
OPT_ENABLED = "enabled"
OPT_DISABLED = "disabled"
OPT_UNKNOWN = "unknown"
OPT_OK = "ok"
OPT_ERROR = "error"
OPT_JOIN = "permit_join"
OPT_STOP_JOIN = "stop_join"
OPT_CANCEL = "cancel"
OPT_KEY_SECURE = "key_secure"
OPT_KEY_LEGACY = "key_legacy"
OPT_NO_FIRMWARE = "no_firmware"
OPT_BIND = "bind"
OPT_UNBIND = "unbind"
OPT_NO_DEVICES = "no_devices"
# noinspection PyAbstractClass,PyUnusedLocal
class DataSelect(XEntity, SelectEntity):
_attr_current_option = None
_attr_device_class = "data"
_attr_options = None
step_id = None
kwargs = None
def set_options(self, option: str = None, options: list = None):
"""Change current option with options list dynamically."""
# always change to None first time
self._attr_current_option = None
if option:
self._attr_options = options or [option]
# important to change current option with delay after options
self.hass.async_create_task(self.async_set_current(option))
else:
# better not leave options list empty
self._attr_options = options or []
async def async_set_current(self, option: str):
"""Delayed update current option."""
self._attr_current_option = option
self.async_write_ha_state()
def set_devices(self, feature: str):
"""Set options list with devices list."""
devices = [
f"{d.mac}: {d.name}" for d in self.gw.filter_devices(feature)
]
if devices:
self.set_options(None, devices)
else:
self.set_options(OPT_NO_DEVICES)
def set_end(self, option: str):
self.set_options(option)
self.device.update({"command": None})
###########################################################################
@callback
def async_set_state(self, data: dict):
"""Can process:
- command step, done by user from another select
- event step, done by receiving some data from gateway
"""
if "command" in data:
self.step_id = data["command"]
func = getattr(self, f"step_command_{self.step_id}", None)
else:
self.step_id = next(k for k in self.subscribed_attrs if k in data)
data = data[self.step_id]
func = getattr(self, f"step_event_{self.step_id}", None)
if func:
func(data)
async def async_select_option(self, option: str) -> None:
"""Can process user step, done by user from this select"""
coro = getattr(self, f"step_user_{self.step_id}", None)
if coro:
await coro(option)
###########################################################################
def step_event_data(self, value: str):
# update select.data value from outside
self.set_options(value)
def step_event_pair(self, value: bool):
if value:
self.set_options(OPT_JOIN, [OPT_JOIN, OPT_CANCEL])
# change select.command
self.device.update({"command": "pair"})
else:
# clear select.command
self.device.update({"command": None})
async def step_user_pair(self, option: str):
assert option == OPT_CANCEL
await self.device_send({"pair": False})
def step_event_discovered_mac(self, value: str):
self.set_options(f"Discovered: 0x{value:>016s}")
def step_event_pair_command(self, value: dict):
secure = value["install_code"]
option = OPT_KEY_SECURE if secure else OPT_KEY_LEGACY
self.set_options(option, self.options + [option])
def step_event_added_device(self, value: dict):
option = f"Paired: {value['model']}"
self.set_options(option, self.options + [option])
def step_event_remove_did(self, value: str):
assert RE_DID.search(value)
device = self.gw.devices.get(value)
utils.remove_device(self.hass, device)
self.set_end(f"Removed: {value[5:]}")
def step_event_ota_progress(self, value: int):
self.set_options(f"Update progress: {value}%")
def step_command_config(self, value: dict):
self.set_devices("zigbee")
async def step_user_config(self, option: str):
did = "lumi." + option[:18].lstrip("0x")
device = self.gw.devices.get(did)
await self.gw.silabs_rejoin(device)
def step_command_ota(self, value: dict):
self.set_devices("zigbee")
async def step_user_ota(self, option: str):
did = "lumi." + option[:18].lstrip("0x")
device = self.gw.devices.get(did)
resp = await utils.run_zigbee_ota(self.hass, self.gw, device)
if resp is True:
resp = OPT_OK
elif resp is False:
resp = OPT_NO_FIRMWARE
else:
resp = OPT_ERROR
self.set_end(resp)
def step_command_bind(self, value: dict):
self.set_devices(self.step_id)
self.step_id = "bind_from"
async def step_user_bind_from(self, option: str):
did = "lumi." + option[:18].lstrip("0x")
self.kwargs = {"bind_from": self.gw.devices.get(did)}
self.set_devices("bind_to")
self.step_id = "bind_to"
async def step_user_bind_to(self, option: str):
did = "lumi." + option[:18].lstrip("0x")
self.kwargs["bind_to"] = self.gw.devices.get(did)
self.set_options(None, [OPT_BIND, OPT_UNBIND])
self.step_id = "bind_type"
async def step_user_bind_type(self, option: str):
if option == OPT_BIND:
await self.gw.silabs_bind(**self.kwargs)
else:
await self.gw.silabs_unbind(**self.kwargs)
self.set_end(OPT_OK)
def step_command_firmwarelock(self, value: dict):
lock = value["lock"]
if lock is None:
self.set_options(
OPT_UNKNOWN, [OPT_UNKNOWN, OPT_ENABLED, OPT_DISABLED]
)
else:
self.set_options(
OPT_ENABLED if lock else OPT_DISABLED,
[OPT_ENABLED, OPT_DISABLED]
)
async def step_user_firmwarelock(self, option: str):
ok = await self.gw.gw3_send_lock(option == OPT_ENABLED)
self.set_end(OPT_OK if ok else OPT_ERROR)
def step_command_reboot(self, value: dict):
self.set_end(OPT_OK if value["ok"] else OPT_ERROR)
def step_command_ftp(self, value: dict):
self.step_command_reboot(value)
def step_command_parentscan(self, value: dict):
self.step_command_reboot(value)
@@ -0,0 +1,187 @@
import asyncio
from asyncio import Task
from datetime import datetime, timedelta, timezone
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.const import *
from homeassistant.core import callback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN
from .core.converters import Converter, STAT_GLOBALS
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
SCAN_INTERVAL = timedelta(seconds=60)
async def async_setup_entry(hass, entry, add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
elif conv.attr == "action":
entity = XiaomiAction(gateway, device, conv)
elif conv.attr in STAT_GLOBALS:
entity = XiaomiStats(gateway, device, conv)
else:
entity = XiaomiSensor(gateway, device, conv)
add_entities([entity])
gw: XGateway = hass.data[DOMAIN][entry.entry_id]
gw.add_setup(__name__, setup)
UNITS = {
"battery": PERCENTAGE,
"humidity": PERCENTAGE,
# zb light and motion and ble flower - lux
"illuminance": LIGHT_LUX,
"power": POWER_WATT,
"voltage": ELECTRIC_POTENTIAL_VOLT,
"current": ELECTRIC_CURRENT_AMPERE,
"pressure": PRESSURE_HPA,
"temperature": TEMP_CELSIUS,
"energy": ENERGY_KILO_WATT_HOUR,
"chip_temperature": TEMP_CELSIUS,
"conductivity": CONDUCTIVITY,
"gas_density": "% LEL",
"idle_time": TIME_SECONDS,
"linkquality": "lqi",
"max_power": POWER_WATT,
"moisture": PERCENTAGE,
"msg_received": "msg",
"msg_missed": "msg",
"new_resets": "rst",
"resets": "rst",
"rssi": SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
"smoke_density": "% obs/ft",
"supply": PERCENTAGE,
"tvoc": CONCENTRATION_PARTS_PER_BILLION,
# "link_quality": "lqi",
# "rssi": "dBm",
# "msg_received": "msg",
# "msg_missed": "msg",
# "unresponsive": "times"
}
# https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics
STATE_CLASSES = {
"energy": SensorStateClass.TOTAL_INCREASING,
}
class XiaomiBaseSensor(XEntity, SensorEntity):
def __init__(self, gateway: 'XGateway', device: XDevice, conv: Converter):
XEntity.__init__(self, gateway, device, conv)
if self.attr in UNITS:
# by default all sensors with units is measurement sensors
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = UNITS[self.attr]
if self.attr in STATE_CLASSES:
self._attr_state_class = STATE_CLASSES[self.attr]
@callback
def async_set_state(self, data: dict):
if self.attr in data:
self._attr_native_value = data[self.attr]
for k, v in data.items():
if k in self.subscribed_attrs and k != self.attr:
self._attr_extra_state_attributes[k] = v
class XiaomiSensor(XiaomiBaseSensor, RestoreEntity):
@callback
def async_set_state(self, data: dict):
if self.attr in data:
self._attr_extra_state_attributes["native_value"] = data[self.attr]
XiaomiBaseSensor.async_set_state(self, data)
@callback
def async_restore_last_state(self, state: str, attrs: dict):
"""Restore previous state."""
self._attr_native_value = attrs.get("native_value", state)
for k, v in attrs.items():
if k in self.subscribed_attrs or k == "native_value":
self._attr_extra_state_attributes[k] = v
async def async_update(self):
await self.device_read(self.subscribed_attrs)
class XiaomiStats(XiaomiBaseSensor):
@property
def available(self):
return True
@callback
def async_update_available(self):
super().async_update_available()
self._attr_extra_state_attributes["available"] = self.available
async def async_added_to_hass(self):
await super().async_added_to_hass()
data = {"available": self.available}
if self.device.decode_ts:
data[self.attr] = datetime.fromtimestamp(
self.device.decode_ts, timezone.utc
)
if self.device.nwk:
data["ieee"] = self.device.mac
data["nwk"] = self.device.nwk
else:
data["mac"] = self.device.mac
self.async_set_state(data)
class XiaomiAction(XiaomiBaseSensor):
_attr_native_value = ""
native_attrs: dict = None
clear_task: Task = None
async def clear_state(self):
await asyncio.sleep(.3)
self._attr_native_value = ""
self.async_write_ha_state()
async def async_added_to_hass(self):
await XEntity.async_added_to_hass(self)
self.native_attrs = self._attr_extra_state_attributes
async def async_will_remove_from_hass(self):
if self.clear_task:
self.clear_task.cancel()
if self.native_value != "":
self._attr_native_value = ""
self.async_write_ha_state()
await XEntity.async_will_remove_from_hass(self)
@callback
def async_set_state(self, data: dict):
# fix 1.4.7_0115 heartbeat error (has button in heartbeat)
if "battery" in data or self.attr not in data or not self.hass:
return
if self.clear_task:
self.clear_task.cancel()
self._attr_native_value = data[self.attr]
if self.native_attrs:
self._attr_extra_state_attributes = {**self.native_attrs, **data}
else:
self._attr_extra_state_attributes = data
# repeat event from Aqara integration
self.hass.bus.async_fire("xiaomi_aqara.click", {
"entity_id": self.entity_id, "click_type": self.native_value
})
self.clear_task = self.hass.loop.create_task(self.clear_state())
@@ -0,0 +1,17 @@
send_command:
description: Send command to Gateway
fields:
host:
description: Gateway IP address
example: 192.168.1.123
selector:
text:
command:
description: Command
example: reboot
selector:
text:
data:
description: Data
selector:
text:
@@ -0,0 +1,47 @@
from homeassistant.const import STATE_ON
from homeassistant.core import callback
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN
from .core.converters import Converter
from .core.device import XDevice
from .core.entity import XEntity
from .core.gateway import XGateway
async def async_setup_entry(hass, config_entry, add_entities):
def setup(gateway: XGateway, device: XDevice, conv: Converter):
if conv.attr in device.entities:
entity: XEntity = device.entities[conv.attr]
entity.gw = gateway
else:
entity = XiaomiSwitch(gateway, device, conv)
add_entities([entity])
gw: XGateway = hass.data[DOMAIN][config_entry.entry_id]
gw.add_setup(__name__, setup)
# noinspection PyAbstractClass
class XiaomiSwitch(XEntity, ToggleEntity, RestoreEntity):
_attr_is_on: bool = None
@callback
def async_set_state(self, data: dict):
"""Handle state update from gateway."""
if self.attr in data:
self._attr_is_on = data[self.attr]
@callback
def async_restore_last_state(self, state: str, attrs: dict):
self._attr_is_on = state == STATE_ON
async def async_turn_on(self):
await self.device_send({self.attr: True})
async def async_turn_off(self):
await self.device_send({self.attr: False})
async def async_update(self):
await self.device_read(self.subscribed_attrs)
@@ -0,0 +1,105 @@
"""Provide info to system health."""
import logging
import re
import traceback
import uuid
from collections import deque
from datetime import datetime
from logging import Logger
from typing import Any
from aiohttp import web
from homeassistant.components import system_health
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from .core.const import DOMAIN, source_hash
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
register.async_register_info(system_health_info)
async def system_health_info(hass: HomeAssistant):
integration = hass.data["integrations"][DOMAIN]
info = {"version": f"{integration.version} ({source_hash()})"}
if DebugView.url:
info["debug"] = {
"type": "failed", "error": "", "more_info": DebugView.url
}
return info
async def setup_debug(hass: HomeAssistant, logger: Logger):
if DebugView.url:
return
view = DebugView(logger)
hass.http.register_view(view)
integration = hass.data["integrations"][DOMAIN]
info = await hass.helpers.system_info.async_get_system_info()
info[DOMAIN + "_version"] = f"{integration.version} ({source_hash()})"
logger.debug(f"SysInfo: {info}")
class DebugView(logging.Handler, HomeAssistantView):
"""Class generate web page with component debug logs."""
name = DOMAIN
requires_auth = False
def __init__(self, logger: Logger):
super().__init__()
# https://waymoot.org/home/python_string/
self.text = deque(maxlen=10000)
self.propagate_level = logger.getEffectiveLevel()
# random url because without authorization!!!
DebugView.url = f"/api/{DOMAIN}/{uuid.uuid4()}"
logger.addHandler(self)
logger.setLevel(logging.DEBUG)
def handle(self, rec: logging.LogRecord):
dt = datetime.fromtimestamp(rec.created).strftime("%Y-%m-%d %H:%M:%S")
msg = f"{dt} [{rec.levelname[0]}] {rec.msg}"
if rec.exc_info:
exc = traceback.format_exception(*rec.exc_info, limit=1)
msg += "|" + "".join(exc[-2:]).replace("\n", "|")
self.text.append(msg)
# prevent debug to Hass log if user don't want it
if self.propagate_level > rec.levelno:
rec.levelno = -1
async def get(self, request: web.Request):
try:
lines = self.text
if 'q' in request.query:
reg = re.compile(fr"({request.query['q']})", re.IGNORECASE)
lines = [p for p in lines if reg.search(p)]
if 't' in request.query:
tail = int(request.query['t'])
lines = lines[-tail:]
body = "\n".join(lines)
r = request.query.get('r', '')
return web.Response(
text='<!DOCTYPE html><html>'
f'<head><meta http-equiv="refresh" content="{r}"></head>'
f'<body><pre>{body}</pre></body>'
'</html>',
content_type="text/html"
)
except Exception:
return web.Response(status=500)
@@ -0,0 +1,78 @@
{
"config": {
"error": {
"no_servers": "Please select at least one server",
"cant_login": "Can't login, check username and password",
"cant_connect": "Can't connect to gateway",
"wrong_model": "Unsupported gateway model",
"wrong_token": "Wrong Mi Home token",
"wrong_telnet": "Wrong open telnet command"
},
"step": {
"user": {
"title": "Select Action",
"data": {
"action": "Action"
}
},
"cloud": {
"title": "Add Mi Cloud Account",
"description": "Select only those servers where you have devices",
"data": {
"username": "Email / Mi Account ID",
"password": "Password",
"servers": "Servers"
}
},
"token": {
"description": "You can obtain Mi Home token automatically with [Cloud integration](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) or [manually](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Read about **open telnet command** in [documentation](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).",
"data": {
"host": "Host",
"token": "Token",
"telnet_cmd": "Open Telnet command"
}
}
}
},
"options": {
"step": {
"cloud": {
"title": "MiCloud devices info",
"data": {
"did": "Device"
},
"description": "{device_info}"
},
"user": {
"title": "Gateway Config",
"data": {
"host": "Host",
"token": "Token",
"telnet_cmd": "Open Telnet command",
"ble": "Support Bluetooth devices",
"stats": "Add statistic sensors",
"debug": "Debug logs",
"buzzer": "[DANGER] Disable buzzer",
"memory": "[DANGER] Use storage in memory",
"zha": "[DANGER] Mode ZHA or zigbee2mqtt"
},
"description": "Change **[DANGER]** options ONLY if you know what you doing"
}
}
},
"device_automation": {
"trigger_type": {
"button": "Button press",
"button_1": "1st button press",
"button_2": "2nd button press",
"button_3": "3rd button press",
"button_4": "4th button press",
"button_5": "5th button press",
"button_6": "6th button press",
"button_both": "Both button press",
"button_both_12": "1st and 2nd button press",
"button_both_13": "1st and 3rd button press",
"button_both_23": "2nd and 3rd button press"
}
}
}
@@ -0,0 +1,78 @@
{
"config": {
"error": {
"no_servers": "Wybierz co najmniej jeden serwer",
"cant_login": "Nie można się zalogować, sprawdź czy wpisałeś poprawny login i hasło",
"cant_connect": "Nie można nawiązać połączenia z bramką",
"wrong_model": "Niewspierany model bramki",
"wrong_token": "Niepoprawny token Mi Home",
"wrong_telnet": "Nieprawidłowa komenda otwarcia protokołu telnet"
},
"step": {
"user": {
"title": "Wybierz akcję",
"data": {
"action": "Akcja"
}
},
"cloud": {
"title": "Dodaj konto Xiaomi Home",
"description": "Wybierz tylko te serwery na której masz urządzenia",
"data": {
"username": "Email / ID konta Xiaomi",
"password": "Hasło",
"servers": "Serwery"
}
},
"token": {
"description": "Możesz uzyskać token automatycznie używając [danych logowania do konta Xiaomi](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) bądź [ręcznie](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Listę wspieranych wersji oprogramowania możesz znaleźć [tutaj](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).",
"data": {
"host": "Host (Adres IP bramki)",
"token": "Token",
"telnet_cmd": "Komenda do otwarcia protokołu telnet"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Informacje o urządzeniach z Xiaomi Home",
"data": {
"did": "Urządzenie"
},
"description": "{device_info}"
},
"user": {
"title": "Ustawienia bramki",
"data": {
"host": "Host",
"token": "Token",
"telnet_cmd": "Komenda do otwarcia protokołu telnet",
"ble": "Wsparcie dla urządzeń BLE (Bluetoth Low Energy)",
"stats": "Dane dotyczące wydajności Zigbee i BLE",
"debug": "Debugowanie",
"buzzer": "[DANGER] Wyłącz głośnik w bramce",
"memory": "[DANGER] Use storage in memory",
"zha": "[DANGER] Mode ZHA or zigbee2mqtt"
},
"description": "Jeśli nie wiesz co robisz to NIGDY nie dotykaj ustawień **[DANGER]**"
}
}
},
"device_automation": {
"trigger_type": {
"button": "Naciśnięcie przycisku",
"button_1": "Naciśnięcie 1 przycisku",
"button_2": "Naciśnięcie 2 przycisku",
"button_3": "Naciśnięcie 3 przycisku",
"button_4": "Naciśnięcie 4 przycisku",
"button_5": "Naciśnięcie 5 przycisku",
"button_6": "Naciśnięcie 6 przycisku",
"button_both": "Naciśnięcie obydwu przycisków",
"button_both_12": "Naciśnięcie przycisków 1 i 2",
"button_both_13": "Naciśnięcie przycisków 1 i 3",
"button_both_23": "Naciśnięcie przycisków 2 i 3"
}
}
}
@@ -0,0 +1,78 @@
{
"config": {
"error": {
"no_servers": "Selecione pelo menos um servidor",
"cant_login": "Não consigo fazer login, verifique o nome de usuário e a senha",
"cant_connect": "Não é possível conectar ao gateway",
"wrong_model": "Modelo de gateway não compatível",
"wrong_token": "Token Mi Home errado",
"wrong_telnet": "Comando telnet aberto errado"
},
"step": {
"user": {
"title": "Selecionar ação",
"data": {
"action": "Ação"
}
},
"cloud": {
"title": "Adicionar conta Mi Cloud",
"description": "Selecione apenas os servidores em que você possui dispositivos",
"data": {
"username": "E-mail / ID da conta Mi",
"password": "Senha",
"servers": "Servidores"
}
},
"token": {
"description": "Você pode obter o token Mi Home automaticamente com [integração na nuvem](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) ou [manualmente](https://github.com/Maxmudjon/ com.xiaomi-miio/blob/master/docs/obtain_token.md). Leia sobre **comando telnet aberto** em [documentação](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).",
"data": {
"host": "Host",
"token": "Token",
"telnet_cmd": "Abra o comando Telnet"
}
}
}
},
"options": {
"step": {
"cloud": {
"title": "Informações de dispositivos MiCloud",
"data": {
"did": "Dispositivo"
},
"description": "{device_info}"
},
"user": {
"title": "Configuração do Gateway",
"data": {
"host": "Host",
"token": "Token",
"telnet_cmd": "Abra o comando Telnet",
"ble": "Suporta dispositivos Bluetooth",
"stats": "Adicionar sensores estatísticos",
"debug": "Debug",
"buzzer": "Desativar campainha [ADV]",
"memory": "Usar armazenamento na memória [ADV]",
"zha": "Modo ZHA ou zigbee2mqtt [ADV]"
},
"description": "Altere as opções **ADV** SOMENTE se você souber o que está fazendo"
}
}
},
"device_automation": {
"trigger_type": {
"button": "Pressione o botão",
"button_1": "1º botão pressionado",
"button_2": "2º botão pressionado",
"button_3": "3º botão pressionado",
"button_4": "4º botão pressionado",
"button_5": "5º botão pressionado",
"button_6": "6º botão pressionado",
"button_both": "Ambos os botões pressionados",
"button_both_12": "1º e 2º botão pressionado",
"button_both_13": "1º e 3º botão pressionado",
"button_both_23": "2º e 3º botão pressionado"
}
}
}
@@ -0,0 +1,76 @@
{
"config": {
"error": {
"no_servers": "Te rog sa selectezi macar un server!",
"cant_login": "Nu ma pot conecta. Mai verifica o data username-ul sau parola!",
"cant_connect": "Nu ma pot conecta la gateway!",
"wrong_model": "Modelul tau de gateway nu este suportat!"
},
"step": {
"user": {
"title": "Selecteaza actiunea",
"data": {
"action": "Actiune"
}
},
"cloud": {
"title": "Adauga un cont Mi Cloud",
"description": "Selecteaza doar serverul unde ai dispozitivele",
"data": {
"username": "Email / ID-ul contului Mi",
"password": "Parola",
"servers": "Servere"
}
},
"token": {
"description": "Poti obtine tokenul Mi Home in mod automat prin [Cloud integration](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) sau [manual](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Verifica versiunile de firmware suportate in [component docs](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).",
"data": {
"host": "IP Gateway",
"token": "Token",
"telnet_cmd": "Comanda de deschidere Telnet"
}
}
}
},
"options": {
"step": {
"cloud": {
"title": "Informatii despre dispozitivele din MiCloud",
"data": {
"did": "Dispozitiv"
},
"description": "{device_info}"
},
"user": {
"title": "Configurare Gateway",
"data": {
"host": "IP Gateway",
"token": "Token",
"telnet_cmd": "Comanda de deschidere Telnet",
"ble": "Dispozitive BLE suportate",
"stats": "Date despre performanta dispozitivelor Zigbee si BLE",
"debug": "Debug",
"buzzer": "[DANGER] Dezactiveaza buzzer",
"memory": "[DANGER] Use storage in memory",
"zha": "[DANGER] Mode ZHA or zigbee2mqtt"
},
"description": "Nu schimba optiunile **[DANGER]** DECAT daca stii ce fac!"
}
}
},
"device_automation": {
"trigger_type": {
"button": "Apasare Buton",
"button_1": "Prima apasare de buton",
"button_2": "A 2-a apasare de buton",
"button_3": "A 3-a apasare de buton",
"button_4": "A 4-a apasare de buton",
"button_5": "A 5-a apasare de buton",
"button_6": "A 6-a apasare de buton",
"button_both": "Ambele butoane apasate",
"button_both_12": "Primul si al 2-lea 2nd buton apasat",
"button_both_13": "Primul si al 3-lea buton apasat",
"button_both_23": "Al 2-lea si al 3-lea buton apasat"
}
}
}
@@ -0,0 +1,78 @@
{
"config": {
"error": {
"no_servers": "Пожалуйста, выберите хотя бы один сервер",
"cant_login": "Ошибка, проверьте имя пользователя и пароль",
"cant_connect": "Не получается подключиться к шлюзу",
"wrong_model": "Модель шлюза не поддерживается",
"wrong_token": "Неверный Mi Home токен",
"wrong_telnet": "Неверная команда для открытия Telnet"
},
"step": {
"user": {
"title": "Выберите действие",
"data": {
"action": "Действие"
}
},
"cloud": {
"title": "Добавить Mi Cloud аккаунт",
"description": "Выбирайте только те серверы, на которых у вас есть устройства",
"data": {
"username": "Email / Mi Account ID",
"password": "Пароль",
"servers": "Серверы"
}
},
"token": {
"description": "Вы можете получить Mi Home токен автоматически с помощью [облачной интеграции](https://github.com/AlexxIT/XiaomiGateway3#obtain-mi-home-device-token) или [вручную](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md). Читайте о настройке **команда для открытия telnet** в [документации](https://github.com/AlexxIT/XiaomiGateway3#supported-firmwares).",
"data": {
"host": "IP-адрес",
"token": "Токен",
"telnet_cmd": "Команда для открытия Telnet"
}
}
}
},
"options": {
"step": {
"cloud": {
"title": "Информация о устройствах MiCloud",
"data": {
"did": "Устройство"
},
"description": "{device_info}"
},
"user": {
"title": "Настройка шлюза",
"data": {
"host": "IP-адрес",
"token": "Токен",
"telnet_cmd": "Команда для открытия Telnet",
"ble": "Поддержка Bluetooth устройств",
"stats": "Добавить сенсоры статистики",
"debug": "Логи отладки (debug)",
"buzzer": "[DANGER] Выключить пищалку",
"memory": "[DANGER] Режим хранилища в памяти",
"zha": "[DANGER] Режим ZHA и zigbee2mqtt"
},
"description": "Изменяйте **[DANGER]** настройки ТОЛЬКО если знаете, что вы делаете"
}
}
},
"device_automation": {
"trigger_type": {
"button": "Нажатие кнопки",
"button_1": "Нажатие первой кнопки",
"button_2": "Нажатие второй кнопки",
"button_3": "Нажатие третьей кнопки",
"button_4": "Нажатие четвёртой кнопки",
"button_5": "Нажатие пятой кнопки",
"button_6": "Нажатие шестой кнопки",
"button_both": "Нажатие двух кнопок",
"button_both_12": "Нажатие первой и второй кнопок",
"button_both_13": "Нажатие первой и третей кнопок",
"button_both_23": "Нажатие второй и третьей кнопок"
}
}
}
@@ -0,0 +1,30 @@
{
"state": {
"command": {
"pair": "Zigbee pairing",
"bind": "Zigbee binding",
"ota": "Zigbee OTA",
"config": "Zigbee reconfig",
"parentscan": "Zigbee parent scan",
"firmwarelock": "Gateway firmware lock",
"reboot": "Gateway reboot",
"ftp": "Gateway run FTP"
},
"data": {
"enabled": "Enabled",
"disabled": "Disabled",
"unknown": "Unknown",
"ok": "OK",
"error": "ERROR",
"permit_join": "Ready to join",
"stop_join": "Pairing stopped",
"cancel": "Cancel",
"key_secure": "Send network key (secure)",
"key_legacy": "Send network key (legacy)",
"no_firmware": "No firmware",
"bind": "Bind",
"unbind": "Unbind",
"no_devices": "No devices"
}
}
}
@@ -0,0 +1,74 @@
{
"config": {
"error": {
"no_servers": "Будь ласка, виберіть хоча б один сервер",
"cant_login": "Помилка, перевірте ім'я користувача і пароль",
"cant_connect": "Не вдається підключитися до шлюзу",
"wrong_model": "Модель шлюзу не підтримується"
},
"step": {
"user": {
"title": "Виберіть дію",
"data": {
"action": "Дія"
}
},
"cloud": {
"title": "Додати Mi Cloud аккаунт",
"description": "Вибирайте тільки ті сервери, на яких у вас є пристрої",
"data": {
"username": "Email / Mi Account ID",
"password": "Пароль",
"servers": "сервери"
}
},
"token": {
"description": "[Отримайте](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md) Mi Home токен. Шлюз з прошивкою **1.4.6_0043** і вище підтримується тільки після [перепрошивки](https://github.com/AlexxIT/XiaomiGateway3/wiki)",
"data": {
"host": "IP-адреса",
"token": "Токен"
}
}
}
},
"options": {
"step": {
"cloud": {
"title": "Інформація про пристрої MiCloud",
"data": {
"did": "Пристрій"
},
"description": "{device_info}"
},
"user": {
"title": "Налаштування шлюзу",
"data": {
"host": "IP-адреса",
"token": "Токен",
"ble": "Підтримка BLE пристроїв",
"stats": "Деталізація роботи Zigbee і BLE",
"debug": "Відлагодження",
"buzzer": "[DANGER] Вимкнути спікер",
"memory": "[DANGER] Use storage in memory",
"zha": "[DANGER] Mode ZHA or zigbee2mqtt"
},
"description": "Змінюйте **[DANGER]** налаштування ТІЛЬКИ якщо знаєте, що ви робите"
}
}
},
"device_automation": {
"trigger_type": {
"button": "Натискання кнопки",
"button_1": "Натискання першої кнопки",
"button_2": "Натискання другої кнопки",
"button_3": "Натискання третьої кнопки",
"button_4": "Натискання четвертої кнопки",
"button_5": "Натискання п'ятої кнопки",
"button_6": "Натискання шостої кнопки",
"button_both": "Натискання двох кнопок",
"button_both_12": "Натискання першої та другої кнопок",
"button_both_13": "Натискання першої і третьої кнопок",
"button_both_23": "Натискання другої і третьої кнопок"
}
}
}
@@ -0,0 +1,76 @@
{
"config": {
"error": {
"no_servers": "请选择至少一个服务器",
"cant_login": "无法登录,请检查用户名和密码",
"cant_connect": "无法连接到网络",
"wrong_model": "网关型号不支持"
},
"step": {
"user": {
"title": "选择动作",
"data": {
"action": "动作"
}
},
"cloud": {
"title": "添加小米云账号",
"description": "选择设备所属的服务器",
"data": {
"username": "邮箱/小米 ID",
"password": "密码",
"servers": "服务器"
}
},
"token": {
"description": "需要[获取](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md)米家 token。\n注意:若网关固件版本高于 **1.4.6_0043** ,需要先[焊接](https://github.com/AlexxIT/XiaomiGateway3/wiki)才能支持。",
"data": {
"host": "主机",
"token": "Token",
"ble": "支持 BLE 设备",
"zha": "Zigbee Home Automation 模式"
}
}
}
},
"options": {
"step": {
"init": {
"title": "小米云的设备信息",
"data": {
"did": "设备"
},
"description": "{device_info}"
},
"user": {
"title": "网关设定",
"data": {
"host": "主机",
"token": "Token",
"ble": "支持 BLE 设备",
"stats": "Zigbee 和 BLE 效能数据",
"debug": "调试信息",
"buzzer": "[DANGER] 关闭蜂鸣器",
"memory": "[DANGER] Use storage in memory",
"zha": "[DANGER] Mode ZHA or zigbee2mqtt"
},
"description": "如果您不清楚了解带有 **[DANGER]** 选项的作用,建议不要更改它们。"
}
}
},
"device_automation": {
"trigger_type": {
"button": "按下按键",
"button_1": "按下第 1 键",
"button_2": "按下第 2 键",
"button_3": "按下第 3 键",
"button_4": "按下第 4 键",
"button_5": "按下第 5 键",
"button_6": "按下第 6 键",
"button_both": "两键同时按下",
"button_both_12": "同时按下第 1、2 键",
"button_both_13": "同时按下第 1、3 键",
"button_both_23": "同时按下第 2、3 键"
}
}
}
@@ -0,0 +1,78 @@
{
"config": {
"error": {
"no_servers": "請至少選擇一個伺服器",
"cant_login": "無法登入,請確認使用者名稱與密碼",
"cant_connect": "無法連線至網關",
"wrong_model": "網關型號不支援",
"wrong_token": "小米智能家庭權杖錯誤",
"wrong_telnet": "開啟 Telnet 指令錯誤"
},
"step": {
"user": {
"title": "選擇動作",
"data": {
"action": "動作"
}
},
"cloud": {
"title": "新增小米雲服務帳號",
"description": "僅選擇已榜定設備的伺服器",
"data": {
"username": "Email / 小米帳號 ID",
"password": "密碼",
"servers": "伺服器"
}
},
"token": {
"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) 以了解支援的韌體版本。",
"data": {
"host": "主機端",
"token": "權杖",
"telnet_cmd": "開啟 Telnet 指令"
}
}
}
},
"options": {
"step": {
"cloud": {
"title": "小米雲設備資訊",
"data": {
"did": "裝置"
},
"description": "{device_info}"
},
"user": {
"title": "網關設定",
"data": {
"host": "主機端",
"token": "密鑰",
"telnet_cmd": "開啟 Telnet 指令",
"ble": "支援 BLE 設備",
"stats": "Zigbee 與 BLE 效能資料",
"debug": "Debug",
"buzzer": "[DANGER] 關閉蜂鳴器",
"memory": "[DANGER] 使用記憶體空間",
"zha": "[DANGER] ZHA 或 zigbee2mqtt 模式"
},
"description": "僅於了解可能之風險後,才建議變更 **ADV** 選項"
}
}
},
"device_automation": {
"trigger_type": {
"button": "按鈕按下",
"button_1": "第 1 個按鈕按下",
"button_2": "第 2 個按鈕按下",
"button_3": "第 3 個按鈕按下",
"button_4": "第 4 個按鈕按下",
"button_5": "第 5 個按鈕按下",
"button_6": "第 6 個按鈕按下",
"button_both": "按鈕同時按下",
"button_both_12": "第 1 與第 2 個按鈕同時按下",
"button_both_13": "第 1 與第 3 個按鈕同時按下",
"button_both_23": "第 2 與第 3 個按鈕同時按下"
}
}
}
@@ -0,0 +1,245 @@
# Copyright 2020 Elelabs International Limited
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# https://github.com/Elelabs/elelabs-zigbee-ezsp-utility
import binascii
import time
# noinspection PyPep8Naming,PyMethodMayBeStatic
class AshProtocolInterface:
FLAG_BYTE = b'\x7E'
RANDOMIZE_START = 0x42
RANDOMIZE_SEQ = 0xB8
RSTACK_FRAME_CMD = b'\x1A\xC0\x38\xBC\x7E'
RSTACK_FRAME_ACK = b'\x1A\xC1\x02\x0B\x0A\x52\x7E'
def __init__(self, serial, config, logger):
self.logger = logger
self.config = config
self.serial = serial
self.ackNum = 0
self.frmNum = 0
def dataRandomize(self, frame):
rand = self.RANDOMIZE_START
out = bytearray()
for x in frame:
out += bytearray([x ^ rand])
if rand % 2:
rand = (rand >> 1) ^ self.RANDOMIZE_SEQ
else:
rand = rand >> 1
return out
def ashFrameBuilder(self, ezsp_frame):
ash_frame = bytearray()
# Control byte
ash_frame += bytearray([(((self.ackNum << 0) & 0xFF) | (
((self.frmNum % 8) << 4) & 0xFF)) & 0xFF])
self.ackNum = (self.ackNum + 1) % 8
self.frmNum = (self.frmNum + 1) % 8
ash_frame += self.dataRandomize(ezsp_frame)
crc = binascii.crc_hqx(ash_frame, 0xFFFF)
ash_frame += bytearray([crc >> 8, crc & 0xFF])
ash_frame = self.replaceReservedBytes(ash_frame)
ash_frame += self.FLAG_BYTE
if self.config.dlevel == 'ASH':
self.logger.debug('[ ASH REQUEST ] ' + ' '.join(
format(x, '02x') for x in ash_frame))
return ash_frame
def revertEscapedBytes(self, msg):
msg = msg.replace(b'\x7d\x5d', b'\x7d')
msg = msg.replace(b'\x7d\x5e', b'\x7e')
msg = msg.replace(b'\x7d\x31', b'\x11')
msg = msg.replace(b'\x7d\x33', b'\x13')
msg = msg.replace(b'\x7d\x38', b'\x18')
msg = msg.replace(b'\x7d\x3a', b'\x1a')
return msg
def replaceReservedBytes(self, msg):
msg = msg.replace(b'\x7d', b'\x7d\x5d')
msg = msg.replace(b'\x7e', b'\x7d\x5e')
msg = msg.replace(b'\x11', b'\x7d\x31')
msg = msg.replace(b'\x13', b'\x7d\x33')
msg = msg.replace(b'\x18', b'\x7d\x38')
msg = msg.replace(b'\x1a', b'\x7d\x3a')
return msg
def getResponse(self, applyRandomize=False):
timeout = time.time() + 3
msg = bytearray()
receivedbyte = None
while (time.time() < timeout) and (receivedbyte != self.FLAG_BYTE):
receivedbyte = self.serial.read()
msg += receivedbyte
if len(msg) == 0:
return -1, None, None
msg = self.revertEscapedBytes(msg)
if self.config.dlevel == 'ASH':
self.logger.debug(
'[ ASH RESPONSE ] ' + ' '.join(format(x, '02x') for x in msg))
if applyRandomize:
msg_parsed = self.dataRandomize(bytearray(msg[1:-3]))
if self.config.dlevel == 'ASH' or self.config.dlevel == 'EZSP':
self.logger.debug('[ EZSP RESPONSE ] ' + ' '.join(
format(x, '02x') for x in msg_parsed))
return 0, msg, msg_parsed
else:
return 0, msg, None
def sendResetFrame(self):
self.serial.flushInput()
self.logger.debug('RESET FRAME')
if self.config.dlevel == 'ASH':
self.logger.debug('[ ASH REQUEST ] ' + ' '.join(
format(x, '02x') for x in self.RSTACK_FRAME_CMD))
self.serial.write(self.RSTACK_FRAME_CMD)
status, ash_response, ezsp_response = self.getResponse()
if status:
return status
if not (self.RSTACK_FRAME_ACK in ash_response):
return -1
return 0
def sendAck(self, ackNum):
ack = bytearray([ackNum & 0x07 | 0x80])
crc = binascii.crc_hqx(ack, 0xFFFF)
ack += bytearray([crc >> 8, crc & 0xFF])
ack = self.replaceReservedBytes(ack)
ack += self.FLAG_BYTE
if self.config.dlevel == 'ASH':
self.logger.debug(
'[ ASH ACK ] ' + ' '.join(format(x, '02x') for x in ack))
self.serial.write(ack)
def sendAshCommand(self, ezspFrame):
ash_frame = self.ashFrameBuilder(ezspFrame)
self.serial.flushInput()
self.serial.write(ash_frame)
status, ash_response, ezsp_response = self.getResponse(True)
if status:
return status, None
self.sendAck(ash_response[0])
return 0, ezsp_response
# noinspection PyRedundantParentheses,PyPep8Naming
class EzspProtocolInterface:
def __init__(self, serial, config, logger):
self.logger = logger
self.config = config
self.INITIAL_EZSP_VERSION = 4
self.VERSION = b'\x00'
self.GET_VALUE = b'\xAA'
self.GET_MFG_TOKEN = b'\x0B'
self.LAUNCH_STANDALONE_BOOTLOADER = b'\x8F'
self.EZSP_VALUE_VERSION_INFO = 0x11
self.EZSP_MFG_STRING = 0x01
self.EZSP_MFG_BOARD_NAME = 0x02
self.STANDALONE_BOOTLOADER_NORMAL_MODE = 1
self.ezspVersion = self.INITIAL_EZSP_VERSION
self.sequenceNum = 0
self.ash = AshProtocolInterface(serial, config, logger)
def ezspFrameBuilder(self, command):
ezsp_frame = bytearray()
# Sequence byte
ezsp_frame += bytearray([self.sequenceNum])
self.sequenceNum = (self.sequenceNum + 1) % 255
ezsp_frame += b'\x00'
if self.ezspVersion >= 5:
# Legacy frame ID - always 0xFF
ezsp_frame += b'\xFF'
# Extended frame control
ezsp_frame += b'\x00'
ezsp_frame = ezsp_frame + command
if self.ezspVersion >= 8:
ezsp_frame[2] = 0x01
ezsp_frame[3] = command[0] & 0xFF # LSB
ezsp_frame[4] = command[0] >> 8 # MSB
if self.config.dlevel == 'ASH' or self.config.dlevel == 'EZSP':
self.logger.debug('[ EZSP REQUEST ] ' + ' '.join(
format(x, '02x') for x in ezsp_frame))
return ezsp_frame
def sendEzspCommand(self, commandData, commandName=''):
self.logger.debug(commandName)
status, response = self.ash.sendAshCommand(
self.ezspFrameBuilder(commandData))
if status:
raise Exception("sendAshCommand status error: %d" % status)
return response
def sendVersion(self, desiredProtocolVersion):
resp = self.sendEzspCommand(
self.VERSION + bytearray([desiredProtocolVersion]),
'sendVersion: V%d' % desiredProtocolVersion)
return resp[3] # protocolVersion
def getValue(self, valueId, valueIdName):
resp = self.sendEzspCommand(self.GET_VALUE + bytearray([valueId]),
'getValue: %s' % valueIdName)
status = resp[5]
valueLength = resp[6]
valueArray = resp[7:]
return status, valueLength, valueArray
def getMfgToken(self, tokenId, tokenIdName):
resp = self.sendEzspCommand(self.GET_MFG_TOKEN + bytearray([tokenId]),
'getMfgToken: %s' % tokenIdName)
tokenDataLength = resp[5]
tokenData = resp[6:]
return tokenDataLength, tokenData
def launchStandaloneBootloader(self, mode, modeName):
resp = self.sendEzspCommand(
self.LAUNCH_STANDALONE_BOOTLOADER + bytearray([mode]),
'launchStandaloneBootloader: %s' % modeName)
status = resp[5]
return status
def initEzspProtocol(self):
ash_status = self.ash.sendResetFrame()
if ash_status:
return ash_status
self.ezspVersion = self.sendVersion(self.INITIAL_EZSP_VERSION)
self.logger.debug("EZSP v%d detected" % self.ezspVersion)
if (self.ezspVersion != self.INITIAL_EZSP_VERSION):
self.sendVersion(self.ezspVersion)
return 0