diff --git a/custom_components/xiaomi_miot/core/device.py b/custom_components/xiaomi_miot/core/device.py index 15f1fe039..2d20f1b4a 100644 --- a/custom_components/xiaomi_miot/core/device.py +++ b/custom_components/xiaomi_miot/core/device.py @@ -38,6 +38,8 @@ get_value, DeviceException, is_offline_exception, + normalize_power_cost_value, + power_cost_period, update_attrs_with_suffix, ) from .templates import template @@ -1214,6 +1216,7 @@ async def update_cloud_statistics(self, commands=None): attrs[anm] = rls elif isinstance(rls, dict): update_attrs_with_suffix(attrs, rls) + attrs = self._filter_power_cost_statistics(attrs, dt.now()) if attrs: self.available = True self.props.update(attrs) @@ -1221,6 +1224,37 @@ async def update_cloud_statistics(self, commands=None): self.dispatch(self.decode_attrs(attrs)) return attrs + def _filter_power_cost_statistics(self, attrs, now): + """Filter invalid and decreasing power cost statistics.""" + result = dict(attrs) + periods = self.data.setdefault('_power_cost_periods', {}) + for key in list(result): + period = power_cost_period(key, now) + if not period: + continue + value = normalize_power_cost_value(result[key]) + if value is None: + result.pop(key) + continue + previous = normalize_power_cost_value(self.props.get(key)) + if ( + periods.get(key) == period + and previous is not None + and value < previous + ): + self.log.warning( + 'Ignore decreasing power cost in the same period: ' + '%s: %s -> %s, period=%s', + key, + previous, + value, + period, + ) + result.pop(key) + continue + periods[key] = period + return result + @cached_property def miio_cloud_records(self): return self.custom_config_list('miio_cloud_records') or [] diff --git a/custom_components/xiaomi_miot/core/templates.py b/custom_components/xiaomi_miot/core/templates.py index ab3dfa8ed..5557f596b 100644 --- a/custom_components/xiaomi_miot/core/templates.py +++ b/custom_components/xiaomi_miot/core/templates.py @@ -104,7 +104,7 @@ "'target_temperature': state[6:8] | int(0,16)," "'load_power': results[2] | default(0) | float | round(2)," "} }}", - 'micloud_statistics_power_cost': "{%- set dat = namespace(today=0,month=0) %}" + 'micloud_statistics_power_cost': "{%- set dat = namespace(today=none,month=none) %}" "{%- set tim = now() %}" "{%- set stm = tim - timedelta(minutes=tim.minute,seconds=tim.second) %}" "{%- set tod = stm - timedelta(hours=tim.hour) %}" @@ -114,17 +114,19 @@ "{%- for d in (result or []) %}" "{%- set t = d.time | default(0) | int(0) %}" "{%- if t >= stm %}" - "{%- set v = (d.value | default('[]') | string | from_json) or [] %}" - "{%- set n = v[0] | default(0) %}" - "{%- if t >= tod %}" - "{%- set dat.today = n %}" + "{%- set v = (d.value | default('[]', true) | string | from_json) or [] %}" + "{%- set n = v[0] | default(none) %}" + "{%- if n is number and n is not boolean and n >= 0 %}" + "{%- if t >= tod %}" + "{%- set dat.today = n %}" + "{%- endif %}" + "{%- set dat.month = (dat.month or 0) + n %}" "{%- endif %}" - "{%- set dat.month = dat.month + n %}" "{%- endif %}" "{%- endfor %}" "{{ {" - "'power_cost_today': dat.today | round(3)," - "'power_cost_month': dat.month | round(3)," + "'power_cost_today': dat.today | round(3) if dat.today is number else none," + "'power_cost_month': dat.month | round(3) if dat.month is number else none," "} }}", 'midr_rv_mirror_cloud_props': "{%- set sta = props.get('prop.Status',0) | int %}" "{%- set pos = props.get('prop.Position','{}') | from_json %}" diff --git a/custom_components/xiaomi_miot/core/utils.py b/custom_components/xiaomi_miot/core/utils.py index d7bff47ba..73a6574a4 100644 --- a/custom_components/xiaomi_miot/core/utils.py +++ b/custom_components/xiaomi_miot/core/utils.py @@ -1,6 +1,7 @@ import os import re import json +import math import locale import aiohttp import asyncio @@ -8,6 +9,7 @@ import logging import fnmatch import voluptuous as vol +from datetime import datetime from typing import Type, Tuple, Optional, Callable, Set from functools import wraps from homeassistant.core import HomeAssistant, split_entity_id # noqa @@ -20,6 +22,30 @@ from .const import DOMAIN, DEVICE_CUSTOMIZES, DATA_CUSTOMIZE from .translation_languages import TRANSLATION_LANGUAGES +POWER_COST_PATTERN = re.compile( + r'(?:^|\.)(power_cost_(today|month)(?:_\d+)?)$' +) + + +def power_cost_period(attribute: str, timestamp: datetime) -> str | None: + """Return the local reset period for a power cost attribute.""" + if match := POWER_COST_PATTERN.search(attribute): + return timestamp.strftime( + '%Y-%m-%d' if match.group(2) == 'today' else '%Y-%m' + ) + return None + + +def normalize_power_cost_value(value) -> float | None: + """Return a finite non-negative power cost value.""" + if isinstance(value, bool): + return None + try: + value = float(value) + except (TypeError, ValueError): + return None + return value if math.isfinite(value) and value >= 0 else None + def get_value(obj, key, def_value=None, sep='.'): keys = f'{key}'.split(sep) diff --git a/custom_components/xiaomi_miot/sensor.py b/custom_components/xiaomi_miot/sensor.py index 3faed42f3..197b746ca 100644 --- a/custom_components/xiaomi_miot/sensor.py +++ b/custom_components/xiaomi_miot/sensor.py @@ -13,6 +13,8 @@ SensorDeviceClass, SensorStateClass, ) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.restore_state import RestoreEntity, RestoredExtraData from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -33,7 +35,13 @@ MiotSpec, MiotService, ) -from .core.utils import local_zone, get_translation +from .core.utils import ( + POWER_COST_PATTERN, + get_translation, + local_zone, + normalize_power_cost_value, + power_cost_period, +) _LOGGER = logging.getLogger(__name__) DATA_KEY = f'{ENTITY_DOMAIN}.{DOMAIN}' @@ -130,6 +138,47 @@ def on_init(self): def get_state(self) -> dict: return {self.attr: self._attr_native_value} + async def async_added_to_hass(self): + await super().async_added_to_hass() + if not POWER_COST_PATTERN.search(self.attr): + return + now = datetime.now(local_zone(self.hass)) + current_period = power_cost_period(self.attr, now) + self._power_cost_period = current_period + restored = await self.async_get_last_state() + if restored: + restored_value = normalize_power_cost_value(restored.state) + restored_at = restored.last_changed.astimezone( + local_zone(self.hass) + ) + restored_period = power_cost_period( + self.attr, + restored_at, + ) + if restored_value is None or restored_period != current_period: + self._attr_native_value = None + else: + self._attr_native_value = restored_value + self.async_on_remove( + async_track_time_change( + self.hass, + self._reset_power_cost_period, + hour=0, + minute=0, + second=0, + ) + ) + + @callback + def _reset_power_cost_period(self, now: datetime): + """Reset accumulated power at an observed local period boundary.""" + period = power_cost_period(self.attr, now) + if not period or period == self._power_cost_period: + return + self._power_cost_period = period + self._attr_native_value = 0 + self.async_write_ha_state() + def set_state(self, data: dict): value = self.conv.value_from_dict(data) prop = self._miot_property @@ -144,8 +193,33 @@ def set_state(self, data: dict): value = round(float(value), 3) except (TypeError, ValueError): value = None + period = power_cost_period( + self.attr, + datetime.now(local_zone(self.hass)), + ) + if period: + value = normalize_power_cost_value(value) + if value is None: + return if self.device_class == SensorDeviceClass.TIMESTAMP: value = datetime_with_tzinfo(value) + previous = getattr(self, '_attr_native_value', None) + if ( + period + and getattr(self, '_power_cost_period', None) == period + and previous is not None + and value < previous + ): + self.log.warning( + 'Ignore decreasing power cost in the same period: ' + '%s: %s -> %s', + self.attr, + previous, + value, + ) + return + if period: + self._power_cost_period = period self._attr_native_value = value @cached_property