Skip to content

Commit 2331027

Browse files
committed
feat: calculate user age from date of birth
- Store optional date_of_birth in user profiles; derive the current age with a birthday-aware calculation (no more annual age updates). - Handshake and body-composition math receive the derived age using the HA-local date; derived sensors refresh once per HA-local day. - Safe migration: legacy age-only profiles keep working unchanged; no DOB is fabricated. Entering a DOB makes it the source of truth. - Options/Config flow: replace the Age field with Date of birth plus validation (invalid/future dates rejected with clear UI errors). - New DOB tests (age math, leap days, validation, migration, handshake age byte, multi-user independence). 104 tests total.
1 parent da2228b commit 2331027

11 files changed

Lines changed: 647 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.8.3] - 2026-09-09
11+
12+
### Added
13+
- **Date-of-birth based user profiles**: the user profile stores an optional
14+
`date_of_birth` instead of collecting an age; the current age is derived
15+
from it with a birthday-aware calculation, so it never needs a manual
16+
yearly update. The handshake and body-composition math receive the
17+
derived age (HA-local date, no UTC off-by-one around birthdays), and
18+
derived sensors refresh once per HA-local day.
19+
- **Safe migration for existing profiles**: profiles created before DOB
20+
support keep their stored age and load unchanged; no DOB is ever
21+
fabricated. Enter a date of birth once through Options → Edit user and it
22+
becomes the source of truth (the age key is dropped on save). A blank DOB
23+
stays allowed for legacy profiles while they keep using their stored age.
24+
- DOB input validation: invalid dates and future dates are rejected with
25+
clear UI errors (`YYYY-MM-DD`, year 1900+).
26+
- Unit tests: birthday-aware age math (including leap days), DOB
27+
validation, legacy profile loading, serialization round-trips, handshake
28+
age byte and multi-user independence (`tests/test_dob.py`).
29+
30+
### Changed
31+
- Options/Config flow user forms: "Age" replaced by "Date of birth" with an
32+
explanatory note ("your age is calculated automatically from this date").
33+
No stable ids, device/entity relationships, Person links, measurement
34+
history or deletion behavior changed.
35+
1036
## [0.8.2] - 2026-09-09
1137

1238
### Changed (repository presentation only - no functionality changes)
@@ -350,7 +376,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
350376
- `realme_scale_measurement` HA event and `realme_scale.reconnect` service.
351377
- Unit tests + hardware-free protocol smoke test (`tests/`).
352378

353-
[Unreleased]: https://github.com/kapilmahawar/realme-scale-ha/compare/v0.8.2...HEAD
379+
[Unreleased]: https://github.com/kapilmahawar/realme-scale-ha/compare/v0.8.3...HEAD
380+
[0.8.3]: https://github.com/kapilmahawar/realme-scale-ha/compare/v0.8.2...v0.8.3
354381
[0.8.2]: https://github.com/kapilmahawar/realme-scale-ha/compare/v0.8.1...v0.8.2
355382
[0.8.1]: https://github.com/kapilmahawar/realme-scale-ha/compare/v0.8.0...v0.8.1
356383
[0.8.0]: https://github.com/kapilmahawar/realme-scale-ha/compare/v0.7.0...v0.8.0

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ all without cloud services.
4141
- Optional linking of users to existing Home Assistant **People**
4242
- Ambiguous or unrecognized readings stay **unassigned** — nothing is
4343
silently guessed
44+
- **Date of birth based profiles**: the current age is derived from the
45+
stored date of birth, so age never needs a manual yearly update
4446

4547
### Body composition
4648

@@ -129,9 +131,11 @@ https://github.com/kapilmahawar/realme-scale-ha
129131

130132
1. Add the integration and pick the discovered scale, or enter its Bluetooth
131133
address (`AA:BB:CC:DD:EE:FF`).
132-
2. Create the **first user** — a profile with sex, age, height and activity
133-
level, plus an optional expected weight and identification tolerances.
134-
You may link it to an existing Home Assistant **Person**.
134+
2. Create the **first user** — a profile with sex, date of birth, height and
135+
activity level, plus an optional expected weight and identification
136+
tolerances. You may link it to an existing Home Assistant **Person**.
137+
Your age is calculated automatically from your date of birth, so you do
138+
not need to update it every year.
135139
3. Add further users later under
136140
**Settings → Devices & services → Realme Smart Scale → Configure**.
137141

