feat: support modify spec and value conversion (#663)

* fix: fix miot_device type error

* fix: fix type error

* feat: remove spec cache storage

* feat: update std_lib and multi_lang logic

* feat: update entity value-range

* feat: update value-list logic

* feat: update prop.format_ logic

* fix: fix miot cloud log error

* fix: fix fan entity

* style: ignore type error

* style: rename spec_filter func name

* feat: move bool_trans from storage to spec

* feat: move sepc_filter from storage to spec, use the YAML format file

* feat: same prop supports multiple sub

* feat: same event supports multiple sub

* fix: fix device remove error

* feat: add func slugify_did

* fix: fix multi lang error

* feat: update action debug logic

* feat: ignore normal disconnect log

* feat: support binary mode

* feat: change miot spec name type define

* style: ignore i18n tranlate type error

* fix: fix pylint warning

* fix: miot storage type error

* feat: support binary display mode configure

* feat: set default sensor state_class

* fix: fix sensor entity type error

* fix: fix __init__ type error

* feat: update test case logic

* fix: github actions add dependencies lib

* fix: fix some type error

* feat: update device list changed notify logic

* feat: update prop expr logic

* feat: add spec modify

* feat: update device sub id logic

* feat: update get miot client instance logic

* fix: fix some type error

* feat: update miot device unit and icon trans

* perf: update spec trans entity logic

* feat: update spec trans entity rule

* feat: update spec_modify

* feat: update sensor ENUM icon

* fix: fix miot device error

* fix: fix miot spec error

* featL update format check and spec modify file

* feat: update checkout rule format

* feat: handle special property.unit

* feat: add expr for cuco-cp1md

* feat: fix climate hvac error

* feat: set sensor suggested display precision

* feat: update climate set hvac logic

* feat: add expr for cuco-v3

* feat: update spec expr for chuangmi-212a01
This commit is contained in:
Paul Shawn 2025-01-22 19:21:02 +08:00 committed by GitHub
parent 3c16f0ffbb
commit 8778b00c3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 598 additions and 309 deletions

View File

@ -258,13 +258,14 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity):
f'{self.entity_id}')
return
# set air-conditioner on
elif self.get_prop_value(prop=self._prop_on) is False:
await self.set_property_async(prop=self._prop_on, value=True)
if self.get_prop_value(prop=self._prop_on) is False:
await self.set_property_async(
prop=self._prop_on, value=True, update=False)
# set mode
mode_value = self.get_map_key(
map_=self._hvac_mode_map, value=hvac_mode)
if (
mode_value is None or
not mode_value or
not await self.set_property_async(
prop=self._prop_mode, value=mode_value)
):
@ -368,9 +369,9 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity):
"""Return the hvac mode. e.g., heat, cool mode."""
if self.get_prop_value(prop=self._prop_on) is False:
return HVACMode.OFF
return self.get_map_key(
return self.get_map_value(
map_=self._hvac_mode_map,
value=self.get_prop_value(prop=self._prop_mode))
key=self.get_prop_value(prop=self._prop_mode))
@property
def fan_mode(self) -> Optional[str]:

View File

