|
| 1 | +"""Backfill Home Assistant long-term statistics from the Fitdays cloud history. |
| 2 | +
|
| 3 | +The account keeps every weigh-in, and the coordinator already downloads |
| 4 | +``HISTORY_DAYS`` of it on each refresh. Without this module the recorder only |
| 5 | +sees the readings that arrive after the integration is installed, so a graph |
| 6 | +starts empty for someone who has been standing on the scale for a year. |
| 7 | +
|
| 8 | +The import writes into the recorder's own statistics tables under each sensor's |
| 9 | +entity id, which is what the statistics graph card and the energy-style long |
| 10 | +term charts read. It does not write state history, so the logbook stays empty |
| 11 | +for the past. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import logging |
| 17 | +from datetime import datetime |
| 18 | +from typing import Any |
| 19 | + |
| 20 | +from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN |
| 21 | +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData |
| 22 | +from homeassistant.components.recorder.statistics import async_import_statistics |
| 23 | +from homeassistant.components.sensor import SensorStateClass |
| 24 | +from homeassistant.config_entries import ConfigEntry |
| 25 | +from homeassistant.core import HomeAssistant |
| 26 | +from homeassistant.helpers import entity_registry as er |
| 27 | +from homeassistant.util import dt as dt_util |
| 28 | + |
| 29 | +from .const import DOMAIN |
| 30 | + |
| 31 | +try: # Home Assistant 2025.11 and later |
| 32 | + from homeassistant.components.recorder.models import StatisticMeanType |
| 33 | +except ImportError: # pragma: no cover - older cores only carry has_mean |
| 34 | + StatisticMeanType = None # type: ignore[assignment] |
| 35 | + |
| 36 | +try: # ``unit_class`` arrived after the 2024.11 floor supported here |
| 37 | + from homeassistant.components.recorder.statistics import ( |
| 38 | + STATISTIC_UNIT_TO_UNIT_CONVERTER, |
| 39 | + ) |
| 40 | +except ImportError: # pragma: no cover - older cores have no unit classes |
| 41 | + STATISTIC_UNIT_TO_UNIT_CONVERTER = None # type: ignore[assignment] |
| 42 | + |
| 43 | +# The recorder fills unit_class in itself today and warns about it; it starts |
| 44 | +# rejecting metadata without the key in 2026.11. |
| 45 | +_HAS_UNIT_CLASS = ( |
| 46 | + STATISTIC_UNIT_TO_UNIT_CONVERTER is not None |
| 47 | + and "unit_class" in StatisticMetaData.__annotations__ |
| 48 | +) |
| 49 | + |
| 50 | +_LOGGER = logging.getLogger(__name__) |
| 51 | + |
| 52 | + |
| 53 | +def _hourly_buckets( |
| 54 | + measurements: list[Any], |
| 55 | + description: Any, |
| 56 | + profile: Any, |
| 57 | + profile_data: dict[str, Any], |
| 58 | +) -> dict[datetime, list[float]]: |
| 59 | + """ |
| 60 | + Group one sensor's historical values into the hours the recorder stores. |
| 61 | +
|
| 62 | + A statistics row covers a whole hour, so two weigh-ins 20 minutes apart |
| 63 | + collapse into one row holding their min, max and mean. |
| 64 | + """ |
| 65 | + buckets: dict[datetime, list[float]] = {} |
| 66 | + for measurement in measurements: |
| 67 | + measured_at = getattr(measurement, "measured_at", None) |
| 68 | + if measured_at is None: |
| 69 | + continue |
| 70 | + value = description.value_fn(measurement, profile, profile_data) |
| 71 | + if isinstance(value, bool) or not isinstance(value, (int, float)): |
| 72 | + continue |
| 73 | + hour = dt_util.as_utc(measured_at).replace(minute=0, second=0, microsecond=0) |
| 74 | + buckets.setdefault(hour, []).append(float(value)) |
| 75 | + return buckets |
| 76 | + |
| 77 | + |
| 78 | +def _metadata(entity_id: str, unit: str | None) -> StatisticMetaData: |
| 79 | + """ |
| 80 | + Describe one sensor's statistics series. |
| 81 | +
|
| 82 | + ``has_mean`` is the pre-2025.11 spelling and the recorder drops it in |
| 83 | + 2026.4, so ``mean_type`` is used wherever the enum exists. ``unit_class`` |
| 84 | + is derived the same way the recorder derives it, which keeps the metadata |
| 85 | + valid once the recorder stops filling it in for us in 2026.11. |
| 86 | + """ |
| 87 | + metadata: dict[str, Any] = { |
| 88 | + "has_sum": False, |
| 89 | + "name": None, |
| 90 | + "source": RECORDER_DOMAIN, |
| 91 | + "statistic_id": entity_id, |
| 92 | + "unit_of_measurement": unit, |
| 93 | + } |
| 94 | + if StatisticMeanType is not None: |
| 95 | + metadata["mean_type"] = StatisticMeanType.ARITHMETIC |
| 96 | + else: |
| 97 | + metadata["has_mean"] = True |
| 98 | + if _HAS_UNIT_CLASS: |
| 99 | + converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(unit) |
| 100 | + metadata["unit_class"] = converter.UNIT_CLASS if converter else None |
| 101 | + return metadata # type: ignore[return-value] |
| 102 | + |
| 103 | + |
| 104 | +def _statistics(buckets: dict[datetime, list[float]]) -> list[StatisticData]: |
| 105 | + """Turn hourly buckets into recorder rows, oldest first.""" |
| 106 | + return [ |
| 107 | + StatisticData( |
| 108 | + start=hour, |
| 109 | + min=min(values), |
| 110 | + max=max(values), |
| 111 | + mean=sum(values) / len(values), |
| 112 | + ) |
| 113 | + for hour, values in sorted(buckets.items()) |
| 114 | + ] |
| 115 | + |
| 116 | + |
| 117 | +async def async_import_history(hass: HomeAssistant, entry: ConfigEntry) -> int: |
| 118 | + """ |
| 119 | + Import every downloaded measurement as long-term statistics. |
| 120 | +
|
| 121 | + Returns the number of hourly rows handed to the recorder. Importing the |
| 122 | + same hour again overwrites that row, so running this on every start costs |
| 123 | + a rewrite rather than a duplicate. |
| 124 | + """ |
| 125 | + if RECORDER_DOMAIN not in hass.config.components: |
| 126 | + _LOGGER.debug("Recorder is not set up, skipping the history import") |
| 127 | + return 0 |
| 128 | + |
| 129 | + # Imported here rather than at module scope: sensor.py imports the |
| 130 | + # coordinator from __init__.py, and __init__.py calls into this module. |
| 131 | + from .sensor import SENSORS # pylint: disable=import-outside-toplevel |
| 132 | + |
| 133 | + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] |
| 134 | + profiles = (coordinator.data or {}).get("profiles") or {} |
| 135 | + if not profiles: |
| 136 | + return 0 |
| 137 | + |
| 138 | + registry = er.async_get(hass) |
| 139 | + entity_ids = { |
| 140 | + registry_entry.unique_id: registry_entry.entity_id |
| 141 | + for registry_entry in er.async_entries_for_config_entry( |
| 142 | + registry, entry.entry_id |
| 143 | + ) |
| 144 | + # A disabled entity has no statistics table to write into. |
| 145 | + if registry_entry.disabled_by is None |
| 146 | + } |
| 147 | + |
| 148 | + rows = 0 |
| 149 | + for suid, profile_data in profiles.items(): |
| 150 | + measurements = profile_data.get("measurements") or [] |
| 151 | + if not measurements: |
| 152 | + continue |
| 153 | + profile = profile_data.get("profile") |
| 154 | + |
| 155 | + for description in SENSORS: |
| 156 | + # Only a MEASUREMENT sensor gets mean/min/max statistics. The |
| 157 | + # counter and the timestamp sensors are not backfillable. |
| 158 | + if description.state_class != SensorStateClass.MEASUREMENT: |
| 159 | + continue |
| 160 | + entity_id = entity_ids.get(f"{entry.entry_id}_{suid}_{description.key}") |
| 161 | + if entity_id is None: |
| 162 | + continue |
| 163 | + |
| 164 | + buckets = _hourly_buckets(measurements, description, profile, profile_data) |
| 165 | + if not buckets: |
| 166 | + continue |
| 167 | + statistics = _statistics(buckets) |
| 168 | + |
| 169 | + metadata = _metadata(entity_id, description.native_unit_of_measurement) |
| 170 | + async_import_statistics(hass, metadata, statistics) |
| 171 | + rows += len(statistics) |
| 172 | + |
| 173 | + _LOGGER.debug("Queued %s hourly statistics rows for %s", rows, entry.title) |
| 174 | + return rows |
0 commit comments