From bbf27aa0b66c0cf9a91f7026e3a4353bd16989c4 Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Wed, 28 Jun 2023 01:56:09 +0800 Subject: [PATCH 1/8] feat: add scene history sensor --- .../xiaomi_miot/core/xiaomi_cloud.py | 71 ++++++----- custom_components/xiaomi_miot/sensor.py | 117 ++++++++++++++++++ 2 files changed, 159 insertions(+), 29 deletions(-) diff --git a/custom_components/xiaomi_miot/core/xiaomi_cloud.py b/custom_components/xiaomi_miot/core/xiaomi_cloud.py index c5ba353f1..fbc4a6b2b 100644 --- a/custom_components/xiaomi_miot/core/xiaomi_cloud.py +++ b/custom_components/xiaomi_miot/core/xiaomi_cloud.py @@ -249,47 +249,60 @@ def get_home_devices(self): } return rdt - async def async_get_devices(self, renew=False): + async def _async_get_devices(self, renew=False): if not self.user_id: return None - fnm = f'xiaomi_miot/devices-{self.user_id}-{self.default_server}.json' - store = Store(self.hass, 1, fnm) + + filename = f'xiaomi_miot/devices-{self.user_id}-{self.default_server}.json' + store = Store(self.hass, 1, filename) now = time.time() - cds = [] - dvs = [] + + cached_data = None + data = None try: - dat = await store.async_load() or {} + cached_data = await store.async_load() or {} except ValueError: await store.async_remove() - dat = {} - if isinstance(dat, dict): - cds = dat.get('devices') or [] - if not renew and dat.get('update_time', 0) > (now - 86400): - dvs = cds - if not dvs: + cached_data = {} + + if isinstance(cached_data, dict): + if not renew and cached_data.get('update_time', 0) > (now - 86400): + data = cached_data + + if not data: try: - dvs = await self.hass.async_add_executor_job(self.get_device_list) - if dvs: - hls = await self.hass.async_add_executor_job(self.get_home_devices) - if hls: - hds = hls.get('devices') or {} - dvs = [ - {**d, **(hds.get(d.get('did')) or {})} - for d in dvs + devices = await self.hass.async_add_executor_job(self.get_device_list) + homes = await self.hass.async_add_executor_job(self.get_home_devices) + + if devices: + if homes: + device2home = homes.get('devices') or {} + devices = [ + {**d, **(device2home.get(d.get('did')) or {})} + for d in devices ] - dat = { + + data = { 'update_time': now, - 'devices': dvs, - 'homes': hls.get('homelist', []), + 'devices': devices, + 'homes': homes.get('homelist', []), } - await store.async_save(dat) - _LOGGER.info('Got %s devices from xiaomi cloud', len(dvs)) + + await store.async_save(data) + _LOGGER.info('Got %s devices from xiaomi cloud', len(devices)) except requests.exceptions.ConnectionError as exc: - if not cds: + if not cached_data: raise exc - dvs = cds - _LOGGER.warning('Get xiaomi devices filed: %s, use cached %s devices.', exc, len(cds)) - return dvs + + data = cached_data + _LOGGER.warning('Get xiaomi devices filed: %s, use cached %s devices.', exc, len(data['devices'])) + return data + + async def async_get_devices(self, renew=False): + return (await self._async_get_devices(renew)).get('devices', []) + + async def async_get_homes(self, renew=False): + return (await self._async_get_devices(renew)).get('homes', []) async def async_renew_devices(self): return await self.async_get_devices(renew=True) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index 1741b8c2d..de0066614 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -65,10 +65,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_data = config_entry.data or {} if isinstance(mic, MiotCloud) and mic.user_id and not config_data.get('disable_message'): hass.data[DOMAIN]['accounts'].setdefault(mic.user_id, {}) + if not hass.data[DOMAIN]['accounts'][mic.user_id].get('messenger'): entity = MihomeMessageSensor(hass, mic) hass.data[DOMAIN]['accounts'][mic.user_id]['messenger'] = entity async_add_entities([entity], update_before_add=False) + + homes = await mic.async_get_homes() + for home in homes: + home_id = home.get('id') + if hass.data[DOMAIN]['accounts'][mic.user_id].get(f'scene_history_{home_id}'): + continue + + entity = MihomeSceneHistorySensor(hass, mic, home_id, home.get('uid')) + hass.data[DOMAIN]['accounts'][mic.user_id][f'scene_history_{home_id}'] = entity + async_add_entities([entity], update_before_add=False) + await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) @@ -708,6 +720,111 @@ async def fetch_latest_message(self): return msg +class MihomeSceneHistorySensor(MiCoordinatorEntity, SensorEntity, RestoreEntity): + _has_none_message = False + + def __init__(self, hass, cloud: MiotCloud, home_id, owner_user_id): + self.hass = hass + self.cloud = cloud + self.home_id = int(home_id) + self.owner_user_id = int(owner_user_id) + self.message = {} + self.entity_id = f'{ENTITY_DOMAIN}.mi_{cloud.user_id}_{home_id}_scene_history' + self._attr_unique_id = f'{DOMAIN}-mihome-scene-history-{cloud.user_id}_{home_id}' + self._attr_name = f'Xiaomi {cloud.user_id}_{home_id} Scene History' + self._attr_icon = 'mdi:message' + self._attr_should_poll = False + self._attr_native_value = None + self._attr_extra_state_attributes = { + 'entity_class': self.__class__.__name__, + } + self.coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=self._attr_unique_id, + update_method=self.fetch_latest_message, + update_interval=timedelta(seconds=5), + ) + super().__init__(self.coordinator) + + async def async_added_to_hass(self): + await super().async_added_to_hass() + self.hass.data[DOMAIN]['entities'][self.entity_id] = self + if sec := self.custom_config_integer('interval_seconds'): + self.coordinator.update_interval = timedelta(seconds=sec) + + if restored := await self.async_get_last_extra_data(): + self._attr_native_value = restored.as_dict().get('state') + self._attr_extra_state_attributes.update(restored.as_dict().get('attrs', {})) + + await self.coordinator.async_config_entry_first_refresh() + + async def async_will_remove_from_hass(self): + """Run when entity will be removed from hass. + To be extended by integrations. + """ + await super().async_will_remove_from_hass() + self.hass.data[DOMAIN]['accounts'].get(self.cloud.user_id, {}).pop(f'scene_history_{self.home_id}', None) + + @property + def extra_restore_state_data(self): + """Return entity specific state data to be restored.""" + return RestoredExtraData({ + 'state': self.native_value, + 'attrs': self._attr_extra_state_attributes, + }) + + def trim_message(self, msg): + return { + "from": msg.get('from'), + "name": msg.get('name'), + "timestamp": msg.get('time'), + "scene_id": msg.get('userSceneId'), + "targets": msg.get('msg', []), + } + + async def async_set_message(self, msg): + if msg == self.message: + return + self.message = msg + + trimed = self.trim_message(msg) + old = self._attr_native_value or {} + self._attr_native_value = trimed.get('name') + _LOGGER.debug('New xiaomi scene history for %s: %s', self.cloud.user_id, self._attr_native_value) + self._attr_extra_state_attributes.update({**trimed, 'prev_value': old.get('name'), 'prev_scene_id': old.get('scene_id')}) + + async def fetch_latest_message(self): + res = await self.cloud.async_request_api('scene/history', data={ + "home_id": self.home_id, + "uid": int(self.cloud.user_id), + "owner_uid": self.owner_user_id, + "command": "history", + "limit": 15, + }) or {} + + messages = (res.get('result') or {}).get('history') or [] + if not messages: + if not self._has_none_message: + _LOGGER.warning('Get xiaomi scene history for %s failed: %s', self.cloud.user_id, res) + + self._has_none_message = True + return {} + + messages.sort(key=lambda x: x.get('time', 0), reverse=False) + prev_timestamp = self._attr_extra_state_attributes.get('timestamp') or 0 + for msg in messages: + ts = msg.get('time', 0) + if ts and ts < prev_timestamp: + continue + + await self.async_set_message(msg) + self._has_none_message = False + return msg + + return {} + + class XiaoaiConversationSensor(MiCoordinatorEntity, BaseSensorSubEntity): def __init__(self, parent, hass, option=None): BaseSensorSubEntity.__init__(self, parent, 'conversation', option) From bc17f0b1f9e90d9001156665cc512a0d449b269c Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Wed, 28 Jun 2023 12:41:39 +0800 Subject: [PATCH 2/8] fix: scene match bugs --- custom_components/xiaomi_miot/sensor.py | 34 +++++++++++++++---------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index de0066614..f891b7740 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -754,8 +754,9 @@ async def async_added_to_hass(self): self.coordinator.update_interval = timedelta(seconds=sec) if restored := await self.async_get_last_extra_data(): - self._attr_native_value = restored.as_dict().get('state') - self._attr_extra_state_attributes.update(restored.as_dict().get('attrs', {})) + restored_dict = restored.as_dict() + self._attr_native_value = restored_dict.get('state') + self._attr_extra_state_attributes.update(restored_dict.get('attrs', {})) await self.coordinator.async_config_entry_first_refresh() @@ -775,11 +776,13 @@ def extra_restore_state_data(self): }) def trim_message(self, msg): + ts = msg.get('time') return { "from": msg.get('from'), "name": msg.get('name'), - "timestamp": msg.get('time'), - "scene_id": msg.get('userSceneId'), + "ts": ts, + 'timestamp': datetime.fromtimestamp(ts, local_zone()) if ts else None, + "scene_id": str(msg.get('userSceneId')), "targets": msg.get('msg', []), } @@ -788,11 +791,11 @@ async def async_set_message(self, msg): return self.message = msg - trimed = self.trim_message(msg) - old = self._attr_native_value or {} - self._attr_native_value = trimed.get('name') - _LOGGER.debug('New xiaomi scene history for %s: %s', self.cloud.user_id, self._attr_native_value) - self._attr_extra_state_attributes.update({**trimed, 'prev_value': old.get('name'), 'prev_scene_id': old.get('scene_id')}) + self._attr_native_value = msg.get('name') + _LOGGER.debug('New xiaomi scene history for %s %d: %s', self.cloud.user_id, self.home_id, self._attr_native_value) + + old = self._attr_extra_state_attributes or {} + self._attr_extra_state_attributes = {**msg, 'prev_value': old.get('name'), 'prev_scene_id': old.get('scene_id')} async def fetch_latest_message(self): res = await self.cloud.async_request_api('scene/history', data={ @@ -803,20 +806,23 @@ async def fetch_latest_message(self): "limit": 15, }) or {} - messages = (res.get('result') or {}).get('history') or [] + messages = [self.trim_message(msg) for msg in (res.get('result') or {}).get('history') or []] if not messages: if not self._has_none_message: - _LOGGER.warning('Get xiaomi scene history for %s failed: %s', self.cloud.user_id, res) + _LOGGER.warning('Get xiaomi scene history for %s %d failed: %s', self.cloud.user_id, self.home_id, res) self._has_none_message = True return {} - messages.sort(key=lambda x: x.get('time', 0), reverse=False) - prev_timestamp = self._attr_extra_state_attributes.get('timestamp') or 0 + prev_timestamp = self._attr_extra_state_attributes.get('ts') or 0 + messages.sort(key=lambda x: x.get('ts', 0), reverse=False) + _LOGGER.warning('Get xiaomi scene history for %s %d success: prev_timestamp= %d messages= %s,', self.cloud.user_id, self.home_id, prev_timestamp, messages) for msg in messages: - ts = msg.get('time', 0) + ts = msg.get('ts', 0) if ts and ts < prev_timestamp: continue + if self.message and self.message == msg: + continue await self.async_set_message(msg) self._has_none_message = False From bc14c34f7a742299b4aed68ccb66a9de9d577ca3 Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Wed, 28 Jun 2023 13:40:41 +0800 Subject: [PATCH 3/8] fix: old state compare --- custom_components/xiaomi_miot/sensor.py | 26 ++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index f891b7740..1681139a0 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -728,7 +728,6 @@ def __init__(self, hass, cloud: MiotCloud, home_id, owner_user_id): self.cloud = cloud self.home_id = int(home_id) self.owner_user_id = int(owner_user_id) - self.message = {} self.entity_id = f'{ENTITY_DOMAIN}.mi_{cloud.user_id}_{home_id}_scene_history' self._attr_unique_id = f'{DOMAIN}-mihome-scene-history-{cloud.user_id}_{home_id}' self._attr_name = f'Xiaomi {cloud.user_id}_{home_id} Scene History' @@ -755,8 +754,17 @@ async def async_added_to_hass(self): if restored := await self.async_get_last_extra_data(): restored_dict = restored.as_dict() + + attrs = restored_dict.get('attrs', {}) + if ts := attrs.get('ts'): + attrs['timestamp'] = datetime.fromtimestamp(ts, local_zone()) if ts else None + + _LOGGER.debug( + 'xiaomi scene history %s %d, async_added_to_hass restore state: state= %s attrs= %s', + self.cloud.user_id, self.home_id, restored_dict.get('state'), attrs, + ) self._attr_native_value = restored_dict.get('state') - self._attr_extra_state_attributes.update(restored_dict.get('attrs', {})) + self._attr_extra_state_attributes.update(attrs) await self.coordinator.async_config_entry_first_refresh() @@ -787,15 +795,11 @@ def trim_message(self, msg): } async def async_set_message(self, msg): - if msg == self.message: - return - self.message = msg - self._attr_native_value = msg.get('name') _LOGGER.debug('New xiaomi scene history for %s %d: %s', self.cloud.user_id, self.home_id, self._attr_native_value) old = self._attr_extra_state_attributes or {} - self._attr_extra_state_attributes = {**msg, 'prev_value': old.get('name'), 'prev_scene_id': old.get('scene_id')} + self._attr_extra_state_attributes.update({**msg, 'prev_value': old.get('name'), 'prev_scene_id': old.get('scene_id')}) async def fetch_latest_message(self): res = await self.cloud.async_request_api('scene/history', data={ @@ -815,13 +819,17 @@ async def fetch_latest_message(self): return {} prev_timestamp = self._attr_extra_state_attributes.get('ts') or 0 + prev_scene_id = self._attr_extra_state_attributes.get('scene_id') or '' messages.sort(key=lambda x: x.get('ts', 0), reverse=False) - _LOGGER.warning('Get xiaomi scene history for %s %d success: prev_timestamp= %d messages= %s,', self.cloud.user_id, self.home_id, prev_timestamp, messages) + _LOGGER.warning( + 'Get xiaomi scene history for %s %d success: prev_timestamp= %d prev_scene_id= %s messages= %s,', + self.cloud.user_id, self.home_id, prev_timestamp, prev_scene_id, messages, + ) for msg in messages: ts = msg.get('ts', 0) if ts and ts < prev_timestamp: continue - if self.message and self.message == msg: + if prev_scene_id == msg.get('scene_id', ''): continue await self.async_set_message(msg) From beab88211a04ede1dacb9438fa001a7fb2ebabf7 Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Thu, 29 Jun 2023 13:23:42 +0800 Subject: [PATCH 4/8] fix: change log level --- custom_components/xiaomi_miot/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index 1681139a0..2f4c9d2d5 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -821,7 +821,7 @@ async def fetch_latest_message(self): prev_timestamp = self._attr_extra_state_attributes.get('ts') or 0 prev_scene_id = self._attr_extra_state_attributes.get('scene_id') or '' messages.sort(key=lambda x: x.get('ts', 0), reverse=False) - _LOGGER.warning( + _LOGGER.debug( 'Get xiaomi scene history for %s %d success: prev_timestamp= %d prev_scene_id= %s messages= %s,', self.cloud.user_id, self.home_id, prev_timestamp, prev_scene_id, messages, ) From 8b620f7fcf548a6b38577300fcb6de573380c850 Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Thu, 29 Jun 2023 17:14:52 +0800 Subject: [PATCH 5/8] feat: add disable flag --- custom_components/xiaomi_miot/sensor.py | 35 ++++++++++++++----------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index 2f4c9d2d5..601e16c20 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -63,23 +63,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities): cfg = hass.data[DOMAIN].get(config_entry.entry_id) or {} mic = cfg.get(CONF_XIAOMI_CLOUD) config_data = config_entry.data or {} - if isinstance(mic, MiotCloud) and mic.user_id and not config_data.get('disable_message'): - hass.data[DOMAIN]['accounts'].setdefault(mic.user_id, {}) - - if not hass.data[DOMAIN]['accounts'][mic.user_id].get('messenger'): - entity = MihomeMessageSensor(hass, mic) - hass.data[DOMAIN]['accounts'][mic.user_id]['messenger'] = entity - async_add_entities([entity], update_before_add=False) - - homes = await mic.async_get_homes() - for home in homes: - home_id = home.get('id') - if hass.data[DOMAIN]['accounts'][mic.user_id].get(f'scene_history_{home_id}'): - continue - entity = MihomeSceneHistorySensor(hass, mic, home_id, home.get('uid')) - hass.data[DOMAIN]['accounts'][mic.user_id][f'scene_history_{home_id}'] = entity - async_add_entities([entity], update_before_add=False) + if isinstance(mic, MiotCloud) and mic.user_id: + if not config_data.get('disable_message'): + hass.data[DOMAIN]['accounts'].setdefault(mic.user_id, {}) + + if not hass.data[DOMAIN]['accounts'][mic.user_id].get('messenger'): + entity = MihomeMessageSensor(hass, mic) + hass.data[DOMAIN]['accounts'][mic.user_id]['messenger'] = entity + async_add_entities([entity], update_before_add=False) + + if not config_data.get('disable_scene_history'): + homes = await mic.async_get_homes() + for home in homes: + home_id = home.get('id') + if hass.data[DOMAIN]['accounts'][mic.user_id].get(f'scene_history_{home_id}'): + continue + + entity = MihomeSceneHistorySensor(hass, mic, home_id, home.get('uid')) + hass.data[DOMAIN]['accounts'][mic.user_id][f'scene_history_{home_id}'] = entity + async_add_entities([entity], update_before_add=False) await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN) From 02d926793da543fa862c0c633d27f0f5390bc9db Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Fri, 30 Jun 2023 00:24:53 +0800 Subject: [PATCH 6/8] fix: message compare --- custom_components/xiaomi_miot/sensor.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index 601e16c20..cd838849b 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -4,7 +4,7 @@ import json from typing import cast from datetime import datetime, timedelta -from functools import partial +from functools import partial, cmp_to_key from homeassistant.const import * # noqa: F401 from homeassistant.helpers.entity import ( @@ -797,6 +797,19 @@ def trim_message(self, msg): "targets": msg.get('msg', []), } + @staticmethod + def _cmp_message(a, b): + a_ts, b_ts = a.get('ts', 0), b.get('ts', 0) + if a_ts != b_ts: + return a_ts - b_ts + + a_scene_id, b_scene_id = a.get('scene_id', 0), b.get('scene_id', 0) + if a_scene_id < b_scene_id: + return -1 + if a_scene_id > b_scene_id: + return 1 + return 0 + async def async_set_message(self, msg): self._attr_native_value = msg.get('name') _LOGGER.debug('New xiaomi scene history for %s %d: %s', self.cloud.user_id, self.home_id, self._attr_native_value) @@ -823,16 +836,13 @@ async def fetch_latest_message(self): prev_timestamp = self._attr_extra_state_attributes.get('ts') or 0 prev_scene_id = self._attr_extra_state_attributes.get('scene_id') or '' - messages.sort(key=lambda x: x.get('ts', 0), reverse=False) + messages.sort(key=cmp_to_key(self._cmp_message), reverse=False) _LOGGER.debug( 'Get xiaomi scene history for %s %d success: prev_timestamp= %d prev_scene_id= %s messages= %s,', self.cloud.user_id, self.home_id, prev_timestamp, prev_scene_id, messages, ) for msg in messages: - ts = msg.get('ts', 0) - if ts and ts < prev_timestamp: - continue - if prev_scene_id == msg.get('scene_id', ''): + if self._cmp_message(msg, self._attr_extra_state_attributes) <= 0: continue await self.async_set_message(msg) From bd12116da6bc0d5f3ba4e2b01264817953a3260b Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Fri, 17 Nov 2023 20:18:49 +0800 Subject: [PATCH 7/8] feat: add disable_scene_history to options --- custom_components/xiaomi_miot/config_flow.py | 2 + .../xiaomi_miot/core/xiaomi_cloud.py | 91 +-- .../xiaomi_miot/core/xiaomi_cloud.py.bak | 723 ++++++++++++++++++ custom_components/xiaomi_miot/sensor.py | 3 +- .../xiaomi_miot/translations/en.json | 3 +- .../xiaomi_miot/translations/zh-Hans.json | 3 +- 6 files changed, 772 insertions(+), 53 deletions(-) create mode 100644 custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak diff --git a/custom_components/xiaomi_miot/config_flow.py b/custom_components/xiaomi_miot/config_flow.py index eda610ab2..3965f53bd 100644 --- a/custom_components/xiaomi_miot/config_flow.py +++ b/custom_components/xiaomi_miot/config_flow.py @@ -623,6 +623,7 @@ async def async_step_cloud(self, user_input=None): vol.In(CONN_MODES), vol.Optional('renew_devices', default=user_input.get('renew_devices', False)): bool, vol.Optional('disable_message', default=user_input.get('disable_message', False)): bool, + vol.Optional('disable_scene_history', default=user_input.get('disable_scene_history', False)): bool, }) return self.async_show_form( step_id='cloud', @@ -646,6 +647,7 @@ async def async_step_cloud_filter(self, user_input=None): cfg.update({ CONF_CONN_MODE: prev_input.get(CONF_CONN_MODE), 'disable_message': prev_input.get('disable_message'), + 'disable_scene_history': prev_input.get('disable_scene_history'), **(user_input or {}), }) self.hass.config_entries.async_update_entry( diff --git a/custom_components/xiaomi_miot/core/xiaomi_cloud.py b/custom_components/xiaomi_miot/core/xiaomi_cloud.py index eaf4bfb37..c84b0a5eb 100644 --- a/custom_components/xiaomi_miot/core/xiaomi_cloud.py +++ b/custom_components/xiaomi_miot/core/xiaomi_cloud.py @@ -249,66 +249,53 @@ def get_home_devices(self): } return rdt - async def _async_all_devices(self, renew=False): + async def async_get_devices(self, renew=False, return_all=False): if not self.user_id: return None - - filename = f'xiaomi_miot/devices-{self.user_id}-{self.default_server}.json' - store = Store(self.hass, 1, filename) + fnm = f'xiaomi_miot/devices-{self.user_id}-{self.default_server}.json' + store = Store(self.hass, 1, fnm) now = time.time() - - cached_data = None + cds = [] + dvs = [] try: - cached_data = await store.async_load() or {} - if cached_data and isinstance(cached_data, dict): - if not renew and cached_data.get('update_time', 0) > (now - 86400): - return cached_data + dat = await store.async_load() or {} except ValueError: await store.async_remove() - - try: - devices = await self.hass.async_add_executor_job(self.get_device_list) - if devices is None: - # exec self.get_device_list failed - return cached_data or {} - - homes = await self.hass.async_add_executor_job(self.get_home_devices) - if homes: - device2home = homes.get('devices') or {} - for device in devices: - home_info = device2home.get(device.get('did')) - if not home_info: - continue - device.update(home_info) - - refreshed = { - 'update_time': now, - 'devices': devices, - 'homes': homes.get('homelist', []), - } - - await store.async_save(refreshed) - _LOGGER.info('Got %s devices from xiaomi cloud', len(devices)) - return refreshed - - except requests.exceptions.ConnectionError as exc: - if not cached_data: - raise exc - - _LOGGER.warning('Get xiaomi devices filed: %s, use cached %s devices.', exc, len(cached_data.get('devices') or [])) - return cached_data or {} - - async def async_get_devices(self, renew=False): - result = await self._async_all_devices(renew=renew) - return result.get('devices', []) + dat = {} + if isinstance(dat, dict): + cds = dat.get('devices') or [] + if not renew and dat.get('update_time', 0) > (now - 86400): + dvs = cds + if not dvs: + try: + dvs = await self.hass.async_add_executor_job(self.get_device_list) + if dvs: + hls = await self.hass.async_add_executor_job(self.get_home_devices) + if hls: + hds = hls.get('devices') or {} + dvs = [ + {**d, **(hds.get(d.get('did')) or {})} + for d in dvs + ] + dat = { + 'update_time': now, + 'devices': dvs, + 'homes': hls.get('homelist', []), + } + await store.async_save(dat) + _LOGGER.info('Got %s devices from xiaomi cloud', len(dvs)) + except requests.exceptions.ConnectionError as exc: + if not cds: + raise exc + dvs = cds + _LOGGER.warning('Get xiaomi devices filed: %s, use cached %s devices.', exc, len(cds)) + if return_all: + return dat + return dvs async def async_renew_devices(self): return await self.async_get_devices(renew=True) - async def async_get_homerooms(self, renew=False): - result = await self._async_all_devices(renew=renew) - return result.get('homes') or [] - async def async_get_devices_by_key(self, key, renew=False, filters=None): dat = {} if filters is None: @@ -340,6 +327,10 @@ async def async_get_devices_by_key(self, key, renew=False, filters=None): dat[k] = d return dat + async def async_get_homerooms(self, renew=False): + dat = await self.async_get_devices(renew=renew, return_all=True) or {} + return dat.get('homes') or [] + async def async_get_beaconkey(self, did): dat = {'did': did or self.miot_did, 'pdid': 1} rdt = await self.async_request_api('v2/device/blt_get_beaconkey', dat) or {} diff --git a/custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak b/custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak new file mode 100644 index 000000000..eaf4bfb37 --- /dev/null +++ b/custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak @@ -0,0 +1,723 @@ +import logging +import json +import time +import string +import random +import base64 +import hashlib +import micloud +import requests +from datetime import datetime +from functools import partial +from urllib import parse + +from homeassistant.const import * +from homeassistant.helpers.storage import Store +from homeassistant.components import persistent_notification + +from .const import DOMAIN, CONF_XIAOMI_CLOUD +from .utils import RC4 +from micloud import miutils +from micloud.micloudexception import MiCloudException + +try: + from micloud.micloudexception import MiCloudAccessDenied +except (ModuleNotFoundError, ImportError): + class MiCloudAccessDenied(MiCloudException): + """ micloud==0.4 """ + +_LOGGER = logging.getLogger(__name__) +ACCOUNT_BASE = 'https://account.xiaomi.com' +UA = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-%s APP/xiaomi.smarthome APPV/62830" + + +class MiotCloud(micloud.MiCloud): + def __init__(self, hass, username, password, country=None, sid=None): + try: + super().__init__(username, password) + except (FileNotFoundError, KeyError): + self.timezone = 'GMT+00:00' + + self.hass = hass + self.username = username + self.password = password + self.default_server = country or 'cn' + self.sid = sid or 'xiaomiio' + self.agent_id = self.get_random_string(16) + self.client_id = self.agent_id + self.useragent = UA % self.client_id + self.http_timeout = int(hass.data[DOMAIN].get('config', {}).get('http_timeout') or 10) + self.login_times = 0 + self.attrs = {} + + @property + def unique_id(self): + uid = self.user_id or self.username + return f'{uid}-{self.default_server}-{self.sid}' + + def get_properties_for_mapping(self, did, mapping: dict): + pms = [] + rmp = {} + for k, v in mapping.items(): + if not isinstance(v, dict): + continue + s = v.get('siid') + p = v.get('piid') + pms.append({'did': str(did), 'siid': s, 'piid': p}) + rmp[f'prop.{s}.{p}'] = k + rls = self.get_props(pms) + if not rls: + return None + dls = [] + for v in rls: + s = v.get('siid') + p = v.get('piid') + k = rmp.get(f'prop.{s}.{p}') + if not k: + continue + v['did'] = k + dls.append(v) + return dls + + def get_props(self, params=None): + return self.request_miot_spec('prop/get', params) + + def set_props(self, params=None): + return self.request_miot_spec('prop/set', params) + + def do_action(self, params=None): + return self.request_miot_spec('action', params) + + def request_miot_spec(self, api, params=None): + rdt = self.request_miot_api('miotspec/' + api, { + 'params': params or [], + }) or {} + rls = rdt.get('result') + if not rls and rdt.get('code'): + raise MiCloudException(json.dumps(rdt)) + return rls + + def get_user_device_data(self, did, key, typ='prop', raw=False, **kwargs): + now = int(time.time()) + timeout = kwargs.pop('timeout', self.http_timeout) + params = { + 'did': did, + 'key': key, + 'type': typ, + 'time_start': now - 86400 * 7, + 'time_end': now + 60, + 'limit': 5, + **kwargs, + } + rdt = self.request_miot_api('user/get_user_device_data', params, timeout=timeout) or {} + return rdt if raw else rdt.get('result') + + def get_last_device_data(self, did, key, typ='prop', **kwargs): + kwargs['raw'] = False + kwargs['limit'] = 1 + rls = self.get_user_device_data(did, key, typ, **kwargs) or [None] + rdt = rls.pop(0) or {} + if kwargs.get('not_value'): + return rdt + val = rdt.get('value') + if val is None: + return None + try: + vls = json.loads(val) + except (TypeError, ValueError): + vls = [val] + return vls.pop(0) + + async def async_check_auth(self, notify=False): + api = 'v2/message/v2/check_new_msg' + dat = { + 'begin_at': int(time.time()) - 60, + } + try: + rdt = await self.async_request_api(api, dat, method='POST') or {} + nid = f'xiaomi-miot-auth-warning-{self.user_id}' + eno = rdt.get('code', 0) + if eno != 3: + return True + except requests.exceptions.ConnectionError: + return None + except requests.exceptions.Timeout: + return None + # auth err + if await self.async_relogin(): + persistent_notification.dismiss(self.hass, nid) + return True + if notify: + persistent_notification.create( + self.hass, + f'Xiaomi account: {self.user_id} auth failed, ' + 'Please update option for this integration to refresh token.\n' + f'小米账号:{self.user_id} 登录失效,请重新保存集成选项以更新登录信息。', + 'Xiaomi Miot Warning', + nid, + ) + _LOGGER.error( + 'Xiaomi account: %s auth failed, Please update option for this integration to refresh token.\n%s', + self.user_id, + rdt, + ) + else: + _LOGGER.warning('Retry login xiaomi account failed: %s', self.username) + return False + + async def async_request_api(self, *args, **kwargs): + if not self.service_token: + await self.async_login() + return await self.hass.async_add_executor_job( + partial(self.request_miot_api, *args, **kwargs) + ) + + def request_miot_api(self, api, data, method='POST', crypt=True, debug=True, **kwargs): + params = {} + if data is not None: + params['data'] = self.json_encode(data) + raw = kwargs.pop('raw', self.sid != 'xiaomiio') + rsp = None + try: + if raw: + rsp = self.request_raw(api, data, method, **kwargs) + elif crypt: + rsp = self.request_rc4_api(api, params, method, **kwargs) + else: + rsp = self.request(self.get_api_url(api), params, **kwargs) + rdt = json.loads(rsp) + if debug: + _LOGGER.debug( + 'Request miot api: %s %s result: %s', + api, data, rsp, + ) + self.attrs['timeouts'] = 0 + except requests.exceptions.Timeout as exc: + rdt = None + self.attrs.setdefault('timeouts', 0) + self.attrs['timeouts'] += 1 + if 5 < self.attrs['timeouts'] <= 10: + _LOGGER.error('Request xiaomi api: %s %s timeout, exception: %s', api, data, exc) + except (TypeError, ValueError): + rdt = None + code = rdt.get('code') if rdt else None + if code == 3: + self._logout() + _LOGGER.warning('Unauthorized while executing request to %s, logged out.', api) + elif code or not rdt: + fun = _LOGGER.info if rdt else _LOGGER.warning + fun('Request xiaomi api: %s %s failed, response: %s', api, data, rsp) + return rdt + + async def async_get_device(self, mac=None, host=None): + dvs = await self.async_get_devices() or [] + for d in dvs: + if not isinstance(d, dict): + continue + if mac and mac == d.get('mac'): + return d + if host and host == d.get('localip'): + return d + return None + + def get_device_list(self): + rdt = self.request_miot_api('home/device_list', { + 'getVirtualModel': True, + 'getHuamiDevices': 1, + 'get_split_device': False, + 'support_smart_home': True, + }, debug=False, timeout=60) or {} + if rdt and 'result' in rdt: + return rdt['result']['list'] + _LOGGER.warning('Got xiaomi cloud devices for %s failed: %s', self.username, rdt) + return None + + def get_home_devices(self): + rdt = self.request_miot_api('homeroom/gethome', { + 'fetch_share_dev': True, + }, debug=False, timeout=60) or {} + rdt = rdt.get('result') or {} + rdt.setdefault('devices', {}) + for h in rdt.get('homelist', []): + for r in h.get('roomlist', []): + for did in r.get('dids', []): + rdt['devices'][did] = { + 'home_id': h.get('id'), + 'room_id': r.get('id'), + 'home_name': h.get('name'), + 'room_name': r.get('name'), + } + return rdt + + async def _async_all_devices(self, renew=False): + if not self.user_id: + return None + + filename = f'xiaomi_miot/devices-{self.user_id}-{self.default_server}.json' + store = Store(self.hass, 1, filename) + now = time.time() + + cached_data = None + try: + cached_data = await store.async_load() or {} + if cached_data and isinstance(cached_data, dict): + if not renew and cached_data.get('update_time', 0) > (now - 86400): + return cached_data + except ValueError: + await store.async_remove() + + try: + devices = await self.hass.async_add_executor_job(self.get_device_list) + if devices is None: + # exec self.get_device_list failed + return cached_data or {} + + homes = await self.hass.async_add_executor_job(self.get_home_devices) + if homes: + device2home = homes.get('devices') or {} + for device in devices: + home_info = device2home.get(device.get('did')) + if not home_info: + continue + device.update(home_info) + + refreshed = { + 'update_time': now, + 'devices': devices, + 'homes': homes.get('homelist', []), + } + + await store.async_save(refreshed) + _LOGGER.info('Got %s devices from xiaomi cloud', len(devices)) + return refreshed + + except requests.exceptions.ConnectionError as exc: + if not cached_data: + raise exc + + _LOGGER.warning('Get xiaomi devices filed: %s, use cached %s devices.', exc, len(cached_data.get('devices') or [])) + return cached_data or {} + + async def async_get_devices(self, renew=False): + result = await self._async_all_devices(renew=renew) + return result.get('devices', []) + + async def async_renew_devices(self): + return await self.async_get_devices(renew=True) + + async def async_get_homerooms(self, renew=False): + result = await self._async_all_devices(renew=renew) + return result.get('homes') or [] + + async def async_get_devices_by_key(self, key, renew=False, filters=None): + dat = {} + if filters is None: + filters = {} + fls = ['ssid', 'bssid', 'home_id', 'model', 'did'] + dvs = await self.async_get_devices(renew=renew) or [] + for d in dvs: + if not isinstance(d, dict): + continue + if self.is_hide(d): + continue + if not d.get('mac'): + d['mac'] = d.get('did') + k = d.get(key) + for f in fls: + ft = filters.get(f'filter_{f}') + if not ft: + continue + ex = ft != 'include' + fl = filters.get(f'{f}_list') or {} + fv = d.get(f) + if ex: + ok = fv not in fl + else: + ok = fv in fl + if not ok: + k = None + if k: + dat[k] = d + return dat + + async def async_get_beaconkey(self, did): + dat = {'did': did or self.miot_did, 'pdid': 1} + rdt = await self.async_request_api('v2/device/blt_get_beaconkey', dat) or {} + return rdt.get('result') + + @staticmethod + def is_hide(d): + did = d.get('did', '') + pid = d.get('pid', '') + if pid == '21': + prt = d.get('parent_id') + if prt and prt in did: + # issues/263 + return True + return False + + async def async_login(self, captcha=None): + if self.login_times > 10: + raise MiCloudException( + 'Too many failures when login to Xiaomi, ' + 'please reload/config xiaomi_miot component.' + ) + self.login_times += 1 + ret = await self.hass.async_add_executor_job(self._login_request, captcha) + if ret: + self.hass.data[DOMAIN]['sessions'][self.unique_id] = self + await self.async_stored_auth(self.user_id, save=True) + self.login_times = 0 + return ret + + async def async_relogin(self): + self._logout() + return await self.async_login() + + def _logout(self): + self.service_token = None + + def _login_request(self, captcha=None): + self._init_session() + auth = self.attrs.pop('login_data', None) + if captcha and auth: + auth['captcha'] = captcha + if not auth: + auth = self._login_step1() + location = self._login_step2(**auth) + response = self._login_step3(location) + http_code = response.status_code + if http_code == 200: + return True + elif http_code == 403: + raise MiCloudAccessDenied(f'Login to xiaomi error: {response.text} ({http_code})') + else: + _LOGGER.error( + 'Xiaomi login request returned status %s, reason: %s, content: %s', + http_code, response.reason, response.text, + ) + raise MiCloudException(f'Login to xiaomi error: {response.text} ({http_code})') + + def _login_step1(self): + response = self.session.get( + f'{ACCOUNT_BASE}/pass/serviceLogin', + params={'sid': self.sid, '_json': 'true'}, + headers={'User-Agent': self.useragent}, + cookies={'sdkVersion': '3.8.6', 'deviceId': self.client_id}, + ) + try: + auth = json.loads(response.text.replace('&&&START&&&', '')) or {} + except Exception as exc: + raise MiCloudException(f'Error getting xiaomi login sign. Cannot parse response. {exc}') + return auth + + def _login_step2(self, captcha=None, **kwargs): + url = f'{ACCOUNT_BASE}/pass/serviceLoginAuth2' + post = { + 'user': self.username, + 'hash': hashlib.md5(self.password.encode()).hexdigest().upper(), + 'callback': kwargs.get('callback') or '', + 'sid': kwargs.get('sid') or self.sid, + 'qs': kwargs.get('qs') or '', + '_sign': kwargs.get('_sign') or '', + } + params = {'_json': 'true'} + headers = {'User-Agent': self.useragent} + cookies = {'sdkVersion': '3.8.6', 'deviceId': self.client_id} + if captcha: + post['captCode'] = captcha + params['_dc'] = int(time.time() * 1000) + cookies['ick'] = self.attrs.pop('captchaIck', '') + response = self.session.post(url, data=post, params=params, headers=headers, cookies=cookies) + auth = json.loads(response.text.replace('&&&START&&&', '')) or {} + code = auth.get('code') + # 20003 InvalidUserNameException + # 22009 PackageNameDeniedException + # 70002 InvalidCredentialException + # 70016 InvalidCredentialException with captchaUrl + # 81003 NeedVerificationException + # 87001 InvalidResponseException captCode error + # other NeedCaptchaException + location = auth.get('location') + if not location: + if cap := auth.get('captchaUrl'): + if cap[:4] != 'http': + cap = f'{ACCOUNT_BASE}{cap}' + if self._get_captcha(cap): + self.attrs['login_data'] = kwargs + if ntf := auth.get('notificationUrl'): + if ntf[:4] != 'http': + ntf = f'{ACCOUNT_BASE}{ntf}' + self.attrs['notificationUrl'] = ntf + _LOGGER.error('Xiaomi serviceLoginAuth2: %s', [url, params, post, headers, cookies]) + raise MiCloudAccessDenied(f'Login to xiaomi error: {response.text}') + self.user_id = str(auth.get('userId', '')) + self.cuser_id = auth.get('cUserId') + self.ssecurity = auth.get('ssecurity') + self.pass_token = auth.get('passToken') + if self.sid != 'xiaomiio': + sign = f'nonce={auth.get("nonce")}&{auth.get("ssecurity")}' + sign = hashlib.sha1(sign.encode()).digest() + sign = base64.b64encode(sign).decode() + location += '&clientSign=' + parse.quote(sign) + _LOGGER.info('Xiaomi serviceLoginAuth2: %s', [auth, response.cookies.get_dict()]) + return location + + def _login_step3(self, location): + self.session.headers.update({'content-type': 'application/x-www-form-urlencoded'}) + response = self.session.get( + location, + headers={'User-Agent': self.useragent}, + cookies={'sdkVersion': '3.8.6', 'deviceId': self.client_id}, + ) + service_token = response.cookies.get('serviceToken') + if service_token: + self.service_token = service_token + else: + err = { + 'location': location, + 'status_code': response.status_code, + 'cookies': response.cookies.get_dict(), + 'response': response.text, + } + raise MiCloudAccessDenied(f'Login to xiaomi error: {err}') + return response + + def _get_captcha(self, url): + response = self.session.get(url) + if ick := response.cookies.get('ick'): + self.attrs['captchaIck'] = ick + self.attrs['captchaImg'] = base64.b64encode(response.content).decode() + return response + + def to_config(self): + return { + CONF_USERNAME: self.username, + CONF_PASSWORD: self.password, + 'server_country': self.default_server, + 'user_id': self.user_id, + 'service_token': self.service_token, + 'ssecurity': self.ssecurity, + 'sid': self.sid, + 'device_id': self.client_id, + } + + @staticmethod + async def from_token(hass, config: dict, login=None): + mic = MiotCloud( + hass, + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get('server_country'), + config.get('sid'), + ) + mic.user_id = str(config.get('user_id') or '') + if a := hass.data[DOMAIN].get('sessions', {}).get(mic.unique_id): + mic = a + if mic.password != config.get(CONF_PASSWORD): + mic.password = config.get(CONF_PASSWORD) + mic.service_token = None + if not mic.service_token: + sdt = await mic.async_stored_auth(mic.user_id, save=False) + config.update(sdt) + mic.service_token = config.get('service_token') + mic.ssecurity = config.get('ssecurity') + did = config.get('device_id') or '' + if did and len(did) <= 32: + mic.client_id = did + mic.useragent = UA % did + if login is None: + if not mic.service_token: + login = True + if login: + await mic.async_login() + else: + hass.data[DOMAIN]['sessions'][mic.unique_id] = mic + return mic + + async def async_change_sid(self, sid: str, login=None): + config = {**self.to_config(), 'sid': sid} + mic = await self.from_token(self.hass, config, login) + return mic + + async def async_stored_auth(self, uid=None, save=False): + if uid is None: + uid = self.user_id or self.username + fnm = f'xiaomi_miot/auth-{uid}-{self.default_server}.json' + if self.sid != 'xiaomiio': + fnm = f'xiaomi_miot/auth-{uid}-{self.default_server}-{self.sid}.json' + store = Store(self.hass, 1, fnm) + try: + old = await store.async_load() or {} + except ValueError: + await store.async_remove() + old = {} + if save: + cfg = self.to_config() + cfg.pop(CONF_PASSWORD, None) + if cfg.get('service_token') == old.get('service_token'): + cfg['update_at'] = old.get('update_at') + else: + cfg['update_at'] = f'{datetime.fromtimestamp(int(time.time()))}' + await store.async_save(cfg) + return cfg + return old + + def api_session(self): + if not self.service_token or not self.user_id: + raise MiCloudException('Cannot execute request. service token or userId missing. Make sure to login.') + + session = requests.Session() + session.headers.update({ + 'X-XIAOMI-PROTOCAL-FLAG-CLI': 'PROTOCAL-HTTP2', + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': self.useragent, + }) + session.cookies.update({ + 'userId': str(self.user_id), + 'yetAnotherServiceToken': self.service_token, + 'serviceToken': self.service_token, + 'locale': str(self.locale), + 'timezone': str(self.timezone), + 'is_daylight': str(time.daylight), + 'dst_offset': str(time.localtime().tm_isdst * 60 * 60 * 1000), + 'channel': 'MI_APP_STORE', + }) + return session + + def request(self, url, params, **kwargs): + self.session = self.api_session() + timeout = kwargs.get('timeout', self.http_timeout) + try: + nonce = miutils.gen_nonce() + signed_nonce = miutils.signed_nonce(self.ssecurity, nonce) + signature = miutils.gen_signature(url.replace('/app/', '/'), signed_nonce, nonce, params) + post_data = { + 'signature': signature, + '_nonce': nonce, + 'data': params['data'], + } + response = self.session.post(url, data=post_data, timeout=timeout) + return response.text + except requests.exceptions.HTTPError as exc: + _LOGGER.error('Error while executing request to %s: %s', url, exc) + except MiCloudException as exc: + _LOGGER.error('Error while decrypting response of request to %s: %s', url, exc) + + def request_rc4_api(self, api, params: dict, method='POST', **kwargs): + self.session = self.api_session() + self.session.headers.update({ + 'MIOT-ENCRYPT-ALGORITHM': 'ENCRYPT-RC4', + 'Accept-Encoding': 'identity', + }) + url = self.get_api_url(api) + timeout = kwargs.get('timeout', self.http_timeout) + try: + params = self.rc4_params(method, url, params) + signed_nonce = self.signed_nonce(params['_nonce']) + if method == 'GET': + response = self.session.get(url, params=params, timeout=timeout) + else: + response = self.session.post(url, data=params, timeout=timeout) + rsp = response.text + if not rsp or 'error' in rsp or 'invalid' in rsp: + _LOGGER.warning('Error while executing request to %s: %s', url, rsp or response.status_code) + elif 'message' not in rsp: + try: + rsp = MiotCloud.decrypt_data(signed_nonce, rsp) + except ValueError: + _LOGGER.warning('Error while decrypting response of request to %s :%s', url, rsp) + return rsp + except requests.exceptions.HTTPError as exc: + _LOGGER.warning('Error while executing request to %s: %s', url, exc) + except MiCloudException as exc: + _LOGGER.warning('Error while decrypting response of request to %s :%s', url, exc) + + def request_raw(self, url, data=None, method='GET', **kwargs): + self.session = self.api_session() + url = self.get_api_url(url) + kwargs.setdefault('params' if method == 'GET' else 'data', data) + kwargs.setdefault('timeout', self.http_timeout) + try: + response = self.session.request(method, url, **kwargs) + if response.status_code == 401: + self._logout() + _LOGGER.warning('Unauthorized while executing request to %s, logged out.', url) + rsp = response.text + if not rsp or 'error' in rsp or 'invalid' in rsp: + log = _LOGGER.info if 'remote/ubus' in url else _LOGGER.warning + log('Error while executing request to %s: %s', url, rsp or response.status_code) + return rsp + except requests.exceptions.HTTPError as exc: + _LOGGER.warning('Error while executing request to %s: %s', url, exc) + return None + + def get_api_by_host(self, host, api=''): + srv = self.default_server.lower() + if srv and srv != 'cn': + host = f'{srv}.{host}' + api = str(api).lstrip('/') + return f'https://{host}/{api}' + + def get_api_url(self, api): + if api[:6] == 'https:' or api[:5] == 'http:': + url = api + else: + api = str(api).lstrip('/') + url = self._get_api_url(self.default_server) + '/' + api + return url + + def rc4_params(self, method, url, params: dict): + nonce = miutils.gen_nonce() + signed_nonce = self.signed_nonce(nonce) + params['rc4_hash__'] = MiotCloud.sha1_sign(method, url, params, signed_nonce) + for k, v in params.items(): + params[k] = MiotCloud.encrypt_data(signed_nonce, v) + params.update({ + 'signature': MiotCloud.sha1_sign(method, url, params, signed_nonce), + 'ssecurity': self.ssecurity, + '_nonce': nonce, + }) + return params + + def signed_nonce(self, nonce): + return miutils.signed_nonce(self.ssecurity, nonce) + + @staticmethod + def json_encode(data): + return json.dumps(data, separators=(',', ':')) + + @staticmethod + def sha1_sign(method, url, dat: dict, nonce): + path = parse.urlparse(url).path + if path[:5] == '/app/': + path = path[4:] + arr = [str(method).upper(), path] + for k, v in dat.items(): + arr.append(f'{k}={v}') + arr.append(nonce) + raw = hashlib.sha1('&'.join(arr).encode('utf-8')).digest() + return base64.b64encode(raw).decode() + + @staticmethod + def encrypt_data(pwd, data): + return base64.b64encode(RC4(base64.b64decode(pwd)).init1024().crypt(data)).decode() + + @staticmethod + def decrypt_data(pwd, data): + return RC4(base64.b64decode(pwd)).init1024().crypt(base64.b64decode(data)) + + @staticmethod + def all_clouds(hass): + cls = {} + for k, v in hass.data[DOMAIN].items(): + if isinstance(v, dict): + v = v.get(CONF_XIAOMI_CLOUD) + if isinstance(v, MiotCloud): + cls[v.unique_id] = v + return list(cls.values()) + + @staticmethod + def get_random_string(length): + seq = string.ascii_uppercase + string.digits + return ''.join((random.choice(seq) for _ in range(length))) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index e41549d7b..a2608130e 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -728,6 +728,7 @@ async def fetch_latest_message(self): class MihomeSceneHistorySensor(MiCoordinatorEntity, SensorEntity, RestoreEntity): MESSAGE_TIMEOUT = 60 + UPDATE_INTERVAL = 15 _has_none_message = False @@ -750,7 +751,7 @@ def __init__(self, hass, cloud: MiotCloud, home_id, owner_user_id): _LOGGER, name=self._attr_unique_id, update_method=self.fetch_latest_message, - update_interval=timedelta(seconds=5), + update_interval=timedelta(seconds=self.UPDATE_INTERVAL), ) super().__init__(self.coordinator) diff --git a/custom_components/xiaomi_miot/translations/en.json b/custom_components/xiaomi_miot/translations/en.json index 9bc39effe..3da8c921e 100644 --- a/custom_components/xiaomi_miot/translations/en.json +++ b/custom_components/xiaomi_miot/translations/en.json @@ -150,7 +150,8 @@ "server_country": "Server location of MiCloud", "conn_mode": "Connection mode for device", "renew_devices": "Force renew devices", - "disable_message": "Disable Mihome notification sensor" + "disable_message": "Disable Mihome notification sensor", + "disable_scene_history": "Disable Mihome scene history sensor" } }, "cloud_filter": { diff --git a/custom_components/xiaomi_miot/translations/zh-Hans.json b/custom_components/xiaomi_miot/translations/zh-Hans.json index d179d9d88..dab10e0d6 100644 --- a/custom_components/xiaomi_miot/translations/zh-Hans.json +++ b/custom_components/xiaomi_miot/translations/zh-Hans.json @@ -92,7 +92,8 @@ "server_country": "小米服务器", "conn_mode": "设备连接模式", "renew_devices": "更新设备列表", - "disable_message": "禁用米家APP通知消息实体" + "disable_message": "禁用米家APP通知消息实体", + "disable_scene_history": "禁用米家场景历史实体" } }, "cloud_filter": { From e2b18bb198fa9a2060744663d6c3d9d652bcb585 Mon Sep 17 00:00:00 2001 From: Samuel CUI Date: Fri, 17 Nov 2023 20:22:25 +0800 Subject: [PATCH 8/8] fix: remote bak --- .../xiaomi_miot/core/xiaomi_cloud.py.bak | 723 ------------------ 1 file changed, 723 deletions(-) delete mode 100644 custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak diff --git a/custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak b/custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak deleted file mode 100644 index eaf4bfb37..000000000 --- a/custom_components/xiaomi_miot/core/xiaomi_cloud.py.bak +++ /dev/null @@ -1,723 +0,0 @@ -import logging -import json -import time -import string -import random -import base64 -import hashlib -import micloud -import requests -from datetime import datetime -from functools import partial -from urllib import parse - -from homeassistant.const import * -from homeassistant.helpers.storage import Store -from homeassistant.components import persistent_notification - -from .const import DOMAIN, CONF_XIAOMI_CLOUD -from .utils import RC4 -from micloud import miutils -from micloud.micloudexception import MiCloudException - -try: - from micloud.micloudexception import MiCloudAccessDenied -except (ModuleNotFoundError, ImportError): - class MiCloudAccessDenied(MiCloudException): - """ micloud==0.4 """ - -_LOGGER = logging.getLogger(__name__) -ACCOUNT_BASE = 'https://account.xiaomi.com' -UA = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-%s APP/xiaomi.smarthome APPV/62830" - - -class MiotCloud(micloud.MiCloud): - def __init__(self, hass, username, password, country=None, sid=None): - try: - super().__init__(username, password) - except (FileNotFoundError, KeyError): - self.timezone = 'GMT+00:00' - - self.hass = hass - self.username = username - self.password = password - self.default_server = country or 'cn' - self.sid = sid or 'xiaomiio' - self.agent_id = self.get_random_string(16) - self.client_id = self.agent_id - self.useragent = UA % self.client_id - self.http_timeout = int(hass.data[DOMAIN].get('config', {}).get('http_timeout') or 10) - self.login_times = 0 - self.attrs = {} - - @property - def unique_id(self): - uid = self.user_id or self.username - return f'{uid}-{self.default_server}-{self.sid}' - - def get_properties_for_mapping(self, did, mapping: dict): - pms = [] - rmp = {} - for k, v in mapping.items(): - if not isinstance(v, dict): - continue - s = v.get('siid') - p = v.get('piid') - pms.append({'did': str(did), 'siid': s, 'piid': p}) - rmp[f'prop.{s}.{p}'] = k - rls = self.get_props(pms) - if not rls: - return None - dls = [] - for v in rls: - s = v.get('siid') - p = v.get('piid') - k = rmp.get(f'prop.{s}.{p}') - if not k: - continue - v['did'] = k - dls.append(v) - return dls - - def get_props(self, params=None): - return self.request_miot_spec('prop/get', params) - - def set_props(self, params=None): - return self.request_miot_spec('prop/set', params) - - def do_action(self, params=None): - return self.request_miot_spec('action', params) - - def request_miot_spec(self, api, params=None): - rdt = self.request_miot_api('miotspec/' + api, { - 'params': params or [], - }) or {} - rls = rdt.get('result') - if not rls and rdt.get('code'): - raise MiCloudException(json.dumps(rdt)) - return rls - - def get_user_device_data(self, did, key, typ='prop', raw=False, **kwargs): - now = int(time.time()) - timeout = kwargs.pop('timeout', self.http_timeout) - params = { - 'did': did, - 'key': key, - 'type': typ, - 'time_start': now - 86400 * 7, - 'time_end': now + 60, - 'limit': 5, - **kwargs, - } - rdt = self.request_miot_api('user/get_user_device_data', params, timeout=timeout) or {} - return rdt if raw else rdt.get('result') - - def get_last_device_data(self, did, key, typ='prop', **kwargs): - kwargs['raw'] = False - kwargs['limit'] = 1 - rls = self.get_user_device_data(did, key, typ, **kwargs) or [None] - rdt = rls.pop(0) or {} - if kwargs.get('not_value'): - return rdt - val = rdt.get('value') - if val is None: - return None - try: - vls = json.loads(val) - except (TypeError, ValueError): - vls = [val] - return vls.pop(0) - - async def async_check_auth(self, notify=False): - api = 'v2/message/v2/check_new_msg' - dat = { - 'begin_at': int(time.time()) - 60, - } - try: - rdt = await self.async_request_api(api, dat, method='POST') or {} - nid = f'xiaomi-miot-auth-warning-{self.user_id}' - eno = rdt.get('code', 0) - if eno != 3: - return True - except requests.exceptions.ConnectionError: - return None - except requests.exceptions.Timeout: - return None - # auth err - if await self.async_relogin(): - persistent_notification.dismiss(self.hass, nid) - return True - if notify: - persistent_notification.create( - self.hass, - f'Xiaomi account: {self.user_id} auth failed, ' - 'Please update option for this integration to refresh token.\n' - f'小米账号:{self.user_id} 登录失效,请重新保存集成选项以更新登录信息。', - 'Xiaomi Miot Warning', - nid, - ) - _LOGGER.error( - 'Xiaomi account: %s auth failed, Please update option for this integration to refresh token.\n%s', - self.user_id, - rdt, - ) - else: - _LOGGER.warning('Retry login xiaomi account failed: %s', self.username) - return False - - async def async_request_api(self, *args, **kwargs): - if not self.service_token: - await self.async_login() - return await self.hass.async_add_executor_job( - partial(self.request_miot_api, *args, **kwargs) - ) - - def request_miot_api(self, api, data, method='POST', crypt=True, debug=True, **kwargs): - params = {} - if data is not None: - params['data'] = self.json_encode(data) - raw = kwargs.pop('raw', self.sid != 'xiaomiio') - rsp = None - try: - if raw: - rsp = self.request_raw(api, data, method, **kwargs) - elif crypt: - rsp = self.request_rc4_api(api, params, method, **kwargs) - else: - rsp = self.request(self.get_api_url(api), params, **kwargs) - rdt = json.loads(rsp) - if debug: - _LOGGER.debug( - 'Request miot api: %s %s result: %s', - api, data, rsp, - ) - self.attrs['timeouts'] = 0 - except requests.exceptions.Timeout as exc: - rdt = None - self.attrs.setdefault('timeouts', 0) - self.attrs['timeouts'] += 1 - if 5 < self.attrs['timeouts'] <= 10: - _LOGGER.error('Request xiaomi api: %s %s timeout, exception: %s', api, data, exc) - except (TypeError, ValueError): - rdt = None - code = rdt.get('code') if rdt else None - if code == 3: - self._logout() - _LOGGER.warning('Unauthorized while executing request to %s, logged out.', api) - elif code or not rdt: - fun = _LOGGER.info if rdt else _LOGGER.warning - fun('Request xiaomi api: %s %s failed, response: %s', api, data, rsp) - return rdt - - async def async_get_device(self, mac=None, host=None): - dvs = await self.async_get_devices() or [] - for d in dvs: - if not isinstance(d, dict): - continue - if mac and mac == d.get('mac'): - return d - if host and host == d.get('localip'): - return d - return None - - def get_device_list(self): - rdt = self.request_miot_api('home/device_list', { - 'getVirtualModel': True, - 'getHuamiDevices': 1, - 'get_split_device': False, - 'support_smart_home': True, - }, debug=False, timeout=60) or {} - if rdt and 'result' in rdt: - return rdt['result']['list'] - _LOGGER.warning('Got xiaomi cloud devices for %s failed: %s', self.username, rdt) - return None - - def get_home_devices(self): - rdt = self.request_miot_api('homeroom/gethome', { - 'fetch_share_dev': True, - }, debug=False, timeout=60) or {} - rdt = rdt.get('result') or {} - rdt.setdefault('devices', {}) - for h in rdt.get('homelist', []): - for r in h.get('roomlist', []): - for did in r.get('dids', []): - rdt['devices'][did] = { - 'home_id': h.get('id'), - 'room_id': r.get('id'), - 'home_name': h.get('name'), - 'room_name': r.get('name'), - } - return rdt - - async def _async_all_devices(self, renew=False): - if not self.user_id: - return None - - filename = f'xiaomi_miot/devices-{self.user_id}-{self.default_server}.json' - store = Store(self.hass, 1, filename) - now = time.time() - - cached_data = None - try: - cached_data = await store.async_load() or {} - if cached_data and isinstance(cached_data, dict): - if not renew and cached_data.get('update_time', 0) > (now - 86400): - return cached_data - except ValueError: - await store.async_remove() - - try: - devices = await self.hass.async_add_executor_job(self.get_device_list) - if devices is None: - # exec self.get_device_list failed - return cached_data or {} - - homes = await self.hass.async_add_executor_job(self.get_home_devices) - if homes: - device2home = homes.get('devices') or {} - for device in devices: - home_info = device2home.get(device.get('did')) - if not home_info: - continue - device.update(home_info) - - refreshed = { - 'update_time': now, - 'devices': devices, - 'homes': homes.get('homelist', []), - } - - await store.async_save(refreshed) - _LOGGER.info('Got %s devices from xiaomi cloud', len(devices)) - return refreshed - - except requests.exceptions.ConnectionError as exc: - if not cached_data: - raise exc - - _LOGGER.warning('Get xiaomi devices filed: %s, use cached %s devices.', exc, len(cached_data.get('devices') or [])) - return cached_data or {} - - async def async_get_devices(self, renew=False): - result = await self._async_all_devices(renew=renew) - return result.get('devices', []) - - async def async_renew_devices(self): - return await self.async_get_devices(renew=True) - - async def async_get_homerooms(self, renew=False): - result = await self._async_all_devices(renew=renew) - return result.get('homes') or [] - - async def async_get_devices_by_key(self, key, renew=False, filters=None): - dat = {} - if filters is None: - filters = {} - fls = ['ssid', 'bssid', 'home_id', 'model', 'did'] - dvs = await self.async_get_devices(renew=renew) or [] - for d in dvs: - if not isinstance(d, dict): - continue - if self.is_hide(d): - continue - if not d.get('mac'): - d['mac'] = d.get('did') - k = d.get(key) - for f in fls: - ft = filters.get(f'filter_{f}') - if not ft: - continue - ex = ft != 'include' - fl = filters.get(f'{f}_list') or {} - fv = d.get(f) - if ex: - ok = fv not in fl - else: - ok = fv in fl - if not ok: - k = None - if k: - dat[k] = d - return dat - - async def async_get_beaconkey(self, did): - dat = {'did': did or self.miot_did, 'pdid': 1} - rdt = await self.async_request_api('v2/device/blt_get_beaconkey', dat) or {} - return rdt.get('result') - - @staticmethod - def is_hide(d): - did = d.get('did', '') - pid = d.get('pid', '') - if pid == '21': - prt = d.get('parent_id') - if prt and prt in did: - # issues/263 - return True - return False - - async def async_login(self, captcha=None): - if self.login_times > 10: - raise MiCloudException( - 'Too many failures when login to Xiaomi, ' - 'please reload/config xiaomi_miot component.' - ) - self.login_times += 1 - ret = await self.hass.async_add_executor_job(self._login_request, captcha) - if ret: - self.hass.data[DOMAIN]['sessions'][self.unique_id] = self - await self.async_stored_auth(self.user_id, save=True) - self.login_times = 0 - return ret - - async def async_relogin(self): - self._logout() - return await self.async_login() - - def _logout(self): - self.service_token = None - - def _login_request(self, captcha=None): - self._init_session() - auth = self.attrs.pop('login_data', None) - if captcha and auth: - auth['captcha'] = captcha - if not auth: - auth = self._login_step1() - location = self._login_step2(**auth) - response = self._login_step3(location) - http_code = response.status_code - if http_code == 200: - return True - elif http_code == 403: - raise MiCloudAccessDenied(f'Login to xiaomi error: {response.text} ({http_code})') - else: - _LOGGER.error( - 'Xiaomi login request returned status %s, reason: %s, content: %s', - http_code, response.reason, response.text, - ) - raise MiCloudException(f'Login to xiaomi error: {response.text} ({http_code})') - - def _login_step1(self): - response = self.session.get( - f'{ACCOUNT_BASE}/pass/serviceLogin', - params={'sid': self.sid, '_json': 'true'}, - headers={'User-Agent': self.useragent}, - cookies={'sdkVersion': '3.8.6', 'deviceId': self.client_id}, - ) - try: - auth = json.loads(response.text.replace('&&&START&&&', '')) or {} - except Exception as exc: - raise MiCloudException(f'Error getting xiaomi login sign. Cannot parse response. {exc}') - return auth - - def _login_step2(self, captcha=None, **kwargs): - url = f'{ACCOUNT_BASE}/pass/serviceLoginAuth2' - post = { - 'user': self.username, - 'hash': hashlib.md5(self.password.encode()).hexdigest().upper(), - 'callback': kwargs.get('callback') or '', - 'sid': kwargs.get('sid') or self.sid, - 'qs': kwargs.get('qs') or '', - '_sign': kwargs.get('_sign') or '', - } - params = {'_json': 'true'} - headers = {'User-Agent': self.useragent} - cookies = {'sdkVersion': '3.8.6', 'deviceId': self.client_id} - if captcha: - post['captCode'] = captcha - params['_dc'] = int(time.time() * 1000) - cookies['ick'] = self.attrs.pop('captchaIck', '') - response = self.session.post(url, data=post, params=params, headers=headers, cookies=cookies) - auth = json.loads(response.text.replace('&&&START&&&', '')) or {} - code = auth.get('code') - # 20003 InvalidUserNameException - # 22009 PackageNameDeniedException - # 70002 InvalidCredentialException - # 70016 InvalidCredentialException with captchaUrl - # 81003 NeedVerificationException - # 87001 InvalidResponseException captCode error - # other NeedCaptchaException - location = auth.get('location') - if not location: - if cap := auth.get('captchaUrl'): - if cap[:4] != 'http': - cap = f'{ACCOUNT_BASE}{cap}' - if self._get_captcha(cap): - self.attrs['login_data'] = kwargs - if ntf := auth.get('notificationUrl'): - if ntf[:4] != 'http': - ntf = f'{ACCOUNT_BASE}{ntf}' - self.attrs['notificationUrl'] = ntf - _LOGGER.error('Xiaomi serviceLoginAuth2: %s', [url, params, post, headers, cookies]) - raise MiCloudAccessDenied(f'Login to xiaomi error: {response.text}') - self.user_id = str(auth.get('userId', '')) - self.cuser_id = auth.get('cUserId') - self.ssecurity = auth.get('ssecurity') - self.pass_token = auth.get('passToken') - if self.sid != 'xiaomiio': - sign = f'nonce={auth.get("nonce")}&{auth.get("ssecurity")}' - sign = hashlib.sha1(sign.encode()).digest() - sign = base64.b64encode(sign).decode() - location += '&clientSign=' + parse.quote(sign) - _LOGGER.info('Xiaomi serviceLoginAuth2: %s', [auth, response.cookies.get_dict()]) - return location - - def _login_step3(self, location): - self.session.headers.update({'content-type': 'application/x-www-form-urlencoded'}) - response = self.session.get( - location, - headers={'User-Agent': self.useragent}, - cookies={'sdkVersion': '3.8.6', 'deviceId': self.client_id}, - ) - service_token = response.cookies.get('serviceToken') - if service_token: - self.service_token = service_token - else: - err = { - 'location': location, - 'status_code': response.status_code, - 'cookies': response.cookies.get_dict(), - 'response': response.text, - } - raise MiCloudAccessDenied(f'Login to xiaomi error: {err}') - return response - - def _get_captcha(self, url): - response = self.session.get(url) - if ick := response.cookies.get('ick'): - self.attrs['captchaIck'] = ick - self.attrs['captchaImg'] = base64.b64encode(response.content).decode() - return response - - def to_config(self): - return { - CONF_USERNAME: self.username, - CONF_PASSWORD: self.password, - 'server_country': self.default_server, - 'user_id': self.user_id, - 'service_token': self.service_token, - 'ssecurity': self.ssecurity, - 'sid': self.sid, - 'device_id': self.client_id, - } - - @staticmethod - async def from_token(hass, config: dict, login=None): - mic = MiotCloud( - hass, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - config.get('server_country'), - config.get('sid'), - ) - mic.user_id = str(config.get('user_id') or '') - if a := hass.data[DOMAIN].get('sessions', {}).get(mic.unique_id): - mic = a - if mic.password != config.get(CONF_PASSWORD): - mic.password = config.get(CONF_PASSWORD) - mic.service_token = None - if not mic.service_token: - sdt = await mic.async_stored_auth(mic.user_id, save=False) - config.update(sdt) - mic.service_token = config.get('service_token') - mic.ssecurity = config.get('ssecurity') - did = config.get('device_id') or '' - if did and len(did) <= 32: - mic.client_id = did - mic.useragent = UA % did - if login is None: - if not mic.service_token: - login = True - if login: - await mic.async_login() - else: - hass.data[DOMAIN]['sessions'][mic.unique_id] = mic - return mic - - async def async_change_sid(self, sid: str, login=None): - config = {**self.to_config(), 'sid': sid} - mic = await self.from_token(self.hass, config, login) - return mic - - async def async_stored_auth(self, uid=None, save=False): - if uid is None: - uid = self.user_id or self.username - fnm = f'xiaomi_miot/auth-{uid}-{self.default_server}.json' - if self.sid != 'xiaomiio': - fnm = f'xiaomi_miot/auth-{uid}-{self.default_server}-{self.sid}.json' - store = Store(self.hass, 1, fnm) - try: - old = await store.async_load() or {} - except ValueError: - await store.async_remove() - old = {} - if save: - cfg = self.to_config() - cfg.pop(CONF_PASSWORD, None) - if cfg.get('service_token') == old.get('service_token'): - cfg['update_at'] = old.get('update_at') - else: - cfg['update_at'] = f'{datetime.fromtimestamp(int(time.time()))}' - await store.async_save(cfg) - return cfg - return old - - def api_session(self): - if not self.service_token or not self.user_id: - raise MiCloudException('Cannot execute request. service token or userId missing. Make sure to login.') - - session = requests.Session() - session.headers.update({ - 'X-XIAOMI-PROTOCAL-FLAG-CLI': 'PROTOCAL-HTTP2', - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': self.useragent, - }) - session.cookies.update({ - 'userId': str(self.user_id), - 'yetAnotherServiceToken': self.service_token, - 'serviceToken': self.service_token, - 'locale': str(self.locale), - 'timezone': str(self.timezone), - 'is_daylight': str(time.daylight), - 'dst_offset': str(time.localtime().tm_isdst * 60 * 60 * 1000), - 'channel': 'MI_APP_STORE', - }) - return session - - def request(self, url, params, **kwargs): - self.session = self.api_session() - timeout = kwargs.get('timeout', self.http_timeout) - try: - nonce = miutils.gen_nonce() - signed_nonce = miutils.signed_nonce(self.ssecurity, nonce) - signature = miutils.gen_signature(url.replace('/app/', '/'), signed_nonce, nonce, params) - post_data = { - 'signature': signature, - '_nonce': nonce, - 'data': params['data'], - } - response = self.session.post(url, data=post_data, timeout=timeout) - return response.text - except requests.exceptions.HTTPError as exc: - _LOGGER.error('Error while executing request to %s: %s', url, exc) - except MiCloudException as exc: - _LOGGER.error('Error while decrypting response of request to %s: %s', url, exc) - - def request_rc4_api(self, api, params: dict, method='POST', **kwargs): - self.session = self.api_session() - self.session.headers.update({ - 'MIOT-ENCRYPT-ALGORITHM': 'ENCRYPT-RC4', - 'Accept-Encoding': 'identity', - }) - url = self.get_api_url(api) - timeout = kwargs.get('timeout', self.http_timeout) - try: - params = self.rc4_params(method, url, params) - signed_nonce = self.signed_nonce(params['_nonce']) - if method == 'GET': - response = self.session.get(url, params=params, timeout=timeout) - else: - response = self.session.post(url, data=params, timeout=timeout) - rsp = response.text - if not rsp or 'error' in rsp or 'invalid' in rsp: - _LOGGER.warning('Error while executing request to %s: %s', url, rsp or response.status_code) - elif 'message' not in rsp: - try: - rsp = MiotCloud.decrypt_data(signed_nonce, rsp) - except ValueError: - _LOGGER.warning('Error while decrypting response of request to %s :%s', url, rsp) - return rsp - except requests.exceptions.HTTPError as exc: - _LOGGER.warning('Error while executing request to %s: %s', url, exc) - except MiCloudException as exc: - _LOGGER.warning('Error while decrypting response of request to %s :%s', url, exc) - - def request_raw(self, url, data=None, method='GET', **kwargs): - self.session = self.api_session() - url = self.get_api_url(url) - kwargs.setdefault('params' if method == 'GET' else 'data', data) - kwargs.setdefault('timeout', self.http_timeout) - try: - response = self.session.request(method, url, **kwargs) - if response.status_code == 401: - self._logout() - _LOGGER.warning('Unauthorized while executing request to %s, logged out.', url) - rsp = response.text - if not rsp or 'error' in rsp or 'invalid' in rsp: - log = _LOGGER.info if 'remote/ubus' in url else _LOGGER.warning - log('Error while executing request to %s: %s', url, rsp or response.status_code) - return rsp - except requests.exceptions.HTTPError as exc: - _LOGGER.warning('Error while executing request to %s: %s', url, exc) - return None - - def get_api_by_host(self, host, api=''): - srv = self.default_server.lower() - if srv and srv != 'cn': - host = f'{srv}.{host}' - api = str(api).lstrip('/') - return f'https://{host}/{api}' - - def get_api_url(self, api): - if api[:6] == 'https:' or api[:5] == 'http:': - url = api - else: - api = str(api).lstrip('/') - url = self._get_api_url(self.default_server) + '/' + api - return url - - def rc4_params(self, method, url, params: dict): - nonce = miutils.gen_nonce() - signed_nonce = self.signed_nonce(nonce) - params['rc4_hash__'] = MiotCloud.sha1_sign(method, url, params, signed_nonce) - for k, v in params.items(): - params[k] = MiotCloud.encrypt_data(signed_nonce, v) - params.update({ - 'signature': MiotCloud.sha1_sign(method, url, params, signed_nonce), - 'ssecurity': self.ssecurity, - '_nonce': nonce, - }) - return params - - def signed_nonce(self, nonce): - return miutils.signed_nonce(self.ssecurity, nonce) - - @staticmethod - def json_encode(data): - return json.dumps(data, separators=(',', ':')) - - @staticmethod - def sha1_sign(method, url, dat: dict, nonce): - path = parse.urlparse(url).path - if path[:5] == '/app/': - path = path[4:] - arr = [str(method).upper(), path] - for k, v in dat.items(): - arr.append(f'{k}={v}') - arr.append(nonce) - raw = hashlib.sha1('&'.join(arr).encode('utf-8')).digest() - return base64.b64encode(raw).decode() - - @staticmethod - def encrypt_data(pwd, data): - return base64.b64encode(RC4(base64.b64decode(pwd)).init1024().crypt(data)).decode() - - @staticmethod - def decrypt_data(pwd, data): - return RC4(base64.b64decode(pwd)).init1024().crypt(base64.b64decode(data)) - - @staticmethod - def all_clouds(hass): - cls = {} - for k, v in hass.data[DOMAIN].items(): - if isinstance(v, dict): - v = v.get(CONF_XIAOMI_CLOUD) - if isinstance(v, MiotCloud): - cls[v.unique_id] = v - return list(cls.values()) - - @staticmethod - def get_random_string(length): - seq = string.ascii_uppercase + string.digits - return ''.join((random.choice(seq) for _ in range(length)))