16 Commits

15 changed files with 226 additions and 31 deletions

View File

@ -29,7 +29,6 @@ jobs:
uses: hacs/action@main
with:
category: integration
ignore: brands
validate-lint:
runs-on: ubuntu-latest

View File

@ -254,13 +254,18 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.OFF and self._prop_on:
# set air-conditioner off
if hvac_mode == HVACMode.OFF:
if not await self.set_property_async(
prop=self._prop_on, value=False):
raise RuntimeError(
f'set climate prop.on failed, {hvac_mode}, '
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)
# set mode
mode_value = self.get_map_value(
map_=self._hvac_mode_map, description=hvac_mode)
if (
@ -366,7 +371,7 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity):
@ property
def hvac_mode(self) -> Optional[HVACMode]:
"""Return the hvac mode. e.g., heat, cool mode."""
if self._prop_on and self.get_prop_value(prop=self._prop_on) is False:
if self.get_prop_value(prop=self._prop_on) is False:
return HVACMode.OFF
return self.get_map_description(
map_=self._hvac_mode_map,

View File

@ -98,7 +98,7 @@ _LOGGER = logging.getLogger(__name__)
class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Xiaomi Home config flow."""
# pylint: disable=unused-argument
# pylint: disable=unused-argument, inconsistent-quotes
VERSION = 1
MINOR_VERSION = 1
_main_loop: asyncio.AbstractEventLoop
@ -575,6 +575,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Xiaomi MiHome options flow."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
_config_entry: config_entries.ConfigEntry
_main_loop: asyncio.AbstractEventLoop
_miot_client: Optional[MIoTClient]
@ -1254,6 +1255,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
async def handle_oauth_webhook(hass, webhook_id, request):
# pylint: disable=inconsistent-quotes
try:
data = dict(request.query)
if data.get('code', None) is None or data.get('state', None) is None:

View File

@ -97,9 +97,9 @@ class Cover(MIoTServiceEntity, CoverEntity):
_prop_motor_value_close: Optional[int]
_prop_motor_value_pause: Optional[int]
_prop_status: Optional[MIoTSpecProperty]
_prop_status_opening: Optional[bool]
_prop_status_closing: Optional[bool]
_prop_status_stop: Optional[bool]
_prop_status_opening: Optional[int]
_prop_status_closing: Optional[int]
_prop_status_stop: Optional[int]
_prop_current_position: Optional[MIoTSpecProperty]
_prop_target_position: Optional[MIoTSpecProperty]
_prop_position_value_min: Optional[int]
@ -120,6 +120,9 @@ class Cover(MIoTServiceEntity, CoverEntity):
self._prop_motor_value_close = None
self._prop_motor_value_pause = None
self._prop_status = None
self._prop_status_opening = None
self._prop_status_closing = None
self._prop_status_stop = None
self._prop_current_position = None
self._prop_target_position = None
self._prop_position_value_min = None
@ -159,11 +162,11 @@ class Cover(MIoTServiceEntity, CoverEntity):
'status value_list is None, %s', self.entity_id)
continue
for item in prop.value_list:
if item['name'].lower() in ['opening']:
if item['name'].lower() in ['opening', 'open']:
self._prop_status_opening = item['value']
elif item['name'].lower() in ['closing']:
elif item['name'].lower() in ['closing', 'close']:
self._prop_status_closing = item['value']
elif item['name'].lower() in ['stop']:
elif item['name'].lower() in ['stop', 'pause']:
self._prop_status_stop = item['value']
self._prop_status = prop
elif prop.name == 'current-position':

View File

@ -62,6 +62,7 @@ class MIoTClient:
"""MIoT client instance."""
# pylint: disable=unused-argument
# pylint: disable=broad-exception-caught
# pylint: disable=inconsistent-quotes
_main_loop: asyncio.AbstractEventLoop
_uid: str

View File

@ -218,6 +218,7 @@ class MIoTOauthClient:
class MIoTHttpClient:
"""MIoT http client."""
# pylint: disable=inconsistent-quotes
GET_PROP_AGGREGATE_INTERVAL: float = 0.2
GET_PROP_MAX_REQ_COUNT = 150
_main_loop: asyncio.AbstractEventLoop
@ -473,6 +474,7 @@ class MIoTHttpClient:
'dids': room.get('dids', [])
}
for room in home.get('roomlist', [])
if 'id' in room
},
'group_id': calc_group_id(
uid=home['uid'], home_id=home['id']),
@ -492,7 +494,10 @@ class MIoTHttpClient:
home_infos['homelist'][home_id]['dids'].extend(info['dids'])
for room_id, info in info['room_info'].items():
home_infos['homelist'][home_id]['room_info'].setdefault(
room_id, {'dids': []})
room_id, {
'room_id': room_id,
'room_name': '',
'dids': []})
home_infos['homelist'][home_id]['room_info'][
room_id]['dids'].extend(info['dids'])
@ -604,30 +609,33 @@ class MIoTHttpClient:
device_type, None) or {}).items():
if isinstance(home_ids, list) and home_id not in home_ids:
continue
home_name: str = home_info['home_name']
group_id: str = home_info['group_id']
homes[device_type].setdefault(
home_id, {
'home_name': home_info['home_name'],
'home_name': home_name,
'uid': home_info['uid'],
'group_id': home_info['group_id'],
'group_id': group_id,
'room_info': {}
})
devices.update({did: {
'home_id': home_id,
'home_name': home_info['home_name'],
'home_name': home_name,
'room_id': home_id,
'room_name': home_info['home_name'],
'group_id': home_info['group_id']
'room_name': home_name,
'group_id': group_id
} for did in home_info.get('dids', [])})
for room_id, room_info in home_info.get('room_info').items():
room_name: str = room_info.get('room_name', '')
homes[device_type][home_id]['room_info'][
room_id] = room_info['room_name']
room_id] = room_name
devices.update({
did: {
'home_id': home_id,
'home_name': home_info['home_name'],
'home_name': home_name,
'room_id': room_id,
'room_name': room_info['room_name'],
'group_id': home_info['group_id']
'room_name': room_name,
'group_id': group_id
} for did in room_info.get('dids', [])})
dids = sorted(list(devices.keys()))
results: dict[str, dict] = await self.get_devices_with_dids_async(

View File

@ -688,6 +688,7 @@ class MIoTDevice:
class MIoTServiceEntity(Entity):
"""MIoT Service Entity."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
miot_device: MIoTDevice
entity_data: MIoTEntityData
@ -968,6 +969,7 @@ class MIoTServiceEntity(Entity):
class MIoTPropertyEntity(Entity):
"""MIoT Property Entity."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
miot_device: MIoTDevice
spec: MIoTSpecProperty
service: MIoTSpecService
@ -1116,6 +1118,7 @@ class MIoTPropertyEntity(Entity):
class MIoTEventEntity(Entity):
"""MIoT Event Entity."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
miot_device: MIoTDevice
spec: MIoTSpecEvent
service: MIoTSpecService
@ -1222,6 +1225,7 @@ class MIoTEventEntity(Entity):
class MIoTActionEntity(Entity):
"""MIoT Action Entity."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
miot_device: MIoTDevice
spec: MIoTSpecAction
service: MIoTSpecService

View File

@ -462,6 +462,7 @@ class MIoTLanDevice:
class MIoTLan:
"""MIoT lan device control."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
OT_HEADER: bytes = b'\x21\x31'
OT_PORT: int = 54321
OT_PROBE_LEN: int = 32

View File

@ -984,6 +984,7 @@ class MipsClient(ABC):
class MipsCloudClient(MipsClient):
"""MIoT Pub/Sub Cloud Client."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
_msg_matcher: MIoTMatcher
def __init__(
@ -1242,6 +1243,7 @@ class MipsCloudClient(MipsClient):
class MipsLocalClient(MipsClient):
"""MIoT Pub/Sub Local Client."""
# pylint: disable=unused-argument
# pylint: disable=inconsistent-quotes
MIPS_RECONNECT_INTERVAL_MIN: int = 6000
MIPS_RECONNECT_INTERVAL_MAX: int = 60000
MIPS_SUB_PATCH: int = 1000

View File

@ -452,6 +452,7 @@ class SpecStdLib:
class MIoTSpecParser:
"""MIoT SPEC parser."""
# pylint: disable=inconsistent-quotes
VERSION: int = 1
DOMAIN: str = 'miot_specs'
_lang: str

View File

@ -106,7 +106,7 @@ SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = {
'environment': {
'required': {
'properties': {
'relative-humidity': {'read', 'write'}
'relative-humidity': {'read'}
}
}
}
@ -130,7 +130,7 @@ SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = {
'environment': {
'required': {
'properties': {
'relative-humidity': {'read', 'write'}
'relative-humidity': {'read'}
}
}
}

View File

@ -53,6 +53,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.components.water_heater import (
STATE_ON,
STATE_OFF,
ATTR_TEMPERATURE,
WaterHeaterEntity,
WaterHeaterEntityFeature
@ -114,8 +116,6 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity):
# temperature
if prop.name == 'temperature':
if isinstance(prop.value_range, dict):
self._attr_min_temp = prop.value_range['min']
self._attr_max_temp = prop.value_range['max']
if (
self._attr_temperature_unit is None
and prop.external_unit
@ -128,6 +128,9 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity):
self.entity_id)
# target-temperature
if prop.name == 'target-temperature':
self._attr_min_temp = prop.value_range['min']
self._attr_max_temp = prop.value_range['max']
self._attr_precision = prop.value_range['step']
if self._attr_temperature_unit is None and prop.external_unit:
self._attr_temperature_unit = prop.external_unit
self._attr_supported_features |= (
@ -149,6 +152,9 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity):
self._attr_supported_features |= (
WaterHeaterEntityFeature.OPERATION_MODE)
self._prop_mode = prop
if not self._attr_operation_list:
self._attr_operation_list = [STATE_ON]
self._attr_operation_list.append(STATE_OFF)
async def async_turn_on(self) -> None:
"""Turn the water heater on."""
@ -167,6 +173,15 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity):
"""Set the operation mode of the water heater.
Must be in the operation_list.
"""
if operation_mode == STATE_OFF:
await self.set_property_async(prop=self._prop_on, value=False)
return
if operation_mode == STATE_ON:
await self.set_property_async(prop=self._prop_on, value=True)
return
if self.get_prop_value(prop=self._prop_on) is False:
await self.set_property_async(
prop=self._prop_on, value=True, update=False)
await self.set_property_async(
prop=self._prop_mode,
value=self.__get_mode_value(description=operation_mode))
@ -188,6 +203,10 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity):
@property
def current_operation(self) -> Optional[str]:
"""Return the current mode."""
if self.get_prop_value(prop=self._prop_on) is False:
return STATE_OFF
if not self._prop_mode and self.get_prop_value(prop=self._prop_on):
return STATE_ON
return self.__get_mode_description(
key=self.get_prop_value(prop=self._prop_mode))

32
test/test_common.py Normal file
View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""Unit test for miot_common.py."""
import pytest
# pylint: disable=import-outside-toplevel, unused-argument
@pytest.mark.github
def test_miot_matcher():
from miot.common import MIoTMatcher
matcher: MIoTMatcher = MIoTMatcher()
# Add
for l1 in range(1, 11):
matcher[f'test/{l1}/#'] = f'test/{l1}/#'
for l2 in range(1, 11):
matcher[f'test/{l1}/{l2}'] = f'test/{l1}/{l2}'
if not matcher.get(topic=f'test/+/{l2}'):
matcher[f'test/+/{l2}'] = f'test/+/{l2}'
# Match
match_result: list[(str, dict)] = list(matcher.iter_all_nodes())
assert len(match_result) == 120
match_result: list[str] = list(matcher.iter_match(topic='test/1/1'))
assert len(match_result) == 3
assert set(match_result) == set(['test/1/1', 'test/+/1', 'test/1/#'])
# Delete
if matcher.get(topic='test/1/1'):
del matcher['test/1/1']
assert len(list(matcher.iter_all_nodes())) == 119
match_result: list[str] = list(matcher.iter_match(topic='test/1/1'))
assert len(match_result) == 2
assert set(match_result) == set(['test/+/1', 'test/1/#'])

117
test/test_lan.py Executable file
View File

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""Unit test for miot_lan.py."""
import pytest
import asyncio
from zeroconf import IPVersion
from zeroconf.asyncio import AsyncZeroconf
# pylint: disable=import-outside-toplevel, unused-argument
@pytest.mark.asyncio
async def test_lan_async():
"""
Use the central hub gateway as a test equipment, and through the local area
network control central hub gateway indicator light switch. Please replace
it for your own device information (did, token) during testing.
xiaomi.gateway.hub1 spec define:
http://poc.miot-spec.srv/miot-spec-v2/instance?type=urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:3
"""
from miot.miot_network import MIoTNetwork
from miot.miot_lan import MIoTLan
from miot.miot_mdns import MipsService
test_did = '<Your central hub gateway did>'
test_token = '<Your central hub gateway token>'
test_model = 'xiaomi.gateway.hub1'
test_if_names = ['<Your computer interface list, such as enp3s0, wlp5s0>']
# Check test params
assert int(test_did) > 0
evt_push_available: asyncio.Event
evt_push_unavailable: asyncio.Event
miot_network = MIoTNetwork()
await miot_network.init_async()
print('miot_network, ', miot_network.network_info)
mips_service = MipsService(
aiozc=AsyncZeroconf(ip_version=IPVersion.V4Only))
await mips_service.init_async()
miot_lan = MIoTLan(
net_ifs=test_if_names,
network=miot_network,
mips_service=mips_service,
enable_subscribe=True)
evt_push_available = asyncio.Event()
evt_push_unavailable = asyncio.Event()
await miot_lan.vote_for_lan_ctrl_async(key='test', vote=True)
async def device_state_change(did: str, state: dict, ctx: any):
print('device state change, ', did, state)
if did != test_did:
return
if (
state.get('online', False)
and state.get('push_available', False)
):
# Test sub prop
miot_lan.sub_prop(
did=did, siid=3, piid=1, handler=lambda msg, ctx:
print(f'sub prop.3.1 msg, {did}={msg}'))
miot_lan.sub_prop(
did=did, handler=lambda msg, ctx:
print(f'sub all device msg, {did}={msg}'))
evt_push_available.set()
else:
# miot_lan.unsub_prop(did=did, siid=3, piid=1)
# miot_lan.unsub_prop(did=did)
evt_push_unavailable.set()
async def lan_state_change(state: bool):
print('lan state change, ', state)
if not state:
return
miot_lan.update_devices(devices={
test_did: {
'token': test_token,
'model': test_model
}
})
# Test sub device state
miot_lan.sub_device_state(
'test', device_state_change)
miot_lan.sub_lan_state('test', lan_state_change)
if miot_lan.init_done:
await lan_state_change(True)
await evt_push_available.wait()
result = await miot_lan.get_dev_list_async()
assert test_did in result
result = await miot_lan.set_prop_async(
did=test_did, siid=3, piid=1, value=True)
assert result.get('code', -1) == 0
await asyncio.sleep(0.2)
result = await miot_lan.set_prop_async(
did=test_did, siid=3, piid=1, value=False)
assert result.get('code', -1) == 0
await asyncio.sleep(0.2)
evt_push_unavailable = asyncio.Event()
await miot_lan.update_subscribe_option(enable_subscribe=False)
await evt_push_unavailable.wait()
result = await miot_lan.get_dev_list_async()
assert test_did in result
result = await miot_lan.set_prop_async(
did=test_did, siid=3, piid=1, value=True)
assert result.get('code', -1) == 0
await asyncio.sleep(0.2)
result = await miot_lan.set_prop_async(
did=test_did, siid=3, piid=1, value=False)
assert result.get('code', -1) == 0
await asyncio.sleep(0.2)
await miot_lan.deinit_async()

View File

@ -158,10 +158,10 @@ async def test_user_config_async(
config = config_base.copy()
assert await storage.update_user_config_async(
uid=test_uid, cloud_server=test_cloud_server, config=config)
# test load all
# Test load all
assert (await storage.load_user_config_async(
uid=test_uid, cloud_server=test_cloud_server)) == config
# test update
# Test update
config_update = {
'test_str': 'test str',
'number_float': 456.123
@ -171,7 +171,7 @@ async def test_user_config_async(
config.update(config_update)
assert (await storage.load_user_config_async(
uid=test_uid, cloud_server=test_cloud_server)) == config
# test replace
# Test replace
config_replace = None
assert await storage.update_user_config_async(
uid=test_uid, cloud_server=test_cloud_server,
@ -179,9 +179,9 @@ async def test_user_config_async(
assert (config_replace := await storage.load_user_config_async(
uid=test_uid, cloud_server=test_cloud_server)) == config_update
print('replace result, ', config_replace)
# test query
# Test query
query_keys = list(config_base.keys())
print('query keys, %s', query_keys)
print('query keys, ', query_keys)
query_result = await storage.load_user_config_async(
uid=test_uid, cloud_server=test_cloud_server, keys=query_keys)
print('query result 1, ', query_result)
@ -194,18 +194,19 @@ async def test_user_config_async(
query_result = await storage.load_user_config_async(
uid=test_uid, cloud_server=test_cloud_server)
print('query result all, ', query_result)
# remove config
# Remove config
assert await storage.update_user_config_async(
uid=test_uid, cloud_server=test_cloud_server, config=None)
query_result = await storage.load_user_config_async(
uid=test_uid, cloud_server=test_cloud_server)
print('remove result, ', query_result)
# remove domain
# Remove domain
assert await storage.remove_domain_async(domain='miot_config')
@pytest.mark.asyncio
@pytest.mark.skip(reason='clean')
@pytest.mark.dependency()
async def test_clear_async(test_cache_path):
from miot.miot_storage import MIoTStorage