@@ -140,11 +144,17 @@ People, remove users, set the handshake profile, adjust automatic
140144
identification defaults, assign or reassign measurements, and **Save &
141145
Close** to apply everything.
142146

147+
**Existing users:** profiles created before date-of-birth support keep using
148+
their stored age until you enter a date of birth once under
149+
*Options → Edit user*. The profile keeps working normally in the meantime
150+
— nothing is guessed or overwritten.
151+
143152
> Three different roles should not be confused:
144153
>
145154
> - a **configured user** is a profile you created;
146-
> - the **handshake profile** is the profile whose sex/age/height is written
147-
> into the scale during the Bluetooth handshake;
155+
> - the **handshake profile** is the profile whose sex/date of birth/height
156+
> is written into the scale during the Bluetooth handshake (age is
157+
> calculated from the date of birth);
148158
> - the **attributed user** is who automatic identification decides was on
149159
> the scale for a given measurement.
150160
>

custom_components/realme_scale/config_flow.py

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
The scale is discovered through Home Assistant's Bluetooth integration
44
(local adapter or proxy that relays advertisements). Because the RMH2011
55
only streams data to an *active* GATT client, the flow additionally asks for
6-
the first user profile (sex / age / height / activity level / initial
7-
weight) that is written into the handshake and used for the local BIA
8-
calculation.
6+
the first user profile (sex / date of birth / height / activity level /
7+
initial weight) that is written into the handshake and used for the local
8+
BIA calculation. The user's age is derived from their date of birth, so it
9+
never needs a manual yearly update.
910
1011
The options flow is a menu for managing **multiple users** after install:
1112
add / edit / remove users, pick the active (handshake) user, tune the
@@ -16,6 +17,7 @@
1617
from __future__ import annotations
1718

1819
import re
20+
from datetime import date, datetime, timezone
1921
from typing import Any
2022
from uuid import uuid4
2123

@@ -32,12 +34,13 @@
3234
)
3335
from homeassistant.const import CONF_ADDRESS, CONF_NAME
3436
from homeassistant.core import HomeAssistant, callback
37+
from homeassistant.util import dt as dt_util
3538

