Skip to content

Commit d43b5b4

Browse files
authored
Merge pull request #3 from AboveColin/feature/import-history
Import the cloud weigh-in history as long-term statistics
2 parents 853c5d6 + 05aa0c4 commit d43b5b4

8 files changed

Lines changed: 211 additions & 3 deletions

File tree

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ Per member profile:
8484
Heart rate only reports on scales that measure it (`measures_heart_rate` on the
8585
device). On a scale without it the sensor stays *unknown*, not *unavailable*.
8686

87+
## History from the cloud
88+
89+
The account keeps every weigh-in, and each refresh downloads 400 days of it.
90+
On every start the integration writes that history into Home Assistant's long
91+
term statistics, one row per hour, under each sensor's own entity id. A
92+
statistics graph card therefore shows the weight trend from before the
93+
integration was installed, not only from the day you set it up.
94+
95+
What that does and does not give you:
96+
97+
- Statistics only, so the numeric sensors (weight, BMI, body fat, and the rest
98+
that carry a measurement state class) get a past. The counter and the
99+
timestamp sensors do not.
100+
- Two weigh-ins in the same hour become one row holding their minimum, maximum
101+
and mean, which is the resolution the recorder stores.
102+
- No state history and no logbook entries for the past. Home Assistant's own
103+
history view starts when the integration does.
104+
- Importing the same hour again overwrites that row, so a restart rewrites
105+
rather than duplicates.
106+
- The import is skipped when the recorder is not set up.
107+
87108
## Notes and limitations
88109

89110
- Cloud polling every 30 minutes. A scale gets stepped on a couple of times a
@@ -93,8 +114,11 @@ device). On a scale without it the sensor stays *unknown*, not *unavailable*.
93114
unknown for that weigh-in rather than holding their previous value, and the
94115
`weight_only` attribute on the weight sensor says why.
95116
- The integration is read-only. It never writes to your Fitdays account.
96-
- Brand artwork in `custom_components/fitdays/brand/` is a generic placeholder
97-
glyph, not the vendor's logo. Home Assistant reads that folder from 2026.3.0
117+
- The 400-day window is the client default. A measurement older than that is
118+
not downloaded, so it cannot be imported either.
119+
- Brand artwork lives in `custom_components/fitdays/brand/` (icon and logo, at
120+
1x and 2x). The Fitdays teal square works on light and dark, so there are no
121+
separate dark-mode variants. Home Assistant reads that folder from 2026.3.0
98122
onward; on older cores the integration renders without an icon.
99123

100124
## Disclaimer

custom_components/fitdays/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
DOMAIN,
3636
HISTORY_DAYS,
3737
)
38+
from .statistics import async_import_history
3839

3940
PLATFORMS: list[Platform] = [Platform.SENSOR]
4041

@@ -103,6 +104,9 @@ async def _async_update_data(self) -> dict[str, Any]:
103104
profiles[str(profile.suid)] = {
104105
"profile": profile,
105106
"latest": measurements[0] if measurements else None,
107+
# The whole window is kept so the statistics import can read
108+
# it; the sensors themselves only ever look at "latest".
109+
"measurements": measurements,
106110
"count": len(measurements),
107111
"first_measured_at": (
108112
measurements[-1].measured_at if measurements else None
@@ -150,6 +154,11 @@ def save_session(session: Session) -> None:
150154
# Deliberately no update listener: renewing a token writes back to the
151155
# entry, and a reload-on-update listener would turn that into a loop.
152156
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
157+
158+
# After the platforms are set up every sensor has an entity id, which is
159+
# what a statistics row is keyed by. The import itself hands the rows to
160+
# the recorder and returns; it does not wait for them to be written.
161+
await async_import_history(hass, entry)
153162
return True
154163

155164

5.63 KB
Loading
14.3 KB
Loading
7.83 KB
Loading
19 KB
Loading
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"domain": "fitdays",
33
"name": "Fitdays",
4+
"after_dependencies": ["recorder"],
45
"codeowners": ["@abovecolin"],
56
"config_flow": true,
67
"dependencies": [],
@@ -9,5 +10,5 @@
910
"iot_class": "cloud_polling",
1011
"issue_tracker": "https://github.com/abovecolin/HA-Fitdays/issues",
1112
"requirements": ["fitdays==1.0.0"],
12-
"version": "1.0.2"
13+
"version": "1.1.0"
1314
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)