From ce7ce7af4bd348213136c9d23887cff7e8867e16 Mon Sep 17 00:00:00 2001 From: Li Shuzhen Date: Tue, 7 Jan 2025 20:21:04 +0800 Subject: [PATCH 01/15] fix: fan speed (#464) * fix: fan speed * fix: fan speed names map * fix: set percentage * docs: the instance code format of valuelist * fix: fan level property * fix: pylint too long line. * style: code format --------- Co-authored-by: topsworld --- README.md | 2 +- custom_components/xiaomi_home/fan.py | 80 +++++++++++++++++++--------- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d0cdc97..e6539e7 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ The instance code is the code of the MIoT-Spec-V2 instance, which is in the form ``` service: # service service::property: # property -service::property::valuelist: # the value in value-list of a property +service::property::valuelist: # The index of a value in the value-list of a property service::event: # event service::action: # action ``` diff --git a/custom_components/xiaomi_home/fan.py b/custom_components/xiaomi_home/fan.py index 42947ce..caf67cb 100644 --- a/custom_components/xiaomi_home/fan.py +++ b/custom_components/xiaomi_home/fan.py @@ -55,7 +55,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.util.percentage import ( percentage_to_ranged_value, - ranged_value_to_percentage + ranged_value_to_percentage, + ordered_list_item_to_percentage, + percentage_to_ordered_list_item ) from .miot.miot_spec import MIoTSpecProperty @@ -90,9 +92,11 @@ class Fan(MIoTServiceEntity, FanEntity): _prop_mode: Optional[MIoTSpecProperty] _prop_horizontal_swing: Optional[MIoTSpecProperty] - _speed_min: Optional[int] - _speed_max: Optional[int] - _speed_step: Optional[int] + _speed_min: int + _speed_max: int + _speed_step: int + _speed_names: Optional[list] + _speed_name_map: Optional[dict[int, str]] _mode_list: Optional[dict[Any, Any]] def __init__( @@ -110,6 +114,9 @@ class Fan(MIoTServiceEntity, FanEntity): self._speed_min = 65535 self._speed_max = 0 self._speed_step = 1 + self._speed_names = [] + self._speed_name_map = {} + self._mode_list = None # properties @@ -124,7 +131,8 @@ class Fan(MIoTServiceEntity, FanEntity): self._speed_min = prop.value_range['min'] self._speed_max = prop.value_range['max'] self._speed_step = prop.value_range['step'] - self._attr_speed_count = self._speed_max - self._speed_min+1 + self._attr_speed_count = int(( + self._speed_max - self._speed_min)/self._speed_step)+1 self._attr_supported_features |= FanEntityFeature.SET_SPEED self._prop_fan_level = prop elif ( @@ -133,10 +141,13 @@ class Fan(MIoTServiceEntity, FanEntity): and prop.value_list ): # Fan level with value-list - for item in prop.value_list: - self._speed_min = min(self._speed_min, item['value']) - self._speed_max = max(self._speed_max, item['value']) - self._attr_speed_count = self._speed_max - self._speed_min+1 + # Fan level with value-range is prior to fan level with + # value-list when a fan has both fan level properties. + self._speed_name_map = { + item['value']: item['description'] + for item in prop.value_list} + self._speed_names = list(self._speed_name_map.values()) + self._attr_speed_count = len(prop.value_list) self._attr_supported_features |= FanEntityFeature.SET_SPEED self._prop_fan_level = prop elif prop.name == 'mode': @@ -182,9 +193,19 @@ class Fan(MIoTServiceEntity, FanEntity): await self.set_property_async(prop=self._prop_on, value=True) # percentage if percentage: - await self.set_property_async( - prop=self._prop_fan_level, - value=int(percentage*self._attr_speed_count/100)) + if self._speed_names: + speed = percentage_to_ordered_list_item( + self._speed_names, percentage) + speed_value = self.get_map_value( + map_=self._speed_name_map, description=speed) + await self.set_property_async( + prop=self._prop_fan_level, value=speed_value) + else: + await self.set_property_async( + prop=self._prop_fan_level, + value=int(percentage_to_ranged_value( + low_high_range=(self._speed_min, self._speed_max), + percentage=percentage))) # preset_mode if preset_mode: await self.set_property_async( @@ -202,11 +223,19 @@ class Fan(MIoTServiceEntity, FanEntity): async def async_set_percentage(self, percentage: int) -> None: """Set the percentage of the fan speed.""" if percentage > 0: - await self.set_property_async( - prop=self._prop_fan_level, - value=int(percentage_to_ranged_value( - low_high_range=(self._speed_min, self._speed_max), - percentage=percentage))) + if self._speed_names: + speed = percentage_to_ordered_list_item( + self._speed_names, percentage) + speed_value = self.get_map_value( + map_=self._speed_name_map, description=speed) + await self.set_property_async( + prop=self._prop_fan_level, value=speed_value) + else: + await self.set_property_async( + prop=self._prop_fan_level, + value=int(percentage_to_ranged_value( + low_high_range=(self._speed_min, self._speed_max), + percentage=percentage))) if not self.is_on: # If the fan is off, turn it on. await self.set_property_async(prop=self._prop_on, value=True) @@ -246,9 +275,15 @@ class Fan(MIoTServiceEntity, FanEntity): def percentage(self) -> Optional[int]: """Return the current percentage of the fan speed.""" fan_level = self.get_prop_value(prop=self._prop_fan_level) - return ranged_value_to_percentage( - low_high_range=(self._speed_min, self._speed_max), - value=fan_level) if fan_level else None + if fan_level is None: + return None + if self._speed_names: + return ordered_list_item_to_percentage( + self._speed_names, self._speed_name_map[fan_level]) + else: + return ranged_value_to_percentage( + low_high_range=(self._speed_min, self._speed_max), + value=fan_level) @property def oscillating(self) -> Optional[bool]: @@ -257,8 +292,3 @@ class Fan(MIoTServiceEntity, FanEntity): self.get_prop_value( prop=self._prop_horizontal_swing) if self._prop_horizontal_swing else None) - - @property - def percentage_step(self) -> float: - """Return the step of the fan speed.""" - return self._speed_step From c0d100ce2b03cf3cfb2ccf36a2614b1372ad57eb Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:21:24 +0800 Subject: [PATCH 02/15] feat: fan entity support direction ctrl (#556) * feat: fan entity support direction * fix: fix value judgement logic --- custom_components/xiaomi_home/fan.py | 48 +++++++++++++++++++ .../xiaomi_home/miot/specs/specv2entity.py | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/custom_components/xiaomi_home/fan.py b/custom_components/xiaomi_home/fan.py index caf67cb..90220db 100644 --- a/custom_components/xiaomi_home/fan.py +++ b/custom_components/xiaomi_home/fan.py @@ -91,6 +91,9 @@ class Fan(MIoTServiceEntity, FanEntity): _prop_fan_level: Optional[MIoTSpecProperty] _prop_mode: Optional[MIoTSpecProperty] _prop_horizontal_swing: Optional[MIoTSpecProperty] + _prop_wind_reverse: Optional[MIoTSpecProperty] + _prop_wind_reverse_forward: Any + _prop_wind_reverse_reverse: Any _speed_min: int _speed_max: int @@ -105,12 +108,16 @@ class Fan(MIoTServiceEntity, FanEntity): """Initialize the Fan.""" super().__init__(miot_device=miot_device, entity_data=entity_data) self._attr_preset_modes = [] + self._attr_current_direction = None self._attr_supported_features = FanEntityFeature(0) self._prop_on = None self._prop_fan_level = None self._prop_mode = None self._prop_horizontal_swing = None + self._prop_wind_reverse = None + self._prop_wind_reverse_forward = None + self._prop_wind_reverse_reverse = None self._speed_min = 65535 self._speed_max = 0 self._speed_step = 1 @@ -167,6 +174,30 @@ class Fan(MIoTServiceEntity, FanEntity): elif prop.name == 'horizontal-swing': self._attr_supported_features |= FanEntityFeature.OSCILLATE self._prop_horizontal_swing = prop + elif prop.name == 'wind-reverse': + if prop.format_ == 'bool': + self._prop_wind_reverse_forward = False + self._prop_wind_reverse_reverse = True + elif ( + isinstance(prop.value_list, list) + and prop.value_list + ): + for item in prop.value_list: + if item['name'].lower() in {'foreward'}: + self._prop_wind_reverse_forward = item['value'] + elif item['name'].lower() in { + 'reversal', 'reverse'}: + self._prop_wind_reverse_reverse = item['value'] + if ( + self._prop_wind_reverse_forward is None + or self._prop_wind_reverse_reverse is None + ): + # NOTICE: Value may be 0 or False + _LOGGER.info( + 'invalid wind-reverse, %s', self.entity_id) + continue + self._attr_supported_features |= FanEntityFeature.DIRECTION + self._prop_wind_reverse = prop def __get_mode_description(self, key: int) -> Optional[str]: if self._mode_list is None: @@ -250,6 +281,14 @@ class Fan(MIoTServiceEntity, FanEntity): async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" + if not self._prop_wind_reverse: + return + await self.set_property_async( + prop=self._prop_wind_reverse, + value=( + self._prop_wind_reverse_reverse + if self.current_direction == 'reverse' + else self._prop_wind_reverse_forward)) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" @@ -271,6 +310,15 @@ class Fan(MIoTServiceEntity, FanEntity): key=self.get_prop_value(prop=self._prop_mode)) if self._prop_mode else None) + @property + def current_direction(self) -> Optional[str]: + """Return the current direction of the fan.""" + if not self._prop_wind_reverse: + return None + return 'reverse' if self.get_prop_value( + prop=self._prop_wind_reverse + ) == self._prop_wind_reverse_reverse else 'forward' + @property def percentage(self) -> Optional[int]: """Return the current percentage of the fan speed.""" diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py index 9763970..9e36011 100644 --- a/custom_components/xiaomi_home/miot/specs/specv2entity.py +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -289,7 +289,7 @@ SPEC_SERVICE_TRANS_MAP: dict[str, dict | str] = { } }, 'optional': { - 'properties': {'mode', 'horizontal-swing'} + 'properties': {'mode', 'horizontal-swing', 'wind-reverse'} }, 'entity': 'fan' }, From 0566546a992a433f68ac9ee4b0e4f5a603488edb Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:21:43 +0800 Subject: [PATCH 03/15] feat: filter miwifi.* devices (#564) * feat: filter miwifi.* devices * feat: update log level * feat: filter special xiaomi router model, xiaomi.router.rd03 --- .../xiaomi_home/miot/miot_cloud.py | 17 +++++++++++++---- .../xiaomi_home/miot/specs/spec_filter.json | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 0cfc272..7ed3875 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -531,9 +531,18 @@ class MIoTHttpClient: name = device.get('name', None) urn = device.get('spec_type', None) model = device.get('model', None) - if did is None or name is None or urn is None or model is None: - _LOGGER.error( - 'get_device_list, cloud, invalid device, %s', device) + if did is None or name is None: + _LOGGER.info( + 'invalid device, cloud, %s', device) + continue + if urn is None or model is None: + _LOGGER.info( + 'missing the urn|model field, cloud, %s', device) + continue + if did.startswith('miwifi.'): + # The miwifi.* routers defined SPEC functions, but none of them + # were implemented. + _LOGGER.info('ignore miwifi.* device, cloud, %s', did) continue device_infos[did] = { 'did': did, @@ -634,7 +643,7 @@ class MIoTHttpClient: for did in dids: if did not in results: devices.pop(did, None) - _LOGGER.error('get device info failed, %s', did) + _LOGGER.info('get device info failed, %s', did) continue devices[did].update(results[did]) # Whether sub devices diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.json b/custom_components/xiaomi_home/miot/specs/spec_filter.json index 5cea69f..274fb34 100644 --- a/custom_components/xiaomi_home/miot/specs/spec_filter.json +++ b/custom_components/xiaomi_home/miot/specs/spec_filter.json @@ -59,5 +59,10 @@ "1", "5" ] + }, + "urn:miot-spec-v2:device:router:0000A036:xiaomi-rd03": { + "services": [ + "*" + ] } } \ No newline at end of file From 5d4b975f85716a962877bcb63e0d025a6ab5f5de Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:22:06 +0800 Subject: [PATCH 04/15] fix: the number of profile models updated from 660 to 823 (#583) --- .../xiaomi_home/miot/lan/profile_models.yaml | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) diff --git a/custom_components/xiaomi_home/miot/lan/profile_models.yaml b/custom_components/xiaomi_home/miot/lan/profile_models.yaml index 5e14086..e56cc06 100644 --- a/custom_components/xiaomi_home/miot/lan/profile_models.yaml +++ b/custom_components/xiaomi_home/miot/lan/profile_models.yaml @@ -18,6 +18,10 @@ ts: 1603967572 1245.airpurifier.dl01: ts: 1607502661 +17216.magic_touch.d150: + ts: 1575097876 +17216.magic_touch.d152: + ts: 1575097876 17216.massage.ec1266a: ts: 1615881124 397.light.hallight: @@ -56,6 +60,10 @@ bj352.airmonitor.m30: ts: 1686644541 bj352.waterpuri.s100cm: ts: 1615795630 +bymiot.gateway.v1: + ts: 1575097876 +bymiot.gateway.v2: + ts: 1575097876 cgllc.airmonitor.b1: ts: 1676339912 cgllc.airmonitor.s1: @@ -64,6 +72,8 @@ cgllc.clock.cgc1: ts: 1686644422 cgllc.clock.dove: ts: 1619607474 +cgllc.gateway.s1: + ts: 1575097876 cgllc.magnet.hodor: ts: 1724329476 cgllc.motion.cgpr1: @@ -120,8 +130,14 @@ chuangmi.cateye.ipc018: ts: 1632735241 chuangmi.cateye.ipc508: ts: 1633677521 +chuangmi.door.hmi508: + ts: 1611733437 chuangmi.door.hmi515: ts: 1640334316 +chuangmi.gateway.ipc011: + ts: 1575097876 +chuangmi.ir.v2: + ts: 1575097876 chuangmi.lock.hmi501: ts: 1614742147 chuangmi.lock.hmi501b01: @@ -142,10 +158,18 @@ chuangmi.plug.v1: ts: 1621925183 chuangmi.plug.v3: ts: 1644480255 +chuangmi.plug.vtl_v1: + ts: 1575097876 chuangmi.radio.v1: ts: 1531108800 chuangmi.radio.v2: ts: 1531108800 +chuangmi.remote.h102a03: + ts: 1575097876 +chuangmi.remote.h102c01: + ts: 1575097876 +chuangmi.remote.v2: + ts: 1575097876 chunmi.cooker.eh1: ts: 1607339278 chunmi.cooker.eh402: @@ -204,6 +228,8 @@ dmaker.airfresh.t2017: ts: 1686731233 dmaker.fan.p5: ts: 1655793784 +doco.fcb.docov001: + ts: 1575097876 dsm.lock.h3: ts: 1615283790 dsm.lock.q3: @@ -218,6 +244,30 @@ fawad.airrtc.fwd20011: ts: 1610607149 fbs.airmonitor.pth02: ts: 1686644918 +fengmi.projector.fm05: + ts: 1575097876 +fengmi.projector.fm15: + ts: 1575097876 +fengmi.projector.fm154k: + ts: 1575097876 +fengmi.projector.l166: + ts: 1650352923 +fengmi.projector.l176: + ts: 1649936204 +fengmi.projector.l246: + ts: 1575097876 +fengmi.projector.m055: + ts: 1652839826 +fengmi.projector.m055d: + ts: 1654067980 +fengyu.intercom.beebird: + ts: 1575097876 +fengyu.intercom.sharkv1: + ts: 1575097876 +fotile.hood.emd1tmi: + ts: 1607483642 +guoshi.other.sem01: + ts: 1602662080 hannto.printer.anise: ts: 1618989537 hannto.printer.honey: @@ -226,14 +276,26 @@ hannto.printer.honey1s: ts: 1614332725 hfjh.fishbowl.v1: ts: 1615278556 +hhcc.bleflowerpot.v2: + ts: 1575097876 hhcc.plantmonitor.v1: ts: 1664163526 hith.foot_bath.q2: ts: 1531108800 +hmpace.bracelet.v4: + ts: 1575097876 +hmpace.scales.mibfs: + ts: 1575097876 +hmpace.scales.miscale2: + ts: 1575097876 huohe.lock.m1: ts: 1635410938 +huoman.litter_box.co1: + ts: 1687165034 hutlon.lock.v0001: ts: 1634799698 +idelan.aircondition.g1: + ts: 1575097876 idelan.aircondition.v1: ts: 1614666973 idelan.aircondition.v2: @@ -248,14 +310,22 @@ ikea.light.led1537r6: ts: 1605162872 ikea.light.led1545g12: ts: 1605162937 +ikea.light.led1546g12: + ts: 1575097876 ikea.light.led1623g12: ts: 1605163009 ikea.light.led1649c5: ts: 1605163064 +ikea.light.led1650r5: + ts: 1575097876 imibar.cooker.mbihr3: ts: 1624620659 imou99.camera.tp2: ts: 1531108800 +inovel.projector.me2: + ts: 1575097876 +iracc.aircondition.d19: + ts: 1609914362 isa.camera.df3: ts: 1531108800 isa.camera.hl5: @@ -266,18 +336,34 @@ isa.camera.isc5: ts: 1531108800 isa.camera.isc5c1: ts: 1621238175 +isa.camera.qf3: + ts: 1575097876 +isa.cateye.hldb6: + ts: 1575097876 isa.magnet.dw2hl: ts: 1638274655 +jieman.magic_touch.js78: + ts: 1575097876 +jiqid.mistory.ipen1: + ts: 1575097876 jiqid.mistory.pro: ts: 1531108800 jiqid.mistory.v1: ts: 1531108800 jiqid.mistudy.v2: ts: 1610612349 +jiqid.robot.cube: + ts: 1575097876 jiwu.lock.jwp01: ts: 1614752632 jyaiot.cm.ccj01: ts: 1611824545 +k0918.toothbrush.kid01: + ts: 1575097876 +kejia.airer.th001: + ts: 1575097876 +ksmb.treadmill.k12: + ts: 1575097876 ksmb.treadmill.v1: ts: 1611211447 ksmb.treadmill.v2: @@ -390,6 +476,8 @@ loock.lock.xfvl10: ts: 1632814256 loock.safe.v1: ts: 1619607755 +lumi.acpartner.mcn02: + ts: 1655791626 lumi.acpartner.v1: ts: 1531108800 lumi.acpartner.v2: @@ -462,6 +550,8 @@ lumi.lock.acn02: ts: 1623928631 lumi.lock.acn03: ts: 1614752574 +lumi.lock.aq1: + ts: 1612518044 lumi.lock.bacn01: ts: 1614741699 lumi.lock.bmcn02: @@ -482,6 +572,8 @@ lumi.lock.mcn007: ts: 1650446757 lumi.lock.mcn01: ts: 1679881881 +lumi.lock.v1: + ts: 1575097876 lumi.lock.wbmcn1: ts: 1619422072 lumi.motion.bmgl01: @@ -510,14 +602,20 @@ lumi.sensor_86sw1.v1: ts: 1609311038 lumi.sensor_86sw2.v1: ts: 1608795035 +lumi.sensor_cube.aqgl01: + ts: 1575097876 lumi.sensor_ht.v1: ts: 1621239877 lumi.sensor_magnet.aq2: ts: 1641112867 +lumi.sensor_magnet.v1: + ts: 1606120416 lumi.sensor_magnet.v2: ts: 1641113779 lumi.sensor_motion.aq2: ts: 1676433994 +lumi.sensor_motion.v1: + ts: 1605093075 lumi.sensor_motion.v2: ts: 1672818550 lumi.sensor_natgas.v1: @@ -530,6 +628,8 @@ lumi.sensor_switch.aq2: ts: 1615256430 lumi.sensor_switch.aq3: ts: 1607399487 +lumi.sensor_switch.v1: + ts: 1606874434 lumi.sensor_switch.v2: ts: 1609310683 lumi.sensor_wleak.aq1: @@ -574,6 +674,20 @@ miaomiaoce.sensor_ht.t1: ts: 1616057242 miaomiaoce.sensor_ht.t2: ts: 1636603553 +miaomiaoce.thermo.t01: + ts: 1575097876 +midea.aircondition.v1: + ts: 1575097876 +midea.aircondition.xa1: + ts: 1575097876 +midea.aircondition.xa2: + ts: 1575097876 +midr.rv_mirror.m2: + ts: 1575097876 +midr.rv_mirror.m5: + ts: 1575097876 +midr.rv_mirror.v1: + ts: 1575097876 miir.aircondition.ir01: ts: 1531108800 miir.aircondition.ir02: @@ -612,6 +726,8 @@ minij.washer.v5: ts: 1622792196 minij.washer.v8: ts: 1615777868 +minuo.tracker.lm001: + ts: 1575097876 miot.light.plato2: ts: 1685518142 miot.light.plato3: @@ -624,18 +740,32 @@ mmgg.feeder.snack: ts: 1607503182 moyu.washer.s1hm: ts: 1624620888 +mrbond.airer.m0: + ts: 1575097876 mrbond.airer.m1pro: ts: 1646393746 mrbond.airer.m1s: ts: 1646393874 +mrbond.airer.m1super: + ts: 1575097876 msj.f_washer.m1: ts: 1614914340 mxiang.cateye.mdb10: ts: 1616140362 mxiang.cateye.xmcatt1: ts: 1616140207 +nhy.airrtc.v1: + ts: 1575097876 +ninebot.scooter.v1: + ts: 1602662395 +ninebot.scooter.v6: + ts: 1575097876 +nuwa.robot.minikiwi: + ts: 1575097876 nwt.derh.wdh318efw1: ts: 1611822375 +onemore.wifispeaker.sm4: + ts: 1575097876 opple.light.bydceiling: ts: 1608187619 opple.light.fanlight: @@ -646,6 +776,8 @@ opple.remote.5pb112: ts: 1627453840 opple.remote.5pb113: ts: 1636599905 +orion.wifispeaker.cm1: + ts: 1575097876 ows.towel_w.mj1x0: ts: 1610604939 philips.light.bceiling1: @@ -696,6 +828,8 @@ pwzn.relay.apple: ts: 1611217196 pwzn.relay.banana: ts: 1646647255 +qicyc.bike.tdp02z: + ts: 1575097876 qike.bhf_light.qk201801: ts: 1608174909 qmi.powerstrip.v1: @@ -726,8 +860,32 @@ roborock.vacuum.t6: ts: 1619423841 rockrobo.vacuum.v1: ts: 1531108800 +roidmi.carairpuri.pro: + ts: 1575097876 +roidmi.carairpuri.v1: + ts: 1575097876 +roidmi.cleaner.f8pro: + ts: 1575097876 +roidmi.cleaner.v1: + ts: 1575097876 +roidmi.cleaner.v2: + ts: 1638514177 +roidmi.cleaner.v382: + ts: 1575097876 +roidmi.vacuum.v1: + ts: 1575097876 +rokid.robot.me: + ts: 1575097876 +rokid.robot.mini: + ts: 1575097876 +rokid.robot.pebble: + ts: 1575097876 +rokid.robot.pebble2: + ts: 1575097876 roome.bhf_light.yf6002: ts: 1531108800 +rotai.magic_touch.sx300: + ts: 1602662578 rotai.massage.rt5728: ts: 1610607000 rotai.massage.rt5850: @@ -738,22 +896,42 @@ rotai.massage.rt5863: ts: 1611827937 rotai.massage.rt5870: ts: 1632376570 +runmi.suitcase.v1: + ts: 1575097876 scishare.coffee.s1102: ts: 1611824402 +shjszn.gateway.c1: + ts: 1575097876 +shjszn.lock.c1: + ts: 1575097876 +shjszn.lock.kx: + ts: 1575097876 +shuii.humidifier.jsq001: + ts: 1575097876 shuii.humidifier.jsq002: ts: 1606376290 +skyrc.feeder.dfeed: + ts: 1626082349 skyrc.pet_waterer.fre1: ts: 1608186812 +smith.w_soften.cxs05ta1: + ts: 1575097876 smith.waterheater.cxea1: ts: 1611826349 smith.waterheater.cxeb1: ts: 1611826388 smith.waterpuri.jnt600: ts: 1531108800 +soocare.toothbrush.m1: + ts: 1575097876 soocare.toothbrush.m1s: ts: 1610611310 +soocare.toothbrush.mc1: + ts: 1575097876 soocare.toothbrush.t501: ts: 1672192586 +soocare.toothbrush.x3: + ts: 1575097876 sxds.pillow.pillow02: ts: 1611222235 syniot.curtain.syc1: @@ -778,6 +956,10 @@ tokit.oven.tk32pro1: ts: 1617002408 tokit.pre_cooker.tkih1: ts: 1607410832 +trios1.bleshoes.v02: + ts: 1602662599 +txdd.wifispeaker.x1: + ts: 1575097876 viomi.aircondition.v10: ts: 1606375041 viomi.aircondition.v21: @@ -830,12 +1012,16 @@ viomi.fridge.u13: ts: 1614667152 viomi.fridge.u15: ts: 1607505693 +viomi.fridge.u17: + ts: 1575097876 viomi.fridge.u18: ts: 1614655755 viomi.fridge.u2: ts: 1531108800 viomi.fridge.u24: ts: 1614667214 +viomi.fridge.u25: + ts: 1575097876 viomi.fridge.u4: ts: 1614667295 viomi.fridge.u6: @@ -992,6 +1178,82 @@ xiaomi.aircondition.ma6: ts: 1721629272 xiaomi.aircondition.ma9: ts: 1721629362 +xiaomi.plc.v1: + ts: 1575097876 +xiaomi.repeater.v1: + ts: 1575097876 +xiaomi.repeater.v2: + ts: 1575097876 +xiaomi.repeater.v3: + ts: 1575097876 +xiaomi.router.d01: + ts: 1575097876 +xiaomi.router.lv1: + ts: 1575097876 +xiaomi.router.lv3: + ts: 1575097876 +xiaomi.router.mv1: + ts: 1575097876 +xiaomi.router.r2100: + ts: 1575097876 +xiaomi.router.r3600: + ts: 1575097876 +xiaomi.router.r3a: + ts: 1575097876 +xiaomi.router.r3d: + ts: 1575097876 +xiaomi.router.r3g: + ts: 1575097876 +xiaomi.router.r3gv2: + ts: 1575097876 +xiaomi.router.r3gv2n: + ts: 1575097876 +xiaomi.router.r3p: + ts: 1575097876 +xiaomi.router.r4: + ts: 1575097876 +xiaomi.router.r4a: + ts: 1575097876 +xiaomi.router.r4ac: + ts: 1575097876 +xiaomi.router.r4c: + ts: 1575097876 +xiaomi.router.r4cm: + ts: 1575097876 +xiaomi.router.rm1800: + ts: 1575097876 +xiaomi.router.v1: + ts: 1575097876 +xiaomi.router.v2: + ts: 1575097876 +xiaomi.router.v3: + ts: 1575097876 +xiaomi.split_tv.b1: + ts: 1575097876 +xiaomi.split_tv.v1: + ts: 1575097876 +xiaomi.tv.b1: + ts: 1661248580 +xiaomi.tv.h1: + ts: 1575097876 +xiaomi.tv.i1: + ts: 1661248572 +xiaomi.tv.v1: + ts: 1670811870 +xiaomi.tvbox.b1: + ts: 1694503508 +xiaomi.tvbox.i1: + ts: 1694503515 +xiaomi.tvbox.v1: + ts: 1694503501 +xiaomi.watch.band1: + ts: 1575097876 +xiaomi.watch.band1A: + ts: 1575097876 +xiaomi.watch.band1S: + ts: 1575097876 +xiaomi.watch.band2: + ts: 1575097876 xiaomi.wifispeaker.l04m: ts: 1658817956 xiaomi.wifispeaker.l06a: @@ -1012,6 +1274,10 @@ xiaomi.wifispeaker.lx5a: ts: 1672299577 xiaomi.wifispeaker.s12: ts: 1672299594 +xiaomi.wifispeaker.v1: + ts: 1575097876 +xiaomi.wifispeaker.v3: + ts: 1575097876 xiaomi.wifispeaker.x08a: ts: 1672818945 xiaomi.wifispeaker.x08c: @@ -1028,6 +1294,44 @@ xiaovv.camera.xvd5: ts: 1531108800 xiaovv.camera.xvsnowman: ts: 1531108800 +xiaoxun.robot.v1: + ts: 1575097876 +xiaoxun.tracker.v1: + ts: 1575097876 +xiaoxun.watch.sw306: + ts: 1575097876 +xiaoxun.watch.sw560: + ts: 1575097876 +xiaoxun.watch.sw705: + ts: 1575097876 +xiaoxun.watch.sw710a2: + ts: 1575097876 +xiaoxun.watch.sw760: + ts: 1575097876 +xiaoxun.watch.sw900: + ts: 1575097876 +xiaoxun.watch.sw960: + ts: 1575097876 +xiaoxun.watch.v1: + ts: 1575097876 +xiaoxun.watch.v10: + ts: 1575097876 +xiaoxun.watch.v11: + ts: 1575097876 +xiaoxun.watch.v2: + ts: 1575097876 +xiaoxun.watch.v3: + ts: 1575097876 +xiaoxun.watch.v4: + ts: 1575097876 +xiaoxun.watch.v5: + ts: 1575097876 +xiaoxun.watch.v7: + ts: 1575097876 +xiaoxun.watch.v8: + ts: 1575097876 +xiaoxun.watch.v9: + ts: 1575097876 xjx.toilet.pro: ts: 1615965466 xjx.toilet.pure: @@ -1054,6 +1358,8 @@ yeelink.bhf_light.v3: ts: 1608790102 yeelink.bhf_light.v5: ts: 1601292562 +yeelink.gateway.v1: + ts: 1575097876 yeelink.light.bslamp1: ts: 1703120679 yeelink.light.bslamp2: @@ -1192,6 +1498,10 @@ yunmi.kettle.r2: ts: 1606372087 yunmi.kettle.r3: ts: 1637309534 +yunmi.kettle.v1: + ts: 1575097876 +yunmi.kettle.v9: + ts: 1602662686 yunmi.plmachine.mg2: ts: 1611833658 yunmi.waterpuri.c5: @@ -1230,18 +1540,26 @@ yunmi.waterpurifier.v2: ts: 1632377061 yunmi.waterpurifier.v3: ts: 1611221428 +yunyi.camera.v1: + ts: 1575097876 yyunyi.wopener.yypy24: ts: 1616741966 +yyzhn.gateway.yn181126: + ts: 1610689325 zdeer.ajh.a8: ts: 1531108800 zdeer.ajh.a9: ts: 1531108800 +zdeer.ajh.ajb: + ts: 1608276454 zdeer.ajh.zda10: ts: 1531108800 zdeer.ajh.zda9: ts: 1531108800 zdeer.ajh.zjy: ts: 1531108800 +zhij.toothbrush.bv1: + ts: 1575097876 zhimi.aircondition.ma1: ts: 1615185265 zhimi.aircondition.ma3: @@ -1250,6 +1568,8 @@ zhimi.aircondition.ma4: ts: 1626334057 zhimi.aircondition.v1: ts: 1610610931 +zhimi.aircondition.v2: + ts: 1575097876 zhimi.aircondition.va1: ts: 1609924720 zhimi.aircondition.za1: @@ -1276,8 +1596,12 @@ zhimi.airpurifier.sa2: ts: 1635820002 zhimi.airpurifier.v1: ts: 1635855633 +zhimi.airpurifier.v2: + ts: 1575097876 zhimi.airpurifier.v3: ts: 1676339933 +zhimi.airpurifier.v5: + ts: 1575097876 zhimi.airpurifier.v6: ts: 1636978652 zhimi.airpurifier.v7: @@ -1318,3 +1642,5 @@ zimi.mosq.v1: ts: 1620728957 zimi.powerstrip.v2: ts: 1620812714 +zimi.projector.v1: + ts: 1575097876 From 6557b22a529695f345e444e71ea40b67e91e1dfe Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:19:24 +0800 Subject: [PATCH 05/15] fix: fix multi ha instance login (#560) * fix: fix multi ha instance login * fix: fix option flow oauth --- custom_components/xiaomi_home/config_flow.py | 25 ++++++++++++------- .../xiaomi_home/miot/miot_client.py | 1 + .../xiaomi_home/miot/miot_cloud.py | 8 +++++- .../xiaomi_home/translations/de.json | 1 + .../xiaomi_home/translations/en.json | 1 + .../xiaomi_home/translations/es.json | 1 + .../xiaomi_home/translations/fr.json | 1 + .../xiaomi_home/translations/ja.json | 1 + .../xiaomi_home/translations/nl.json | 1 + .../xiaomi_home/translations/pt-BR.json | 1 + .../xiaomi_home/translations/pt.json | 1 + .../xiaomi_home/translations/ru.json | 1 + .../xiaomi_home/translations/zh-Hans.json | 1 + .../xiaomi_home/translations/zh-Hant.json | 1 + 14 files changed, 35 insertions(+), 10 deletions(-) diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 667e5fe..8e48849 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -68,6 +68,7 @@ from homeassistant.components.webhook import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.instance_id import async_get import homeassistant.helpers.config_validation as cv from .miot.const import ( @@ -247,6 +248,13 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: self._cloud_server = user_input.get( 'cloud_server', self._cloud_server) + # Gen instance uuid + ha_uuid = await async_get(self.hass) + if not ha_uuid: + raise AbortFlow(reason='ha_uuid_get_failed') + self._uuid = hashlib.sha256( + f'{ha_uuid}.{self._virtual_did}.{self._cloud_server}'.encode( + 'utf-8')).hexdigest()[:32] self._integration_language = user_input.get( 'integration_language', DEFAULT_INTEGRATION_LANGUAGE) self._miot_i18n = MIoTI18n( @@ -415,9 +423,11 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): miot_oauth = MIoTOauthClient( client_id=OAUTH2_CLIENT_ID, redirect_url=self._oauth_redirect_url_full, - cloud_server=self._cloud_server - ) - state = str(secrets.randbits(64)) + cloud_server=self._cloud_server, + uuid=self._uuid, + loop=self._main_loop) + state = hashlib.sha1( + f'd=ha.{self._uuid}'.encode('utf-8')).hexdigest() self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state self._cc_oauth_auth_url = miot_oauth.gen_auth_url( redirect_url=self._oauth_redirect_url_full, state=state) @@ -498,11 +508,6 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): client_id=OAUTH2_CLIENT_ID, access_token=auth_info['access_token']) self._auth_info = auth_info - # Gen uuid - self._uuid = hashlib.sha256( - f'{self._virtual_did}.{auth_info["access_token"]}'.encode( - 'utf-8') - ).hexdigest()[:32] try: self._nick_name = ( await self._miot_http.get_user_info_async() or {} @@ -1145,7 +1150,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_oauth(self, user_input=None): try: if self._cc_task_oauth is None: - state = str(secrets.randbits(64)) + state = hashlib.sha1( + f'd=ha.{self._entry_data["uuid"]}'.encode('utf-8') + ).hexdigest() self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state self._miot_oauth.set_redirect_url( redirect_url=self._oauth_redirect_url_full) diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index b762ce3..b618ea5 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -257,6 +257,7 @@ class MIoTClient: client_id=OAUTH2_CLIENT_ID, redirect_url=self._entry_data['oauth_redirect_url'], cloud_server=self._cloud_server, + uuid=self._entry_data["uuid"], loop=self._main_loop) # MIoT http client instance self._http = MIoTHttpClient( diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 7ed3875..4c076fe 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -75,10 +75,11 @@ class MIoTOauthClient: _oauth_host: str _client_id: int _redirect_url: str + _device_id: str def __init__( self, client_id: str, redirect_url: str, cloud_server: str, - loop: Optional[asyncio.AbstractEventLoop] = None + uuid: str, loop: Optional[asyncio.AbstractEventLoop] = None ) -> None: self._main_loop = loop or asyncio.get_running_loop() if client_id is None or client_id.strip() == '': @@ -87,6 +88,8 @@ class MIoTOauthClient: raise MIoTOauthError('invalid redirect_url') if not cloud_server: raise MIoTOauthError('invalid cloud_server') + if not uuid: + raise MIoTOauthError('invalid uuid') self._client_id = int(client_id) self._redirect_url = redirect_url @@ -94,6 +97,7 @@ class MIoTOauthClient: self._oauth_host = DEFAULT_OAUTH2_API_HOST else: self._oauth_host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}' + self._device_id = f'ha.{uuid}' self._session = aiohttp.ClientSession(loop=self._main_loop) async def deinit_async(self) -> None: @@ -132,6 +136,7 @@ class MIoTOauthClient: 'redirect_uri': redirect_url or self._redirect_url, 'client_id': self._client_id, 'response_type': 'code', + 'device_id': self._device_id } if state: params['state'] = state @@ -191,6 +196,7 @@ class MIoTOauthClient: 'client_id': self._client_id, 'redirect_uri': self._redirect_url, 'code': code, + 'device_id': self._device_id }) async def refresh_access_token_async(self, refresh_token: str) -> dict: diff --git a/custom_components/xiaomi_home/translations/de.json b/custom_components/xiaomi_home/translations/de.json index 68a0373..25dfd02 100644 --- a/custom_components/xiaomi_home/translations/de.json +++ b/custom_components/xiaomi_home/translations/de.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Xiaomi MQTT Broker-Adresse ist nicht erreichbar, bitte überprüfen Sie die Netzwerkkonfiguration." }, "abort": { + "ha_uuid_get_failed": "Fehler beim Abrufen der Home Assistant-UUID.", "network_connect_error": "Konfiguration fehlgeschlagen. Netzwerkverbindung fehlgeschlagen. Überprüfen Sie die Netzwerkkonfiguration des Geräts.", "already_configured": "Dieser Benutzer hat die Konfiguration bereits abgeschlossen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Konfiguration zu ändern.", "invalid_auth_info": "Authentifizierungsinformationen sind abgelaufen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Authentifizierung erneut durchzuführen.", diff --git a/custom_components/xiaomi_home/translations/en.json b/custom_components/xiaomi_home/translations/en.json index 2244730..0ee151c 100644 --- a/custom_components/xiaomi_home/translations/en.json +++ b/custom_components/xiaomi_home/translations/en.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Unable to reach Xiaomi MQTT Broker address, please check network configuration." }, "abort": { + "ha_uuid_get_failed": "Failed to get Home Assistant UUID.", "network_connect_error": "Configuration failed. The network connection is abnormal. Please check the equipment network configuration.", "already_configured": "Configuration for this user is already completed. Please go to the integration page and click the CONFIGURE button for modifications.", "invalid_auth_info": "Authentication information has expired. Please go to the integration page and click the CONFIGURE button to re-authenticate.", diff --git a/custom_components/xiaomi_home/translations/es.json b/custom_components/xiaomi_home/translations/es.json index 2942567..e7b0c75 100644 --- a/custom_components/xiaomi_home/translations/es.json +++ b/custom_components/xiaomi_home/translations/es.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "No se puede acceder a la dirección del Broker MQTT de Xiaomi, por favor verifique la configuración de la red." }, "abort": { + "ha_uuid_get_failed": "Error al obtener el UUID de Home Assistant.", "network_connect_error": "La configuración ha fallado. Existe un problema con la conexión de red, verifique la configuración de red del dispositivo.", "already_configured": "Esta cuenta ya ha finalizado la configuración. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para modificar la configuración.", "invalid_auth_info": "La información de autorización ha caducado. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para volver a autenticarse.", diff --git a/custom_components/xiaomi_home/translations/fr.json b/custom_components/xiaomi_home/translations/fr.json index fa1b84d..63b9c44 100644 --- a/custom_components/xiaomi_home/translations/fr.json +++ b/custom_components/xiaomi_home/translations/fr.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Impossible d'atteindre l'adresse du Broker MQTT de Xiaomi, veuillez vérifier la configuration réseau." }, "abort": { + "ha_uuid_get_failed": "Échec de l'obtention de l'UUID de Home Assistant.", "network_connect_error": "La configuration a échoué. Erreur de connexion réseau. Veuillez vérifier la configuration du réseau de l'appareil.", "already_configured": "Cet utilisateur a déjà terminé la configuration. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour modifier la configuration.", "invalid_auth_info": "Les informations d'authentification ont expiré. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour vous authentifier à nouveau.", diff --git a/custom_components/xiaomi_home/translations/ja.json b/custom_components/xiaomi_home/translations/ja.json index d63201b..2b07b06 100644 --- a/custom_components/xiaomi_home/translations/ja.json +++ b/custom_components/xiaomi_home/translations/ja.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Xiaomi MQTT ブローカーアドレスにアクセスできません。ネットワーク設定を確認してください。" }, "abort": { + "ha_uuid_get_failed": "Home Assistant インスタンスIDを取得できませんでした。", "network_connect_error": "設定に失敗しました。ネットワーク接続に異常があります。デバイスのネットワーク設定を確認してください。", "already_configured": "このユーザーはすでに設定が完了しています。統合ページにアクセスして、「設定」ボタンをクリックして設定を変更してください。", "invalid_auth_info": "認証情報が期限切れになりました。統合ページにアクセスして、「設定」ボタンをクリックして再度認証してください。", diff --git a/custom_components/xiaomi_home/translations/nl.json b/custom_components/xiaomi_home/translations/nl.json index 6c4c436..6e28936 100644 --- a/custom_components/xiaomi_home/translations/nl.json +++ b/custom_components/xiaomi_home/translations/nl.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Kan Xiaomi MQTT Broker-adres niet bereiken, controleer de netwerkconfiguratie." }, "abort": { + "ha_uuid_get_failed": "Mislukt bij het ophalen van Home Assistant UUID.", "network_connect_error": "Configuratie mislukt. De netwerkverbinding is abnormaal. Controleer de netwerkinstellingen van de apparatuur.", "already_configured": "Configuratie voor deze gebruiker is al voltooid. Ga naar de integratiepagina en klik op de CONFIGUREER-knop om wijzigingen aan te brengen.", "invalid_auth_info": "Authenticatie-informatie is verlopen. Ga naar de integratiepagina en klik op de CONFIGUREER-knop om opnieuw te authentiseren.", diff --git a/custom_components/xiaomi_home/translations/pt-BR.json b/custom_components/xiaomi_home/translations/pt-BR.json index 0c453b5..3adcd0d 100644 --- a/custom_components/xiaomi_home/translations/pt-BR.json +++ b/custom_components/xiaomi_home/translations/pt-BR.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Não é possível acessar o endereço do Broker MQTT da Xiaomi, verifique a configuração da rede." }, "abort": { + "ha_uuid_get_failed": "Falha ao obter o UUID do Home Assistant.", "network_connect_error": "Configuração falhou. A conexão de rede está anormal. Verifique a configuração de rede do equipamento.", "already_configured": "A configuração para este usuário já foi concluída. Vá para a página de integrações e clique no botão CONFIGURAR para modificações.", "invalid_auth_info": "As informações de autenticação expiraram. Vá para a página de integrações e clique em CONFIGURAR para reautenticar.", diff --git a/custom_components/xiaomi_home/translations/pt.json b/custom_components/xiaomi_home/translations/pt.json index 787ddcd..ce58cd5 100644 --- a/custom_components/xiaomi_home/translations/pt.json +++ b/custom_components/xiaomi_home/translations/pt.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Não é possível acessar o endereço do Broker MQTT da Xiaomi, verifique a configuração da rede." }, "abort": { + "ha_uuid_get_failed": "Não foi possível obter o UUID do Home Assistant.", "network_connect_error": "A configuração falhou. A ligação de rede é anormal. Verifique a configuração de rede do equipamento.", "already_configured": "A configuração para este utilizador já foi concluída. Vá à página de integrações e clique em CONFIGURAR para efetuar alterações.", "invalid_auth_info": "A informação de autenticação expirou. Vá à página de integrações e clique em CONFIGURAR para reautenticar.", diff --git a/custom_components/xiaomi_home/translations/ru.json b/custom_components/xiaomi_home/translations/ru.json index 7e06055..a492869 100644 --- a/custom_components/xiaomi_home/translations/ru.json +++ b/custom_components/xiaomi_home/translations/ru.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Не удается подключиться к адресу MQTT брокера Xiaomi, проверьте настройки сети." }, "abort": { + "ha_uuid_get_failed": "Не удалось получить UUID Home Assistant.", "network_connect_error": "Ошибка настройки. Сетевое подключение недоступно. Проверьте настройки сети устройства.", "already_configured": "Этот пользователь уже настроен. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы изменить настройки.", "invalid_auth_info": "Информация об авторизации истекла. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы переавторизоваться.", diff --git a/custom_components/xiaomi_home/translations/zh-Hans.json b/custom_components/xiaomi_home/translations/zh-Hans.json index 1b6a138..39859da 100644 --- a/custom_components/xiaomi_home/translations/zh-Hans.json +++ b/custom_components/xiaomi_home/translations/zh-Hans.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "无法访问小米 MQTT Broker 地址,请检查网络配置。" }, "abort": { + "ha_uuid_get_failed": "获取 Home Assistant UUID 失败。", "network_connect_error": "配置失败。网络连接异常,请检查设备网络配置。", "already_configured": "该用户已配置完成。请进入集成页面,点击“配置”按钮修改配置。", "invalid_auth_info": "认证信息已过期。请进入集成页面,点击“配置”按钮重新认证。", diff --git a/custom_components/xiaomi_home/translations/zh-Hant.json b/custom_components/xiaomi_home/translations/zh-Hant.json index 7fcfb67..59580ae 100644 --- a/custom_components/xiaomi_home/translations/zh-Hant.json +++ b/custom_components/xiaomi_home/translations/zh-Hant.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "無法訪問小米 MQTT Broker 地址,請檢查網絡配置。" }, "abort": { + "ha_uuid_get_failed": "獲取 Home Assistant UUID 失敗。", "network_connect_error": "配置失敗。網絡連接異常,請檢查設備網絡配置。", "already_configured": "該用戶已配置完成。請進入集成頁面,點擊“配置”按鈕修改配置。", "invalid_auth_info": "認證信息已過期。請進入集成頁面,點擊“配置”按鈕重新認證。", From 152933a22398d05f6adb727db6938fcbd262d1e8 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:46:34 +0800 Subject: [PATCH 06/15] docs: update changelog and version to v0.1.5b1 (#616) --- CHANGELOG.md | 11 +++++++++++ custom_components/xiaomi_home/manifest.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fdb9a..c07dace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +## v0.1.5b1 +This version will cause some Xiaomi routers that do not support access (#564) to become unavailable. You can update the device list in the configuration or delete it manually. +### Added +- Fan entity support direction ctrl [#556](https://github.com/XiaoMi/ha_xiaomi_home/pull/556) +### Changed +- Filter miwifi.* devices and xiaomi.router.rd03 [#564](https://github.com/XiaoMi/ha_xiaomi_home/pull/564) +### Fixed +- Fix multi ha instance login [#560](https://github.com/XiaoMi/ha_xiaomi_home/pull/560) +- Fix fan speed [#464](https://github.com/XiaoMi/ha_xiaomi_home/pull/464) +- The number of profile models updated from 660 to 823. [#583](https://github.com/XiaoMi/ha_xiaomi_home/pull/583) + ## v0.1.5b0 ### Added - Add missing parameter state_class [#101](https://github.com/XiaoMi/ha_xiaomi_home/pull/101) diff --git a/custom_components/xiaomi_home/manifest.json b/custom_components/xiaomi_home/manifest.json index 3e07f1c..624ae29 100644 --- a/custom_components/xiaomi_home/manifest.json +++ b/custom_components/xiaomi_home/manifest.json @@ -25,7 +25,7 @@ "cryptography", "psutil" ], - "version": "v0.1.5b0", + "version": "v0.1.5b1", "zeroconf": [ "_miot-central._tcp.local." ] From 9ceca34b28d67bc2564e28615ed77596e5fa6fc0 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Fri, 10 Jan 2025 21:46:00 +0800 Subject: [PATCH 07/15] refactor: refactor miot mips & fix type errors (#365) * remove use of tev & fix type errors * lint fix * make private classes private * simplify inheritance * fix thread naming * fix the deleted public data class * remove tev * fix access violation * style: format code * style: param init * fix: fix event async set * fix: fix mips re-connect error --------- Co-authored-by: topsworld --- .../xiaomi_home/miot/miot_client.py | 4 +- custom_components/xiaomi_home/miot/miot_ev.py | 324 ----- .../xiaomi_home/miot/miot_i18n.py | 4 +- .../xiaomi_home/miot/miot_lan.py | 27 +- .../xiaomi_home/miot/miot_mips.py | 1155 +++++++---------- test/conftest.py | 1 - test/test_ev.py | 55 - 7 files changed, 493 insertions(+), 1077 deletions(-) delete mode 100644 custom_components/xiaomi_home/miot/miot_ev.py delete mode 100644 test/test_ev.py diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index b618ea5..58fb504 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -357,7 +357,7 @@ class MIoTClient: # Cloud mips self._mips_cloud.unsub_mips_state( key=f'{self._uid}-{self._cloud_server}') - self._mips_cloud.disconnect() + self._mips_cloud.deinit() # Cancel refresh cloud devices if self._refresh_cloud_devices_timer: self._refresh_cloud_devices_timer.cancel() @@ -370,7 +370,7 @@ class MIoTClient: for mips in self._mips_local.values(): mips.on_dev_list_changed = None mips.unsub_mips_state(key=mips.group_id) - mips.disconnect() + mips.deinit() if self._mips_local_state_changed_timers: for timer_item in ( self._mips_local_state_changed_timers.values()): diff --git a/custom_components/xiaomi_home/miot/miot_ev.py b/custom_components/xiaomi_home/miot/miot_ev.py deleted file mode 100644 index c0cc97f..0000000 --- a/custom_components/xiaomi_home/miot/miot_ev.py +++ /dev/null @@ -1,324 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright (C) 2024 Xiaomi Corporation. - -The ownership and intellectual property rights of Xiaomi Home Assistant -Integration and related Xiaomi cloud service API interface provided under this -license, including source code and object code (collectively, "Licensed Work"), -are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi -hereby grants you a personal, limited, non-exclusive, non-transferable, -non-sublicensable, and royalty-free license to reproduce, use, modify, and -distribute the Licensed Work only for your use of Home Assistant for -non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize -you to use the Licensed Work for any other purpose, including but not limited -to use Licensed Work to develop applications (APP), Web services, and other -forms of software. - -You may reproduce and distribute copies of the Licensed Work, with or without -modifications, whether in source or object form, provided that you must give -any other recipients of the Licensed Work a copy of this License and retain all -copyright and disclaimers. - -Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR -CONDITIONS OF ANY KIND, either express or implied, including, without -limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR -OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or -FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible -for any direct, indirect, special, incidental, or consequential damages or -losses arising from the use or inability to use the Licensed Work. - -Xiaomi reserves all rights not expressly granted to you in this License. -Except for the rights expressly granted by Xiaomi under this License, Xiaomi -does not authorize you in any form to use the trademarks, copyrights, or other -forms of intellectual property rights of Xiaomi and its affiliates, including, -without limitation, without obtaining other written permission from Xiaomi, you -shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that -may make the public associate with Xiaomi in any form to publicize or promote -the software or hardware devices that use the Licensed Work. - -Xiaomi has the right to immediately terminate all your authorization under this -License in the event: -1. You assert patent invalidation, litigation, or other claims against patents -or other intellectual property rights of Xiaomi or its affiliates; or, -2. You make, have made, manufacture, sell, or offer to sell products that knock -off Xiaomi or its affiliates' products. - -MIoT event loop. -""" -import selectors -import heapq -import time -import traceback -from typing import Any, Callable, TypeVar -import logging -import threading - -# pylint: disable=relative-beyond-top-level -from .miot_error import MIoTEvError - -_LOGGER = logging.getLogger(__name__) - -TimeoutHandle = TypeVar('TimeoutHandle') - - -class MIoTFdHandler: - """File descriptor handler.""" - fd: int - read_handler: Callable[[Any], None] - read_handler_ctx: Any - write_handler: Callable[[Any], None] - write_handler_ctx: Any - - def __init__( - self, fd: int, - read_handler: Callable[[Any], None] = None, - read_handler_ctx: Any = None, - write_handler: Callable[[Any], None] = None, - write_handler_ctx: Any = None - ) -> None: - self.fd = fd - self.read_handler = read_handler - self.read_handler_ctx = read_handler_ctx - self.write_handler = write_handler - self.write_handler_ctx = write_handler_ctx - - -class MIoTTimeout: - """Timeout handler.""" - key: TimeoutHandle - target: int - handler: Callable[[Any], None] - handler_ctx: Any - - def __init__( - self, key: str = None, target: int = None, - handler: Callable[[Any], None] = None, - handler_ctx: Any = None - ) -> None: - self.key = key - self.target = target - self.handler = handler - self.handler_ctx = handler_ctx - - def __lt__(self, other): - return self.target < other.target - - -class MIoTEventLoop: - """MIoT event loop.""" - _poll_fd: selectors.DefaultSelector - - _fd_handlers: dict[str, MIoTFdHandler] - - _timer_heap: list[MIoTTimeout] - _timer_handlers: dict[str, MIoTTimeout] - _timer_handle_seed: int - - # Label if the current fd handler is freed inside a read handler to - # avoid invalid reading. - _fd_handler_freed_in_read_handler: bool - - def __init__(self) -> None: - self._poll_fd = selectors.DefaultSelector() - self._timer_heap = [] - self._timer_handlers = {} - self._timer_handle_seed = 1 - self._fd_handlers = {} - self._fd_handler_freed_in_read_handler = False - - def loop_forever(self) -> None: - """Run an event loop in current thread.""" - next_timeout: int - while True: - next_timeout = 0 - # Handle timer - now_ms: int = self.__get_monotonic_ms - while len(self._timer_heap) > 0: - timer: MIoTTimeout = self._timer_heap[0] - if timer is None: - break - if timer.target <= now_ms: - heapq.heappop(self._timer_heap) - del self._timer_handlers[timer.key] - if timer.handler: - timer.handler(timer.handler_ctx) - else: - next_timeout = timer.target-now_ms - break - # Are there any files to listen to - if next_timeout == 0 and self._fd_handlers: - next_timeout = None # None == infinite - # Wait for timers & fds - if next_timeout == 0: - # Neither timer nor fds exist, exit loop - break - # Handle fd event - events = self._poll_fd.select( - timeout=next_timeout/1000.0 if next_timeout else next_timeout) - for key, mask in events: - fd_handler: MIoTFdHandler = key.data - if fd_handler is None: - continue - self._fd_handler_freed_in_read_handler = False - fd_key = str(id(fd_handler.fd)) - if fd_key not in self._fd_handlers: - continue - if ( - mask & selectors.EVENT_READ > 0 - and fd_handler.read_handler - ): - fd_handler.read_handler(fd_handler.read_handler_ctx) - if ( - mask & selectors.EVENT_WRITE > 0 - and self._fd_handler_freed_in_read_handler is False - and fd_handler.write_handler - ): - fd_handler.write_handler(fd_handler.write_handler_ctx) - - def loop_stop(self) -> None: - """Stop the event loop.""" - if self._poll_fd: - self._poll_fd.close() - self._poll_fd = None - self._fd_handlers = {} - self._timer_heap = [] - self._timer_handlers = {} - - def set_timeout( - self, timeout_ms: int, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> TimeoutHandle: - """Set a timer.""" - if timeout_ms is None or handler is None: - raise MIoTEvError('invalid params') - new_timeout: MIoTTimeout = MIoTTimeout() - new_timeout.key = self.__get_next_timeout_handle - new_timeout.target = self.__get_monotonic_ms + timeout_ms - new_timeout.handler = handler - new_timeout.handler_ctx = handler_ctx - heapq.heappush(self._timer_heap, new_timeout) - self._timer_handlers[new_timeout.key] = new_timeout - return new_timeout.key - - def clear_timeout(self, timer_key: TimeoutHandle) -> None: - """Stop and remove the timer.""" - if timer_key is None: - return - timer: MIoTTimeout = self._timer_handlers.pop(timer_key, None) - if timer: - self._timer_heap = list(self._timer_heap) - self._timer_heap.remove(timer) - heapq.heapify(self._timer_heap) - - def set_read_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any = None - ) -> bool: - """Set a read handler for a file descriptor. - - Returns: - bool: True, success. False, failed. - """ - self.__set_handler( - fd, is_read=True, handler=handler, handler_ctx=handler_ctx) - - def set_write_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any = None - ) -> bool: - """Set a write handler for a file descriptor. - - Returns: - bool: True, success. False, failed. - """ - self.__set_handler( - fd, is_read=False, handler=handler, handler_ctx=handler_ctx) - - def __set_handler( - self, fd, is_read: bool, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> bool: - """Set a handler.""" - if fd is None: - raise MIoTEvError('invalid params') - - if not self._poll_fd: - raise MIoTEvError('event loop not started') - - fd_key: str = str(id(fd)) - fd_handler = self._fd_handlers.get(fd_key, None) - - if fd_handler is None: - fd_handler = MIoTFdHandler(fd=fd) - fd_handler.fd = fd - self._fd_handlers[fd_key] = fd_handler - - read_handler_existed = fd_handler.read_handler is not None - write_handler_existed = fd_handler.write_handler is not None - if is_read is True: - fd_handler.read_handler = handler - fd_handler.read_handler_ctx = handler_ctx - else: - fd_handler.write_handler = handler - fd_handler.write_handler_ctx = handler_ctx - - if fd_handler.read_handler is None and fd_handler.write_handler is None: - # Remove from epoll and map - try: - self._poll_fd.unregister(fd) - except (KeyError, ValueError, OSError) as e: - del e - self._fd_handlers.pop(fd_key, None) - # May be inside a read handler, if not, this has no effect - self._fd_handler_freed_in_read_handler = True - elif read_handler_existed is False and write_handler_existed is False: - # Add to epoll - events = 0x0 - if fd_handler.read_handler: - events |= selectors.EVENT_READ - if fd_handler.write_handler: - events |= selectors.EVENT_WRITE - try: - self._poll_fd.register(fd, events=events, data=fd_handler) - except (KeyError, ValueError, OSError) as e: - _LOGGER.error( - '%s, register fd, error, %s, %s, %s, %s, %s', - threading.current_thread().name, - 'read' if is_read else 'write', - fd_key, handler, e, traceback.format_exc()) - self._fd_handlers.pop(fd_key, None) - return False - elif ( - read_handler_existed != (fd_handler.read_handler is not None) - or write_handler_existed != (fd_handler.write_handler is not None) - ): - # Modify epoll - events = 0x0 - if fd_handler.read_handler: - events |= selectors.EVENT_READ - if fd_handler.write_handler: - events |= selectors.EVENT_WRITE - try: - self._poll_fd.modify(fd, events=events, data=fd_handler) - except (KeyError, ValueError, OSError) as e: - _LOGGER.error( - '%s, modify fd, error, %s, %s, %s, %s, %s', - threading.current_thread().name, - 'read' if is_read else 'write', - fd_key, handler, e, traceback.format_exc()) - self._fd_handlers.pop(fd_key, None) - return False - - return True - - @property - def __get_next_timeout_handle(self) -> str: - # Get next timeout handle, that is not larger than the maximum - # value of UINT64 type. - self._timer_handle_seed += 1 - # uint64 max - self._timer_handle_seed %= 0xFFFFFFFFFFFFFFFF - return str(self._timer_handle_seed) - - @property - def __get_monotonic_ms(self) -> int: - """Get monotonic ms timestamp.""" - return int(time.monotonic()*1000) diff --git a/custom_components/xiaomi_home/miot/miot_i18n.py b/custom_components/xiaomi_home/miot/miot_i18n.py index 152bc08..b6e96f4 100644 --- a/custom_components/xiaomi_home/miot/miot_i18n.py +++ b/custom_components/xiaomi_home/miot/miot_i18n.py @@ -48,7 +48,7 @@ MIoT internationalization translation. import asyncio import logging import os -from typing import Optional +from typing import Optional, Union # pylint: disable=relative-beyond-top-level from .common import load_json_file @@ -98,7 +98,7 @@ class MIoTI18n: def translate( self, key: str, replace: Optional[dict[str, str]] = None - ) -> str | dict | None: + ) -> Union[str, dict, None]: result = self._data for item in key.split('.'): if item not in result: diff --git a/custom_components/xiaomi_home/miot/miot_lan.py b/custom_components/xiaomi_home/miot/miot_lan.py index 3191166..fd9ff47 100644 --- a/custom_components/xiaomi_home/miot/miot_lan.py +++ b/custom_components/xiaomi_home/miot/miot_lan.py @@ -381,7 +381,8 @@ class _MIoTLanDevice: _MIoTLanDeviceState(state.value+1)) # Fast ping if self._if_name is None: - _LOGGER.error('if_name is Not set for device, %s', self.did) + _LOGGER.error( + 'if_name is Not set for device, %s', self.did) return if self.ip is None: _LOGGER.error('ip is Not set for device, %s', self.did) @@ -419,10 +420,10 @@ class _MIoTLanDevice: self.online = True else: _LOGGER.info('unstable device detected, %s', self.did) - self._online_offline_timer = \ + self._online_offline_timer = ( self._manager.internal_loop.call_later( self.NETWORK_UNSTABLE_RESUME_TH, - self.__online_resume_handler) + self.__online_resume_handler)) def __online_resume_handler(self) -> None: _LOGGER.info('unstable resume threshold past, %s', self.did) @@ -508,9 +509,9 @@ class MIoTLan: key='miot_lan', group_id='*', handler=self.__on_mips_service_change) self._enable_subscribe = enable_subscribe - self._virtual_did = str(virtual_did) \ - if (virtual_did is not None) \ - else str(secrets.randbits(64)) + self._virtual_did = ( + str(virtual_did) if (virtual_did is not None) + else str(secrets.randbits(64))) # Init socket probe message probe_bytes = bytearray(self.OT_PROBE_LEN) probe_bytes[:20] = ( @@ -948,7 +949,7 @@ class MIoTLan: # The following methods SHOULD ONLY be called in the internal loop - def ping(self, if_name: str | None, target_ip: str) -> None: + def ping(self, if_name: Optional[str], target_ip: str) -> None: if not target_ip: return self.__sendto( @@ -964,7 +965,7 @@ class MIoTLan: ) -> None: if timeout_ms and not handler: raise ValueError('handler is required when timeout_ms is set') - device: _MIoTLanDevice | None = self._lan_devices.get(did) + device: Optional[_MIoTLanDevice] = self._lan_devices.get(did) if not device: raise ValueError('invalid device') if not device.cipher: @@ -1232,7 +1233,7 @@ class MIoTLan: return # Keep alive message did: str = str(struct.unpack('>Q', data[4:12])[0]) - device: _MIoTLanDevice | None = self._lan_devices.get(did) + device: Optional[_MIoTLanDevice] = self._lan_devices.get(did) if not device: return timestamp: int = struct.unpack('>I', data[12:16])[0] @@ -1272,8 +1273,8 @@ class MIoTLan: _LOGGER.warning('invalid message, no id, %s, %s', did, msg) return # Reply - req: _MIoTLanRequestData | None = \ - self._pending_requests.pop(msg['id'], None) + req: Optional[_MIoTLanRequestData] = ( + self._pending_requests.pop(msg['id'], None)) if req: if req.timeout: req.timeout.cancel() @@ -1334,7 +1335,7 @@ class MIoTLan: return False def __sendto( - self, if_name: str | None, data: bytes, address: str, port: int + self, if_name: Optional[str], data: bytes, address: str, port: int ) -> None: if if_name is None: # Broadcast @@ -1356,7 +1357,7 @@ class MIoTLan: try: # Scan devices self.ping(if_name=None, target_ip='255.255.255.255') - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # pylint: disable=broad-exception-caught # Ignore any exceptions to avoid blocking the loop _LOGGER.error('ping device error, %s', err) pass diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 6c6b358..1cade87 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -48,8 +48,6 @@ MIoT Pub/Sub client. import asyncio import json import logging -import os -import queue import random import re import ssl @@ -58,24 +56,24 @@ import threading from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Callable, Optional, final +from typing import Any, Callable, Optional, final, Coroutine from paho.mqtt.client import ( MQTT_ERR_SUCCESS, MQTT_ERR_UNKNOWN, Client, - MQTTv5) + MQTTv5, + MQTTMessage) # pylint: disable=relative-beyond-top-level from .common import MIoTMatcher from .const import MIHOME_MQTT_KEEPALIVE from .miot_error import MIoTErrorCode, MIoTMipsError -from .miot_ev import MIoTEventLoop, TimeoutHandle _LOGGER = logging.getLogger(__name__) -class MipsMsgTypeOptions(Enum): +class _MipsMsgTypeOptions(Enum): """MIoT Pub/Sub message type.""" ID = 0 RET_TOPIC = auto() @@ -84,16 +82,16 @@ class MipsMsgTypeOptions(Enum): MAX = auto() -class MipsMessage: +class _MipsMessage: """MIoT Pub/Sub message.""" mid: int = 0 - msg_from: str = None - ret_topic: str = None - payload: str = None + msg_from: Optional[str] = None + ret_topic: Optional[str] = None + payload: Optional[str] = None @staticmethod - def unpack(data: bytes): - mips_msg = MipsMessage() + def unpack(data: bytes) -> '_MipsMessage': + mips_msg = _MipsMessage() data_len = len(data) data_start = 0 data_end = 0 @@ -104,15 +102,15 @@ class MipsMessage: unpack_data = data[data_end:data_end+unpack_len] # string end with \x00 match unpack_type: - case MipsMsgTypeOptions.ID.value: + case _MipsMsgTypeOptions.ID.value: mips_msg.mid = int.from_bytes( unpack_data, byteorder='little') - case MipsMsgTypeOptions.RET_TOPIC.value: + case _MipsMsgTypeOptions.RET_TOPIC.value: mips_msg.ret_topic = str( unpack_data.strip(b'\x00'), 'utf-8') - case MipsMsgTypeOptions.PAYLOAD.value: + case _MipsMsgTypeOptions.PAYLOAD.value: mips_msg.payload = str(unpack_data.strip(b'\x00'), 'utf-8') - case MipsMsgTypeOptions.FROM.value: + case _MipsMsgTypeOptions.FROM.value: mips_msg.msg_from = str( unpack_data.strip(b'\x00'), 'utf-8') case _: @@ -122,155 +120,73 @@ class MipsMessage: @staticmethod def pack( - mid: int, payload: str, msg_from: str = None, ret_topic: str = None + mid: int, + payload: str, + msg_from: Optional[str] = None, + ret_topic: Optional[str] = None ) -> bytes: if mid is None or payload is None: raise MIoTMipsError('invalid mid or payload') pack_msg: bytes = b'' # mid - pack_msg += struct.pack(' str: return f'{self.mid}, {self.msg_from}, {self.ret_topic}, {self.payload}' -class MipsCmdType(Enum): - """MIoT Pub/Sub command type.""" - CONNECT = 0 - DISCONNECT = auto() - DEINIT = auto() - SUB = auto() - UNSUB = auto() - CALL_API = auto() - REG_BROADCAST = auto() - UNREG_BROADCAST = auto() - - REG_MIPS_STATE = auto() - UNREG_MIPS_STATE = auto() - REG_DEVICE_STATE = auto() - UNREG_DEVICE_STATE = auto() - - @dataclass -class MipsCmd: - """MIoT Pub/Sub command.""" - type_: MipsCmdType - data: Any - - def __init__(self, type_: MipsCmdType, data: Any) -> None: - self.type_ = type_ - self.data = data - - -@dataclass -class MipsRequest: +class _MipsRequest: """MIoT Pub/Sub request.""" - mid: int = None - on_reply: Callable[[str, Any], None] = None - on_reply_ctx: Any = None - timer: TimeoutHandle = None + mid: int + on_reply: Callable[[str, Any], None] + on_reply_ctx: Any + timer: Optional[asyncio.TimerHandle] @dataclass -class MipsRequestData: - """MIoT Pub/Sub request data.""" - topic: str = None - payload: str = None - on_reply: Callable[[str, Any], None] = None - on_reply_ctx: Any = None - timeout_ms: int = None - - -@dataclass -class MipsSendBroadcastData: - """MIoT Pub/Sub send broadcast data.""" - topic: str = None - payload: str = None - - -@dataclass -class MipsIncomingApiCall: - """MIoT Pub/Sub incoming API call.""" - mid: int = None - ret_topic: str = None - timer: TimeoutHandle = None - - -@dataclass -class MipsApi: - """MIoT Pub/Sub API.""" - topic: str = None - """ - param1: session - param2: payload - param3: handler_ctx - """ - handler: Callable[[MipsIncomingApiCall, str, Any], None] = None - handler_ctx: Any = None - - -class MipsRegApi(MipsApi): - """.MIoT Pub/Sub register API.""" - - -@dataclass -class MipsReplyData: - """MIoT Pub/Sub reply data.""" - session: MipsIncomingApiCall = None - payload: str = None - - -@dataclass -class MipsBroadcast: +class _MipsBroadcast: """MIoT Pub/Sub broadcast.""" - topic: str = None + topic: str """ param 1: msg topic param 2: msg payload param 3: handle_ctx """ - handler: Callable[[str, str, Any], None] = None - handler_ctx: Any = None + handler: Callable[[str, str, Any], None] + handler_ctx: Any def __str__(self) -> str: return f'{self.topic}, {id(self.handler)}, {id(self.handler_ctx)}' -class MipsRegBroadcast(MipsBroadcast): - """MIoT Pub/Sub register broadcast.""" - - @dataclass -class MipsState: +class _MipsState: """MIoT Pub/Sub state.""" - key: str = None + key: str """ str: key bool: mips connect state """ - handler: Callable[[str, bool], asyncio.Future] = None - - -class MipsRegState(MipsState): - """MIoT Pub/Sub register state.""" + handler: Callable[[str, bool], Coroutine] class MIoTDeviceState(Enum): @@ -283,69 +199,66 @@ class MIoTDeviceState(Enum): @dataclass class MipsDeviceState: """MIoT Pub/Sub device state.""" - did: str = None + did: Optional[str] = None """handler str: did MIoTDeviceState: online/offline/disable Any: ctx """ - handler: Callable[[str, MIoTDeviceState, Any], None] = None + handler: Optional[Callable[[str, MIoTDeviceState, Any], None]] = None handler_ctx: Any = None -class MipsRegDeviceState(MipsDeviceState): - """MIoT Pub/Sub register device state.""" - - -class MipsClient(ABC): +class _MipsClient(ABC): """MIoT Pub/Sub client.""" # pylint: disable=unused-argument - MQTT_INTERVAL_MS = 1000 + MQTT_INTERVAL_S = 1 MIPS_QOS: int = 2 UINT32_MAX: int = 0xFFFFFFFF - MIPS_RECONNECT_INTERVAL_MIN: int = 30000 - MIPS_RECONNECT_INTERVAL_MAX: int = 600000 + MIPS_RECONNECT_INTERVAL_MIN: float = 30 + MIPS_RECONNECT_INTERVAL_MAX: float = 600 MIPS_SUB_PATCH: int = 300 - MIPS_SUB_INTERVAL: int = 1000 + MIPS_SUB_INTERVAL: float = 1 main_loop: asyncio.AbstractEventLoop - _logger: logging.Logger + _logger: Optional[logging.Logger] _client_id: str _host: str _port: int - _username: str - _password: str - _ca_file: str - _cert_file: str - _key_file: str + _username: Optional[str] + _password: Optional[str] + _ca_file: Optional[str] + _cert_file: Optional[str] + _key_file: Optional[str] + _tls_done: bool - _mqtt_logger: logging.Logger + _mqtt_logger: Optional[logging.Logger] _mqtt: Client _mqtt_fd: int - _mqtt_timer: TimeoutHandle + _mqtt_timer: Optional[asyncio.TimerHandle] _mqtt_state: bool _event_connect: asyncio.Event _event_disconnect: asyncio.Event - _mev: MIoTEventLoop - _mips_thread: threading.Thread - _mips_queue: queue.Queue - _cmd_event_fd: os.eventfd + _internal_loop: asyncio.AbstractEventLoop + _mips_thread: Optional[threading.Thread] _mips_reconnect_tag: bool - _mips_reconnect_interval: int - _mips_reconnect_timer: Optional[TimeoutHandle] - _mips_state_sub_map: dict[str, MipsState] + _mips_reconnect_interval: float + _mips_reconnect_timer: Optional[asyncio.TimerHandle] + _mips_state_sub_map: dict[str, _MipsState] + _mips_state_sub_map_lock: threading.Lock _mips_sub_pending_map: dict[str, int] - _mips_sub_pending_timer: Optional[TimeoutHandle] - - _on_mips_cmd: Callable[[MipsCmd], None] - _on_mips_message: Callable[[str, bytes], None] - _on_mips_connect: Callable[[int, dict], None] - _on_mips_disconnect: Callable[[int, dict], None] + _mips_sub_pending_timer: Optional[asyncio.TimerHandle] def __init__( - self, client_id: str, host: str, port: int, - username: str = None, password: str = None, - ca_file: str = None, cert_file: str = None, key_file: str = None, + self, + client_id: str, + host: str, + port: int, + username: Optional[str] = None, + password: Optional[str] = None, + ca_file: Optional[str] = None, + cert_file: Optional[str] = None, + key_file: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None ) -> None: # MUST run with running loop @@ -359,6 +272,7 @@ class MipsClient(ABC): self._ca_file = ca_file self._cert_file = cert_file self._key_file = key_file + self._tls_done = False self._mqtt_logger = None self._mqtt_fd = -1 @@ -372,26 +286,15 @@ class MipsClient(ABC): # Mips init self._event_connect = asyncio.Event() self._event_disconnect = asyncio.Event() + self._mips_thread = None self._mips_reconnect_tag = False self._mips_reconnect_interval = 0 self._mips_reconnect_timer = None self._mips_state_sub_map = {} + self._mips_state_sub_map_lock = threading.Lock() self._mips_sub_pending_map = {} self._mips_sub_pending_timer = None - self._mev = MIoTEventLoop() - self._mips_queue = queue.Queue() - self._cmd_event_fd = os.eventfd(0, os.O_NONBLOCK) - self.mev_set_read_handler( - self._cmd_event_fd, self.__mips_cmd_read_handler, None) - self._mips_thread = threading.Thread(target=self.__mips_loop_thread) - self._mips_thread.daemon = True - self._mips_thread.name = self._client_id - self._mips_thread.start() - - self._on_mips_cmd = None - self._on_mips_message = None - self._on_mips_connect = None - self._on_mips_disconnect = None + # DO NOT start the thread yet. Do that on connect @property def client_id(self) -> str: @@ -415,29 +318,54 @@ class MipsClient(ABC): """ return self._mqtt and self._mqtt.is_connected() - @final - def mips_deinit(self) -> None: - self._mips_send_cmd(type_=MipsCmdType.DEINIT, data=None) + def connect(self, thread_name: Optional[str] = None) -> None: + """mips connect.""" + # Start mips thread + if self._mips_thread: + return + self._internal_loop = asyncio.new_event_loop() + self._mips_thread = threading.Thread(target=self.__mips_loop_thread) + self._mips_thread.daemon = True + self._mips_thread.name = ( + self._client_id if thread_name is None else thread_name) + self._mips_thread.start() + + async def connect_async(self) -> None: + """mips connect async.""" + self.connect() + await self._event_connect.wait() + + def disconnect(self) -> None: + """mips disconnect.""" + if not self._mips_thread: + return + self._internal_loop.call_soon_threadsafe(self.__mips_disconnect) self._mips_thread.join() self._mips_thread = None + self._internal_loop.close() + + async def disconnect_async(self) -> None: + """mips disconnect async.""" + self.disconnect() + await self._event_disconnect.wait() + + @final + def deinit(self) -> None: + self.disconnect() self._logger = None - self._client_id = None - self._host = None - self._port = None self._username = None self._password = None self._ca_file = None self._cert_file = None self._key_file = None + self._tls_done = False self._mqtt_logger = None - self._mips_state_sub_map = None - self._mips_sub_pending_map = None + with self._mips_state_sub_map_lock: + self._mips_state_sub_map.clear() + self._mips_sub_pending_map.clear() self._mips_sub_pending_timer = None - self._event_connect = None - self._event_disconnect = None - def update_mqtt_password(self, password: str) -> None: self._password = password self._mqtt.username_pw_set( @@ -466,166 +394,74 @@ class MipsClient(ABC): else: self._mqtt.disable_logger() - @final - def mips_connect(self) -> None: - """mips connect.""" - return self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) - - @final - async def mips_connect_async(self) -> None: - """mips connect async.""" - self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) - return await self._event_connect.wait() - - @final - def mips_disconnect(self) -> None: - """mips disconnect.""" - return self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) - - @final - async def mips_disconnect_async(self) -> None: - """mips disconnect async.""" - self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) - return await self._event_disconnect.wait() - @final def sub_mips_state( - self, key: str, handler: Callable[[str, bool], asyncio.Future] + self, key: str, handler: Callable[[str, bool], Coroutine] ) -> bool: """Subscribe mips state. NOTICE: callback to main loop thread + This will be called before the client is connected. + So use mutex instead of IPC. """ if isinstance(key, str) is False or handler is None: raise MIoTMipsError('invalid params') - return self._mips_send_cmd( - type_=MipsCmdType.REG_MIPS_STATE, - data=MipsRegState(key=key, handler=handler)) + state = _MipsState(key=key, handler=handler) + with self._mips_state_sub_map_lock: + self._mips_state_sub_map[key] = state + self.log_debug(f'mips register mips state, {key}') + return True @final def unsub_mips_state(self, key: str) -> bool: """Unsubscribe mips state.""" if isinstance(key, str) is False: raise MIoTMipsError('invalid params') - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_MIPS_STATE, data=MipsRegState(key=key)) - - @final - def mev_set_timeout( - self, timeout_ms: int, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> Optional[TimeoutHandle]: - """set timeout. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return None - return self._mev.set_timeout( - timeout_ms=timeout_ms, handler=handler, handler_ctx=handler_ctx) - - @final - def mev_clear_timeout(self, handle: TimeoutHandle) -> None: - """clear timeout. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return - self._mev.clear_timeout(handle) - - @final - def mev_set_read_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any - ) -> bool: - """set read handler. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return False - return self._mev.set_read_handler( - fd=fd, handler=handler, handler_ctx=handler_ctx) - - @final - def mev_set_write_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any - ) -> bool: - """set write handler. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return False - return self._mev.set_write_handler( - fd=fd, handler=handler, handler_ctx=handler_ctx) - - @property - def on_mips_cmd(self) -> Callable[[MipsCmd], None]: - return self._on_mips_cmd - - @on_mips_cmd.setter - def on_mips_cmd(self, handler: Callable[[MipsCmd], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the **mips** thread - """ - self._on_mips_cmd = handler - - @property - def on_mips_message(self) -> Callable[[str, bytes], None]: - return self._on_mips_message - - @on_mips_message.setter - def on_mips_message(self, handler: Callable[[str, bytes], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the **mips** thread - """ - self._on_mips_message = handler - - @property - def on_mips_connect(self) -> Callable[[int, dict], None]: - return self._on_mips_connect - - @on_mips_connect.setter - def on_mips_connect(self, handler: Callable[[int, dict], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the - **main loop** thread - """ - self._on_mips_connect = handler - - @property - def on_mips_disconnect(self) -> Callable[[int, dict], None]: - return self._on_mips_disconnect - - @on_mips_disconnect.setter - def on_mips_disconnect(self, handler: Callable[[int, dict], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the - **main loop** thread - """ - self._on_mips_disconnect = handler + with self._mips_state_sub_map_lock: + del self._mips_state_sub_map[key] + self.log_debug(f'mips unregister mips state, {key}') + return True @abstractmethod def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: ... @abstractmethod def unsub_prop( - self, did: str, siid: int = None, piid: int = None + self, + did: str, + siid: Optional[int] = None, + piid: Optional[int] = None ) -> bool: ... @abstractmethod def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: ... @abstractmethod def unsub_event( - self, did: str, siid: int = None, eiid: int = None + self, + did: str, + siid: Optional[int] = None, + eiid: Optional[int] = None ) -> bool: ... @abstractmethod async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, + payload: Optional[str] = None, + timeout_ms: int = 10000 ) -> dict[str, dict]: ... @abstractmethod @@ -637,13 +473,22 @@ class MipsClient(ABC): async def set_prop_async( self, did: str, siid: int, piid: int, value: Any, timeout_ms: int = 10000 - ) -> bool: ... + ) -> dict: ... @abstractmethod async def action_async( self, did: str, siid: int, aiid: int, in_list: list, timeout_ms: int = 10000 - ) -> tuple[bool, list]: ... + ) -> dict: ... + + @abstractmethod + def _on_mips_message(self, topic: str, payload: bytes) -> None: ... + + @abstractmethod + def _on_mips_connect(self, rc: int, props: dict) -> None: ... + + @abstractmethod + def _on_mips_disconnect(self, rc: int, props: dict) -> None: ... @final def _mips_sub_internal(self, topic: str) -> None: @@ -657,8 +502,8 @@ class MipsClient(ABC): if topic not in self._mips_sub_pending_map: self._mips_sub_pending_map[topic] = 0 if not self._mips_sub_pending_timer: - self._mips_sub_pending_timer = self.mev_set_timeout( - 10, self.__mips_sub_internal_pending_handler, topic) + self._mips_sub_pending_timer = self._internal_loop.call_later( + 0.01, self.__mips_sub_internal_pending_handler, topic) except Exception as err: # pylint: disable=broad-exception-caught # Catch all exception self.log_error(f'mips sub internal error, {topic}. {err}') @@ -707,75 +552,24 @@ class MipsClient(ABC): self.log_error(f'mips publish internal error, {err}') return False - @final - def _mips_send_cmd(self, type_: MipsCmdType, data: Any) -> bool: - if self._mips_queue is None or self._cmd_event_fd is None: - raise MIoTMipsError('send mips cmd disable') - # Put data to queue - self._mips_queue.put(MipsCmd(type_=type_, data=data)) - # Write event fd - os.eventfd_write(self._cmd_event_fd, 1) - # self.log_debug(f'send mips cmd, {type}, {data}') - return True - def __thread_check(self) -> None: if threading.current_thread() is not self._mips_thread: raise MIoTMipsError('illegal call') - def __mips_cmd_read_handler(self, ctx: Any) -> None: - fd_value = os.eventfd_read(self._cmd_event_fd) - if fd_value == 0: - return - while self._mips_queue.empty() is False: - mips_cmd: MipsCmd = self._mips_queue.get(block=False) - if mips_cmd.type_ == MipsCmdType.CONNECT: - self._mips_reconnect_tag = True - self.__mips_try_reconnect(immediately=True) - elif mips_cmd.type_ == MipsCmdType.DISCONNECT: - self._mips_reconnect_tag = False - self.__mips_disconnect() - elif mips_cmd.type_ == MipsCmdType.DEINIT: - self.log_info('mips client recv deinit cmd') - self.__mips_disconnect() - # Close cmd event fd - if self._cmd_event_fd: - self.mev_set_read_handler( - self._cmd_event_fd, None, None) - os.close(self._cmd_event_fd) - self._cmd_event_fd = None - if self._mips_queue: - self._mips_queue = None - # ev loop stop - if self._mev: - self._mev.loop_stop() - self._mev = None - break - elif mips_cmd.type_ == MipsCmdType.REG_MIPS_STATE: - state: MipsState = mips_cmd.data - self._mips_state_sub_map[state.key] = state - self.log_debug(f'mips register mips state, {state.key}') - elif mips_cmd.type_ == MipsCmdType.UNREG_MIPS_STATE: - state: MipsState = mips_cmd.data - del self._mips_state_sub_map[state.key] - self.log_debug(f'mips unregister mips state, {state.key}') - else: - if self._on_mips_cmd: - self._on_mips_cmd(mips_cmd=mips_cmd) + def __mqtt_read_handler(self) -> None: + self.__mqtt_loop_handler() - def __mqtt_read_handler(self, ctx: Any) -> None: - self.__mqtt_loop_handler(ctx=ctx) + def __mqtt_write_handler(self) -> None: + self._internal_loop.remove_writer(self._mqtt_fd) + self.__mqtt_loop_handler() - def __mqtt_write_handler(self, ctx: Any) -> None: - self.mev_set_write_handler(self._mqtt_fd, None, None) - self.__mqtt_loop_handler(ctx=ctx) - - def __mqtt_timer_handler(self, ctx: Any) -> None: - self.__mqtt_loop_handler(ctx=ctx) + def __mqtt_timer_handler(self) -> None: + self.__mqtt_loop_handler() if self._mqtt: - self._mqtt_timer = self.mev_set_timeout( - self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + self._mqtt_timer = self._internal_loop.call_later( + self.MQTT_INTERVAL_S, self.__mqtt_timer_handler) - def __mqtt_loop_handler(self, ctx: Any) -> None: + def __mqtt_loop_handler(self) -> None: try: if self._mqtt: self._mqtt.loop_read() @@ -784,8 +578,8 @@ class MipsClient(ABC): if self._mqtt: self._mqtt.loop_misc() if self._mqtt and self._mqtt.want_write(): - self.mev_set_write_handler( - self._mqtt_fd, self.__mqtt_write_handler, None) + self._internal_loop.add_writer( + self._mqtt_fd, self.__mqtt_write_handler) except Exception as err: # pylint: disable=broad-exception-caught # Catch all exception self.log_error(f'__mqtt_loop_handler, {err}') @@ -797,25 +591,29 @@ class MipsClient(ABC): if self._username: self._mqtt.username_pw_set( username=self._username, password=self._password) - if ( - self._ca_file - and self._cert_file - and self._key_file - ): - self._mqtt.tls_set( - tls_version=ssl.PROTOCOL_TLS_CLIENT, - ca_certs=self._ca_file, - certfile=self._cert_file, - keyfile=self._key_file) - else: - self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) - self._mqtt.tls_insecure_set(True) + if not self._tls_done: + if ( + self._ca_file + and self._cert_file + and self._key_file + ): + self._mqtt.tls_set( + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ca_certs=self._ca_file, + certfile=self._cert_file, + keyfile=self._key_file) + else: + self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) + self._mqtt.tls_insecure_set(True) + self._tls_done = True self._mqtt.on_connect = self.__on_connect self._mqtt.on_connect_fail = self.__on_connect_failed self._mqtt.on_disconnect = self.__on_disconnect self._mqtt.on_message = self.__on_message + # Connect to mips + self.__mips_start_connect_tries() # Run event loop - self._mev.loop_forever() + self._internal_loop.run_forever() self.log_info('mips_loop_thread exit!') def __on_connect(self, client, user_data, flags, rc, props) -> None: @@ -823,23 +621,23 @@ class MipsClient(ABC): return self.log_info(f'mips connect, {flags}, {rc}, {props}') self._mqtt_state = True - if self._on_mips_connect: - self.mev_set_timeout( - timeout_ms=0, - handler=lambda ctx: - self._on_mips_connect(rc, props)) - for item in self._mips_state_sub_map.values(): - if item.handler is None: - continue - self.main_loop.call_soon_threadsafe( - self.main_loop.create_task, - item.handler(item.key, True)) + self._internal_loop.call_soon( + self._on_mips_connect, rc, props) + with self._mips_state_sub_map_lock: + for item in self._mips_state_sub_map.values(): + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + item.handler(item.key, True)) # Resolve future - self._event_connect.set() - self._event_disconnect.clear() + self.main_loop.call_soon_threadsafe( + self._event_connect.set) + self.main_loop.call_soon_threadsafe( + self._event_disconnect.clear) - def __on_connect_failed(self, client, user_data, flags, rc) -> None: - self.log_error(f'mips connect failed, {flags}, {rc}') + def __on_connect_failed(self, client: Client, user_data: Any) -> None: + self.log_error('mips connect failed') # Try to reconnect self.__mips_try_reconnect() @@ -848,53 +646,44 @@ class MipsClient(ABC): self.log_error(f'mips disconnect, {rc}, {props}') self._mqtt_state = False if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 # Clear retry sub if self._mips_sub_pending_timer: - self.mev_clear_timeout(self._mips_sub_pending_timer) + self._mips_sub_pending_timer.cancel() self._mips_sub_pending_timer = None self._mips_sub_pending_map = {} - if self._on_mips_disconnect: - self.mev_set_timeout( - timeout_ms=0, - handler=lambda ctx: - self._on_mips_disconnect(rc, props)) + self._internal_loop.call_soon( + self._on_mips_disconnect, rc, props) # Call state sub handler - for item in self._mips_state_sub_map.values(): - if item.handler is None: - continue - self.main_loop.call_soon_threadsafe( - self.main_loop.create_task, - item.handler(item.key, False)) + with self._mips_state_sub_map_lock: + for item in self._mips_state_sub_map.values(): + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + item.handler(item.key, False)) # Try to reconnect self.__mips_try_reconnect() # Set event - self._event_disconnect.set() - self._event_connect.clear() + self.main_loop.call_soon_threadsafe( + self._event_disconnect.set) + self.main_loop.call_soon_threadsafe( + self._event_connect.clear) - def __on_message(self, client, user_data, msg) -> None: + def __on_message( + self, + client: Client, + user_data: Any, + msg: MQTTMessage + ) -> None: self._on_mips_message(topic=msg.topic, payload=msg.payload) - def __mips_try_reconnect(self, immediately: bool = False) -> None: - if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) - self._mips_reconnect_timer = None - if not self._mips_reconnect_tag: - return - interval: int = 0 - if not immediately: - interval = self.__get_next_reconnect_time() - self.log_error( - 'mips try reconnect after %sms', interval) - self._mips_reconnect_timer = self.mev_set_timeout( - interval, self.__mips_connect, None) - def __mips_sub_internal_pending_handler(self, ctx: Any) -> None: subbed_count = 1 for topic in list(self._mips_sub_pending_map.keys()): @@ -916,25 +705,25 @@ class MipsClient(ABC): f'retry mips sub internal, {count}, {topic}, {result}, {mid}') if len(self._mips_sub_pending_map): - self._mips_sub_pending_timer = self.mev_set_timeout( + self._mips_sub_pending_timer = self._internal_loop.call_later( self.MIPS_SUB_INTERVAL, self.__mips_sub_internal_pending_handler, None) else: self._mips_sub_pending_timer = None - def __mips_connect(self, ctx: Any = None) -> None: + def __mips_connect(self) -> None: result = MQTT_ERR_UNKNOWN if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer.cancel() self._mips_reconnect_timer = None try: # Try clean mqtt fd before mqtt connect if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 result = self._mqtt.connect( host=self._host, port=self._port, @@ -944,33 +733,59 @@ class MipsClient(ABC): self.log_error('__mips_connect, connect error, %s', error) if result == MQTT_ERR_SUCCESS: - self._mqtt_fd = self._mqtt.socket() + socket = self._mqtt.socket() + if socket is None: + self.log_error( + '__mips_connect, connect success, but socket is None') + self.__mips_try_reconnect() + return + self._mqtt_fd = socket.fileno() self.log_debug(f'__mips_connect, _mqtt_fd, {self._mqtt_fd}') - self.mev_set_read_handler( - self._mqtt_fd, self.__mqtt_read_handler, None) + self._internal_loop.add_reader( + self._mqtt_fd, self.__mqtt_read_handler) if self._mqtt.want_write(): - self.mev_set_write_handler( - self._mqtt_fd, self.__mqtt_write_handler, None) - self._mqtt_timer = self.mev_set_timeout( - self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + self._internal_loop.add_writer( + self._mqtt_fd, self.__mqtt_write_handler) + self._mqtt_timer = self._internal_loop.call_later( + self.MQTT_INTERVAL_S, self.__mqtt_timer_handler) else: self.log_error(f'__mips_connect error result, {result}') self.__mips_try_reconnect() - def __mips_disconnect(self) -> None: + def __mips_try_reconnect(self, immediately: bool = False) -> None: if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer.cancel() + self._mips_reconnect_timer = None + if not self._mips_reconnect_tag: + return + interval: float = 0 + if not immediately: + interval = self.__get_next_reconnect_time() + self.log_error( + 'mips try reconnect after %ss', interval) + self._mips_reconnect_timer = self._internal_loop.call_later( + interval, self.__mips_connect) + + def __mips_start_connect_tries(self) -> None: + self._mips_reconnect_tag = True + self.__mips_try_reconnect(immediately=True) + + def __mips_disconnect(self) -> None: + self._mips_reconnect_tag = False + if self._mips_reconnect_timer: + self._mips_reconnect_timer.cancel() self._mips_reconnect_timer = None if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 self._mqtt.disconnect() + self._internal_loop.stop() - def __get_next_reconnect_time(self) -> int: + def __get_next_reconnect_time(self) -> float: if self._mips_reconnect_interval == 0: self._mips_reconnect_interval = self.MIPS_RECONNECT_INTERVAL_MIN else: @@ -980,7 +795,7 @@ class MipsClient(ABC): return self._mips_reconnect_interval -class MipsCloudClient(MipsClient): +class MipsCloudClient(_MipsClient): """MIoT Pub/Sub Cloud Client.""" # pylint: disable=unused-argument # pylint: disable=inconsistent-quotes @@ -996,45 +811,25 @@ class MipsCloudClient(MipsClient): client_id=f'ha.{uuid}', host=f'{cloud_server}-ha.mqtt.io.mi.com', port=port, username=app_id, password=token, loop=loop) - self.on_mips_cmd = self.__on_mips_cmd_handler - self.on_mips_message = self.__on_mips_message_handler - self.on_mips_connect = self.__on_mips_connect_handler - self.on_mips_disconnect = self.__on_mips_disconnect_handler - - def deinit(self) -> None: - self.mips_deinit() - self._msg_matcher = None - self.on_mips_cmd = None - self.on_mips_message = None - self.on_mips_connect = None - - @final - def connect(self) -> None: - self.mips_connect() - - @final - async def connect_async(self) -> None: - await self.mips_connect_async() - @final def disconnect(self) -> None: - self.mips_disconnect() - self._msg_matcher = MIoTMatcher() - - @final - async def disconnect_async(self) -> None: - await self.mips_disconnect_async() + super().disconnect() self._msg_matcher = MIoTMatcher() def update_access_token(self, access_token: str) -> bool: if not isinstance(access_token, str): raise MIoTMipsError('invalid token') - return self.update_mqtt_password(password=access_token) + self.update_mqtt_password(password=access_token) + return True @final def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if not isinstance(did, str) or handler is None: raise MIoTMipsError('invalid params') @@ -1043,7 +838,7 @@ class MipsCloudClient(MipsClient): f'device/{did}/up/properties_changed/' f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') - def on_prop_msg(topic: str, payload: str, ctx: Any) -> bool: + def on_prop_msg(topic: str, payload: str, ctx: Any) -> None: try: msg: dict = json.loads(payload) except json.JSONDecodeError: @@ -1062,22 +857,31 @@ class MipsCloudClient(MipsClient): if handler: self.log_debug('on properties_changed, %s', payload) handler(msg['params'], ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) @final - 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: if not isinstance(did, str): raise MIoTMipsError('invalid params') topic: str = ( f'device/{did}/up/properties_changed/' f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if not isinstance(did, str) or handler is None: raise MIoTMipsError('invalid params') @@ -1086,7 +890,7 @@ class MipsCloudClient(MipsClient): f'device/{did}/up/event_occured/' f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') - def on_event_msg(topic: str, payload: str, ctx: Any) -> bool: + def on_event_msg(topic: str, payload: str, ctx: Any) -> None: try: msg: dict = json.loads(payload) except json.JSONDecodeError: @@ -1106,18 +910,23 @@ class MipsCloudClient(MipsClient): self.log_debug('on on_event_msg, %s', payload) msg['params']['from'] = 'cloud' handler(msg['params'], ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) @final - 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: if not isinstance(did, str): raise MIoTMipsError('invalid params') # Spelling error: event_occured topic: str = ( f'device/{did}/up/event_occured/' f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_device_state( @@ -1145,7 +954,7 @@ class MipsCloudClient(MipsClient): handler( did, MIoTDeviceState.ONLINE if msg['event'] == 'online' else MIoTDeviceState.OFFLINE, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_state_msg, handler_ctx=handler_ctx) @final @@ -1153,10 +962,10 @@ class MipsCloudClient(MipsClient): if not isinstance(did, str): raise MIoTMipsError('invalid params') topic: str = f'device/{did}/state/#' - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, payload: Optional[str] = None, timeout_ms: int = 10000 ) -> dict[str, dict]: raise NotImplementedError('please call in http client') @@ -1168,97 +977,95 @@ class MipsCloudClient(MipsClient): async def set_prop_async( self, did: str, siid: int, piid: int, value: Any, timeout_ms: int = 10000 - ) -> bool: + ) -> dict: raise NotImplementedError('please call in http client') async def action_async( self, did: str, siid: int, aiid: int, in_list: list, timeout_ms: int = 10000 - ) -> tuple[bool, list]: + ) -> dict: raise NotImplementedError('please call in http client') - def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: - """ - NOTICE thread safe, this function will be called at the **mips** thread - """ - if mips_cmd.type_ == MipsCmdType.REG_BROADCAST: - reg_bc: MipsRegBroadcast = mips_cmd.data - if not self._msg_matcher.get(topic=reg_bc.topic): - sub_bc: MipsBroadcast = MipsBroadcast( - topic=reg_bc.topic, handler=reg_bc.handler, - handler_ctx=reg_bc.handler_ctx) - self._msg_matcher[reg_bc.topic] = sub_bc - self._mips_sub_internal(topic=reg_bc.topic) - else: - self.log_debug(f'mips cloud re-reg broadcast, {reg_bc.topic}') - elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: - unreg_bc: MipsRegBroadcast = mips_cmd.data - if self._msg_matcher.get(topic=unreg_bc.topic): - del self._msg_matcher[unreg_bc.topic] - self._mips_unsub_internal(topic=unreg_bc.topic) + def __reg_broadcast_external( + self, topic: str, handler: Callable[[str, str, Any], None], + handler_ctx: Any = None + ) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__reg_broadcast, topic, handler, handler_ctx) + return True + + def __unreg_broadcast_external(self, topic: str) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__unreg_broadcast, topic) + return True def __reg_broadcast( self, topic: str, handler: Callable[[str, str, Any], None], handler_ctx: Any = None - ) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.REG_BROADCAST, - data=MipsRegBroadcast( - topic=topic, handler=handler, handler_ctx=handler_ctx)) + ) -> None: + if not self._msg_matcher.get(topic=topic): + sub_bc: _MipsBroadcast = _MipsBroadcast( + topic=topic, handler=handler, + handler_ctx=handler_ctx) + self._msg_matcher[topic] = sub_bc + self._mips_sub_internal(topic=topic) + else: + self.log_debug(f'mips cloud re-reg broadcast, {topic}') - def __unreg_broadcast(self, topic: str) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_BROADCAST, - data=MipsRegBroadcast(topic=topic)) + def __unreg_broadcast(self, topic: str) -> None: + if self._msg_matcher.get(topic=topic): + del self._msg_matcher[topic] + self._mips_unsub_internal(topic=topic) - def __on_mips_connect_handler(self, rc, props) -> None: + def _on_mips_connect(self, rc: int, props: dict) -> None: """sub topic.""" for topic, _ in list( self._msg_matcher.iter_all_nodes()): self._mips_sub_internal(topic=topic) - def __on_mips_disconnect_handler(self, rc, props) -> None: + def _on_mips_disconnect(self, rc: int, props: dict) -> None: """unsub topic.""" pass - def __on_mips_message_handler(self, topic: str, payload) -> None: + def _on_mips_message(self, topic: str, payload: bytes) -> None: """ NOTICE thread safe, this function will be called at the **mips** thread """ # broadcast - bc_list: list[MipsBroadcast] = list( + bc_list: list[_MipsBroadcast] = list( self._msg_matcher.iter_match(topic)) if not bc_list: return + # The message from the cloud is not packed. + payload_str: str = payload.decode('utf-8') # self.log_debug(f"on broadcast, {topic}, {payload}") for item in bc_list or []: if item.handler is None: continue # NOTICE: call threadsafe self.main_loop.call_soon_threadsafe( - item.handler, topic, payload, item.handler_ctx) + item.handler, topic, payload_str, item.handler_ctx) -class MipsLocalClient(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_RECONNECT_INTERVAL_MIN: float = 6 + MIPS_RECONNECT_INTERVAL_MAX: float = 60 MIPS_SUB_PATCH: int = 1000 - MIPS_SUB_INTERVAL: int = 100 + MIPS_SUB_INTERVAL: float = 0.1 _did: str _group_id: str _home_name: str _mips_seed_id: int _reply_topic: str _dev_list_change_topic: str - _request_map: dict[str, MipsRequest] + _request_map: dict[str, _MipsRequest] _msg_matcher: MIoTMatcher - _device_state_sub_map: dict[str, MipsDeviceState] _get_prop_queue: dict[str, list] - _get_prop_timer: asyncio.TimerHandle - _on_dev_list_changed: Callable[[Any, list[str]], asyncio.Future] + _get_prop_timer: Optional[asyncio.TimerHandle] + _on_dev_list_changed: Optional[Callable[[Any, list[str]], Coroutine]] def __init__( self, did: str, host: str, group_id: str, @@ -1274,7 +1081,6 @@ class MipsLocalClient(MipsClient): self._dev_list_change_topic = f'{did}/appMsg/devListChange' self._request_map = {} self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} self._get_prop_queue = {} self._get_prop_timer = None self._on_dev_list_changed = None @@ -1282,34 +1088,11 @@ class MipsLocalClient(MipsClient): super().__init__( client_id=did, host=host, port=port, ca_file=ca_file, cert_file=cert_file, key_file=key_file, loop=loop) - # MIPS local thread name use group_id - self._mips_thread.name = self._group_id - - self.on_mips_cmd = self.__on_mips_cmd_handler - self.on_mips_message = self.__on_mips_message_handler - self.on_mips_connect = self.__on_mips_connect_handler @property def group_id(self) -> str: return self._group_id - def deinit(self) -> None: - self.mips_deinit() - self._did = None - self._mips_seed_id = None - self._reply_topic = None - self._dev_list_change_topic = None - self._request_map = None - self._msg_matcher = None - self._device_state_sub_map = None - self._get_prop_queue = None - self._get_prop_timer = None - self._on_dev_list_changed = None - - self.on_mips_cmd = None - self.on_mips_message = None - self.on_mips_connect = None - def log_debug(self, msg, *args, **kwargs) -> None: if self._logger: self._logger.debug(f'{self._home_name}, '+msg, *args, **kwargs) @@ -1323,31 +1106,24 @@ class MipsLocalClient(MipsClient): self._logger.error(f'{self._home_name}, '+msg, *args, **kwargs) @final - def connect(self) -> None: - self.mips_connect() - - @final - async def connect_async(self) -> None: - await self.mips_connect_async() + def connect(self, thread_name: Optional[str] = None) -> None: + # MIPS local thread name use group_id + super().connect(self._group_id) @final def disconnect(self) -> None: - self.mips_disconnect() + super().disconnect() self._request_map = {} self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} - - @final - async def disconnect_async(self) -> None: - await self.mips_disconnect_async() - self._request_map = {} - self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} @final def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/property/' @@ -1367,20 +1143,29 @@ class MipsLocalClient(MipsClient): if handler: self.log_debug('local, on properties_changed, %s', payload) handler(msg, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) @final - 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: str = ( f'appMsg/notify/iot/{did}/property/' f'{"#" if siid is None or piid is None else f"{siid}.{piid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/event/' @@ -1400,15 +1185,20 @@ class MipsLocalClient(MipsClient): if handler: self.log_debug('local, on event_occurred, %s', payload) handler(msg, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) @final - 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: str = ( f'appMsg/notify/iot/{did}/event/' f'{"#" if siid is None or eiid is None else f"{siid}.{eiid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final async def get_prop_safe_async( @@ -1426,7 +1216,9 @@ class MipsLocalClient(MipsClient): 'timeout_ms': timeout_ms }) if self._get_prop_timer is None: - self._get_prop_timer = self.main_loop.create_task( + self._get_prop_timer = self.main_loop.call_later( + 0.1, + self.main_loop.create_task, self.__get_prop_timer_handle()) return await fut @@ -1515,13 +1307,13 @@ class MipsLocalClient(MipsClient): @final async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, payload: Optional[str] = None, timeout_ms: int = 10000 ) -> dict[str, dict]: result_obj = await self.__request_async( topic='proxy/getDevList', payload=payload or '{}', timeout_ms=timeout_ms) if not result_obj or 'devList' not in result_obj: - return None + raise MIoTMipsError('invalid result') device_list = {} for did, info in result_obj['devList'].items(): name: str = info.get('name', None) @@ -1557,7 +1349,7 @@ class MipsLocalClient(MipsClient): payload='{}', timeout_ms=timeout_ms) if not result_obj or 'result' not in result_obj: - return None + raise MIoTMipsError('invalid result') return result_obj['result'] @final @@ -1579,79 +1371,73 @@ class MipsLocalClient(MipsClient): @final @property - def on_dev_list_changed(self) -> Callable[[Any, list[str]], asyncio.Future]: + def on_dev_list_changed( + self + ) -> Optional[Callable[[Any, list[str]], Coroutine]]: return self._on_dev_list_changed @final @on_dev_list_changed.setter def on_dev_list_changed( - self, func: Callable[[Any, list[str]], asyncio.Future] + self, func: Callable[[Any, list[str]], Coroutine] ) -> None: """run in main loop.""" self._on_dev_list_changed = func - @final - def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: - if mips_cmd.type_ == MipsCmdType.CALL_API: - req_data: MipsRequestData = mips_cmd.data - req = MipsRequest() - req.mid = self.__gen_mips_id - req.on_reply = req_data.on_reply - req.on_reply_ctx = req_data.on_reply_ctx - pub_topic: str = f'master/{req_data.topic}' - result = self.__mips_publish( - topic=pub_topic, payload=req_data.payload, mid=req.mid, - ret_topic=self._reply_topic) - self.log_debug( - f'mips local call api, {result}, {req.mid}, {pub_topic}, ' - f'{req_data.payload}') + def __request( + self, topic: str, payload: str, + on_reply: Callable[[str, Any], None], + on_reply_ctx: Any = None, timeout_ms: int = 10000 + ) -> None: + req = _MipsRequest( + mid=self.__gen_mips_id, + on_reply=on_reply, + on_reply_ctx=on_reply_ctx, + timer=None) + pub_topic: str = f'master/{topic}' + result = self.__mips_publish( + topic=pub_topic, payload=payload, mid=req.mid, + ret_topic=self._reply_topic) + self.log_debug( + f'mips local call api, {result}, {req.mid}, {pub_topic}, ' + f'{payload}') - def on_request_timeout(req: MipsRequest): - self.log_error( - f'on mips request timeout, {req.mid}, {pub_topic}' - f', {req_data.payload}') - self._request_map.pop(str(req.mid), None) - req.on_reply( - '{"error":{"code":-10006, "message":"timeout"}}', - req.on_reply_ctx) - req.timer = self.mev_set_timeout( - req_data.timeout_ms, on_request_timeout, req) - self._request_map[str(req.mid)] = req - elif mips_cmd.type_ == MipsCmdType.REG_BROADCAST: - reg_bc: MipsRegBroadcast = mips_cmd.data - sub_topic: str = f'{self._did}/{reg_bc.topic}' - if not self._msg_matcher.get(sub_topic): - sub_bc: MipsBroadcast = MipsBroadcast( - topic=sub_topic, handler=reg_bc.handler, - handler_ctx=reg_bc.handler_ctx) - self._msg_matcher[sub_topic] = sub_bc - self._mips_sub_internal(topic=f'master/{reg_bc.topic}') - else: - self.log_debug(f'mips re-reg broadcast, {sub_topic}') - elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: - unreg_bc: MipsRegBroadcast = mips_cmd.data - # Central hub gateway needs to add prefix - unsub_topic: str = f'{self._did}/{unreg_bc.topic}' - if self._msg_matcher.get(unsub_topic): - del self._msg_matcher[unsub_topic] - self._mips_unsub_internal( - topic=re.sub(f'^{self._did}', 'master', unsub_topic)) - elif mips_cmd.type_ == MipsCmdType.REG_DEVICE_STATE: - reg_dev_state: MipsRegDeviceState = mips_cmd.data - self._device_state_sub_map[reg_dev_state.did] = reg_dev_state - self.log_debug( - f'mips local reg device state, {reg_dev_state.did}') - elif mips_cmd.type_ == MipsCmdType.UNREG_DEVICE_STATE: - unreg_dev_state: MipsRegDeviceState = mips_cmd.data - del self._device_state_sub_map[unreg_dev_state.did] - self.log_debug( - f'mips local unreg device state, {unreg_dev_state.did}') - else: + def on_request_timeout(req: _MipsRequest): self.log_error( - f'mips local recv unknown cmd, {mips_cmd.type_}, ' - f'{mips_cmd.data}') + f'on mips request timeout, {req.mid}, {pub_topic}' + f', {payload}') + self._request_map.pop(str(req.mid), None) + req.on_reply( + '{"error":{"code":-10006, "message":"timeout"}}', + req.on_reply_ctx) + req.timer = self._internal_loop.call_later( + timeout_ms/1000, on_request_timeout, req) + self._request_map[str(req.mid)] = req - def __on_mips_connect_handler(self, rc, props) -> None: + def __reg_broadcast( + self, topic: str, handler: Callable[[str, str, Any], None], + handler_ctx: Any + ) -> None: + sub_topic: str = f'{self._did}/{topic}' + if not self._msg_matcher.get(sub_topic): + sub_bc: _MipsBroadcast = _MipsBroadcast( + topic=sub_topic, handler=handler, + handler_ctx=handler_ctx) + self._msg_matcher[sub_topic] = sub_bc + self._mips_sub_internal(topic=f'master/{topic}') + else: + self.log_debug(f'mips re-reg broadcast, {sub_topic}') + + def __unreg_broadcast(self, topic) -> None: + # Central hub gateway needs to add prefix + unsub_topic: str = f'{self._did}/{topic}' + if self._msg_matcher.get(unsub_topic): + del self._msg_matcher[unsub_topic] + self._mips_unsub_internal( + topic=re.sub(f'^{self._did}', 'master', unsub_topic)) + + @final + def _on_mips_connect(self, rc: int, props: dict) -> None: self.log_debug('__on_mips_connect_handler') # Sub did/#, include reply topic self._mips_sub_internal(f'{self._did}/#') @@ -1665,24 +1451,30 @@ class MipsLocalClient(MipsClient): topic=re.sub(f'^{self._did}', 'master', topic)) @final - def __on_mips_message_handler(self, topic: str, payload: bytes) -> None: - mips_msg: MipsMessage = MipsMessage.unpack(payload) + def _on_mips_disconnect(self, rc: int, props: dict) -> None: + pass + + @final + def _on_mips_message(self, topic: str, payload: bytes) -> None: + mips_msg: _MipsMessage = _MipsMessage.unpack(payload) # self.log_debug( # f"mips local client, on_message, {topic} -> {mips_msg}") # Reply if topic == self._reply_topic: self.log_debug(f'on request reply, {mips_msg}') - req: MipsRequest = self._request_map.pop(str(mips_msg.mid), None) + req: Optional[_MipsRequest] = self._request_map.pop( + str(mips_msg.mid), None) if req: # Cancel timer - self.mev_clear_timeout(req.timer) + if req.timer: + req.timer.cancel() if req.on_reply: self.main_loop.call_soon_threadsafe( req.on_reply, mips_msg.payload or '{}', req.on_reply_ctx) return # Broadcast - bc_list: list[MipsBroadcast] = list(self._msg_matcher.iter_match( + bc_list: list[_MipsBroadcast] = list(self._msg_matcher.iter_match( topic=topic)) if bc_list: self.log_debug(f'on broadcast, {topic}, {mips_msg}') @@ -1695,6 +1487,9 @@ class MipsLocalClient(MipsClient): return # Device list change if topic == self._dev_list_change_topic: + if mips_msg.payload is None: + self.log_error('devListChange msg is None') + return payload_obj: dict = json.loads(mips_msg.payload) dev_list = payload_obj.get('devList', None) if not isinstance(dev_list, list) or not dev_list: @@ -1704,7 +1499,7 @@ class MipsLocalClient(MipsClient): if self._on_dev_list_changed: self.main_loop.call_soon_threadsafe( self.main_loop.create_task, - self._on_dev_list_changed(self, payload_obj['devList'])) + self._on_dev_list_changed(self, dev_list)) return self.log_debug( @@ -1717,45 +1512,45 @@ class MipsLocalClient(MipsClient): return mips_id def __mips_publish( - self, topic: str, payload: str | bytes, mid: int = None, - ret_topic: str = None, wait_for_publish: bool = False, - timeout_ms: int = 10000 + self, + topic: str, + payload: str, + mid: Optional[int] = None, + ret_topic: Optional[str] = None, + wait_for_publish: bool = False, + timeout_ms: int = 10000 ) -> bool: - mips_msg: bytes = MipsMessage.pack( + mips_msg: bytes = _MipsMessage.pack( mid=mid or self.__gen_mips_id, payload=payload, msg_from='local', ret_topic=ret_topic) return self._mips_publish_internal( topic=topic.strip(), payload=mips_msg, wait_for_publish=wait_for_publish, timeout_ms=timeout_ms) - def __request( + def __request_external( self, topic: str, payload: str, on_reply: Callable[[str, Any], None], on_reply_ctx: Any = None, timeout_ms: int = 10000 ) -> bool: if topic is None or payload is None or on_reply is None: raise MIoTMipsError('invalid params') - req_data: MipsRequestData = MipsRequestData() - req_data.topic = topic - req_data.payload = payload - req_data.on_reply = on_reply - req_data.on_reply_ctx = on_reply_ctx - req_data.timeout_ms = timeout_ms - return self._mips_send_cmd(type_=MipsCmdType.CALL_API, data=req_data) + self._internal_loop.call_soon_threadsafe( + self.__request, topic, payload, on_reply, on_reply_ctx, timeout_ms) + return True - def __reg_broadcast( + def __reg_broadcast_external( self, topic: str, handler: Callable[[str, str, Any], None], handler_ctx: Any ) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.REG_BROADCAST, - data=MipsRegBroadcast( - topic=topic, handler=handler, handler_ctx=handler_ctx)) + self._internal_loop.call_soon_threadsafe( + self.__reg_broadcast, + topic, handler, handler_ctx) + return True - def __unreg_broadcast(self, topic) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_BROADCAST, - data=MipsRegBroadcast(topic=topic)) + def __unreg_broadcast_external(self, topic) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__unreg_broadcast, topic) + return True @final async def __request_async( @@ -1767,7 +1562,7 @@ class MipsLocalClient(MipsClient): fut: asyncio.Future = ctx if fut: self.main_loop.call_soon_threadsafe(fut.set_result, payload) - if not self.__request( + if not self.__request_external( topic=topic, payload=payload, on_reply=on_msg_reply, diff --git a/test/conftest.py b/test/conftest.py index 9263402..64687f7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -20,7 +20,6 @@ def load_py_file(): 'const.py', 'miot_cloud.py', 'miot_error.py', - 'miot_ev.py', 'miot_i18n.py', 'miot_lan.py', 'miot_mdns.py', diff --git a/test/test_ev.py b/test/test_ev.py deleted file mode 100644 index 6353fe8..0000000 --- a/test/test_ev.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -"""Unit test for miot_ev.py.""" -import os -import pytest - -# pylint: disable=import-outside-toplevel, disable=unused-argument - - -@pytest.mark.github -def test_mev_timer_and_fd(): - from miot.miot_ev import MIoTEventLoop, TimeoutHandle - - mev = MIoTEventLoop() - assert mev - event_fd: os.eventfd = os.eventfd(0, os.O_NONBLOCK) - assert event_fd - timer4: TimeoutHandle = None - - def event_handler(event_fd): - value: int = os.eventfd_read(event_fd) - if value == 1: - mev.clear_timeout(timer4) - print('cancel timer4') - elif value == 2: - print('event write twice in a row') - elif value == 3: - mev.set_read_handler(event_fd, None, None) - os.close(event_fd) - event_fd = None - print('close event fd') - - def timer1_handler(event_fd): - os.eventfd_write(event_fd, 1) - - def timer2_handler(event_fd): - os.eventfd_write(event_fd, 1) - os.eventfd_write(event_fd, 1) - - def timer3_handler(event_fd): - os.eventfd_write(event_fd, 3) - - def timer4_handler(event_fd): - raise ValueError('unreachable code') - - mev.set_read_handler( - event_fd, event_handler, event_fd) - - mev.set_timeout(500, timer1_handler, event_fd) - mev.set_timeout(1000, timer2_handler, event_fd) - mev.set_timeout(1500, timer3_handler, event_fd) - timer4 = mev.set_timeout(2000, timer4_handler, event_fd) - - mev.loop_forever() - # Loop will exit when there are no timers or fd handlers. - mev.loop_stop() From 5903c9a5a8e48d791f643b919f6e0d78b4beaa64 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 09:38:44 +0800 Subject: [PATCH 08/15] test: add miot cloud test case (#620) * test: add miot cloud test case * feat: improve miot cloud logic * feat: simplify oauth logic * test: improve miot cloud test case * fix: fix pylint error * feat: use random value replace uuid, random_did * fix: import error --- custom_components/xiaomi_home/config_flow.py | 23 +- .../xiaomi_home/miot/miot_cloud.py | 11 +- test/conftest.py | 38 ++ test/test_cloud.py | 485 ++++++++++++++++++ 4 files changed, 541 insertions(+), 16 deletions(-) create mode 100755 test/test_cloud.py diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 8e48849..1c3f12c 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -426,14 +426,12 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): cloud_server=self._cloud_server, uuid=self._uuid, loop=self._main_loop) - state = hashlib.sha1( - f'd=ha.{self._uuid}'.encode('utf-8')).hexdigest() - self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state self._cc_oauth_auth_url = miot_oauth.gen_auth_url( - redirect_url=self._oauth_redirect_url_full, state=state) + redirect_url=self._oauth_redirect_url_full) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( + miot_oauth.state) _LOGGER.info( - 'async_step_oauth, oauth_url: %s', - self._cc_oauth_auth_url) + 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( self.hass, webhook_id=self._virtual_did) webhook_async_register( @@ -1150,17 +1148,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_oauth(self, user_input=None): try: if self._cc_task_oauth is None: - state = hashlib.sha1( - f'd=ha.{self._entry_data["uuid"]}'.encode('utf-8') - ).hexdigest() - self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state - self._miot_oauth.set_redirect_url( - redirect_url=self._oauth_redirect_url_full) self._cc_oauth_auth_url = self._miot_oauth.gen_auth_url( - redirect_url=self._oauth_redirect_url_full, state=state) + redirect_url=self._oauth_redirect_url_full) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( + self._miot_oauth.state) _LOGGER.info( - 'async_step_oauth, oauth_url: %s', - self._cc_oauth_auth_url) + 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( self.hass, webhook_id=self._virtual_did) webhook_async_register( diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 4c076fe..e70930f 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -47,6 +47,7 @@ MIoT http client. """ import asyncio import base64 +import hashlib import json import logging import re @@ -76,6 +77,7 @@ class MIoTOauthClient: _client_id: int _redirect_url: str _device_id: str + _state: str def __init__( self, client_id: str, redirect_url: str, cloud_server: str, @@ -98,8 +100,14 @@ class MIoTOauthClient: else: self._oauth_host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}' self._device_id = f'ha.{uuid}' + self._state = hashlib.sha1( + f'd={self._device_id}'.encode('utf-8')).hexdigest() self._session = aiohttp.ClientSession(loop=self._main_loop) + @property + def state(self) -> str: + return self.state + async def deinit_async(self) -> None: if self._session and not self._session.closed: await self._session.close() @@ -136,7 +144,8 @@ class MIoTOauthClient: 'redirect_uri': redirect_url or self._redirect_url, 'client_id': self._client_id, 'response_type': 'code', - 'device_id': self._device_id + 'device_id': self._device_id, + 'state': self._state } if state: params['state'] = state diff --git a/test/conftest.py b/test/conftest.py index 64687f7..63464cd 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,16 +1,22 @@ # -*- coding: utf-8 -*- """Pytest fixtures.""" +import random import shutil import pytest from os import path, makedirs +from uuid import uuid4 TEST_ROOT_PATH: str = path.dirname(path.abspath(__file__)) TEST_FILES_PATH: str = path.join(TEST_ROOT_PATH, 'miot') TEST_CACHE_PATH: str = path.join(TEST_ROOT_PATH, 'test_cache') +TEST_OAUTH2_REDIRECT_URL: str = 'http://homeassistant.local:8123' TEST_LANG: str = 'zh-Hans' TEST_UID: str = '123456789' TEST_CLOUD_SERVER: str = 'cn' +DOMAIN_OAUTH2: str = 'oauth2_info' +DOMAIN_USER_INFO: str = 'user_info' + @pytest.fixture(scope='session', autouse=True) def load_py_file(): @@ -23,6 +29,7 @@ def load_py_file(): 'miot_i18n.py', 'miot_lan.py', 'miot_mdns.py', + 'miot_mips.py', 'miot_network.py', 'miot_spec.py', 'miot_storage.py'] @@ -59,6 +66,10 @@ def load_py_file(): yield + # NOTICE: All test files and data (tokens, device information, etc.) will + # be deleted after the test is completed. For some test cases that + # require caching data, you can comment out the following code. + if path.exists(TEST_FILES_PATH): shutil.rmtree(TEST_FILES_PATH) print('\nremoved test files, ', TEST_FILES_PATH) @@ -79,6 +90,11 @@ def test_cache_path() -> str: return TEST_CACHE_PATH +@pytest.fixture(scope='session') +def test_oauth2_redirect_url() -> str: + return TEST_OAUTH2_REDIRECT_URL + + @pytest.fixture(scope='session') def test_lang() -> str: return TEST_LANG @@ -89,6 +105,28 @@ def test_uid() -> str: return TEST_UID +@pytest.fixture(scope='session') +def test_random_did() -> str: + # Gen random did + return str(random.getrandbits(64)) + + +@pytest.fixture(scope='session') +def test_uuid() -> str: + # Gen uuid + return uuid4().hex + + @pytest.fixture(scope='session') def test_cloud_server() -> str: return TEST_CLOUD_SERVER + + +@pytest.fixture(scope='session') +def test_domain_oauth2() -> str: + return DOMAIN_OAUTH2 + + +@pytest.fixture(scope='session') +def test_domain_user_info() -> str: + return DOMAIN_USER_INFO diff --git a/test/test_cloud.py b/test/test_cloud.py new file mode 100755 index 0000000..acece12 --- /dev/null +++ b/test/test_cloud.py @@ -0,0 +1,485 @@ +# -*- coding: utf-8 -*- +"""Unit test for miot_cloud.py.""" +import asyncio +import time +import webbrowser +import pytest + +# pylint: disable=import-outside-toplevel, unused-argument + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_oauth_async( + test_cache_path: str, + test_cloud_server: str, + test_oauth2_redirect_url: str, + test_domain_oauth2: str, + test_uuid: str +) -> dict: + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTOauthClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + local_uuid = await miot_storage.load_async( + domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + uuid = str(local_uuid or test_uuid) + print(f'uuid: {uuid}') + miot_oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=test_oauth2_redirect_url, + cloud_server=test_cloud_server, + uuid=uuid) + + oauth_info = None + load_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + if ( + isinstance(load_info, dict) + and 'access_token' in load_info + and 'expires_ts' in load_info + and load_info['expires_ts'] > int(time.time()) + ): + print(f'load oauth info, {load_info}') + oauth_info = load_info + if oauth_info is None: + # gen oauth url + auth_url: str = miot_oauth.gen_auth_url() + assert isinstance(auth_url, str) + print('auth url: ', auth_url) + # get code + webbrowser.open(auth_url) + code: str = input('input code: ') + assert code is not None + # get access_token + res_obj = await miot_oauth.get_access_token_async(code=code) + assert res_obj is not None + oauth_info = res_obj + print(f'get_access_token result: {res_obj}') + rc = await miot_storage.save_async( + test_domain_oauth2, test_cloud_server, oauth_info) + assert rc + print('save oauth info') + rc = await miot_storage.save_async( + test_domain_oauth2, f'{test_cloud_server}_uuid', uuid) + assert rc + print('save uuid') + + access_token = oauth_info.get('access_token', None) + assert isinstance(access_token, str) + print(f'access_token: {access_token}') + refresh_token = oauth_info.get('refresh_token', None) + assert isinstance(refresh_token, str) + print(f'refresh_token: {refresh_token}') + return oauth_info + + +@pytest.mark.asyncio +@pytest.mark.dependency(on=['test_miot_oauth_async']) +async def test_miot_oauth_refresh_token( + test_cache_path: str, + test_cloud_server: str, + test_oauth2_redirect_url: str, + test_domain_oauth2: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTOauthClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + uuid = await miot_storage.load_async( + domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + assert isinstance(uuid, str) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) + assert 'access_token' in oauth_info + assert 'refresh_token' in oauth_info + assert 'expires_ts' in oauth_info + remaining_time = oauth_info['expires_ts'] - int(time.time()) + print(f'token remaining valid time: {remaining_time}s') + # Refresh token + miot_oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=test_oauth2_redirect_url, + cloud_server=test_cloud_server, + uuid=uuid) + refresh_token = oauth_info.get('refresh_token', None) + assert refresh_token + update_info = await miot_oauth.refresh_access_token_async( + refresh_token=refresh_token) + assert update_info + assert 'access_token' in update_info + assert 'refresh_token' in update_info + assert 'expires_ts' in update_info + remaining_time = update_info['expires_ts'] - int(time.time()) + assert remaining_time > 0 + print(f'refresh token, remaining valid time: {remaining_time}s') + # Save token + rc = await miot_storage.save_async( + test_domain_oauth2, test_cloud_server, update_info) + assert rc + print(f'refresh token success, {update_info}') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_nickname_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get nickname + user_info = await miot_http.get_user_info_async() + assert isinstance(user_info, dict) and 'miliaoNick' in user_info + nickname = user_info['miliaoNick'] + print(f'your nickname: {nickname}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_uid_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + uid = await miot_http.get_uid_async() + assert isinstance(uid, str) + print(f'your uid: {uid}\n') + # Save uid + rc = await miot_storage.save_async( + domain=test_domain_user_info, + name=f'uid_{test_cloud_server}', data=uid) + assert rc + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_homeinfos_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get homeinfos + homeinfos = await miot_http.get_homeinfos_async() + assert isinstance(homeinfos, dict) + assert 'uid' in homeinfos and isinstance(homeinfos['uid'], str) + assert 'home_list' in homeinfos and isinstance( + homeinfos['home_list'], dict) + assert 'share_home_list' in homeinfos and isinstance( + homeinfos['share_home_list'], dict) + # Get uid + uid = homeinfos.get('uid', '') + # Compare uid with uid in storage + uid2 = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'uid_{test_cloud_server}', type_=str) + assert uid == uid2 + print(f'your uid: {uid}\n') + # Get homes + home_list = homeinfos.get('home_list', {}) + print(f'your home_list: {home_list}\n') + # Get share homes + share_home_list = homeinfos.get('share_home_list', {}) + print(f'your share_home_list: {share_home_list}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_devices_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get devices + devices = await miot_http.get_devices_async() + assert isinstance(devices, dict) + assert 'uid' in devices and isinstance(devices['uid'], str) + assert 'homes' in devices and isinstance(devices['homes'], dict) + assert 'devices' in devices and isinstance(devices['devices'], dict) + # Compare uid with uid in storage + uid = devices.get('uid', '') + uid2 = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'uid_{test_cloud_server}', type_=str) + assert uid == uid2 + print(f'your uid: {uid}\n') + # Get homes + homes = devices['homes'] + print(f'your homes: {homes}\n') + # Get devices + devices = devices['devices'] + print(f'your devices count: {len(devices)}\n') + # Storage homes and devices + rc = await miot_storage.save_async( + domain=test_domain_user_info, + name=f'homes_{test_cloud_server}', data=homes) + assert rc + rc = await miot_storage.save_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', data=devices) + assert rc + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_devices_with_dids_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get device with dids + test_list = did_list[:6] + devices_info = await miot_http.get_devices_with_dids_async( + dids=test_list) + assert isinstance(devices_info, dict) + print(f'test did list, {len(test_list)}, {test_list}\n') + print(f'test result: {len(devices_info)}, {list(devices_info.keys())}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_prop_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get prop + test_list = did_list[:6] + for did in test_list: + prop_value = await miot_http.get_prop_async(did=did, siid=2, piid=1) + device_name = local_devices[did]['name'] + print(f'{device_name}({did}), prop.2.1: {prop_value}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_props_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get props + test_list = did_list[:6] + prop_values = await miot_http.get_props_async(params=[ + {'did': did, 'siid': 2, 'piid': 1} for did in test_list]) + print(f'test did list, {len(test_list)}, {test_list}\n') + print(f'test result: {len(prop_values)}, {prop_values}\n') + + +@pytest.mark.skip(reason='skip danger operation') +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_set_prop_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + """ + WARNING: This test case will control the actual device and is not enabled + by default. You can uncomment @pytest.mark.skip to enable it. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + assert len(local_devices) > 0 + # Set prop + # Find central hub gateway, control its indicator light switch + # You can replace it with the device you want to control. + test_did = '' + for did, dev in local_devices.items(): + if dev['model'] == 'xiaomi.gateway.hub1': + test_did = did + break + assert test_did != '', 'no central hub gateway found' + result = await miot_http.set_prop_async(params=[{ + 'did': test_did, 'siid': 3, 'piid': 1, 'value': False}]) + print(f'test did, {test_did}, prop.3.1=False -> {result}\n') + await asyncio.sleep(1) + result = await miot_http.set_prop_async(params=[{ + 'did': test_did, 'siid': 3, 'piid': 1, 'value': True}]) + print(f'test did, {test_did}, prop.3.1=True -> {result}\n') + + +@pytest.mark.skip(reason='skip danger operation') +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_action_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + """ + WARNING: This test case will control the actual device and is not enabled + by default. You can uncomment @pytest.mark.skip to enable it. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + assert len(local_devices) > 0 + # Action + # Find central hub gateway, trigger its virtual events + # You can replace it with the device you want to control. + test_did = '' + for did, dev in local_devices.items(): + if dev['model'] == 'xiaomi.gateway.hub1': + test_did = did + break + assert test_did != '', 'no central hub gateway found' + result = await miot_http.action_async( + did=test_did, siid=4, aiid=1, + in_list=[{'piid': 1, 'value': 'hello world.'}]) + print(f'test did, {test_did}, action.4.1 -> {result}\n') From 045528fbf2e7d0835da9a974552bdc43c2915e54 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 10:54:18 +0800 Subject: [PATCH 09/15] style: using logging for test case log print (#636) * style: using logging for test case log print * fix: fix miot cloud test case resource error --- test/check_rule_format.py | 43 ++++++++++------ test/conftest.py | 29 +++++++++-- test/test_cloud.py | 103 ++++++++++++++++++++++---------------- test/test_common.py | 2 +- test/test_lan.py | 13 +++-- test/test_mdns.py | 12 +++-- test/test_network.py | 13 +++-- test/test_spec.py | 9 ++-- test/test_storage.py | 17 ++++--- 9 files changed, 153 insertions(+), 88 deletions(-) diff --git a/test/check_rule_format.py b/test/check_rule_format.py index 3c20afa..5075367 100644 --- a/test/check_rule_format.py +++ b/test/check_rule_format.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Test rule format.""" import json +import logging from os import listdir, path from typing import Optional import pytest import yaml +_LOGGER = logging.getLogger(__name__) + ROOT_PATH: str = path.dirname(path.abspath(__file__)) TRANS_RELATIVE_PATH: str = path.join( ROOT_PATH, '../custom_components/xiaomi_home/translations') @@ -27,10 +30,10 @@ def load_json_file(file_path: str) -> Optional[dict]: with open(file_path, 'r', encoding='utf-8') as file: return json.load(file) except FileNotFoundError: - print(file_path, 'is not found.') + _LOGGER.info('%s is not found.', file_path,) return None except json.JSONDecodeError: - print(file_path, 'is not a valid JSON file.') + _LOGGER.info('%s is not a valid JSON file.', file_path) return None @@ -44,10 +47,10 @@ def load_yaml_file(file_path: str) -> Optional[dict]: with open(file_path, 'r', encoding='utf-8') as file: return yaml.safe_load(file) except FileNotFoundError: - print(file_path, 'is not found.') + _LOGGER.info('%s is not found.', file_path) return None except yaml.YAMLError: - print(file_path, 'is not a valid YAML file.') + _LOGGER.info('%s, is not a valid YAML file.', file_path) return None @@ -116,37 +119,43 @@ def bool_trans(d: dict) -> bool: return False default_trans: dict = d['translate'].pop('default') if not default_trans: - print('default trans is empty') + _LOGGER.info('default trans is empty') return False default_keys: set[str] = set(default_trans.keys()) for key, trans in d['translate'].items(): trans_keys: set[str] = set(trans.keys()) if set(trans.keys()) != default_keys: - print('bool trans inconsistent', key, default_keys, trans_keys) + _LOGGER.info( + 'bool trans inconsistent, %s, %s, %s', + key, default_keys, trans_keys) return False return True def compare_dict_structure(dict1: dict, dict2: dict) -> bool: if not isinstance(dict1, dict) or not isinstance(dict2, dict): - print('invalid type') + _LOGGER.info('invalid type') return False if dict1.keys() != dict2.keys(): - print('inconsistent key values, ', dict1.keys(), dict2.keys()) + _LOGGER.info( + 'inconsistent key values, %s, %s', dict1.keys(), dict2.keys()) return False for key in dict1: if isinstance(dict1[key], dict) and isinstance(dict2[key], dict): if not compare_dict_structure(dict1[key], dict2[key]): - print('inconsistent key values, dict, ', key) + _LOGGER.info( + 'inconsistent key values, dict, %s', key) return False elif isinstance(dict1[key], list) and isinstance(dict2[key], list): if not all( isinstance(i, type(j)) for i, j in zip(dict1[key], dict2[key])): - print('inconsistent key values, list, ', key) + _LOGGER.info( + 'inconsistent key values, list, %s', key) return False elif not isinstance(dict1[key], type(dict2[key])): - print('inconsistent key values, type, ', key) + _LOGGER.info( + 'inconsistent key values, type, %s', key) return False return True @@ -239,7 +248,8 @@ def test_miot_lang_integrity(): compare_dict: dict = load_json_file( path.join(TRANS_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - print('compare_dict_structure failed /translations, ', name) + _LOGGER.info( + 'compare_dict_structure failed /translations, %s', name) assert False # Check i18n files structure default_dict = load_json_file( @@ -248,7 +258,8 @@ def test_miot_lang_integrity(): compare_dict: dict = load_json_file( path.join(MIOT_I18N_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - print('compare_dict_structure failed /miot/i18n, ', name) + _LOGGER.info( + 'compare_dict_structure failed /miot/i18n, %s', name) assert False @@ -284,10 +295,10 @@ def test_miot_data_sort(): def test_sort_spec_data(): sort_data: dict = sort_bool_trans(file_path=SPEC_BOOL_TRANS_FILE) save_json_file(file_path=SPEC_BOOL_TRANS_FILE, data=sort_data) - print(SPEC_BOOL_TRANS_FILE, 'formatted.') + _LOGGER.info('%s formatted.', SPEC_BOOL_TRANS_FILE) sort_data = sort_multi_lang(file_path=SPEC_MULTI_LANG_FILE) save_json_file(file_path=SPEC_MULTI_LANG_FILE, data=sort_data) - print(SPEC_MULTI_LANG_FILE, 'formatted.') + _LOGGER.info('%s formatted.', SPEC_MULTI_LANG_FILE) sort_data = sort_spec_filter(file_path=SPEC_FILTER_FILE) save_json_file(file_path=SPEC_FILTER_FILE, data=sort_data) - print(SPEC_FILTER_FILE, 'formatted.') + _LOGGER.info('%s formatted.', SPEC_FILTER_FILE) diff --git a/test/conftest.py b/test/conftest.py index 63464cd..48f0794 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Pytest fixtures.""" +import logging import random import shutil import pytest @@ -17,6 +18,21 @@ TEST_CLOUD_SERVER: str = 'cn' DOMAIN_OAUTH2: str = 'oauth2_info' DOMAIN_USER_INFO: str = 'user_info' +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(scope='session', autouse=True) +def set_logger(): + logger = logging.getLogger() + logger.setLevel(logging.INFO) + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + _LOGGER.info('set logger, %s', logger) + @pytest.fixture(scope='session', autouse=True) def load_py_file(): @@ -41,28 +57,28 @@ def load_py_file(): TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot', file_name), path.join(TEST_FILES_PATH, file_name)) - print('\nloaded test py files, ', file_list) + _LOGGER.info('\nloaded test py files, %s', file_list) # Copy spec files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/specs'), dst=path.join(TEST_FILES_PATH, 'specs'), dirs_exist_ok=True) - print('loaded spec test folder, specs') + _LOGGER.info('loaded spec test folder, specs') # Copy lan files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/lan'), dst=path.join(TEST_FILES_PATH, 'lan'), dirs_exist_ok=True) - print('loaded lan test folder, lan') + _LOGGER.info('loaded lan test folder, lan') # Copy i18n files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/i18n'), dst=path.join(TEST_FILES_PATH, 'i18n'), dirs_exist_ok=True) - print('loaded i18n test folder, i18n') + _LOGGER.info('loaded i18n test folder, i18n') yield @@ -127,6 +143,11 @@ def test_domain_oauth2() -> str: return DOMAIN_OAUTH2 +@pytest.fixture(scope='session') +def test_name_uuid() -> str: + return f'{TEST_CLOUD_SERVER}_uuid' + + @pytest.fixture(scope='session') def test_domain_user_info() -> str: return DOMAIN_USER_INFO diff --git a/test/test_cloud.py b/test/test_cloud.py index acece12..410420c 100755 --- a/test/test_cloud.py +++ b/test/test_cloud.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- """Unit test for miot_cloud.py.""" import asyncio +import logging import time import webbrowser import pytest # pylint: disable=import-outside-toplevel, unused-argument +_LOGGER = logging.getLogger(__name__) @pytest.mark.asyncio @@ -15,18 +17,18 @@ async def test_miot_oauth_async( test_cloud_server: str, test_oauth2_redirect_url: str, test_domain_oauth2: str, - test_uuid: str + test_uuid: str, + test_name_uuid: str ) -> dict: from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTOauthClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) local_uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + domain=test_domain_oauth2, name=test_name_uuid, type_=str) uuid = str(local_uuid or test_uuid) - print(f'uuid: {uuid}') + _LOGGER.info('uuid: %s', uuid) miot_oauth = MIoTOauthClient( client_id=OAUTH2_CLIENT_ID, redirect_url=test_oauth2_redirect_url, @@ -42,13 +44,13 @@ async def test_miot_oauth_async( and 'expires_ts' in load_info and load_info['expires_ts'] > int(time.time()) ): - print(f'load oauth info, {load_info}') + _LOGGER.info('load oauth info, %s', load_info) oauth_info = load_info if oauth_info is None: # gen oauth url auth_url: str = miot_oauth.gen_auth_url() assert isinstance(auth_url, str) - print('auth url: ', auth_url) + _LOGGER.info('auth url: %s', auth_url) # get code webbrowser.open(auth_url) code: str = input('input code: ') @@ -57,22 +59,24 @@ async def test_miot_oauth_async( res_obj = await miot_oauth.get_access_token_async(code=code) assert res_obj is not None oauth_info = res_obj - print(f'get_access_token result: {res_obj}') + _LOGGER.info('get_access_token result: %s', res_obj) rc = await miot_storage.save_async( test_domain_oauth2, test_cloud_server, oauth_info) assert rc - print('save oauth info') + _LOGGER.info('save oauth info') rc = await miot_storage.save_async( - test_domain_oauth2, f'{test_cloud_server}_uuid', uuid) + test_domain_oauth2, test_name_uuid, uuid) assert rc - print('save uuid') + _LOGGER.info('save uuid') access_token = oauth_info.get('access_token', None) assert isinstance(access_token, str) - print(f'access_token: {access_token}') + _LOGGER.info('access_token: %s', access_token) refresh_token = oauth_info.get('refresh_token', None) assert isinstance(refresh_token, str) - print(f'refresh_token: {refresh_token}') + _LOGGER.info('refresh_token: %s', refresh_token) + + await miot_oauth.deinit_async() return oauth_info @@ -82,16 +86,16 @@ async def test_miot_oauth_refresh_token( test_cache_path: str, test_cloud_server: str, test_oauth2_redirect_url: str, - test_domain_oauth2: str + test_domain_oauth2: str, + test_name_uuid: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTOauthClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + domain=test_domain_oauth2, name=test_name_uuid, type_=str) assert isinstance(uuid, str) oauth_info = await miot_storage.load_async( domain=test_domain_oauth2, name=test_cloud_server, type_=dict) @@ -100,7 +104,7 @@ async def test_miot_oauth_refresh_token( assert 'refresh_token' in oauth_info assert 'expires_ts' in oauth_info remaining_time = oauth_info['expires_ts'] - int(time.time()) - print(f'token remaining valid time: {remaining_time}s') + _LOGGER.info('token remaining valid time: %ss', remaining_time) # Refresh token miot_oauth = MIoTOauthClient( client_id=OAUTH2_CLIENT_ID, @@ -117,12 +121,14 @@ async def test_miot_oauth_refresh_token( assert 'expires_ts' in update_info remaining_time = update_info['expires_ts'] - int(time.time()) assert remaining_time > 0 - print(f'refresh token, remaining valid time: {remaining_time}s') + _LOGGER.info('refresh token, remaining valid time: %ss', remaining_time) # Save token rc = await miot_storage.save_async( test_domain_oauth2, test_cloud_server, update_info) assert rc - print(f'refresh token success, {update_info}') + _LOGGER.info('refresh token success, %s', update_info) + + await miot_oauth.deinit_async() @pytest.mark.asyncio @@ -135,7 +141,6 @@ async def test_miot_cloud_get_nickname_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -149,7 +154,9 @@ async def test_miot_cloud_get_nickname_async( user_info = await miot_http.get_user_info_async() assert isinstance(user_info, dict) and 'miliaoNick' in user_info nickname = user_info['miliaoNick'] - print(f'your nickname: {nickname}\n') + _LOGGER.info('your nickname: %s', nickname) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -163,7 +170,6 @@ async def test_miot_cloud_get_uid_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -175,13 +181,15 @@ async def test_miot_cloud_get_uid_async( uid = await miot_http.get_uid_async() assert isinstance(uid, str) - print(f'your uid: {uid}\n') + _LOGGER.info('your uid: %s', uid) # Save uid rc = await miot_storage.save_async( domain=test_domain_user_info, name=f'uid_{test_cloud_server}', data=uid) assert rc + await miot_http.deinit_async() + @pytest.mark.asyncio @pytest.mark.dependency() @@ -194,7 +202,6 @@ async def test_miot_cloud_get_homeinfos_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -219,13 +226,15 @@ async def test_miot_cloud_get_homeinfos_async( domain=test_domain_user_info, name=f'uid_{test_cloud_server}', type_=str) assert uid == uid2 - print(f'your uid: {uid}\n') + _LOGGER.info('your uid: %s', uid) # Get homes home_list = homeinfos.get('home_list', {}) - print(f'your home_list: {home_list}\n') + _LOGGER.info('your home_list: ,%s', home_list) # Get share homes share_home_list = homeinfos.get('share_home_list', {}) - print(f'your share_home_list: {share_home_list}\n') + _LOGGER.info('your share_home_list: %s', share_home_list) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -239,7 +248,6 @@ async def test_miot_cloud_get_devices_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -261,13 +269,13 @@ async def test_miot_cloud_get_devices_async( domain=test_domain_user_info, name=f'uid_{test_cloud_server}', type_=str) assert uid == uid2 - print(f'your uid: {uid}\n') + _LOGGER.info('your uid: %s', uid) # Get homes homes = devices['homes'] - print(f'your homes: {homes}\n') + _LOGGER.info('your homes: %s', homes) # Get devices devices = devices['devices'] - print(f'your devices count: {len(devices)}\n') + _LOGGER.info('your devices count: %s', len(devices)) # Storage homes and devices rc = await miot_storage.save_async( domain=test_domain_user_info, @@ -278,6 +286,8 @@ async def test_miot_cloud_get_devices_async( name=f'devices_{test_cloud_server}', data=devices) assert rc + await miot_http.deinit_async() + @pytest.mark.asyncio @pytest.mark.dependency() @@ -290,7 +300,6 @@ async def test_miot_cloud_get_devices_with_dids_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -312,8 +321,11 @@ async def test_miot_cloud_get_devices_with_dids_async( devices_info = await miot_http.get_devices_with_dids_async( dids=test_list) assert isinstance(devices_info, dict) - print(f'test did list, {len(test_list)}, {test_list}\n') - print(f'test result: {len(devices_info)}, {list(devices_info.keys())}\n') + _LOGGER.info('test did list, %s, %s', len(test_list), test_list) + _LOGGER.info( + 'test result: %s, %s', len(devices_info), list(devices_info.keys())) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -327,7 +339,6 @@ async def test_miot_cloud_get_prop_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -349,7 +360,9 @@ async def test_miot_cloud_get_prop_async( for did in test_list: prop_value = await miot_http.get_prop_async(did=did, siid=2, piid=1) device_name = local_devices[did]['name'] - print(f'{device_name}({did}), prop.2.1: {prop_value}\n') + _LOGGER.info('%s(%s), prop.2.1: %s', device_name, did, prop_value) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -363,7 +376,6 @@ async def test_miot_cloud_get_props_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -384,8 +396,11 @@ async def test_miot_cloud_get_props_async( test_list = did_list[:6] prop_values = await miot_http.get_props_async(params=[ {'did': did, 'siid': 2, 'piid': 1} for did in test_list]) - print(f'test did list, {len(test_list)}, {test_list}\n') - print(f'test result: {len(prop_values)}, {prop_values}\n') + + _LOGGER.info('test did list, %s, %s', len(test_list), test_list) + _LOGGER.info('test result, %s, %s', len(prop_values), prop_values) + + await miot_http.deinit_async() @pytest.mark.skip(reason='skip danger operation') @@ -404,7 +419,6 @@ async def test_miot_cloud_set_prop_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -431,11 +445,13 @@ async def test_miot_cloud_set_prop_async( assert test_did != '', 'no central hub gateway found' result = await miot_http.set_prop_async(params=[{ 'did': test_did, 'siid': 3, 'piid': 1, 'value': False}]) - print(f'test did, {test_did}, prop.3.1=False -> {result}\n') + _LOGGER.info('test did, %s, prop.3.1=False -> %s', test_did, result) await asyncio.sleep(1) result = await miot_http.set_prop_async(params=[{ 'did': test_did, 'siid': 3, 'piid': 1, 'value': True}]) - print(f'test did, {test_did}, prop.3.1=True -> {result}\n') + _LOGGER.info('test did, %s, prop.3.1=True -> %s', test_did, result) + + await miot_http.deinit_async() @pytest.mark.skip(reason='skip danger operation') @@ -454,7 +470,6 @@ async def test_miot_cloud_action_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -482,4 +497,6 @@ async def test_miot_cloud_action_async( result = await miot_http.action_async( did=test_did, siid=4, aiid=1, in_list=[{'piid': 1, 'value': 'hello world.'}]) - print(f'test did, {test_did}, action.4.1 -> {result}\n') + _LOGGER.info('test did, %s, action.4.1 -> %s', test_did, result) + + await miot_http.deinit_async() diff --git a/test/test_common.py b/test/test_common.py index a6d68bc..18a4736 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -18,7 +18,7 @@ def test_miot_matcher(): 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()) + match_result: list[str] = 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 diff --git a/test/test_lan.py b/test/test_lan.py index a6051c0..a2861cc 100755 --- a/test/test_lan.py +++ b/test/test_lan.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Unit test for miot_lan.py.""" +import logging from typing import Any import pytest import asyncio from zeroconf import IPVersion from zeroconf.asyncio import AsyncZeroconf +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -67,7 +70,7 @@ async def test_lan_async(test_devices: dict): miot_network = MIoTNetwork() await miot_network.init_async() - print('miot_network, ', miot_network.network_info) + _LOGGER.info('miot_network, %s', miot_network.network_info) mips_service = MipsService( aiozc=AsyncZeroconf(ip_version=IPVersion.V4Only)) await mips_service.init_async() @@ -81,7 +84,7 @@ async def test_lan_async(test_devices: dict): 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) + _LOGGER.info('device state change, %s, %s', did, state) if did != test_did: return if ( @@ -91,10 +94,10 @@ async def test_lan_async(test_devices: dict): # 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}')) + _LOGGER.info('sub prop.3.1 msg, %s=%s', did, msg)) miot_lan.sub_prop( did=did, handler=lambda msg, ctx: - print(f'sub all device msg, {did}={msg}')) + _LOGGER.info('sub all device msg, %s=%s', did, msg)) evt_push_available.set() else: # miot_lan.unsub_prop(did=did, siid=3, piid=1) @@ -102,7 +105,7 @@ async def test_lan_async(test_devices: dict): evt_push_unavailable.set() async def lan_state_change(state: bool): - print('lan state change, ', state) + _LOGGER.info('lan state change, %s', state) if not state: return miot_lan.update_devices(devices={ diff --git a/test/test_mdns.py b/test/test_mdns.py index ddf6a10..82cf477 100755 --- a/test/test_mdns.py +++ b/test/test_mdns.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """Unit test for miot_mdns.py.""" +import logging import pytest from zeroconf import IPVersion from zeroconf.asyncio import AsyncZeroconf +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -13,7 +16,7 @@ async def test_service_loop_async(): async def on_service_state_change( group_id: str, state: MipsServiceState, data: MipsServiceData): - print( + _LOGGER.info( 'on_service_state_change, %s, %s, %s', group_id, state, data) async with AsyncZeroconf(ip_version=IPVersion.V4Only) as aiozc: @@ -21,8 +24,9 @@ async def test_service_loop_async(): mips_service.sub_service_change('test', '*', on_service_state_change) await mips_service.init_async() services_detail = mips_service.get_services() - print('get all service, ', services_detail.keys()) + _LOGGER.info('get all service, %s', services_detail.keys()) for name, data in services_detail.items(): - print( - '\tinfo, ', name, data['did'], data['addresses'], data['port']) + _LOGGER.info( + '\tinfo, %s, %s, %s, %s', + name, data['did'], data['addresses'], data['port']) await mips_service.deinit_async() diff --git a/test/test_network.py b/test/test_network.py index aa81a4e..f59ddb2 100755 --- a/test/test_network.py +++ b/test/test_network.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- """Unit test for miot_network.py.""" +import logging import pytest import asyncio +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -12,16 +15,16 @@ async def test_network_monitor_loop_async(): miot_net = MIoTNetwork() async def on_network_status_changed(status: bool): - print(f'on_network_status_changed, {status}') + _LOGGER.info('on_network_status_changed, %s', status) miot_net.sub_network_status(key='test', handler=on_network_status_changed) async def on_network_info_changed( status: InterfaceStatus, info: NetworkInfo): - print(f'on_network_info_changed, {status}, {info}') + _LOGGER.info('on_network_info_changed, %s, %s', status, info) miot_net.sub_network_info(key='test', handler=on_network_info_changed) - await miot_net.init_async(3) + await miot_net.init_async() await asyncio.sleep(3) - print(f'net status: {miot_net.network_status}') - print(f'net info: {miot_net.network_info}') + _LOGGER.info('net status: %s', miot_net.network_status) + _LOGGER.info('net info: %s', miot_net.network_info) await miot_net.deinit_async() diff --git a/test/test_spec.py b/test/test_spec.py index 57ccbb6..248e9d8 100755 --- a/test/test_spec.py +++ b/test/test_spec.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Unit test for miot_spec.py.""" import json +import logging import random import time from urllib.request import Request, urlopen import pytest +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -79,10 +82,10 @@ async def test_spec_random_parse_async(test_cache_path, test_lang): storage = MIoTStorage(test_cache_path) spec_parser = MIoTSpecParser(lang=test_lang, storage=storage) await spec_parser.init_async() - start_ts: int = time.time()*1000 + start_ts = time.time()*1000 for index in test_urn_index: urn: str = test_urns[int(index)] result = await spec_parser.parse(urn=urn, skip_cache=True) assert result is not None - end_ts: int = time.time()*1000 - print(f'takes time, {test_count}, {end_ts-start_ts}') + end_ts = time.time()*1000 + _LOGGER.info('takes time, %s, %s', test_count, end_ts-start_ts) diff --git a/test/test_storage.py b/test/test_storage.py index 76ec510..ace0c53 100755 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """Unit test for miot_storage.py.""" import asyncio +import logging from os import path import pytest +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -101,7 +104,7 @@ async def test_multi_task_load_async(test_cache_path): for _ in range(task_count): task_list.append(asyncio.create_task(storage.load_async( domain=test_domain, name=name, type_=dict))) - print(f'\ntask count, {len(task_list)}') + _LOGGER.info('task count, %s', len(task_list)) result: list = await asyncio.gather(*task_list) assert None not in result @@ -178,28 +181,28 @@ async def test_user_config_async( config=config_update, replace=True) assert (config_replace := await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server)) == config_update - print('replace result, ', config_replace) + _LOGGER.info('replace result, %s', config_replace) # Test query query_keys = list(config_base.keys()) - print('query keys, ', query_keys) + _LOGGER.info('query keys, %s', 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) + _LOGGER.info('query result 1, %s', query_result) assert await storage.update_user_config_async( uid=test_uid, cloud_server=test_cloud_server, config=config_base, replace=True) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server, keys=query_keys) - print('query result 2, ', query_result) + _LOGGER.info('query result 2, %s', query_result) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server) - print('query result all, ', query_result) + _LOGGER.info('query result all, %s', query_result) # 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) + _LOGGER.info('remove result, %s', query_result) # Remove domain assert await storage.remove_domain_async(domain='miot_config') From 3b89536bda64887356d9e1a6c6c65e109d1b99ba Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:23:53 +0800 Subject: [PATCH 10/15] fix: fix miot cloud and mdns error (#637) * fix: fix miot cloud state error * style: code format --- custom_components/xiaomi_home/miot/miot_cloud.py | 2 +- custom_components/xiaomi_home/miot/miot_mdns.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index e70930f..98d5204 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -106,7 +106,7 @@ class MIoTOauthClient: @property def state(self) -> str: - return self.state + return self._state async def deinit_async(self) -> None: if self._session and not self._session.closed: diff --git a/custom_components/xiaomi_home/miot/miot_mdns.py b/custom_components/xiaomi_home/miot/miot_mdns.py index a6b3002..ba661aa 100644 --- a/custom_components/xiaomi_home/miot/miot_mdns.py +++ b/custom_components/xiaomi_home/miot/miot_mdns.py @@ -117,7 +117,7 @@ class MipsServiceData: self.type = service_info.type self.server = service_info.server or '' # Parse profile - self.did = str(int.from_bytes(self.profile_bin[1:9])) + self.did = str(int.from_bytes(self.profile_bin[1:9], byteorder='big')) self.group_id = binascii.hexlify( self.profile_bin[9:17][::-1]).decode('utf-8') self.role = int(self.profile_bin[20] >> 4) From 72d8977e6ebdf0e5570fb1f4e3c7399fe52aba22 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:20:48 +0800 Subject: [PATCH 11/15] test: add test case for user cert (#638) --- test/conftest.py | 31 +++++-- test/test_cloud.py | 197 ++++++++++++++++++++++++++++++++------------- 2 files changed, 166 insertions(+), 62 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 48f0794..9e9160a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -15,8 +15,7 @@ TEST_LANG: str = 'zh-Hans' TEST_UID: str = '123456789' TEST_CLOUD_SERVER: str = 'cn' -DOMAIN_OAUTH2: str = 'oauth2_info' -DOMAIN_USER_INFO: str = 'user_info' +DOMAIN_CLOUD_CACHE: str = 'cloud_cache' _LOGGER = logging.getLogger(__name__) @@ -139,8 +138,18 @@ def test_cloud_server() -> str: @pytest.fixture(scope='session') -def test_domain_oauth2() -> str: - return DOMAIN_OAUTH2 +def test_domain_cloud_cache() -> str: + return DOMAIN_CLOUD_CACHE + + +@pytest.fixture(scope='session') +def test_name_oauth2_info() -> str: + return f'{TEST_CLOUD_SERVER}_oauth2_info' + + +@pytest.fixture(scope='session') +def test_name_uid() -> str: + return f'{TEST_CLOUD_SERVER}_uid' @pytest.fixture(scope='session') @@ -149,5 +158,15 @@ def test_name_uuid() -> str: @pytest.fixture(scope='session') -def test_domain_user_info() -> str: - return DOMAIN_USER_INFO +def test_name_rd_did() -> str: + return f'{TEST_CLOUD_SERVER}_rd_did' + + +@pytest.fixture(scope='session') +def test_name_homes() -> str: + return f'{TEST_CLOUD_SERVER}_homes' + + +@pytest.fixture(scope='session') +def test_name_devices() -> str: + return f'{TEST_CLOUD_SERVER}_devices' diff --git a/test/test_cloud.py b/test/test_cloud.py index 410420c..f1c74b9 100755 --- a/test/test_cloud.py +++ b/test/test_cloud.py @@ -16,8 +16,9 @@ async def test_miot_oauth_async( test_cache_path: str, test_cloud_server: str, test_oauth2_redirect_url: str, - test_domain_oauth2: str, test_uuid: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, test_name_uuid: str ) -> dict: from miot.const import OAUTH2_CLIENT_ID @@ -26,7 +27,7 @@ async def test_miot_oauth_async( miot_storage = MIoTStorage(test_cache_path) local_uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_name_uuid, type_=str) + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) uuid = str(local_uuid or test_uuid) _LOGGER.info('uuid: %s', uuid) miot_oauth = MIoTOauthClient( @@ -37,7 +38,7 @@ async def test_miot_oauth_async( oauth_info = None load_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) if ( isinstance(load_info, dict) and 'access_token' in load_info @@ -61,11 +62,11 @@ async def test_miot_oauth_async( oauth_info = res_obj _LOGGER.info('get_access_token result: %s', res_obj) rc = await miot_storage.save_async( - test_domain_oauth2, test_cloud_server, oauth_info) + test_domain_cloud_cache, test_name_oauth2_info, oauth_info) assert rc _LOGGER.info('save oauth info') rc = await miot_storage.save_async( - test_domain_oauth2, test_name_uuid, uuid) + test_domain_cloud_cache, test_name_uuid, uuid) assert rc _LOGGER.info('save uuid') @@ -86,7 +87,8 @@ async def test_miot_oauth_refresh_token( test_cache_path: str, test_cloud_server: str, test_oauth2_redirect_url: str, - test_domain_oauth2: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, test_name_uuid: str ): from miot.const import OAUTH2_CLIENT_ID @@ -95,10 +97,10 @@ async def test_miot_oauth_refresh_token( miot_storage = MIoTStorage(test_cache_path) uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_name_uuid, type_=str) + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) assert isinstance(uuid, str) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) assert 'access_token' in oauth_info assert 'refresh_token' in oauth_info @@ -122,9 +124,9 @@ async def test_miot_oauth_refresh_token( remaining_time = update_info['expires_ts'] - int(time.time()) assert remaining_time > 0 _LOGGER.info('refresh token, remaining valid time: %ss', remaining_time) - # Save token + # Save oauth2 info rc = await miot_storage.save_async( - test_domain_oauth2, test_cloud_server, update_info) + test_domain_cloud_cache, test_name_oauth2_info, update_info) assert rc _LOGGER.info('refresh token success, %s', update_info) @@ -136,7 +138,8 @@ async def test_miot_oauth_refresh_token( async def test_miot_cloud_get_nickname_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -144,7 +147,7 @@ async def test_miot_cloud_get_nickname_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -164,8 +167,9 @@ async def test_miot_cloud_get_nickname_async( async def test_miot_cloud_get_uid_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -173,7 +177,7 @@ async def test_miot_cloud_get_uid_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -184,8 +188,7 @@ async def test_miot_cloud_get_uid_async( _LOGGER.info('your uid: %s', uid) # Save uid rc = await miot_storage.save_async( - domain=test_domain_user_info, - name=f'uid_{test_cloud_server}', data=uid) + domain=test_domain_cloud_cache, name=test_name_uid, data=uid) assert rc await miot_http.deinit_async() @@ -196,8 +199,9 @@ async def test_miot_cloud_get_uid_async( async def test_miot_cloud_get_homeinfos_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -205,7 +209,7 @@ async def test_miot_cloud_get_homeinfos_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -223,8 +227,7 @@ async def test_miot_cloud_get_homeinfos_async( uid = homeinfos.get('uid', '') # Compare uid with uid in storage uid2 = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'uid_{test_cloud_server}', type_=str) + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) assert uid == uid2 _LOGGER.info('your uid: %s', uid) # Get homes @@ -242,8 +245,11 @@ async def test_miot_cloud_get_homeinfos_async( async def test_miot_cloud_get_devices_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str, + test_name_homes: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -251,7 +257,7 @@ async def test_miot_cloud_get_devices_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -266,8 +272,7 @@ async def test_miot_cloud_get_devices_async( # Compare uid with uid in storage uid = devices.get('uid', '') uid2 = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'uid_{test_cloud_server}', type_=str) + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) assert uid == uid2 _LOGGER.info('your uid: %s', uid) # Get homes @@ -278,12 +283,10 @@ async def test_miot_cloud_get_devices_async( _LOGGER.info('your devices count: %s', len(devices)) # Storage homes and devices rc = await miot_storage.save_async( - domain=test_domain_user_info, - name=f'homes_{test_cloud_server}', data=homes) + domain=test_domain_cloud_cache, name=test_name_homes, data=homes) assert rc rc = await miot_storage.save_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', data=devices) + domain=test_domain_cloud_cache, name=test_name_devices, data=devices) assert rc await miot_http.deinit_async() @@ -294,8 +297,9 @@ async def test_miot_cloud_get_devices_async( async def test_miot_cloud_get_devices_with_dids_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -303,7 +307,7 @@ async def test_miot_cloud_get_devices_with_dids_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -311,8 +315,7 @@ async def test_miot_cloud_get_devices_with_dids_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) did_list = list(local_devices.keys()) assert len(did_list) > 0 @@ -328,13 +331,96 @@ async def test_miot_cloud_get_devices_with_dids_async( await miot_http.deinit_async() +@pytest.mark.asyncio +async def test_miot_cloud_get_cert( + test_cache_path: str, + test_cloud_server: str, + test_random_did: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str, + test_name_rd_did: str +): + """ + NOTICE: Currently, only certificate acquisition in the CN region is + supported. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTCert, MIoTStorage + + if test_cloud_server.lower() != 'cn': + _LOGGER.info('only support CN region') + return + + miot_storage = MIoTStorage(test_cache_path) + uid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert isinstance(uid, str) + _LOGGER.info('your uid: %s', uid) + random_did = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, type_=str) + if not random_did: + random_did = test_random_did + rc = await miot_storage.save_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, + data=random_did) + assert rc + assert isinstance(random_did, str) + _LOGGER.info('your random_did: %s', random_did) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) + assert 'access_token' in oauth_info + access_token = oauth_info['access_token'] + + # Get certificates + miot_cert = MIoTCert(storage=miot_storage, uid=uid, cloud_server='CN') + assert await miot_cert.verify_ca_cert_async(), 'invalid ca cert' + remaining_time: int = await miot_cert.user_cert_remaining_time_async() + if remaining_time > 0: + _LOGGER.info( + 'user cert is valid, remaining time, %ss', remaining_time) + _LOGGER.info(( + 'if you want to obtain it again, please delete the ' + 'key, csr, and cert files in %s.'), test_cache_path) + return + + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=access_token) + + user_key = miot_cert.gen_user_key() + assert isinstance(user_key, str) + _LOGGER.info('user_key str, %s', user_key) + user_csr = miot_cert.gen_user_csr(user_key=user_key, did=random_did) + assert isinstance(user_csr, str) + _LOGGER.info('user_csr str, %s', user_csr) + cert_str = await miot_http.get_central_cert_async(csr=user_csr) + assert isinstance(cert_str, str) + _LOGGER.info('user_cert str, %s', cert_str) + rc = await miot_cert.update_user_key_async(key=user_key) + assert rc + rc = await miot_cert.update_user_cert_async(cert=cert_str) + assert rc + # verify user certificates + remaining_time = await miot_cert.user_cert_remaining_time_async( + cert_data=cert_str.encode('utf-8'), did=random_did) + assert remaining_time > 0 + _LOGGER.info('user cert remaining time, %ss', remaining_time) + + await miot_http.deinit_async() + + @pytest.mark.asyncio @pytest.mark.dependency() async def test_miot_cloud_get_prop_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -342,7 +428,7 @@ async def test_miot_cloud_get_prop_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -350,8 +436,7 @@ async def test_miot_cloud_get_prop_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) did_list = list(local_devices.keys()) assert len(did_list) > 0 @@ -370,8 +455,9 @@ async def test_miot_cloud_get_prop_async( async def test_miot_cloud_get_props_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -379,7 +465,7 @@ async def test_miot_cloud_get_props_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -387,8 +473,7 @@ async def test_miot_cloud_get_props_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) did_list = list(local_devices.keys()) assert len(did_list) > 0 @@ -409,8 +494,9 @@ async def test_miot_cloud_get_props_async( async def test_miot_cloud_set_prop_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): """ WARNING: This test case will control the actual device and is not enabled @@ -422,7 +508,7 @@ async def test_miot_cloud_set_prop_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -430,8 +516,7 @@ async def test_miot_cloud_set_prop_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) assert len(local_devices) > 0 # Set prop @@ -460,8 +545,9 @@ async def test_miot_cloud_set_prop_async( async def test_miot_cloud_action_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): """ WARNING: This test case will control the actual device and is not enabled @@ -473,7 +559,7 @@ async def test_miot_cloud_action_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -481,8 +567,7 @@ async def test_miot_cloud_action_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) assert len(local_devices) > 0 # Action From e0eb06144fb8ae89d7896fb5177b8587412fd637 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:22:23 +0800 Subject: [PATCH 12/15] feat: support remove device (#622) * feat: support remove device * feat: simplify the unsub logic * feat: update notify after rm device --- custom_components/xiaomi_home/__init__.py | 40 +++++++++++++++++++ .../xiaomi_home/miot/miot_client.py | 24 +++++++++++ 2 files changed, 64 insertions(+) diff --git a/custom_components/xiaomi_home/__init__.py b/custom_components/xiaomi_home/__init__.py index 3b534e3..694154d 100644 --- a/custom_components/xiaomi_home/__init__.py +++ b/custom_components/xiaomi_home/__init__.py @@ -308,3 +308,43 @@ async def async_remove_entry( await miot_cert.remove_user_cert_async() await miot_cert.remove_user_key_async() return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: device_registry.DeviceEntry +) -> bool: + """Remove the device.""" + miot_client: MIoTClient = await get_miot_instance_async( + hass=hass, entry_id=config_entry.entry_id) + + if len(device_entry.identifiers) != 1: + _LOGGER.error( + 'remove device failed, invalid identifiers, %s, %s', + device_entry.id, device_entry.identifiers) + return False + identifiers = list(device_entry.identifiers)[0] + if identifiers[0] != DOMAIN: + _LOGGER.error( + 'remove device failed, invalid domain, %s, %s', + device_entry.id, device_entry.identifiers) + return False + device_info = identifiers[1].split('_') + if len(device_info) != 2: + _LOGGER.error( + 'remove device failed, invalid device info, %s, %s', + device_entry.id, device_entry.identifiers) + return False + did = device_info[1] + if did not in miot_client.device_list: + _LOGGER.error( + 'remove device failed, device not found, %s, %s', + device_entry.id, device_entry.identifiers) + return False + # Remove device + await miot_client.remove_device_async(did) + device_registry.async_get(hass).async_remove_device(device_entry.id) + _LOGGER.info( + 'remove device, %s, %s, %s', device_info[0], did, device_entry.id) + return True diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index 58fb504..203c377 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -848,6 +848,30 @@ class MIoTClient: _LOGGER.debug('client unsub device state, %s', did) return True + async def remove_device_async(self, did: str) -> None: + if did not in self._device_list_cache: + return + sub_from = self._sub_source_list.pop(did, None) + # Unsub + if sub_from: + if sub_from == 'cloud': + self._mips_cloud.unsub_prop(did=did) + self._mips_cloud.unsub_event(did=did) + elif sub_from == 'lan': + self._miot_lan.unsub_prop(did=did) + self._miot_lan.unsub_event(did=did) + elif sub_from in self._mips_local: + mips = self._mips_local[sub_from] + mips.unsub_prop(did=did) + mips.unsub_event(did=did) + # Storage + await self._storage.save_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + data=self._device_list_cache) + # Update notify + self.__request_show_devices_changed_notify() + def __get_exec_error_with_rc(self, rc: int) -> str: err_msg: str = self._i18n.translate(key=f'error.common.{rc}') if not err_msg: From 1cdcb785b5c43bcb57f66a37ae492a43d1ff4e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=94=E5=AD=90?= Date: Tue, 14 Jan 2025 09:19:28 +0800 Subject: [PATCH 13/15] feat: add power properties trans (#571) --- custom_components/xiaomi_home/miot/specs/specv2entity.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py index 9e36011..c5bdbea 100644 --- a/custom_components/xiaomi_home/miot/specs/specv2entity.py +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -431,6 +431,14 @@ SPEC_PROP_TRANS_MAP: dict[str, dict | str] = { 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR } }, + 'power': { + 'device_class': SensorDeviceClass.POWER, + 'entity': 'sensor', + 'optional': { + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfPower.WATT + } + }, 'total-battery': { 'device_class': SensorDeviceClass.ENERGY, 'entity': 'sensor', From 288194807675227de3abd75815c6c34abd88fe50 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Tue, 14 Jan 2025 16:59:35 +0800 Subject: [PATCH 14/15] feat: move web page to html (#627) * move web page to html * move loading into function * make the loading async * fix usage * Fix function naming * fix lint * fix lint * feat: use get_running_loop replace get_event_loop * feat: translate using the i18n module * docs: update zh-Hant translate content --------- Co-authored-by: topsworld --- custom_components/xiaomi_home/config_flow.py | 55 +++- .../xiaomi_home/miot/i18n/de.json | 16 ++ .../xiaomi_home/miot/i18n/en.json | 16 ++ .../xiaomi_home/miot/i18n/es.json | 16 ++ .../xiaomi_home/miot/i18n/fr.json | 16 ++ .../xiaomi_home/miot/i18n/ja.json | 16 ++ .../xiaomi_home/miot/i18n/nl.json | 16 ++ .../xiaomi_home/miot/i18n/pt-BR.json | 16 ++ .../xiaomi_home/miot/i18n/pt.json | 16 ++ .../xiaomi_home/miot/i18n/ru.json | 16 ++ .../xiaomi_home/miot/i18n/zh-Hans.json | 16 ++ .../xiaomi_home/miot/i18n/zh-Hant.json | 16 ++ .../xiaomi_home/miot/miot_error.py | 2 + .../miot/resource/oauth_redirect_page.html | 136 +++++++++ .../xiaomi_home/miot/web_pages.py | 258 ++---------------- 15 files changed, 386 insertions(+), 241 deletions(-) create mode 100644 custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 1c3f12c..5b78c27 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -91,7 +91,8 @@ from .miot.miot_cloud import MIoTHttpClient, MIoTOauthClient from .miot.miot_storage import MIoTStorage, MIoTCert from .miot.miot_mdns import MipsService from .miot.web_pages import oauth_redirect_page -from .miot.miot_error import MIoTConfigError, MIoTError, MIoTOauthError +from .miot.miot_error import ( + MIoTConfigError, MIoTError, MIoTErrorCode, MIoTOauthError) from .miot.miot_i18n import MIoTI18n from .miot.miot_network import MIoTNetwork from .miot.miot_client import MIoTClient, get_miot_instance_async @@ -430,6 +431,8 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): redirect_url=self._oauth_redirect_url_full) self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( miot_oauth.state) + self.hass.data[DOMAIN][self._virtual_did]['i18n'] = ( + self._miot_i18n) _LOGGER.info( 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( @@ -1152,6 +1155,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): redirect_url=self._oauth_redirect_url_full) self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( self._miot_oauth.state) + self.hass.data[DOMAIN][self._virtual_did]['i18n'] = ( + self._miot_i18n) _LOGGER.info( 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( @@ -1967,29 +1972,61 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def _handle_oauth_webhook(hass, webhook_id, request): """Webhook to handle oauth2 callback.""" # pylint: disable=inconsistent-quotes + i18n: MIoTI18n = hass.data[DOMAIN][webhook_id].get('i18n', None) try: data = dict(request.query) if data.get('code', None) is None or data.get('state', None) is None: - raise MIoTConfigError('invalid oauth code') + raise MIoTConfigError( + 'invalid oauth code or state', + MIoTErrorCode.CODE_CONFIG_INVALID_INPUT) if data['state'] != hass.data[DOMAIN][webhook_id]['oauth_state']: raise MIoTConfigError( - f'invalid oauth state, ' - f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}, ' - f'{data["state"]}') + f'inconsistent state, ' + f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}!=' + f'{data["state"]}', MIoTErrorCode.CODE_CONFIG_INVALID_STATE) fut_oauth_code: asyncio.Future = hass.data[DOMAIN][webhook_id].pop( 'fut_oauth_code', None) fut_oauth_code.set_result(data['code']) _LOGGER.info('webhook code: %s', data['code']) + success_trans: dict = {} + if i18n: + success_trans = i18n.translate( + 'oauth2.success') or {} # type: ignore + # Delete + del hass.data[DOMAIN][webhook_id]['oauth_state'] + del hass.data[DOMAIN][webhook_id]['i18n'] return web.Response( - body=oauth_redirect_page( - hass.config.language, 'success'), content_type='text/html') + body=await oauth_redirect_page( + title=success_trans.get('title', 'Success'), + content=success_trans.get( + 'content', ( + 'Please close this page and return to the account ' + 'authentication page to click NEXT')), + button=success_trans.get('button', 'Close Page'), + success=True, + ), content_type='text/html') - except MIoTConfigError: + except Exception as err: # pylint: disable=broad-exception-caught + fail_trans: dict = {} + err_msg: str = str(err) + if i18n: + if isinstance(err, MIoTConfigError): + err_msg = i18n.translate( + f'oauth2.error_msg.{err.code.value}' + ) or err.message # type: ignore + fail_trans = i18n.translate('oauth2.fail') or {} # type: ignore return web.Response( - body=oauth_redirect_page(hass.config.language, 'fail'), + body=await oauth_redirect_page( + title=fail_trans.get('title', 'Authentication Failed'), + content=str(fail_trans.get('content', ( + '{error_msg}, Please close this page and return to the ' + 'account authentication page to click the authentication ' + 'link again.'))).replace('{error_msg}', err_msg), + button=fail_trans.get('button', 'Close Page'), + success=False), content_type='text/html') diff --git a/custom_components/xiaomi_home/miot/i18n/de.json b/custom_components/xiaomi_home/miot/i18n/de.json index 81fb203..9dce0e9 100644 --- a/custom_components/xiaomi_home/miot/i18n/de.json +++ b/custom_components/xiaomi_home/miot/i18n/de.json @@ -64,6 +64,22 @@ "net_unavailable": "Schnittstelle nicht verfügbar" } }, + "oauth2": { + "success": { + "title": "Authentifizierung erfolgreich", + "content": "Bitte schließen Sie diese Seite und kehren Sie zur Kontoauthentifizierungsseite zurück, um auf „Weiter“ zu klicken.", + "button": "Schließen" + }, + "fail": { + "title": "Authentifizierung fehlgeschlagen", + "content": "{error_msg}, bitte schließen Sie diese Seite und kehren Sie zur Kontoauthentifizierungsseite zurück, um den Authentifizierungslink erneut zu klicken.", + "button": "Schließen" + }, + "error_msg": { + "-10100": "Ungültige Antwortparameter ('code' oder 'state' Feld ist leer)", + "-10101": "Übergebenes 'state' Feld stimmt nicht überein" + } + }, "miot": { "client": { "invalid_oauth_info": "Ungültige Authentifizierungsinformationen, Cloud-Verbindung nicht verfügbar, bitte betreten Sie die Xiaomi Home-Integrationsseite und klicken Sie auf 'Optionen', um die Authentifizierung erneut durchzuführen", diff --git a/custom_components/xiaomi_home/miot/i18n/en.json b/custom_components/xiaomi_home/miot/i18n/en.json index 219b276..7cf0ecb 100644 --- a/custom_components/xiaomi_home/miot/i18n/en.json +++ b/custom_components/xiaomi_home/miot/i18n/en.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface unavailable" } }, + "oauth2": { + "success": { + "title": "Authentication Successful", + "content": "Please close this page and return to the account authentication page to click 'Next'.", + "button": "Close" + }, + "fail": { + "title": "Authentication Failed", + "content": "{error_msg}, please close this page and return to the account authentication page to click the authentication link again.", + "button": "Close" + }, + "error_msg": { + "-10100": "Invalid response parameters ('code' or 'state' field is empty)", + "-10101": "Passed-in 'state' field mismatch" + } + }, "miot": { "client": { "invalid_oauth_info": "Authentication information is invalid, cloud link will be unavailable, please enter the Xiaomi Home integration page, click 'Options' to re-authenticate", diff --git a/custom_components/xiaomi_home/miot/i18n/es.json b/custom_components/xiaomi_home/miot/i18n/es.json index 49a6ea6..a71312f 100644 --- a/custom_components/xiaomi_home/miot/i18n/es.json +++ b/custom_components/xiaomi_home/miot/i18n/es.json @@ -64,6 +64,22 @@ "net_unavailable": "Interfaz no disponible" } }, + "oauth2": { + "success": { + "title": "Autenticación exitosa", + "content": "Por favor, cierre esta página y regrese a la página de autenticación de la cuenta para hacer clic en 'Siguiente'.", + "button": "Cerrar" + }, + "fail": { + "title": "Autenticación fallida", + "content": "{error_msg}, por favor, cierre esta página y regrese a la página de autenticación de la cuenta para hacer clic en el enlace de autenticación nuevamente.", + "button": "Cerrar" + }, + "error_msg": { + "-10100": "Parámetros de respuesta inválidos ('code' o 'state' está vacío)", + "-10101": "El campo 'state' proporcionado no coincide" + } + }, "miot": { "client": { "invalid_oauth_info": "La información de autenticación es inválida, la conexión en la nube no estará disponible, por favor, vaya a la página de integración de Xiaomi Home, haga clic en 'Opciones' para volver a autenticar", diff --git a/custom_components/xiaomi_home/miot/i18n/fr.json b/custom_components/xiaomi_home/miot/i18n/fr.json index 40feb65..e64b614 100644 --- a/custom_components/xiaomi_home/miot/i18n/fr.json +++ b/custom_components/xiaomi_home/miot/i18n/fr.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface non disponible" } }, + "oauth2": { + "success": { + "title": "Authentification réussie", + "content": "Veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer sur 'Suivant'.", + "button": "Fermer" + }, + "fail": { + "title": "Échec de l'authentification", + "content": "{error_msg}, veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer à nouveau sur le lien d'authentification.", + "button": "Fermer" + }, + "error_msg": { + "-10100": "Paramètres de réponse invalides ('code' ou 'state' est vide)", + "-10101": "Le champ 'state' transmis ne correspond pas" + } + }, "miot": { "client": { "invalid_oauth_info": "Informations d'authentification non valides, le lien cloud ne sera pas disponible, veuillez accéder à la page d'intégration Xiaomi Home, cliquez sur \"Options\" pour vous réauthentifier", diff --git a/custom_components/xiaomi_home/miot/i18n/ja.json b/custom_components/xiaomi_home/miot/i18n/ja.json index 3ffc22a..087467c 100644 --- a/custom_components/xiaomi_home/miot/i18n/ja.json +++ b/custom_components/xiaomi_home/miot/i18n/ja.json @@ -64,6 +64,22 @@ "net_unavailable": "インターフェースが利用できません" } }, + "oauth2": { + "success": { + "title": "認証成功", + "content": "このページを閉じて、アカウント認証ページに戻り、「次へ」をクリックしてください。", + "button": "閉じる" + }, + "fail": { + "title": "認証失敗", + "content": "{error_msg}、このページを閉じて、アカウント認証ページに戻り、再度認証リンクをクリックしてください。", + "button": "閉じる" + }, + "error_msg": { + "-10100": "無効な応答パラメータ('code'または'state'フィールドが空です)", + "-10101": "渡された'state'フィールドが一致しません" + } + }, "miot": { "client": { "invalid_oauth_info": "認証情報が無効です。クラウドリンクは利用できません。Xiaomi Home統合ページに入り、[オプション]をクリックして再認証してください", diff --git a/custom_components/xiaomi_home/miot/i18n/nl.json b/custom_components/xiaomi_home/miot/i18n/nl.json index 101ff3a..d71e90e 100644 --- a/custom_components/xiaomi_home/miot/i18n/nl.json +++ b/custom_components/xiaomi_home/miot/i18n/nl.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface niet beschikbaar" } }, + "oauth2": { + "success": { + "title": "Authenticatie geslaagd", + "content": "Sluit deze pagina en ga terug naar de accountauthenticatiepagina om op 'Volgende' te klikken.", + "button": "Sluiten" + }, + "fail": { + "title": "Authenticatie mislukt", + "content": "{error_msg}, sluit deze pagina en ga terug naar de accountauthenticatiepagina om opnieuw op de authenticatielink te klikken.", + "button": "Sluiten" + }, + "error_msg": { + "-10100": "Ongeldige antwoordparameters ('code' of 'state' veld is leeg)", + "-10101": "Doorgegeven 'state' veld komt niet overeen" + } + }, "miot": { "client": { "invalid_oauth_info": "Authenticatie-informatie is ongeldig, cloudverbinding zal niet beschikbaar zijn. Ga naar de Xiaomi Home-integratiepagina en klik op 'Opties' om opnieuw te verifiëren.", diff --git a/custom_components/xiaomi_home/miot/i18n/pt-BR.json b/custom_components/xiaomi_home/miot/i18n/pt-BR.json index 8e37ecb..0364f7d 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt-BR.json +++ b/custom_components/xiaomi_home/miot/i18n/pt-BR.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface indisponível" } }, + "oauth2": { + "success": { + "title": "Autenticação bem-sucedida", + "content": "Por favor, feche esta página e volte para a página de autenticação da conta para clicar em 'Próximo'.", + "button": "Fechar" + }, + "fail": { + "title": "Falha na autenticação", + "content": "{error_msg}, por favor, feche esta página e volte para a página de autenticação da conta para clicar no link de autenticação novamente.", + "button": "Fechar" + }, + "error_msg": { + "-10100": "Parâmetros de resposta inválidos ('code' ou 'state' está vazio)", + "-10101": "O campo 'state' fornecido não corresponde" + } + }, "miot": { "client": { "invalid_oauth_info": "Informações de autenticação inválidas, a conexão com a nuvem estará indisponível. Vá para a página de integração do Xiaomi Home e clique em 'Opções' para reautenticar.", diff --git a/custom_components/xiaomi_home/miot/i18n/pt.json b/custom_components/xiaomi_home/miot/i18n/pt.json index 08afe4d..d02180f 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt.json +++ b/custom_components/xiaomi_home/miot/i18n/pt.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface indisponível" } }, + "oauth2": { + "success": { + "title": "Autenticação bem-sucedida", + "content": "Por favor, feche esta página e volte para a página de autenticação da conta para clicar em 'Seguinte'.", + "button": "Fechar" + }, + "fail": { + "title": "Falha na autenticação", + "content": "{error_msg}, por favor, feche esta página e volte para a página de autenticação da conta para clicar no link de autenticação novamente.", + "button": "Fechar" + }, + "error_msg": { + "-10100": "Parâmetros de resposta inválidos ('code' ou 'state' está vazio)", + "-10101": "O campo 'state' fornecido não corresponde" + } + }, "miot": { "client": { "invalid_oauth_info": "Informações de autenticação inválidas, a conexão na nuvem ficará indisponível. Por favor, acesse a página de integração do Xiaomi Home e clique em 'Opções' para autenticar novamente.", diff --git a/custom_components/xiaomi_home/miot/i18n/ru.json b/custom_components/xiaomi_home/miot/i18n/ru.json index d018603..7065c39 100644 --- a/custom_components/xiaomi_home/miot/i18n/ru.json +++ b/custom_components/xiaomi_home/miot/i18n/ru.json @@ -64,6 +64,22 @@ "net_unavailable": "Интерфейс недоступен" } }, + "oauth2": { + "success": { + "title": "Аутентификация успешна", + "content": "Пожалуйста, закройте эту страницу и вернитесь на страницу аутентификации учетной записи, чтобы нажать 'Далее'.", + "button": "Закрыть" + }, + "fail": { + "title": "Аутентификация не удалась", + "content": "{error_msg}, пожалуйста, закройте эту страницу и вернитесь на страницу аутентификации учетной записи, чтобы снова нажать на ссылку аутентификации.", + "button": "Закрыть" + }, + "error_msg": { + "-10100": "Недействительные параметры ответа ('code' или 'state' поле пусто)", + "-10101": "Переданное поле 'state' не совпадает" + } + }, "miot": { "client": { "invalid_oauth_info": "Информация об аутентификации недействительна, облако будет недоступно, пожалуйста, войдите на страницу интеграции Xiaomi Home, нажмите 'Опции' для повторной аутентификации", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json index d8f7c8a..3d47d2a 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json @@ -64,6 +64,22 @@ "net_unavailable": "接口不可用" } }, + "oauth2": { + "success": { + "title": "认证成功", + "content": "请关闭此页面,返回账号认证页面点击“下一步”", + "button": "关闭" + }, + "fail": { + "title": "认证失败", + "content": "{error_msg},请关闭此页面,返回账号认证页面重新点击认链接进行认证。", + "button": "关闭" + }, + "error_msg": { + "-10100": "无效的响应参数(“code”或者“state”字段为空)", + "-10101": "传入“state”字段不一致" + } + }, "miot": { "client": { "invalid_oauth_info": "认证信息失效,云端链路将不可用,请进入 Xiaomi Home 集成页面,点击“选项”重新认证", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json index 73bfa98..3c541a7 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json @@ -64,6 +64,22 @@ "net_unavailable": "接口不可用" } }, + "oauth2": { + "success": { + "title": "認證成功", + "content": "請關閉此頁面,返回帳號認證頁面點擊“下一步”", + "button": "關閉" + }, + "fail": { + "title": "認證失敗", + "content": "{error_msg},請關閉此頁面,返回帳號認證頁面重新點擊認鏈接進行認證。", + "button": "關閉" + }, + "error_msg": { + "-10100": "無效的響應參數(“code”或者“state”字段為空)", + "-10101": "傳入的“state”字段不一致" + } + }, "miot": { "client": { "invalid_oauth_info": "認證信息失效,雲端鏈路將不可用,請進入 Xiaomi Home 集成頁面,點擊“選項”重新認證", diff --git a/custom_components/xiaomi_home/miot/miot_error.py b/custom_components/xiaomi_home/miot/miot_error.py index 6e65ad8..e32103e 100644 --- a/custom_components/xiaomi_home/miot/miot_error.py +++ b/custom_components/xiaomi_home/miot/miot_error.py @@ -72,6 +72,8 @@ class MIoTErrorCode(Enum): # MIoT ev error code, -10080 # Mips service error code, -10090 # Config flow error code, -10100 + CODE_CONFIG_INVALID_INPUT = -10100 + CODE_CONFIG_INVALID_STATE = -10101 # Options flow error code , -10110 # MIoT lan error code, -10120 CODE_LAN_UNAVAILABLE = -10120 diff --git a/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html b/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html new file mode 100644 index 0000000..1205f10 --- /dev/null +++ b/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html @@ -0,0 +1,136 @@ + + + + + + + + TITLE_PLACEHOLDER + + + + +
+ +
+ + 编组 + Created with Sketch. + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/web_pages.py b/custom_components/xiaomi_home/miot/web_pages.py index e4cde5a..d6ffd9f 100644 --- a/custom_components/xiaomi_home/miot/web_pages.py +++ b/custom_components/xiaomi_home/miot/web_pages.py @@ -46,237 +46,31 @@ off Xiaomi or its affiliates' products. MIoT redirect web pages. """ -# pylint: disable=line-too-long +import os +import asyncio -def oauth_redirect_page(lang: str, status: str) -> str: +_template = '' + + +def _load_page_template(): + path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'resource/oauth_redirect_page.html') + with open(path, 'r', encoding='utf-8') as f: + global _template + _template = f.read() + + +async def oauth_redirect_page( + title: str, content: str, button: str, success: bool +) -> str: """Return oauth redirect page.""" - return ''' - - - - - - - - - - -
- -
- 编组 - Created with Sketch. - - - - - - - - - - - - - - - - -
- -
- -
- -
- -
- - -
- - - - ''' + if _template == '': + await asyncio.get_running_loop().run_in_executor( + None, _load_page_template) + web_page = _template.replace('TITLE_PLACEHOLDER', title) + web_page = web_page.replace('CONTENT_PLACEHOLDER', content) + web_page = web_page.replace('BUTTON_PLACEHOLDER', button) + web_page = web_page.replace( + 'STATUS_PLACEHOLDER', 'true' if success else 'false') + return web_page From 75e44f4f93bf52c6aa6bd1d6d5170b881bf8398d Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:55:49 +0800 Subject: [PATCH 15/15] feat: change mips reconnect logic & add mips test case (#641) * test: add test case for mips * feat: change mips reconnect logic * fix: fix test_mdns type error --- .../xiaomi_home/miot/miot_mips.py | 96 +++++-- test/test_mdns.py | 9 +- test/test_mips.py | 264 ++++++++++++++++++ 3 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 test/test_mips.py diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 1cade87..865c44c 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -229,10 +229,9 @@ class _MipsClient(ABC): _ca_file: Optional[str] _cert_file: Optional[str] _key_file: Optional[str] - _tls_done: bool _mqtt_logger: Optional[logging.Logger] - _mqtt: Client + _mqtt: Optional[Client] _mqtt_fd: int _mqtt_timer: Optional[asyncio.TimerHandle] _mqtt_state: bool @@ -272,16 +271,12 @@ class _MipsClient(ABC): self._ca_file = ca_file self._cert_file = cert_file self._key_file = key_file - self._tls_done = False self._mqtt_logger = None self._mqtt_fd = -1 self._mqtt_timer = None self._mqtt_state = False - # mqtt init for API_VERSION2, - # callback_api_version=CallbackAPIVersion.VERSION2, - self._mqtt = Client(client_id=self._client_id, protocol=MQTTv5) - self._mqtt.enable_logger(logger=self._mqtt_logger) + self._mqtt = None # Mips init self._event_connect = asyncio.Event() @@ -316,7 +311,9 @@ class _MipsClient(ABC): Returns: bool: True: connected, False: disconnected """ - return self._mqtt and self._mqtt.is_connected() + if self._mqtt: + return self._mqtt.is_connected() + return False def connect(self, thread_name: Optional[str] = None) -> None: """mips connect.""" @@ -359,7 +356,22 @@ class _MipsClient(ABC): self._ca_file = None self._cert_file = None self._key_file = None - self._tls_done = False + self._mqtt_logger = None + with self._mips_state_sub_map_lock: + self._mips_state_sub_map.clear() + self._mips_sub_pending_map.clear() + self._mips_sub_pending_timer = None + + @final + async def deinit_async(self) -> None: + await self.disconnect_async() + + self._logger = None + self._username = None + self._password = None + self._ca_file = None + self._cert_file = None + self._key_file = None self._mqtt_logger = None with self._mips_state_sub_map_lock: self._mips_state_sub_map.clear() @@ -368,8 +380,9 @@ class _MipsClient(ABC): def update_mqtt_password(self, password: str) -> None: self._password = password - self._mqtt.username_pw_set( - username=self._username, password=self._password) + if self._mqtt: + self._mqtt.username_pw_set( + username=self._username, password=self._password) def log_debug(self, msg, *args, **kwargs) -> None: if self._logger: @@ -389,10 +402,12 @@ class _MipsClient(ABC): def enable_mqtt_logger( self, logger: Optional[logging.Logger] = None ) -> None: - if logger: - self._mqtt.enable_logger(logger=logger) - else: - self._mqtt.disable_logger() + self._mqtt_logger = logger + if self._mqtt: + if logger: + self._mqtt.enable_logger(logger=logger) + else: + self._mqtt.disable_logger() @final def sub_mips_state( @@ -587,25 +602,27 @@ class _MipsClient(ABC): def __mips_loop_thread(self) -> None: self.log_info('mips_loop_thread start') + # mqtt init for API_VERSION2, + # callback_api_version=CallbackAPIVersion.VERSION2, + self._mqtt = Client(client_id=self._client_id, protocol=MQTTv5) + self._mqtt.enable_logger(logger=self._mqtt_logger) # Set mqtt config if self._username: self._mqtt.username_pw_set( username=self._username, password=self._password) - if not self._tls_done: - if ( - self._ca_file - and self._cert_file - and self._key_file - ): - self._mqtt.tls_set( - tls_version=ssl.PROTOCOL_TLS_CLIENT, - ca_certs=self._ca_file, - certfile=self._cert_file, - keyfile=self._key_file) - else: - self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) - self._mqtt.tls_insecure_set(True) - self._tls_done = True + if ( + self._ca_file + and self._cert_file + and self._key_file + ): + self._mqtt.tls_set( + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ca_certs=self._ca_file, + certfile=self._cert_file, + keyfile=self._key_file) + else: + self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) + self._mqtt.tls_insecure_set(True) self._mqtt.on_connect = self.__on_connect self._mqtt.on_connect_fail = self.__on_connect_failed self._mqtt.on_disconnect = self.__on_disconnect @@ -617,6 +634,9 @@ class _MipsClient(ABC): self.log_info('mips_loop_thread exit!') def __on_connect(self, client, user_data, flags, rc, props) -> None: + if not self._mqtt: + _LOGGER.error('__on_connect, but mqtt is None') + return if not self._mqtt.is_connected(): return self.log_info(f'mips connect, {flags}, {rc}, {props}') @@ -685,6 +705,10 @@ class _MipsClient(ABC): self._on_mips_message(topic=msg.topic, payload=msg.payload) def __mips_sub_internal_pending_handler(self, ctx: Any) -> None: + if not self._mqtt or not self._mqtt.is_connected(): + _LOGGER.error( + 'mips sub internal pending, but mqtt is None or disconnected') + return subbed_count = 1 for topic in list(self._mips_sub_pending_map.keys()): if subbed_count > self.MIPS_SUB_PATCH: @@ -712,6 +736,9 @@ class _MipsClient(ABC): self._mips_sub_pending_timer = None def __mips_connect(self) -> None: + if not self._mqtt: + _LOGGER.error('__mips_connect, but mqtt is None') + return result = MQTT_ERR_UNKNOWN if self._mips_reconnect_timer: self._mips_reconnect_timer.cancel() @@ -782,7 +809,14 @@ class _MipsClient(ABC): self._internal_loop.remove_reader(self._mqtt_fd) self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 - self._mqtt.disconnect() + # Clear retry sub + if self._mips_sub_pending_timer: + self._mips_sub_pending_timer.cancel() + self._mips_sub_pending_timer = None + self._mips_sub_pending_map = {} + if self._mqtt: + self._mqtt.disconnect() + self._mqtt = None self._internal_loop.stop() def __get_next_reconnect_time(self) -> float: diff --git a/test/test_mdns.py b/test/test_mdns.py index 82cf477..a0e148a 100755 --- a/test/test_mdns.py +++ b/test/test_mdns.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Unit test for miot_mdns.py.""" +import asyncio import logging import pytest from zeroconf import IPVersion @@ -12,10 +13,10 @@ _LOGGER = logging.getLogger(__name__) @pytest.mark.asyncio async def test_service_loop_async(): - from miot.miot_mdns import MipsService, MipsServiceData, MipsServiceState + from miot.miot_mdns import MipsService, MipsServiceState async def on_service_state_change( - group_id: str, state: MipsServiceState, data: MipsServiceData): + group_id: str, state: MipsServiceState, data: dict): _LOGGER.info( 'on_service_state_change, %s, %s, %s', group_id, state, data) @@ -23,8 +24,10 @@ async def test_service_loop_async(): mips_service = MipsService(aiozc) mips_service.sub_service_change('test', '*', on_service_state_change) await mips_service.init_async() + # Wait for service to discover + await asyncio.sleep(3) services_detail = mips_service.get_services() - _LOGGER.info('get all service, %s', services_detail.keys()) + _LOGGER.info('get all service, %s', list(services_detail.keys())) for name, data in services_detail.items(): _LOGGER.info( '\tinfo, %s, %s, %s, %s', diff --git a/test/test_mips.py b/test/test_mips.py new file mode 100644 index 0000000..d808f22 --- /dev/null +++ b/test/test_mips.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""Unit test for miot_mips.py. +NOTICE: When running this test case, you need to run test_cloud.py first to +obtain the token and certificate information, and at the same time avoid data +deletion. +""" +import ipaddress +from typing import Any, Tuple +import pytest +import asyncio +import logging + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable = import-outside-toplevel, unused-argument + +@pytest.mark.parametrize('central_info', [ + ('', 'Gateway did', 'Gateway ip', 8883), +]) +@pytest.mark.asyncio +async def test_mips_local_async( + test_cache_path: str, + test_domain_cloud_cache: str, + test_name_uid: str, + test_name_rd_did: str, + central_info: Tuple[str, str, str, int] +): + """ + NOTICE: + - Mips local is used to connect to the central gateway and is only + supported in the Chinese mainland region. + - Before running this test case, you need to run test_mdns.py first to + obtain the group_id, did, ip, and port of the hub, and then fill in this + information in the parametrize. you can enter multiple central connection + information items for separate tests. + - This test case requires running test_cloud.py first to obtain the + central connection certificate. + - This test case will control the indicator light switch of the central + gateway. + """ + from miot.miot_storage import MIoTStorage, MIoTCert + from miot.miot_mips import MipsLocalClient + + central_group_id: str = central_info[0] + assert isinstance(central_group_id, str) + central_did: str = central_info[1] + assert central_did.isdigit() + central_ip: str = central_info[2] + assert ipaddress.ip_address(central_ip) + central_port: int = central_info[3] + assert isinstance(central_port, int) + + miot_storage = MIoTStorage(test_cache_path) + uid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert isinstance(uid, str) + random_did = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, type_=str) + assert isinstance(random_did, str) + miot_cert = MIoTCert(storage=miot_storage, uid=uid, cloud_server='CN') + assert miot_cert.ca_file + assert miot_cert.cert_file + assert miot_cert.key_file + _LOGGER.info( + 'cert info, %s, %s, %s', miot_cert.ca_file, miot_cert.cert_file, + miot_cert.key_file) + + mips_local = MipsLocalClient( + did=random_did, + host=central_ip, + group_id=central_group_id, + ca_file=miot_cert.ca_file, + cert_file=miot_cert.cert_file, + key_file=miot_cert.key_file, + port=central_port, + home_name='mips local test') + mips_local.enable_logger(logger=_LOGGER) + mips_local.enable_mqtt_logger(logger=_LOGGER) + + async def on_mips_state_changed_async(key: str, state: bool): + _LOGGER.info('on mips state changed, %s, %s', key, state) + + async def on_dev_list_changed_async( + mips: MipsLocalClient, did_list: list[str] + ): + _LOGGER.info('dev list changed, %s', did_list) + + def on_prop_changed(payload: dict, ctx: Any): + _LOGGER.info('prop changed, %s=%s', ctx, payload) + + def on_event_occurred(payload: dict, ctx: Any): + _LOGGER.info('event occurred, %s=%s', ctx, payload) + + # Reg mips state + mips_local.sub_mips_state( + key='mips_local', handler=on_mips_state_changed_async) + mips_local.on_dev_list_changed = on_dev_list_changed_async + # Connect + await mips_local.connect_async() + await asyncio.sleep(0.5) + # Get device list + device_list = await mips_local.get_dev_list_async() + assert isinstance(device_list, dict) + _LOGGER.info( + 'get_dev_list, %d, %s', len(device_list), list(device_list.keys())) + # Sub Prop + mips_local.sub_prop( + did=central_did, handler=on_prop_changed, + handler_ctx=f'{central_did}.*') + # Sub Event + mips_local.sub_event( + did=central_did, handler=on_event_occurred, + handler_ctx=f'{central_did}.*') + # Get/set prop + test_siid = 3 + test_piid = 1 + # mips_local.sub_prop( + # did=central_did, siid=test_siid, piid=test_piid, + # handler=on_prop_changed, + # handler_ctx=f'{central_did}.{test_siid}.{test_piid}') + result1 = await mips_local.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result1, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result1) + result2 = await mips_local.set_prop_async( + did=central_did, siid=test_siid, piid=test_piid, value=not result1) + _LOGGER.info( + 'set prop.%s.%s=%s, result=%s', + test_siid, test_piid, not result1, result2) + assert isinstance(result2, dict) + result3 = await mips_local.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result3, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result3) + # Action + test_siid = 4 + test_aiid = 1 + in_list = [{'piid': 1, 'value': 'hello world.'}] + result4 = await mips_local.action_async( + did=central_did, siid=test_siid, aiid=test_aiid, + in_list=in_list) + assert isinstance(result4, dict) + _LOGGER.info( + 'action.%s.%s=%s, result=%s', test_siid, test_piid, in_list, result4) + # Disconnect + await mips_local.disconnect_async() + await mips_local.deinit_async() + + +@pytest.mark.asyncio +async def test_mips_cloud_async( + test_cache_path: str, + test_name_uuid: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + """ + NOTICE: + - This test case requires running test_cloud.py first to obtain the + central connection certificate. + - This test case will control the indicator light switch of the central + gateway. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_storage import MIoTStorage + from miot.miot_mips import MipsCloudClient + from miot.miot_cloud import MIoTHttpClient + + miot_storage = MIoTStorage(test_cache_path) + uuid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) + assert isinstance(uuid, str) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + access_token = oauth_info['access_token'] + _LOGGER.info('connect info, %s, %s', uuid, access_token) + mips_cloud = MipsCloudClient( + uuid=uuid, + cloud_server=test_cloud_server, + app_id=OAUTH2_CLIENT_ID, + token=access_token) + mips_cloud.enable_logger(logger=_LOGGER) + mips_cloud.enable_mqtt_logger(logger=_LOGGER) + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=access_token) + + async def on_mips_state_changed_async(key: str, state: bool): + _LOGGER.info('on mips state changed, %s, %s', key, state) + + def on_prop_changed(payload: dict, ctx: Any): + _LOGGER.info('prop changed, %s=%s', ctx, payload) + + def on_event_occurred(payload: dict, ctx: Any): + _LOGGER.info('event occurred, %s=%s', ctx, payload) + + await mips_cloud.connect_async() + await asyncio.sleep(0.5) + + # Sub mips state + mips_cloud.sub_mips_state( + key='mips_cloud', handler=on_mips_state_changed_async) + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + central_did = '' + for did, info in local_devices.items(): + if info['model'] != 'xiaomi.gateway.hub1': + continue + central_did = did + break + if central_did: + # Sub Prop + mips_cloud.sub_prop( + did=central_did, handler=on_prop_changed, + handler_ctx=f'{central_did}.*') + # Sub Event + mips_cloud.sub_event( + did=central_did, handler=on_event_occurred, + handler_ctx=f'{central_did}.*') + # Get/set prop + test_siid = 3 + test_piid = 1 + # mips_cloud.sub_prop( + # did=central_did, siid=test_siid, piid=test_piid, + # handler=on_prop_changed, + # handler_ctx=f'{central_did}.{test_siid}.{test_piid}') + result1 = await miot_http.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result1, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result1) + result2 = await miot_http.set_prop_async(params=[{ + 'did': central_did, 'siid': test_siid, 'piid': test_piid, + 'value': not result1}]) + _LOGGER.info( + 'set prop.%s.%s=%s, result=%s', + test_siid, test_piid, not result1, result2) + assert isinstance(result2, list) + result3 = await miot_http.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result3, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result3) + # Action + test_siid = 4 + test_aiid = 1 + in_list = [{'piid': 1, 'value': 'hello world.'}] + result4 = await miot_http.action_async( + did=central_did, siid=test_siid, aiid=test_aiid, + in_list=in_list) + assert isinstance(result4, dict) + _LOGGER.info( + 'action.%s.%s=%s, result=%s', + test_siid, test_piid, in_list, result4) + await asyncio.sleep(1) + # Disconnect + await mips_cloud.disconnect_async() + await mips_cloud.deinit_async() + await miot_http.deinit_async()