33The scale is discovered through Home Assistant's Bluetooth integration
44(local adapter or proxy that relays advertisements). Because the RMH2011
55only 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
1011The options flow is a menu for managing **multiple users** after install:
1112add / edit / remove users, pick the active (handshake) user, tune the
1617from __future__ import annotations
1718
1819import re
20+ from datetime import date , datetime , timezone
1921from typing import Any
2022from uuid import uuid4
2123
3234)
3335from homeassistant .const import CONF_ADDRESS , CONF_NAME
3436from homeassistant .core import HomeAssistant , callback
37+ from homeassistant .util import dt as dt_util
3538
3639from .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 ,
6164from .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+
112125def 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
0 commit comments