first commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user