3639
from .const import (
3740
ACTIVITY_LEVELS,
3841
CONF_ACTIVITY_LEVEL,
39-
CONF_AGE,
4042
CONF_AUTO_ASSIGN_KG,
43+
CONF_DATE_OF_BIRTH,
4144
CONF_EXPECTED_WEIGHT,
4245
CONF_HEIGHT,
4346
CONF_IMPEDANCE_TOL_OHM,
@@ -61,6 +64,8 @@
6164
from .scale_controller import (
6265
ScaleUser,
6366
build_user_options,
67+
dob_error,
68+
parse_dob,
6469
parse_user_options,
6570
)
6671

@@ -109,6 +114,14 @@ def person_choices(hass: HomeAssistant) -> dict[str, str]:
109114
return choices
110115

111116

117+
def _local_date(hass: HomeAssistant) -> date:
118+
"""Today's date in the HA-configured time zone (flow validation)."""
119+
tz = dt_util.get_time_zone(hass.config.time_zone)
120+
if tz is None: # pragma: no cover - HA always sets a time zone
121+
tz = timezone.utc
122+
return datetime.now(tz=tz).date()
123+
124+
112125
def profile_schema(
113126
data: dict[str, Any] | None = None,
114127
persons: dict[str, str] | None = None,
@@ -117,6 +130,11 @@ def profile_schema(
117130
118131
``persons`` adds an optional dropdown linking the profile to an existing
119132
Home Assistant person entity (the profile name auto-fills from it).
133+
134+
The user's age is *not* collected: ``date_of_birth`` is stored and the
135+
current age is derived from it. New-user steps enforce the DOB being
136+
present in code (like the other fields); editing a legacy profile may
137+
leave it blank so its stored age keeps being used until a DOB is given.
120138
"""
121139
data = data or {}
122140
schema: dict[vol.Marker, Any] = {
@@ -128,10 +146,10 @@ def profile_schema(
128146
CONF_SEX,
129147
default=data.get(CONF_SEX, SEX_MALE),
130148
): vol.In(_sex_options()),
131-
vol.Required(
132-
CONF_AGE,
133-
default=data.get(CONF_AGE, 30),
134-
): vol.All(vol.Coerce(int), vol.Range(min=1, max=120)),
149+
vol.Optional(
150+
CONF_DATE_OF_BIRTH,
151+
default=data.get(CONF_DATE_OF_BIRTH, ""),
152+
): str,
135153
vol.Required(
136154
CONF_HEIGHT,
137155
default=data.get(CONF_HEIGHT, 175.0),
@@ -185,22 +203,31 @@ def _profile_from_input(
185203
user_input: dict[str, Any],
186204
user_id: str,
187205
persons: dict[str, str] | None = None,
206+
*,
207+
legacy_age: int = 30,
188208
) -> ScaleUser:
189209
"""Build a ScaleUser from validated form input.
190210
191211
When no name was typed but a person was picked, the profile name is
192212
taken from that person so setup can be a single dropdown + confirm.
213+
214+
The form collects ``date_of_birth``; the age is derived from it at run
215+
time. A blank DOB (legacy profile being edited without one yet) keeps
216+
the profile's stored age via ``legacy_age``.
193217
"""
194218
name = str(user_input.get(CONF_USER_NAME, "")).strip()
195219
person = str(user_input.get(CONF_PERSON_ENTITY, ""))
196220
if not name and person and persons:
197221
name = str(persons.get(person, "")).strip()
222+
dob_raw = str(user_input.get(CONF_DATE_OF_BIRTH, "") or "").strip()
223+
dob = parse_dob(dob_raw)
198224
return ScaleUser(
199225
user_id=user_id,
200226
name=name,
201227
person_entity_id=person,
202228
sex=str(user_input[CONF_SEX]),
203-
age=int(user_input[CONF_AGE]),
229+
age=legacy_age if dob is None else 30,
230+
date_of_birth=dob.isoformat() if dob is not None else "",
204231
height_cm=float(user_input[CONF_HEIGHT]),
205232
activity_level=str(user_input[CONF_ACTIVITY_LEVEL]),
206233
initial_weight=float(user_input.get(CONF_INITIAL_WEIGHT, 0.0)),
@@ -217,7 +244,7 @@ def _profile_prefill(user: ScaleUser) -> dict[str, Any]:
217244
return {
218245
CONF_USER_NAME: user.name,
219246
CONF_SEX: user.sex,
220-
CONF_AGE: user.age,
247+
CONF_DATE_OF_BIRTH: user.date_of_birth,
221248
CONF_HEIGHT: user.height_cm,
222249
CONF_ACTIVITY_LEVEL: user.activity_level,
223250
CONF_INITIAL_WEIGHT: user.initial_weight,
@@ -380,19 +407,26 @@ async def async_step_profile(
380407
381408
Offers a dropdown of existing HA People: picking one auto-fills the
382409
profile name; the profile numbers still need confirming once
383-
(they drive the handshake and body-composition math).
410+
(they drive the handshake and body-composition math). Age is
411+
derived from the date of birth, which is required for a new user.
384412
"""
385413
errors: dict[str, str] = {}
386414
persons = person_choices(self.hass)
387415
if user_input is not None:
388-
user = _profile_from_input(user_input, _new_user_id(), persons)
389-
return self.async_create_entry(
390-
title=f"{self._name} ({self._address})",
391-
data={CONF_ADDRESS: self._address, CONF_NAME: self._name},
392-
options=build_user_options(
393-
[user], user.user_id, DEFAULT_AUTO_ASSIGN_KG
394-
),
416+
dob_err = dob_error(
417+
user_input.get(CONF_DATE_OF_BIRTH), on=_local_date(self.hass)
395418
)
419+
if dob_err is not None:
420+
errors[CONF_DATE_OF_BIRTH] = dob_err
421+
else:
422+
user = _profile_from_input(user_input, _new_user_id(), persons)
423+
return self.async_create_entry(
424+
title=f"{self._name} ({self._address})",
425+
data={CONF_ADDRESS: self._address, CONF_NAME: self._name},
426+
options=build_user_options(
427+
[user], user.user_id, DEFAULT_AUTO_ASSIGN_KG
428+
),
429+
)
396430

397431
return self.async_show_form(
398432
step_id="profile",
@@ -526,15 +560,24 @@ async def async_step_menu(
526560
async def async_step_add_user(
527561
self, user_input: dict[str, Any] | None = None
528562
) -> ConfigFlowResult:
529-
"""Collect a new user profile (optionally linked to an HA person)."""
563+
"""Collect a new user profile (optionally linked to an HA person).
564+
565+
A new user must provide a date of birth: age is derived from it, so
566+
there is no age input to fall back to.
567+
"""
530568
errors: dict[str, str] = {}
531569
persons = person_choices(self.hass)
532570
if user_input is not None:
533571
name_blank = not str(user_input.get(CONF_USER_NAME, "")).strip()
534572
person = str(user_input.get(CONF_PERSON_ENTITY, ""))
535573
if name_blank and not person:
536574
errors[CONF_USER_NAME] = "name_required"
537-
else:
575+
dob_err = dob_error(
576+
user_input.get(CONF_DATE_OF_BIRTH), on=_local_date(self.hass)
577+
)
578+
if dob_err is not None:
579+
errors[CONF_DATE_OF_BIRTH] = dob_err
580+
if not errors:
538581
user = _profile_from_input(user_input, _new_user_id(), persons)
539582
self._users_or_default().append(user)
540583
return await self.async_step_init()
@@ -566,7 +609,12 @@ async def async_step_edit_user(
566609
async def async_step_edit_user_form(
567610
self, user_input: dict[str, Any] | None = None
568611
) -> ConfigFlowResult:
569-
"""Edit one user's profile (person link optional)."""
612+
"""Edit one user's profile (person link optional).
613+
614+
A legacy profile without a date of birth may keep using its stored
615+
age by leaving the DOB blank. Once a DOB is set it becomes the
616+
source of truth, so it cannot be silently cleared back to nothing.
617+
"""
570618
users = self._users_or_default()
571619
target_id = self._edit_user_id
572620
target = next((u for u in users if u.user_id == target_id), None)
@@ -580,8 +628,18 @@ async def async_step_edit_user_form(
580628
person = str(user_input.get(CONF_PERSON_ENTITY, ""))
581629
if name_blank and not person:
582630
errors[CONF_USER_NAME] = "name_required"
583-
else:
584-
updated = _profile_from_input(user_input, target_id, persons)
631+
raw_dob = str(user_input.get(CONF_DATE_OF_BIRTH, "") or "").strip()
632+
if raw_dob or target.date_of_birth:
633+
# Entering a new DOB, or keeping/editing an existing one:
634+
# it must be a valid, non-future date.
635+
dob_err = dob_error(raw_dob, on=_local_date(self.hass))
636+
if dob_err is not None:
637+
errors[CONF_DATE_OF_BIRTH] = dob_err
638+
# blank DOB on a legacy profile -> keep its stored age.
639+
if not errors:
640+
updated = _profile_from_input(
641+
user_input, target_id, persons, legacy_age=target.age
642+
)
585643
users[users.index(target)] = updated
586644
return await self.async_step_init()
587645

custom_components/realme_scale/const.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@
5252
CONF_USER_NAME: Final = "user_name"
5353
CONF_PERSON_ENTITY: Final = "person_entity_id" # optional HA person.* link
5454
CONF_SEX: Final = "sex" # "male" | "female"
55-
CONF_AGE: Final = "age" # years (int)
55+
CONF_AGE: Final = "age" # legacy years (int); kept for migration
56+
# Date of birth, ISO "YYYY-MM-DD". Empty/absent = not configured yet; the
57+
# current age is derived from it (CONF_AGE remains the fallback for legacy
58+
# profiles that predate date-of-birth support).
59+
CONF_DATE_OF_BIRTH: Final = "date_of_birth"
5660
CONF_HEIGHT: Final = "height" # cm (float)
5761
CONF_ACTIVITY_LEVEL: Final = "activity_level" # openScale ActivityLevel name
5862
CONF_INITIAL_WEIGHT: Final = "initial_weight" # kg (float); 0 -> 0xFFFF sentinel

0 commit comments

Comments
 (0)