@ -150,7 +150,7 @@ class MIoTClient:
# Device list update timestamp
_device_list_update_ts: int
_sub_source_list: dict[str, str]
_sub_source_list: dict[str, Optional[str]]
_sub_tree: MIoTMatcher
_sub_device_state: dict[str, MipsDeviceState]
@ -620,7 +620,7 @@ class MIoTClient:
# Priority local control
if self._ctrl_mode == CtrlMode.AUTO:
# Gateway control
device_gw: dict = self._device_list_gateway.get(did, None)
device_gw = self._device_list_gateway.get(did, None)
if (
device_gw and device_gw.get('online', False)
and device_gw.get('specv2_access', False)
@ -641,7 +641,7 @@ class MIoTClient:
raise MIoTClientError(
self.__get_exec_error_with_rc(rc=rc))
# Lan control
device_lan: dict = self._device_list_lan.get(did, None)
device_lan = self._device_list_lan.get(did, None)
if device_lan and device_lan.get('online', False):
result = await self._miot_lan.set_prop_async(
did=did, siid=siid, piid=piid, value=value)
@ -657,7 +657,7 @@ class MIoTClient:
# Cloud control
device_cloud = self._device_list_cloud.get(did, None)
if device_cloud and device_cloud.get('online', False):
result: list = await self._http.set_prop_async(
result = await self._http.set_prop_async(
params=[
{'did': did, 'siid': siid, 'piid': piid, 'value': value}
])
@ -746,7 +746,7 @@ class MIoTClient:
if did not in self._device_list_cache:
raise MIoTClientError(f'did not exist, {did}')
device_gw: dict = self._device_list_gateway.get(did, None)
device_gw = self._device_list_gateway.get(did, None)
# Priority local control
if self._ctrl_mode == CtrlMode.AUTO:
if (
@ -782,7 +782,7 @@ class MIoTClient:
self.__get_exec_error_with_rc(rc=rc))
# Cloud control
device_cloud = self._device_list_cloud.get(did, None)
if device_cloud.get('online', False):
if device_cloud and device_cloud.get('online', False):
result: dict = await self._http.action_async(
did=did, siid=siid, aiid=aiid, in_list=in_list)
if result:
@ -798,14 +798,15 @@ class MIoTClient:
dids=[did]))
raise MIoTClientError(
self.__get_exec_error_with_rc(rc=rc))
# Show error message
# TODO: Show error message
_LOGGER.error(
'client action failed, %s.%d.%d', did, siid, aiid)
return None
return []
def sub_prop(
self, did: str, handler: Callable[[dict, Any], None],
siid: int = None, piid: int = None, handler_ctx: Any = None
siid: Optional[int] = None, piid: Optional[int] = None,
handler_ctx: Any = None
) -> bool:
if did not in self._device_list_cache:
raise MIoTClientError(f'did not exist, {did}')
@ -818,7 +819,9 @@ class MIoTClient:
_LOGGER.debug('client sub prop, %s', topic)
return True
def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool:
def unsub_prop(
self, did: str, siid: Optional[int] = None, piid: Optional[int] = None
) -> bool:
topic = (
f'{did}/p/'
f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}')
@ -829,7 +832,8 @@ class MIoTClient:
def sub_event(
self, did: str, handler: Callable[[dict, Any], None],
siid: int = None, eiid: int = None, handler_ctx: Any = None
siid: Optional[int] = None, eiid: Optional[int] = None,
handler_ctx: Any = None
) -> bool:
if did not in self._device_list_cache:
raise MIoTClientError(f'did not exist, {did}')
@ -841,7 +845,9 @@ class MIoTClient:
_LOGGER.debug('client sub event, %s', topic)
return True
def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool:
def unsub_event(
self, did: str, siid: Optional[int] = None, eiid: Optional[int] = None
) -> bool:
topic = (
f'{did}/e/'
f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}')
@ -1081,7 +1087,7 @@ class MIoTClient:
if state_old == state_new:
continue
self._device_list_cache[did]['online'] = state_new
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx)
self.__request_show_devices_changed_notify()
@ -1091,8 +1097,8 @@ class MIoTClient:
self, group_id: str, state: bool
) -> None:
_LOGGER.info('local mips state changed, %s, %s', group_id, state)
mips: MipsLocalClient = self._mips_local.get(group_id, None)
if mips is None:
mips = self._mips_local.get(group_id, None)
if not mips:
_LOGGER.error(
'local mips state changed, mips not exist, %s', group_id)
return
@ -1124,7 +1130,7 @@ class MIoTClient:
if state_old == state_new:
continue
self._device_list_cache[did]['online'] = state_new
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx)
self.__request_show_devices_changed_notify()
@ -1171,7 +1177,7 @@ class MIoTClient:
if state_old == state_new:
continue
self._device_list_cache[did]['online'] = state_new
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx)
self._device_list_lan = {}
@ -1201,7 +1207,7 @@ class MIoTClient:
if state_old == state_new:
return
self._device_list_cache[did]['online'] = state_new
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(
did, MIoTDeviceState.ONLINE if state_new
@ -1257,7 +1263,7 @@ class MIoTClient:
if state_old == state_new:
return
self._device_list_cache[did]['online'] = state_new
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(
did, MIoTDeviceState.ONLINE if state_new
@ -1301,9 +1307,8 @@ class MIoTClient:
async def __load_cache_device_async(self) -> None:
"""Load device list from cache."""
cache_list: Optional[dict[str, dict]] = await self._storage.load_async(
domain='miot_devices',
name=f'{self._uid}_{self._cloud_server}',
type_=dict)
domain='miot_devices', name=f'{self._uid}_{self._cloud_server}',
type_=dict) # type: ignore
if not cache_list:
self.__show_client_error_notify(
message=self._i18n.translate(
@ -1346,7 +1351,7 @@ class MIoTClient:
cloud_state_old: Optional[bool] = self._device_list_cloud.get(
did, {}).get('online', None)
cloud_state_new: Optional[bool] = None
device_new: dict = cloud_list.pop(did, None)
device_new = cloud_list.pop(did, None)
if device_new:
cloud_state_new = device_new.get('online', None)
# Update cache device info
@ -1371,7 +1376,7 @@ class MIoTClient:
continue
info['online'] = state_new
# Call device state changed callback
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(
did, MIoTDeviceState.ONLINE if state_new
@ -1426,8 +1431,7 @@ class MIoTClient:
self, dids: list[str]
) -> None:
_LOGGER.debug('refresh cloud device with dids, %s', dids)
cloud_list: dict[str, dict] = (
await self._http.get_devices_with_dids_async(dids=dids))
cloud_list = await self._http.get_devices_with_dids_async(dids=dids)
if cloud_list is None:
_LOGGER.error('cloud http get_dev_list_async failed, %s', dids)
return
@ -1466,11 +1470,11 @@ class MIoTClient:
for did, info in self._device_list_cache.items():
if did not in filter_dids:
continue
device_old: dict = self._device_list_gateway.get(did, None)
device_old = self._device_list_gateway.get(did, None)
gw_state_old = device_old.get(
'online', False) if device_old else False
gw_state_new: bool = False
device_new: dict = gw_list.pop(did, None)
device_new = gw_list.pop(did, None)
if device_new:
# Update gateway device info
self._device_list_gateway[did] = {
@ -1493,7 +1497,7 @@ class MIoTClient:
if state_old == state_new:
continue
info['online'] = state_new
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(
did, MIoTDeviceState.ONLINE if state_new
@ -1518,7 +1522,7 @@ class MIoTClient:
if state_old == state_new:
continue
self._device_list_cache[did]['online'] = state_new
sub: MipsDeviceState = self._sub_device_state.get(did, None)
sub = self._sub_device_state.get(did, None)
if sub and sub.handler:
sub.handler(
did, MIoTDeviceState.ONLINE if state_new
@ -1533,7 +1537,7 @@ class MIoTClient:
'refresh gw devices with group_id, %s', group_id)
# Remove timer
self._mips_local_state_changed_timers.pop(group_id, None)
mips: MipsLocalClient = self._mips_local.get(group_id, None)
mips = self._mips_local.get(group_id, None)
if not mips:
_LOGGER.error('mips not exist, %s', group_id)
return
@ -1900,77 +1904,73 @@ async def get_miot_instance_async(
) -> MIoTClient:
if entry_id is None:
raise MIoTClientError('invalid entry_id')
miot_client: MIoTClient = None
if a := hass.data[DOMAIN].get('miot_clients', {}).get(entry_id, None):
miot_client = hass.data[DOMAIN].get('miot_clients', {}).get(entry_id, None)
if miot_client:
_LOGGER.info('instance exist, %s', entry_id)
miot_client = a
else:
if entry_data is None:
raise MIoTClientError('entry data is None')
# Get running loop
loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
if loop is None:
raise MIoTClientError('loop is None')
# MIoT storage
storage: Optional[MIoTStorage] = hass.data[DOMAIN].get(
'miot_storage', None)
if not storage:
storage = MIoTStorage(
root_path=entry_data['storage_path'], loop=loop)
hass.data[DOMAIN]['miot_storage'] = storage
_LOGGER.info('create miot_storage instance')
global_config: dict = await storage.load_user_config_async(
uid='global_config', cloud_server='all',
keys=['network_detect_addr', 'net_interfaces', 'enable_subscribe'])
# MIoT network
network_detect_addr: dict = global_config.get(
'network_detect_addr', {})
network: Optional[MIoTNetwork] = hass.data[DOMAIN].get(
'miot_network', None)
if not network:
network = MIoTNetwork(
ip_addr_list=network_detect_addr.get('ip', []),
url_addr_list=network_detect_addr.get('url', []),
refresh_interval=NETWORK_REFRESH_INTERVAL,
loop=loop)
hass.data[DOMAIN]['miot_network'] = network
await network.init_async()
_LOGGER.info('create miot_network instance')
# MIoT service
mips_service: Optional[MipsService] = hass.data[DOMAIN].get(
'mips_service', None)
if not mips_service:
aiozc = await zeroconf.async_get_async_instance(hass)
mips_service = MipsService(aiozc=aiozc, loop=loop)
hass.data[DOMAIN]['mips_service'] = mips_service
await mips_service.init_async()
_LOGGER.info('create mips_service instance')
# MIoT lan
miot_lan: Optional[MIoTLan] = hass.data[DOMAIN].get(
'miot_lan', None)
if not miot_lan:
miot_lan = MIoTLan(
net_ifs=global_config.get('net_interfaces', []),
network=network,
mips_service=mips_service,
enable_subscribe=global_config.get('enable_subscribe', False),
loop=loop)
hass.data[DOMAIN]['miot_lan'] = miot_lan
_LOGGER.info('create miot_lan instance')
# MIoT client
miot_client = MIoTClient(
entry_id=entry_id,
entry_data=entry_data,
return miot_client
# Create new instance
if not entry_data:
raise MIoTClientError('entry data is None')
# Get running loop
loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
if not loop:
raise MIoTClientError('loop is None')
# MIoT storage
storage: Optional[MIoTStorage] = hass.data[DOMAIN].get(
'miot_storage', None)
if not storage:
storage = MIoTStorage(
root_path=entry_data['storage_path'], loop=loop)
hass.data[DOMAIN]['miot_storage'] = storage
_LOGGER.info('create miot_storage instance')
global_config: dict = await storage.load_user_config_async(
uid='global_config', cloud_server='all',
keys=['network_detect_addr', 'net_interfaces', 'enable_subscribe'])
# MIoT network
network_detect_addr: dict = global_config.get('network_detect_addr', {})
network: Optional[MIoTNetwork] = hass.data[DOMAIN].get(
'miot_network', None)
if not network:
network = MIoTNetwork(
ip_addr_list=network_detect_addr.get('ip', []),
url_addr_list=network_detect_addr.get('url', []),
refresh_interval=NETWORK_REFRESH_INTERVAL,
loop=loop)
hass.data[DOMAIN]['miot_network'] = network
await network.init_async()
_LOGGER.info('create miot_network instance')
# MIoT service
mips_service: Optional[MipsService] = hass.data[DOMAIN].get(
'mips_service', None)
if not mips_service:
aiozc = await zeroconf.async_get_async_instance(hass)
mips_service = MipsService(aiozc=aiozc, loop=loop)
hass.data[DOMAIN]['mips_service'] = mips_service
await mips_service.init_async()
_LOGGER.info('create mips_service instance')
# MIoT lan
miot_lan: Optional[MIoTLan] = hass.data[DOMAIN].get('miot_lan', None)
if not miot_lan:
miot_lan = MIoTLan(
net_ifs=global_config.get('net_interfaces', []),
network=network,
storage=storage,
mips_service=mips_service,
miot_lan=miot_lan,
loop=loop
)
miot_client.persistent_notify = persistent_notify
hass.data[DOMAIN]['miot_clients'].setdefault(entry_id, miot_client)
_LOGGER.info(
'new miot_client instance, %s, %s', entry_id, entry_data)
await miot_client.init_async()
enable_subscribe=global_config.get('enable_subscribe', False),
loop=loop)
hass.data[DOMAIN]['miot_lan'] = miot_lan
_LOGGER.info('create miot_lan instance')
# MIoT client
miot_client = MIoTClient(
entry_id=entry_id,
entry_data=entry_data,
network=network,
storage=storage,
mips_service=mips_service,
miot_lan=miot_lan,
loop=loop
)
miot_client.persistent_notify = persistent_notify
hass.data[DOMAIN]['miot_clients'].setdefault(entry_id, miot_client)
_LOGGER.info('new miot_client instance, %s, %s', entry_id, entry_data)
await miot_client.init_async()
return miot_client

View File

@ -382,7 +382,7 @@ class MIoTHttpClient:
return res_obj['data']
async def get_central_cert_async(self, csr: str) -> Optional[str]:
async def get_central_cert_async(self, csr: str) -> str:
if not isinstance(csr, str):
raise MIoTHttpError('invalid params')

View File

@ -56,6 +56,7 @@ from homeassistant.const import (
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
DEGREE,
LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
@ -72,6 +73,7 @@ from homeassistant.const import (
UnitOfPower,
UnitOfVolume,
UnitOfVolumeFlowRate,
UnitOfDataRate
)
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.components.switch import SwitchDeviceClass
@ -243,12 +245,12 @@ class MIoTDevice:
def sub_device_state(
self, key: str, handler: Callable[[str, MIoTDeviceState], None]
) -> int:
self._sub_id += 1
sub_id = self.__gen_sub_id()
if key in self._device_state_sub_list:
self._device_state_sub_list[key][str(self._sub_id)] = handler
self._device_state_sub_list[key][str(sub_id)] = handler
else:
self._device_state_sub_list[key] = {str(self._sub_id): handler}
return self._sub_id
self._device_state_sub_list[key] = {str(sub_id): handler}
return sub_id
def unsub_device_state(self, key: str, sub_id: int) -> None:
sub_list = self._device_state_sub_list.get(key, None)
@ -266,14 +268,14 @@ class MIoTDevice:
for handler in self._value_sub_list[key].values():
handler(params, ctx)
self._sub_id += 1
sub_id = self.__gen_sub_id()
if key in self._value_sub_list:
self._value_sub_list[key][str(self._sub_id)] = handler
self._value_sub_list[key][str(sub_id)] = handler
else:
self._value_sub_list[key] = {str(self._sub_id): handler}
self._value_sub_list[key] = {str(sub_id): handler}
self.miot_client.sub_prop(
did=self._did, handler=_on_prop_changed, siid=siid, piid=piid)
return self._sub_id
return sub_id
def unsub_property(self, siid: int, piid: int, sub_id: int) -> None:
key: str = f'p.{siid}.{piid}'
@ -294,14 +296,14 @@ class MIoTDevice:
for handler in self._value_sub_list[key].values():
handler(params, ctx)
self._sub_id += 1
sub_id = self.__gen_sub_id()
if key in self._value_sub_list:
self._value_sub_list[key][str(self._sub_id)] = handler
self._value_sub_list[key][str(sub_id)] = handler
else:
self._value_sub_list[key] = {str(self._sub_id): handler}
self._value_sub_list[key] = {str(sub_id): handler}
self.miot_client.sub_event(
did=self._did, handler=_on_event_occurred, siid=siid, eiid=eiid)
return self._sub_id
return sub_id
def unsub_event(self, siid: int, eiid: int, sub_id: int) -> None:
key: str = f'e.{siid}.{eiid}'
@ -414,10 +416,12 @@ class MIoTDevice:
spec_name: str = spec_instance.name
if isinstance(SPEC_DEVICE_TRANS_MAP[spec_name], str):
spec_name = SPEC_DEVICE_TRANS_MAP[spec_name]
if 'required' not in SPEC_DEVICE_TRANS_MAP[spec_name]:
return None
# 1. The device shall have all required services.
required_services = SPEC_DEVICE_TRANS_MAP[spec_name]['required'].keys()
if not {
service.name for service in spec_instance.services
service.name for service in spec_instance.services
}.issuperset(required_services):
return None
optional_services = SPEC_DEVICE_TRANS_MAP[spec_name]['optional'].keys()
@ -427,9 +431,13 @@ class MIoTDevice:
for service in spec_instance.services:
if service.platform:
continue
required_properties: dict
optional_properties: dict
required_actions: set
optional_actions: set
# 2. The service shall have all required properties, actions.
if service.name in required_services:
required_properties: dict = SPEC_DEVICE_TRANS_MAP[spec_name][
required_properties = SPEC_DEVICE_TRANS_MAP[spec_name][
'required'].get(
service.name, {}
).get('required', {}).get('properties', {})
@ -446,7 +454,7 @@ class MIoTDevice:
service.name, {}
).get('optional', {}).get('actions', set({}))
elif service.name in optional_services:
required_properties: dict = SPEC_DEVICE_TRANS_MAP[spec_name][
required_properties = SPEC_DEVICE_TRANS_MAP[spec_name][
'optional'].get(
service.name, {}
).get('required', {}).get('properties', {})
@ -484,7 +492,7 @@ class MIoTDevice:
set(required_properties.keys()), optional_properties):
if prop.unit:
prop.external_unit = self.unit_convert(prop.unit)
prop.icon = self.icon_convert(prop.unit)
# prop.icon = self.icon_convert(prop.unit)
prop.platform = platform
entity_data.props.add(prop)
# action
@ -499,85 +507,95 @@ class MIoTDevice:
return entity_data
def parse_miot_service_entity(
self, service_instance: MIoTSpecService
self, miot_service: MIoTSpecService
) -> Optional[MIoTEntityData]:
service = service_instance
if service.platform or (service.name not in SPEC_SERVICE_TRANS_MAP):
if (
miot_service.platform
or miot_service.name not in SPEC_SERVICE_TRANS_MAP
):
return None
service_name = service.name
service_name = miot_service.name
if isinstance(SPEC_SERVICE_TRANS_MAP[service_name], str):
service_name = SPEC_SERVICE_TRANS_MAP[service_name]
# 1. The service shall have all required properties.
if 'required' not in SPEC_SERVICE_TRANS_MAP[service_name]:
return None
# Required properties, required access mode
required_properties: dict = SPEC_SERVICE_TRANS_MAP[service_name][
'required'].get('properties', {})
if not {
prop.name for prop in service.properties if prop.access
prop.name for prop in miot_service.properties if prop.access
}.issuperset(set(required_properties.keys())):
return None
# 2. The required property shall have all required access mode.
for prop in service.properties:
for prop in miot_service.properties:
if prop.name in required_properties:
if not set(prop.access).issuperset(
required_properties[prop.name]):
return None
# Required actions
# Required events
platform = SPEC_SERVICE_TRANS_MAP[service_name]['entity']
entity_data = MIoTEntityData(platform=platform, spec=service_instance)
entity_data = MIoTEntityData(platform=platform, spec=miot_service)
# Optional properties
optional_properties = SPEC_SERVICE_TRANS_MAP[service_name][
'optional'].get('properties', set({}))
for prop in service.properties:
for prop in miot_service.properties:
if prop.name in set.union(
set(required_properties.keys()), optional_properties):
if prop.unit:
prop.external_unit = self.unit_convert(prop.unit)
prop.icon = self.icon_convert(prop.unit)
# prop.icon = self.icon_convert(prop.unit)
prop.platform = platform
entity_data.props.add(prop)
# action
# event
# No actions or events is in SPEC_SERVICE_TRANS_MAP now.
service.platform = platform
# Optional actions
# Optional events
miot_service.platform = platform
return entity_data
def parse_miot_property_entity(
self, property_instance: MIoTSpecProperty
) -> Optional[dict[str, str]]:
prop = property_instance
def parse_miot_property_entity(self, miot_prop: MIoTSpecProperty) -> bool:
if (
prop.platform
or (prop.name not in SPEC_PROP_TRANS_MAP['properties'])
miot_prop.platform
or miot_prop.name not in SPEC_PROP_TRANS_MAP['properties']
):
return None
prop_name = prop.name
return False
prop_name = miot_prop.name
if isinstance(SPEC_PROP_TRANS_MAP['properties'][prop_name], str):
prop_name = SPEC_PROP_TRANS_MAP['properties'][prop_name]
platform = SPEC_PROP_TRANS_MAP['properties'][prop_name]['entity']
# Check
prop_access: set = set({})
if prop.readable:
if miot_prop.readable:
prop_access.add('read')
if prop.writable:
if miot_prop.writable:
prop_access.add('write')
if prop_access != (SPEC_PROP_TRANS_MAP[
'entities'][platform]['access']):
return None
if prop.format_.__name__ not in SPEC_PROP_TRANS_MAP[
return False
if miot_prop.format_.__name__ not in SPEC_PROP_TRANS_MAP[
'entities'][platform]['format']:
return None
if prop.unit:
prop.external_unit = self.unit_convert(prop.unit)
prop.icon = self.icon_convert(prop.unit)
device_class = SPEC_PROP_TRANS_MAP['properties'][prop_name][
return False
miot_prop.device_class = SPEC_PROP_TRANS_MAP['properties'][prop_name][
'device_class']
result = {'platform': platform, 'device_class': device_class}
# optional:
if 'optional' in SPEC_PROP_TRANS_MAP['properties'][prop_name]:
optional = SPEC_PROP_TRANS_MAP['properties'][prop_name]['optional']
if 'state_class' in optional:
result['state_class'] = optional['state_class']
if not prop.unit and 'unit_of_measurement' in optional:
result['unit_of_measurement'] = optional['unit_of_measurement']
return result
# Optional params
if 'state_class' in SPEC_PROP_TRANS_MAP['properties'][prop_name]:
miot_prop.state_class = SPEC_PROP_TRANS_MAP['properties'][
prop_name]['state_class']
if (
not miot_prop.external_unit
and 'unit_of_measurement' in SPEC_PROP_TRANS_MAP['properties'][
prop_name]
):
# Priority: spec_modify.unit > unit_convert > specv2entity.unit
miot_prop.external_unit = SPEC_PROP_TRANS_MAP['properties'][
prop_name]['unit_of_measurement']
if (
not miot_prop.icon
and 'icon' in SPEC_PROP_TRANS_MAP['properties'][prop_name]
):
# Priority: spec_modify.icon > icon_convert > specv2entity.icon
miot_prop.icon = SPEC_PROP_TRANS_MAP['properties'][prop_name][
'icon']
miot_prop.platform = platform
return True
def spec_transform(self) -> None:
"""Parse service, property, event, action from device spec."""
@ -589,7 +607,7 @@ class MIoTDevice:
# STEP 2: service conversion
for service in self.spec_instance.services:
service_entity = self.parse_miot_service_entity(
service_instance=service)
miot_service=service)
if service_entity:
self.append_entity(entity_data=service_entity)
# STEP 3.1: property conversion
@ -598,20 +616,11 @@ class MIoTDevice:
continue
if prop.unit:
prop.external_unit = self.unit_convert(prop.unit)
prop.icon = self.icon_convert(prop.unit)
prop_entity = self.parse_miot_property_entity(
property_instance=prop)
if prop_entity:
prop.platform = prop_entity['platform']
prop.device_class = prop_entity['device_class']
if 'state_class' in prop_entity:
prop.state_class = prop_entity['state_class']
if 'unit_of_measurement' in prop_entity:
prop.external_unit = self.unit_convert(
prop_entity['unit_of_measurement'])
prop.icon = self.icon_convert(
prop_entity['unit_of_measurement'])
# general conversion
if not prop.icon:
prop.icon = self.icon_convert(prop.unit)
# Special conversion
self.parse_miot_property_entity(miot_prop=prop)
# General conversion
if not prop.platform:
if prop.writable:
if prop.format_ == str:
@ -625,7 +634,7 @@ class MIoTDevice:
prop.platform = 'number'
else:
# Irregular property will not be transformed.
pass
continue
elif prop.readable or prop.notifiable:
if prop.format_ == bool:
prop.platform = 'binary_sensor'
@ -653,11 +662,66 @@ class MIoTDevice:
self.append_action(action=action)
def unit_convert(self, spec_unit: str) -> Optional[str]:
"""Convert MIoT unit to Home Assistant unit."""
"""Convert MIoT unit to Home Assistant unit.
25/01/20: All online prop unit statistical tables: unit, quantity.
{
"no_unit": 148499,
"percentage": 10042,
"kelvin": 1895,
"rgb": 772, // color
"celsius": 5762,
"none": 16106,
"hours": 1540,
"minutes": 5061,
"ms": 27,
"watt": 216,
"arcdegrees": 159,
"ppm": 177,
"μg/m3": 106,
"days": 571,
"seconds": 2749,
"B/s": 21,
"pascal": 110,
"mg/m3": 339,
"lux": 125,
"kWh": 124,
"mv": 2,
"V": 38,
"A": 29,
"mV": 4,
"L": 352,
"m": 37,
"毫摩尔每升": 2, // blood-sugar, cholesterol
"mmol/L": 1, // urea
"weeks": 26,
"meter": 3,
"dB": 26,
"hour": 14,
"calorie": 19, // 1 cal = 4.184 J
"ppb": 3,
"arcdegress": 30,
"bpm": 4, // realtime-heartrate
"gram": 7,
"km/h": 9,
"W": 1,
"m3/h": 2,
"kilopascal": 1,
"mL": 4,
"mmHg": 4,
"w": 1,
"liter": 1,
"cm": 3,
"mA": 2,
"kilogram": 2,
"kcal/d": 2, // basal-metabolism
"times": 1 // exercise-count
}
"""
unit_map = {
'percentage': PERCENTAGE,
'weeks': UnitOfTime.WEEKS,
'days': UnitOfTime.DAYS,
'hour': UnitOfTime.HOURS,
'hours': UnitOfTime.HOURS,
'minutes': UnitOfTime.MINUTES,
'seconds': UnitOfTime.SECONDS,
@ -672,30 +736,48 @@ class MIoTDevice:
'ppb': CONCENTRATION_PARTS_PER_BILLION,
'lux': LIGHT_LUX,
'pascal': UnitOfPressure.PA,
'kilopascal': UnitOfPressure.KPA,
'mmHg': UnitOfPressure.MMHG,
'bar': UnitOfPressure.BAR,
'watt': UnitOfPower.WATT,
'L': UnitOfVolume.LITERS,
'liter': UnitOfVolume.LITERS,
'mL': UnitOfVolume.MILLILITERS,
'km/h': UnitOfSpeed.KILOMETERS_PER_HOUR,
'm/s': UnitOfSpeed.METERS_PER_SECOND,
'watt': UnitOfPower.WATT,
'w': UnitOfPower.WATT,
'W': UnitOfPower.WATT,
'kWh': UnitOfEnergy.KILO_WATT_HOUR,
'A': UnitOfElectricCurrent.AMPERE,
'mA': UnitOfElectricCurrent.MILLIAMPERE,
'V': UnitOfElectricPotential.VOLT,
'mv': UnitOfElectricPotential.MILLIVOLT,
'mV': UnitOfElectricPotential.MILLIVOLT,
'cm': UnitOfLength.CENTIMETERS,
'm': UnitOfLength.METERS,
'meter': UnitOfLength.METERS,
'km': UnitOfLength.KILOMETERS,
'm3/h': UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
'gram': UnitOfMass.GRAMS,
'kilogram': UnitOfMass.KILOGRAMS,
'dB': SIGNAL_STRENGTH_DECIBELS,
'arcdegrees': DEGREE,
'arcdegress': DEGREE,
'kB': UnitOfInformation.KILOBYTES,
'MB': UnitOfInformation.MEGABYTES,
'GB': UnitOfInformation.GIGABYTES,
'TB': UnitOfInformation.TERABYTES,
'B/s': UnitOfDataRate.BYTES_PER_SECOND,
'KB/s': UnitOfDataRate.KILOBYTES_PER_SECOND,
'MB/s': UnitOfDataRate.MEGABYTES_PER_SECOND,
'GB/s': UnitOfDataRate.GIGABYTES_PER_SECOND
}
# Handle UnitOfConductivity separately since
# it might not be available in all HA versions
try:
# pylint: disable=import-outside-toplevel
from homeassistant.const import UnitOfConductivity
from homeassistant.const import UnitOfConductivity # type: ignore
unit_map['μS/cm'] = UnitOfConductivity.MICROSIEMENS_PER_CM
except Exception: # pylint: disable=broad-except
unit_map['μS/cm'] = 'μS/cm'
@ -703,59 +785,66 @@ class MIoTDevice:
return unit_map.get(spec_unit, None)
def icon_convert(self, spec_unit: str) -> Optional[str]:
if spec_unit in ['percentage']:
if spec_unit in {'percentage'}:
return 'mdi:percent'
if spec_unit in [
'weeks', 'days', 'hours', 'minutes', 'seconds', 'ms', 'μs']:
if spec_unit in {
'weeks', 'days', 'hour', 'hours', 'minutes', 'seconds', 'ms', 'μs'
}:
return 'mdi:clock'
if spec_unit in ['celsius']:
if spec_unit in {'celsius'}:
return 'mdi:temperature-celsius'
if spec_unit in ['fahrenheit']:
if spec_unit in {'fahrenheit'}:
return 'mdi:temperature-fahrenheit'
if spec_unit in ['kelvin']:
if spec_unit in {'kelvin'}:
return 'mdi:temperature-kelvin'
if spec_unit in ['μg/m3', 'mg/m3', 'ppm', 'ppb']:
if spec_unit in {'μg/m3', 'mg/m3', 'ppm', 'ppb'}:
return 'mdi:blur'
if spec_unit in ['lux']:
if spec_unit in {'lux'}:
return 'mdi:brightness-6'
if spec_unit in ['pascal', 'megapascal', 'bar']:
if spec_unit in {'pascal', 'kilopascal', 'megapascal', 'mmHg', 'bar'}:
return 'mdi:gauge'
if spec_unit in ['watt']:
if spec_unit in {'watt', 'w', 'W'}:
return 'mdi:flash-triangle'
if spec_unit in ['L', 'mL']:
if spec_unit in {'L', 'mL'}:
return 'mdi:gas-cylinder'
if spec_unit in ['km/h', 'm/s']:
if spec_unit in {'km/h', 'm/s'}:
return 'mdi:speedometer'
if spec_unit in ['kWh']:
if spec_unit in {'kWh'}:
return 'mdi:transmission-tower'
if spec_unit in ['A', 'mA']:
if spec_unit in {'A', 'mA'}:
return 'mdi:current-ac'
if spec_unit in ['V', 'mV']:
if spec_unit in {'V', 'mv', 'mV'}:
return 'mdi:current-dc'
if spec_unit in ['m', 'km']:
if spec_unit in {'cm', 'm', 'meter', 'km'}:
return 'mdi:ruler'
if spec_unit in ['rgb']:
if spec_unit in {'rgb'}:
return 'mdi:palette'
if spec_unit in ['m3/h', 'L/s']:
if spec_unit in {'m3/h', 'L/s'}:
return 'mdi:pipe-leak'
if spec_unit in ['μS/cm']:
if spec_unit in {'μS/cm'}:
return 'mdi:resistor-nodes'
if spec_unit in ['gram']:
if spec_unit in {'gram', 'kilogram'}:
return 'mdi:weight'
if spec_unit in ['dB']:
if spec_unit in {'dB'}:
return 'mdi:signal-distance-variant'
if spec_unit in ['times']:
if spec_unit in {'times'}:
return 'mdi:counter'
if spec_unit in ['mmol/L']:
if spec_unit in {'mmol/L'}:
return 'mdi:dots-hexagon'
if spec_unit in ['arcdegress']:
return 'mdi:angle-obtuse'
if spec_unit in ['kB']:
if spec_unit in {'kB', 'MB', 'GB'}:
return 'mdi:network-pos'
if spec_unit in ['calorie', 'kCal']:
if spec_unit in {'arcdegress', 'arcdegrees'}:
return 'mdi:angle-obtuse'
if spec_unit in {'B/s', 'KB/s', 'MB/s', 'GB/s'}:
return 'mdi:network'
if spec_unit in {'calorie', 'kCal'}:
return 'mdi:food'
return None
def __gen_sub_id(self) -> int:
self._sub_id += 1
return self._sub_id
def __on_device_state_changed(
self, did: str, state: MIoTDeviceState, ctx: Any
) -> None:
@ -1184,6 +1273,7 @@ class MIoTPropertyEntity(Entity):
def __on_value_changed(self, params: dict, ctx: Any) -> None:
_LOGGER.debug('property changed, %s', params)
self._value = self.spec.value_format(params['value'])
self._value = self.spec.eval_expr(self._value)
if not self._pending_write_ha_state_timer:
self.async_write_ha_state()

View File

@ -1414,7 +1414,7 @@ class MipsLocalClient(_MipsClient):
@final
@on_dev_list_changed.setter
def on_dev_list_changed(
self, func: Callable[[Any, list[str]], Coroutine]
self, func: Optional[Callable[[Any, list[str]], Coroutine]]
) -> None:
"""run in main loop."""
self._on_dev_list_changed = func

View File

@ -469,14 +469,13 @@ class _MIoTSpecBase:
proprietary: bool
need_filter: bool
name: str
icon: Optional[str]
# External params
platform: Optional[str]
device_class: Any
state_class: Any
icon: Optional[str]
external_unit: Any
expression: Optional[str]
spec_id: int
@ -489,13 +488,12 @@ class _MIoTSpecBase:
self.proprietary = spec.get('proprietary', False)
self.need_filter = spec.get('need_filter', False)
self.name = spec.get('name', 'xiaomi')
self.icon = spec.get('icon', None)
self.platform = None
self.device_class = None
self.state_class = None
self.icon = None
self.external_unit = None
self.expression = None
self.spec_id = hash(f'{self.type_}.{self.iid}')
@ -510,6 +508,7 @@ class MIoTSpecProperty(_MIoTSpecBase):
"""MIoT SPEC property class."""
unit: Optional[str]
precision: int
expr: Optional[str]
_format_: Type
_value_range: Optional[MIoTSpecValueRange]
@ -531,7 +530,8 @@ class MIoTSpecProperty(_MIoTSpecBase):
unit: Optional[str] = None,
value_range: Optional[dict] = None,
value_list: Optional[list[dict]] = None,
precision: Optional[int] = None
precision: Optional[int] = None,
expr: Optional[str] = None
) -> None:
super().__init__(spec=spec)
self.service = service
@ -541,6 +541,7 @@ class MIoTSpecProperty(_MIoTSpecBase):
self.value_range = value_range
self.value_list = value_list
self.precision = precision or 1
self.expr = expr
self.spec_id = hash(
f'p.{self.name}.{self.service.iid}.{self.iid}')
@ -613,6 +614,18 @@ class MIoTSpecProperty(_MIoTSpecBase):
elif isinstance(value, MIoTSpecValueList):
self._value_list = value
def eval_expr(self, src_value: Any) -> Any:
if not self.expr:
return src_value
try:
# pylint: disable=eval-used
return eval(self.expr, {'src_value': src_value})
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.error(
'eval expression error, %s, %s, %s, %s',
self.iid, src_value, self.expr, err)
return src_value
def value_format(self, value: Any) -> Any:
if value is None:
return None
@ -639,7 +652,9 @@ class MIoTSpecProperty(_MIoTSpecBase):
'value_range': (
self._value_range.dump() if self._value_range else None),
'value_list': self._value_list.dump() if self._value_list else None,
'precision': self.precision
'precision': self.precision,
'expr': self.expr,
'icon': self.icon
}
@ -732,7 +747,6 @@ class MIoTSpecService(_MIoTSpecBase):
}
# @dataclass
class MIoTSpecInstance:
"""MIoT SPEC instance class."""
urn: str
@ -774,7 +788,8 @@ class MIoTSpecInstance:
unit=prop['unit'],
value_range=prop['value_range'],
value_list=prop['value_list'],
precision=prop.get('precision', None))
precision=prop.get('precision', None),
expr=prop.get('expr', None))
spec_service.properties.append(spec_prop)
for event in service['events']:
spec_event = MIoTSpecEvent(
@ -1119,6 +1134,79 @@ class _SpecFilter:
return False
class _SpecModify:
"""MIoT-Spec-V2 modify for entity conversion."""
_SPEC_MODIFY_FILE = 'specs/spec_modify.yaml'
_main_loop: asyncio.AbstractEventLoop
_data: Optional[dict]
_selected: Optional[dict]
def __init__(
self, loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
self._main_loop = loop or asyncio.get_running_loop()
self._data = None
async def init_async(self) -> None:
if isinstance(self._data, dict):
return
modify_data = None
self._data = {}
self._selected = None
try:
modify_data = await self._main_loop.run_in_executor(
None, load_yaml_file,
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
self._SPEC_MODIFY_FILE))
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.error('spec modify, load file error, %s', err)
return
if not isinstance(modify_data, dict):
_LOGGER.error('spec modify, invalid spec modify content')
return
for key, value in modify_data.items():
if not isinstance(key, str) or not isinstance(value, (dict, str)):
_LOGGER.error('spec modify, invalid spec modify data')
return
self._data = modify_data
async def deinit_async(self) -> None:
self._data = None
self._selected = None
async def set_spec_async(self, urn: str) -> None:
if not self._data:
return
self._selected = self._data.get(urn, None)
if isinstance(self._selected, str):
return await self.set_spec_async(urn=self._selected)
def get_prop_unit(self, siid: int, piid: int) -> Optional[str]:
return self.__get_prop_item(siid=siid, piid=piid, key='unit')
def get_prop_expr(self, siid: int, piid: int) -> Optional[str]:
return self.__get_prop_item(siid=siid, piid=piid, key='expr')
def get_prop_icon(self, siid: int, piid: int) -> Optional[str]:
return self.__get_prop_item(siid=siid, piid=piid, key='icon')
def get_prop_access(self, siid: int, piid: int) -> Optional[list]:
access = self.__get_prop_item(siid=siid, piid=piid, key='access')
if not isinstance(access, list):
return None
return access
def __get_prop_item(self, siid: int, piid: int, key: str) -> Optional[str]:
if not self._selected:
return None
prop = self._selected.get(f'prop.{siid}.{piid}', None)
if not prop:
return None
return prop.get(key, None)
class MIoTSpecParser:
"""MIoT SPEC parser."""
# pylint: disable=inconsistent-quotes
@ -1132,6 +1220,7 @@ class MIoTSpecParser:
_multi_lang: _MIoTSpecMultiLang
_bool_trans: _SpecBoolTranslation
_spec_filter: _SpecFilter
_spec_modify: _SpecModify
_init_done: bool
@ -1149,6 +1238,7 @@ class MIoTSpecParser:
self._bool_trans = _SpecBoolTranslation(
lang=self._lang, loop=self._main_loop)
self._spec_filter = _SpecFilter(loop=self._main_loop)
self._spec_modify = _SpecModify(loop=self._main_loop)
self._init_done = False
@ -1157,6 +1247,7 @@ class MIoTSpecParser:
return
await self._bool_trans.init_async()
await self._spec_filter.init_async()
await self._spec_modify.init_async()
std_lib_cache = await self._storage.load_async(
domain=self._DOMAIN, name='spec_std_lib', type_=dict)
if (
@ -1196,6 +1287,7 @@ class MIoTSpecParser:
# self._std_lib.deinit()
await self._bool_trans.deinit_async()
await self._spec_filter.deinit_async()
await self._spec_modify.deinit_async()
async def parse(
self, urn: str, skip_cache: bool = False,
@ -1275,6 +1367,8 @@ class MIoTSpecParser:
await self._multi_lang.set_spec_async(urn=urn)
# Set spec filter
await self._spec_filter.set_spec_spec(urn_key=urn_key)
# Set spec modify
await self._spec_modify.set_spec_async(urn=urn)
# Parse device type
spec_instance: MIoTSpecInstance = MIoTSpecInstance(
urn=urn, name=urn_strs[3],
@ -1320,12 +1414,14 @@ class MIoTSpecParser:
):
continue
p_type_strs: list[str] = property_['type'].split(':')
# Handle special property.unit
unit = property_.get('unit', None)
spec_prop: MIoTSpecProperty = MIoTSpecProperty(
spec=property_,
service=spec_service,
format_=property_['format'],
access=property_['access'],
unit=property_.get('unit', None))
unit=unit if unit != 'none' else None)
spec_prop.name = p_type_strs[3]
# Filter spec property
spec_prop.need_filter = (
@ -1365,7 +1461,19 @@ class MIoTSpecParser:
if v_descriptions:
# bool without value-list.name
spec_prop.value_list = v_descriptions
# Prop modify
spec_prop.unit = self._spec_modify.get_prop_unit(
siid=service['iid'], piid=property_['iid']
) or spec_prop.unit
spec_prop.expr = self._spec_modify.get_prop_expr(
siid=service['iid'], piid=property_['iid'])
spec_prop.icon = self._spec_modify.get_prop_icon(
siid=service['iid'], piid=property_['iid'])
spec_service.properties.append(spec_prop)
custom_access = self._spec_modify.get_prop_access(
siid=service['iid'], piid=property_['iid'])
if custom_access:
spec_prop.access = custom_access
# Parse service event
for event in service.get('events', []):
if (

View File

@ -59,43 +59,6 @@ data:
urn:miot-spec-v2:property:wifi-ssid-hidden:000000E3: yes_no
urn:miot-spec-v2:property:wind-reverse:00000117: yes_no
translate:
contact_state:
de:
'false': Kein Kontakt
'true': Kontakt
en:
'false': No Contact
'true': Contact
es:
'false': Sin contacto
'true': Contacto
fr:
'false': Pas de contact
'true': Contact
it:
'false': Nessun contatto
'true': Contatto
ja:
'false': 非接触
'true': 接触
nl:
'false': Geen contact
'true': Contact
pt:
'false': Sem contato
'true': Contato
pt-BR:
'false': Sem contato
'true': Contato
ru:
'false': Нет контакта
'true': Контакт
zh-Hans:
'false': 分离
'true': 接触
zh-Hant:
'false': 分離
'true': 接觸
default:
de:
'false': Falsch
@ -133,6 +96,43 @@ translate:
zh-Hant:
'false':
'true':
contact_state:
de:
'false': Kein Kontakt
'true': Kontakt
en:
'false': No Contact
'true': Contact
es:
'false': Sin contacto
'true': Contacto
fr:
'false': Pas de contact
'true': Contact
it:
'false': Nessun contatto
'true': Contatto
ja:
'false': 非接触
'true': 接触
nl:
'false': Geen contact
'true': Contact
pt:
'false': Sem contato
'true': Contato
pt-BR:
'false': Sem contato
'true': Contato
ru:
'false': Нет контакта
'true': Контакт
zh-Hans:
'false': 分离
'true': 接触
zh-Hant:
'false': 分離
'true': 接觸
motion_state:
de:
'false': Keine Bewegung erkannt

View File

@ -0,0 +1,44 @@
urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1:
prop.2.1:
name: access-mode
access:
- read
- notify
prop.2.2:
name: ip-address
icon: mdi:ip
prop.2.3:
name: wifi-ssid
access:
- read
- notify
urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:2: urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1
urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:3: urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:1
urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1:
prop.5.1:
name: power-consumption
expr: round(src_value/1000, 3)
urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:2: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1
urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:3: urn:miot-spec-v2:device:outlet:0000A002:chuangmi-212a01:1
urn:miot-spec-v2:device:outlet:0000A002:cuco-cp1md:1:
prop.2.2:
name: power-consumption
expr: round(src_value/1000, 3)
urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:1:
prop.11.1:
name: power-consumption
expr: round(src_value/100, 2)
urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:2: urn:miot-spec-v2:device:outlet:0000A002:cuco-v3:1
urn:miot-spec-v2:device:outlet:0000A002:zimi-zncz01:2:0000C816:
prop.3.1:
name: electric-power
expr: round(src_value/100, 2)
urn:miot-spec-v2:device:router:0000A036:xiaomi-rd08:1:
prop.2.1:
name: download-speed
icon: mdi:download
unit: B/s
prop.2.2:
name: upload-speed
icon: mdi:upload
unit: B/s

View File

@ -50,10 +50,15 @@ from homeassistant.components.sensor import SensorStateClass
from homeassistant.components.event import EventDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
LIGHT_LUX,
UnitOfEnergy,
UnitOfPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfTemperature,
UnitOfPressure,
PERCENTAGE
)
# pylint: disable=pointless-string-statement
@ -96,7 +101,7 @@ from homeassistant.const import (
}
}
"""
SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = {
SPEC_DEVICE_TRANS_MAP: dict = {
'humidifier': {
'required': {
'humidifier': {
@ -263,7 +268,7 @@ SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = {
}
}
"""
SPEC_SERVICE_TRANS_MAP: dict[str, dict | str] = {
SPEC_SERVICE_TRANS_MAP: dict = {
'light': {
'required': {
'properties': {
@ -334,15 +339,13 @@ SPEC_SERVICE_TRANS_MAP: dict[str, dict | str] = {
'<property instance name>':{
'device_class': str,
'entity': str,
'optional':{
'state_class': str,
'unit_of_measurement': str
}
'state_class'?: str,
'unit_of_measurement'?: str
}
}
}
"""
SPEC_PROP_TRANS_MAP: dict[str, dict | str] = {
SPEC_PROP_TRANS_MAP: dict = {
'entities': {
'sensor': {
'format': {'int', 'float'},
@ -356,107 +359,111 @@ SPEC_PROP_TRANS_MAP: dict[str, dict | str] = {
'properties': {
'temperature': {
'device_class': SensorDeviceClass.TEMPERATURE,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfTemperature.CELSIUS
},
'relative-humidity': {
'device_class': SensorDeviceClass.HUMIDITY,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': PERCENTAGE
},
'air-quality-index': {
'device_class': SensorDeviceClass.AQI,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
},
'pm2.5-density': {
'device_class': SensorDeviceClass.PM25,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
},
'pm10-density': {
'device_class': SensorDeviceClass.PM10,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
},
'pm1': {
'device_class': SensorDeviceClass.PM1,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
},
'atmospheric-pressure': {
'device_class': SensorDeviceClass.ATMOSPHERIC_PRESSURE,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfPressure.PA
},
'tvoc-density': {
'device_class': SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT
},
'voc-density': 'tvoc-density',
'battery-level': {
'device_class': SensorDeviceClass.BATTERY,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': PERCENTAGE
},
'voltage': {
'device_class': SensorDeviceClass.VOLTAGE,
'entity': 'sensor',
'optional': {
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfElectricPotential.VOLT
}
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfElectricPotential.VOLT
},
'electric-current': {
'device_class': SensorDeviceClass.CURRENT,
'entity': 'sensor',
'optional': {
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfElectricCurrent.AMPERE
}
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfElectricCurrent.AMPERE
},
'illumination': {
'device_class': SensorDeviceClass.ILLUMINANCE,
'entity': 'sensor'
'entity': 'sensor',
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': LIGHT_LUX
},
'no-one-determine-time': {
'device_class': SensorDeviceClass.DURATION,
'entity': 'sensor'
},
'has-someone-duration': 'no-one-determine-time',
'no-one-duration': 'no-one-determine-time',
'electric-power': {
'device_class': SensorDeviceClass.POWER,
'entity': 'sensor',
'optional': {
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfPower.WATT
}
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfPower.WATT
},
'surge-power': {
'device_class': SensorDeviceClass.POWER,
'entity': 'sensor',
'optional': {
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfPower.WATT
}
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfPower.WATT
},
'power-consumption': {
'device_class': SensorDeviceClass.ENERGY,
'entity': 'sensor',
'optional': {
'state_class': SensorStateClass.TOTAL_INCREASING,
'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR
}
'state_class': SensorStateClass.TOTAL_INCREASING,
'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR
},
'power': {
'device_class': SensorDeviceClass.POWER,
'entity': 'sensor',
'optional': {
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfPower.WATT
}
'state_class': SensorStateClass.MEASUREMENT,
'unit_of_measurement': UnitOfPower.WATT
},
'total-battery': {
'device_class': SensorDeviceClass.ENERGY,
'entity': 'sensor',
'optional': {
'state_class': SensorStateClass.TOTAL_INCREASING,
'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR
}
},
'has-someone-duration': 'no-one-determine-time',
'no-one-duration': 'no-one-determine-time'
'state_class': SensorStateClass.TOTAL_INCREASING,
'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR
}
}
}

View File

@ -95,7 +95,7 @@ class Sensor(MIoTPropertyEntity, SensorEntity):
# Set device_class
if self._value_list:
self._attr_device_class = SensorDeviceClass.ENUM
self._attr_icon = 'mdi:message-text'
self._attr_icon = 'mdi:format-text'
self._attr_native_unit_of_measurement = None
self._attr_options = self._value_list.descriptions
else:
@ -109,6 +109,9 @@ class Sensor(MIoTPropertyEntity, SensorEntity):
self._attr_device_class, None) # type: ignore
self._attr_native_unit_of_measurement = list(
unit_sets)[0] if unit_sets else None
# Set suggested precision
if spec.format_ in {int, float}:
self._attr_suggested_display_precision = spec.precision
# Set state_class
if spec.state_class:
self._attr_state_class = spec.state_class

View File

@ -20,6 +20,9 @@ SPEC_BOOL_TRANS_FILE = path.join(
SPEC_FILTER_FILE = path.join(
ROOT_PATH,
'../custom_components/xiaomi_home/miot/specs/spec_filter.yaml')
SPEC_MODIFY_FILE = path.join(
ROOT_PATH,
'../custom_components/xiaomi_home/miot/specs/spec_modify.yaml')
def load_json_file(file_path: str) -> Optional[dict]:
@ -54,7 +57,8 @@ def load_yaml_file(file_path: str) -> Optional[dict]:
def save_yaml_file(file_path: str, data: dict) -> None:
with open(file_path, 'w', encoding='utf-8') as file:
yaml.safe_dump(
data, file, default_flow_style=False, allow_unicode=True, indent=2)
data, file, default_flow_style=False,
allow_unicode=True, indent=2, sort_keys=False)
def dict_str_str(d: dict) -> bool:
@ -135,6 +139,21 @@ def bool_trans(d: dict) -> bool:
return True
def spec_modify(data: dict) -> bool:
"""dict[str, str | dict[str, dict]]"""
if not isinstance(data, dict):
return False
for urn, content in data.items():
if not isinstance(urn, str) or not isinstance(content, (dict, str)):
return False
if isinstance(content, str):
continue
for key, value in content.items():
if not isinstance(key, str) or not isinstance(value, dict):
return False
return True
def compare_dict_structure(dict1: dict, dict2: dict) -> bool:
if not isinstance(dict1, dict) or not isinstance(dict2, dict):
_LOGGER.info('invalid type')
@ -181,6 +200,12 @@ def sort_spec_filter(file_path: str):
return filter_data
def sort_spec_modify(file_path: str):
filter_data = load_yaml_file(file_path=file_path)
assert isinstance(filter_data, dict), f'{file_path} format error'
return dict(sorted(filter_data.items()))
@pytest.mark.github
def test_bool_trans():
data = load_yaml_file(SPEC_BOOL_TRANS_FILE)
@ -197,6 +222,14 @@ def test_spec_filter():
assert spec_filter(data), f'{SPEC_FILTER_FILE} format error'
@pytest.mark.github
def test_spec_modify():
data = load_yaml_file(SPEC_MODIFY_FILE)
assert isinstance(data, dict)
assert data, f'load {SPEC_MODIFY_FILE} failed'
assert spec_modify(data), f'{SPEC_MODIFY_FILE} format error'
@pytest.mark.github
def test_miot_i18n():
for file_name in listdir(MIOT_I18N_RELATIVE_PATH):
@ -286,3 +319,6 @@ def test_sort_spec_data():
sort_data = sort_spec_filter(file_path=SPEC_FILTER_FILE)
save_yaml_file(file_path=SPEC_FILTER_FILE, data=sort_data)
_LOGGER.info('%s formatted.', SPEC_FILTER_FILE)
sort_data = sort_spec_modify(file_path=SPEC_MODIFY_FILE)
save_yaml_file(file_path=SPEC_MODIFY_FILE, data=sort_data)
_LOGGER.info('%s formatted.', SPEC_MODIFY_FILE)