Skip to content

Commit 013c3e0

Browse files
Disable unreliable battery entity by default and improve user resolution logic for measurements
1 parent 2b5a12f commit 013c3e0

7 files changed

Lines changed: 104 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Known-incompatible:
5959
| Marketed model | HVIN | FCC ID |
6060
|----------------|-------------|---------------------|
6161
| ES-CS20M | `ESCS20MB2` | `2A26P-ESCS20MB2` |
62+
| ES-CS20M | - | `2APXUES-CS20M` |
6263

6364
**How to find your HVIN:** look at the regulatory sticker on the back of the scale. Most stickers have a dedicated `HVIN:` field alongside the FCC ID. Some stickers don't — in that case, look at the trailing portion of the **FCC ID** (`2A26P-<code>`), which encodes the same identifier. The trailing revision suffix (`A2`, `B2`, `N`, or absent altogether) is what matters. If your HVIN or FCC ID matches a confirmed-working entry above, this integration is likely to work; if it matches a known-incompatible entry it's unlikely to work; if it doesn't match an entry in either table, try it out and report back on the issue tracker. (Note: neither HVIN nor model number is readable over BLE, so the device code can't be shown on the Home Assistant device card — you have to read it off the sticker.)
6465

custom_components/renpho_fitness_scale_ble/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from homeassistant.core import HomeAssistant, ServiceCall, callback
1313
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
1414
from homeassistant.helpers import config_validation as cv
15+
from homeassistant.helpers import entity_registry as er
1516
from homeassistant.helpers.start import async_at_started
1617

1718
from .const import (
@@ -157,6 +158,42 @@ async def _handle_mobile_app_action(hass: HomeAssistant, event) -> None:
157158
# ---------------------------------------------------------------------------
158159

159160

161+
@callback
162+
def _disable_battery_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
163+
"""Disable any still-enabled ``*_battery`` sensor for this entry.
164+
165+
Tested devices seem to report a static 100% on the Battery Level
166+
characteristic (see :class:`ScaleBatterySensor`), so the entity is
167+
disabled by default. New installs get that via
168+
``entity_registry_enabled_default``; this brings existing installs in
169+
line. Only entities the user hasn't already disabled are touched, and the
170+
one-time ``minor_version`` guard in :func:`async_migrate_entry` means a
171+
later deliberate re-enable is never overridden.
172+
"""
173+
registry = er.async_get(hass)
174+
for reg_entry in er.async_entries_for_config_entry(registry, entry.entry_id):
175+
if (
176+
reg_entry.domain == "sensor"
177+
and reg_entry.unique_id.endswith("_battery")
178+
and reg_entry.disabled_by is None
179+
):
180+
registry.async_update_entity(
181+
reg_entry.entity_id,
182+
disabled_by=er.RegistryEntryDisabler.INTEGRATION,
183+
)
184+
185+
186+
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
187+
"""Migrate older config entries. Called automatically by HA when a stored
188+
entry's version is behind the config flow's ``VERSION``/``MINOR_VERSION``."""
189+
if entry.version == 1 and entry.minor_version < 2:
190+
# 1.1 → 1.2: disable the (unreliable on observed hardware) battery
191+
# entity for installs that registered it while it was enabled.
192+
_disable_battery_entities(hass, entry)
193+
hass.config_entries.async_update_entry(entry, minor_version=2)
194+
return True
195+
196+
160197
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
161198
"""Set up a Renpho scale from a config entry."""
162199
hass.data.setdefault(DOMAIN, {})

custom_components/renpho_fitness_scale_ble/config_flow.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ class ScaleConfigFlow(ConfigFlow, domain=DOMAIN):
342342
"""Renpho scale config flow."""
343343

344344
VERSION = 1
345+
# minor_version 2: battery entity disabled by default (see
346+
# async_migrate_entry). New entries start here; older entries migrate up.
347+
MINOR_VERSION = 2
345348

346349
def __init__(self) -> None:
347350
self._discovered_devices: dict[str, Discovery] = {}

custom_components/renpho_fitness_scale_ble/coordinator.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,20 +1358,31 @@ def _on_scale_data(self, data: ScaleData) -> None:
13581358
)
13591359
# Decide which user this measurement is for.
13601360
# - multi-user mode: the async resolver may have already picked one,
1361-
# in which case `_last_resolved_user_id` is set.
1361+
# in which case `_last_resolved_user_id` is set. Otherwise, try to
1362+
# resolve and if there's still no clear winner, treat as pending.
13621363
# - fixed-user / weight-only mode (1 configured user): the library
13631364
# doesn't call our resolver at all, but the answer is unambiguous —
13641365
# route to the only user.
1365-
# - otherwise: store as pending and fire the ambiguous-notification flow.
13661366
user_id = self._last_resolved_user_id
13671367
self._last_resolved_user_id = None
1368-
if user_id is None and len(self._user_profiles) == 1:
1369-
user_id = self._user_profiles[0][CONF_USER_ID]
1368+
1369+
candidate_ids: list[str] | None = None
1370+
if user_id is None:
1371+
if len(self._user_profiles) == 1:
1372+
user_id = self._user_profiles[0][CONF_USER_ID]
1373+
else:
1374+
candidate_ids = self._compute_pending_candidates(measurement.weight_kg)
1375+
if len(candidate_ids) == 1:
1376+
user_id = candidate_ids[0]
13701377

13711378
if user_id is not None:
13721379
self._commit_measurement_to_user(user_id, measurement, data)
13731380
else:
1374-
self._add_pending_measurement(measurement, data)
1381+
# Reuse the candidate list already computed above (if any) so we
1382+
# don't run the router a second time for the same measurement.
1383+
self._add_pending_measurement(
1384+
measurement, data, candidate_ids=candidate_ids
1385+
)
13751386

13761387
# Push firmware revision into the device registry (no-op until the
13771388
# library reads it; harmless to call repeatedly).
@@ -1590,10 +1601,17 @@ def _maybe_compute_bmi(self, data: ScaleData, user_id: str) -> None:
15901601
)
15911602

15921603
def _add_pending_measurement(
1593-
self, measurement: WeightMeasurement, data: ScaleData
1604+
self,
1605+
measurement: WeightMeasurement,
1606+
data: ScaleData,
1607+
candidate_ids: list[str] | None = None,
15941608
) -> None:
15951609
"""Store a measurement that wasn't attributed at weigh time.
15961610
1611+
``candidate_ids`` may be passed by the caller when it has already
1612+
computed the candidate list (via ``_compute_pending_candidates``), so
1613+
the router isn't evaluated a second time for the same measurement.
1614+
15971615
Triggers the ambiguous-notification flow (mobile push to candidate
15981616
users + persistent notification) and keeps the measurement in
15991617
``_pending_measurements`` until either:
@@ -1605,7 +1623,8 @@ def _add_pending_measurement(
16051623
# Opportunistic cleanup: drop any pending entries older than the
16061624
# max age before adding the new one.
16071625
self._cleanup_aged_pending_measurements()
1608-
candidate_ids = self._compute_pending_candidates(measurement.weight_kg)
1626+
if candidate_ids is None:
1627+
candidate_ids = self._compute_pending_candidates(measurement.weight_kg)
16091628
timestamp_iso = measurement.timestamp.isoformat()
16101629
# Reuse the measurement's pre-computed display string when present
16111630
# (it always is for live measurements; falls back to a fresh render
@@ -1634,7 +1653,9 @@ def _add_pending_measurement(
16341653
)
16351654

16361655
def _compute_pending_candidates(self, weight_kg: float) -> list[str]:
1637-
"""Return the same candidate list ``_resolve_user`` would have produced.
1656+
"""Return the same candidate list ``_resolve_user`` would have produced,
1657+
but with added logic to fallback to all users if no candidates match
1658+
the weight.
16381659
16391660
Used in the unresolved (weight-only) path to populate the pending
16401661
record's ``candidates`` so we can mobile-notify those users.
@@ -1650,9 +1671,30 @@ def _compute_pending_candidates(self, weight_kg: float) -> list[str]:
16501671
ranked = self._router.evaluate_measurement(synthetic)
16511672
except Exception:
16521673
_LOGGER.exception("Router evaluation failed in pending-candidate compute")
1653-
return []
1674+
ranked = []
16541675
candidate_ids = [c.user_id for c in ranked]
1655-
return self._filter_candidates_by_location(candidate_ids)
1676+
filtered_candidates = self._filter_candidates_by_location(candidate_ids)
1677+
if filtered_candidates:
1678+
return filtered_candidates
1679+
if candidate_ids:
1680+
return candidate_ids
1681+
1682+
# Fallback: if no candidates match weight, include all users
1683+
all_user_ids = [
1684+
u[CONF_USER_ID] for u in self._user_profiles if CONF_USER_ID in u
1685+
]
1686+
if not all_user_ids:
1687+
return []
1688+
1689+
filtered_all = self._filter_candidates_by_location(all_user_ids)
1690+
if filtered_all:
1691+
return filtered_all
1692+
1693+
_LOGGER.debug(
1694+
"No pending candidates found for weight %.2f kg, falling back to all configured users",
1695+
weight_kg,
1696+
)
1697+
return all_user_ids
16561698

16571699
def _update_config_entry(self) -> None:
16581700
"""Persist coordinator state to the config entry's data dict.

custom_components/renpho_fitness_scale_ble/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"issue_tracker": "https://github.com/ronnnnnnnnnnnnn/renpho_fitness_scale_ble/issues",
3737
"requirements": [
3838
"renpho-escs20m==0.3.0",
39-
"multi-user-scale-core==0.1.1"
39+
"multi-user-scale-core==0.1.2"
4040
],
41-
"version": "0.2.1"
41+
"version": "0.2.2"
4242
}

custom_components/renpho_fitness_scale_ble/sensor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def _handle_update(self, data: ScaleData) -> None:
159159
else:
160160
self._attr_available = True
161161
self._attr_native_value = value
162+
self._attr_force_update = True
162163
self.async_write_ha_state()
163164

164165

@@ -211,6 +212,13 @@ class ScaleBatterySensor(RestoreSensor):
211212
_attr_native_unit_of_measurement = PERCENTAGE
212213
_attr_state_class = SensorStateClass.MEASUREMENT
213214
_attr_name = "Battery"
215+
# Disabled by default: the ES-CS20M / QN-series firmware reports a static
216+
# 100% on the standard Battery Level characteristic and does not decrement
217+
# it as the cells drain, so a first-class battery entity would mislead users
218+
# and never fire low-battery automations. Users whose hardware happens to
219+
# report a real value (or who want the raw reading) can opt in by enabling
220+
# the entity. The value also remains available in the diagnostics download.
221+
_attr_entity_registry_enabled_default = False
214222

215223
def __init__(
216224
self,

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
renpho-escs20m==0.3.0
2-
multi-user-scale-core==0.1.1
2+
multi-user-scale-core==0.1.2
33
ruff==0.6.2

0 commit comments

Comments
 (0)