Merge pull request #7 from ReneNulschDE/fix/reduce-recorder-load

Stop flooding the recorder with timestamp and false-precision noise
This commit is contained in:
Rene Nulsch
2026-08-28 08:42:51 +02:00
committed by GitHub
4 changed files with 107 additions and 6 deletions
@@ -58,3 +58,9 @@ VOLATILE_FIELDS: Final[dict[str, tuple[str, ...]]] = {
# it drops; anything shorter is left to the regular poll so a bike that cannot
# hold a connection does not spin in a reconnect loop.
MIN_LINK_SECONDS_FOR_FAST_RECONNECT: Final = 5.0
# Granularity of the "Last Seen" entity. Notifications arrive about once a
# second; publishing each one made this timestamp the single biggest recorder
# writer in a real installation. Its job - telling you how fresh the values are
# - needs nothing near that resolution. The precise value is still persisted.
LAST_SEEN_RESOLUTION: Final = 30 # seconds
@@ -35,6 +35,7 @@ from .const import (
PROTOCOL_REQUEST_MESSAGE,
CLOSE_MESSAGE,
SCAN_INTERVAL,
LAST_SEEN_RESOLUTION,
MIN_LINK_SECONDS_FOR_FAST_RECONNECT,
STORAGE_SAVE_DELAY,
STORAGE_VERSION,
@@ -172,6 +173,24 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"disabled" if self._manual_disconnect else "enabled",
)
@callback
def _publish_last_seen(self) -> None:
"""Move the entity-visible timestamp forward at most every 30 seconds.
`self._last_seen` stays exact - it is what gets persisted - but writing
it to the entity on every notification produced roughly 2800 database
rows per hour per bike, more than any other entity in a real install.
"""
if self._last_seen is None:
return
published = self._parser.state.get("last_seen")
if (
published is not None
and (self._last_seen - published).total_seconds() < LAST_SEEN_RESOLUTION
):
return
self._parser.state["last_seen"] = self._last_seen
def _persist_data(self) -> dict[str, Any]:
"""Build the payload written to .storage."""
return {
@@ -427,7 +446,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception:
state["rssi"] = None
state["last_seen"] = self._last_seen
self._publish_last_seen()
_LOGGER.debug(
"%s: connected=%s advertisements_seen=%s rssi=%s",
self._address,
@@ -509,7 +528,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._parser.handle_message(bytes(data))
self._last_seen = dt_util.utcnow()
self._parser.state["last_seen"] = self._last_seen
self._publish_last_seen()
self._schedule_save()
# Update coordinator data
+15 -4
View File
@@ -28,6 +28,17 @@ from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
from .coordinator import MySmartBikeCoordinator
def round_km(value: Any) -> float | None:
"""Round a distance to 0.1 km.
The ebikemotion frames carry these as `read32 / 10000`, which yields four
decimals of false precision - 14.6065, 14.6529, 14.6297 for a range that is
really "14.6". Every flicker in the last digit would be another state write
and another database row.
"""
return None if value is None else round(float(value), 1)
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
"""Safely get nested dictionary values."""
if data is None:
@@ -108,7 +119,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:counter",
value_fn=lambda data: safe_get(data, "ebm", "odometry"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "odometry")),
),
MySmartBikeSensorEntityDescription(
key="range",
@@ -117,7 +128,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
value_fn=lambda data: safe_get(data, "ebm", "autonomy"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "autonomy")),
),
MySmartBikeSensorEntityDescription(
key="trip_distance",
@@ -126,7 +137,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:bike",
value_fn=lambda data: safe_get(data, "ebm", "trip_odometry"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_odometry")),
),
MySmartBikeSensorEntityDescription(
key="trip_range",
@@ -136,7 +147,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
entity_registry_enabled_default=False,
value_fn=lambda data: safe_get(data, "ebm", "trip_autonomy"),
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_autonomy")),
),
MySmartBikeSensorEntityDescription(
key="light",
@@ -537,3 +537,68 @@ async def test_no_fast_reconnect_when_link_died_immediately(
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
mock_connect.assert_not_called()
async def test_last_seen_is_throttled(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
freezer,
) -> None:
"""A timestamp that moves on every packet floods the recorder.
Notifications arrive about once a second; the entity only needs to say how
fresh the values are, so it moves at LAST_SEEN_RESOLUTION granularity while
the exact time is still what gets persisted.
"""
from custom_components.mysmartbike_ble.const import LAST_SEEN_RESOLUTION
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
frame = bytearray.fromhex("246a245a2300008402e1000001f50148494a2340")
coordinator._notification_handler(0, frame)
first = coordinator.data["last_seen"]
assert first is not None
# A burst of notifications inside the window must not move the entity
for _ in range(10):
freezer.tick(timedelta(seconds=1))
coordinator._notification_handler(0, frame)
assert coordinator.data["last_seen"] == first
# ...while the persisted timestamp keeps tracking reality
assert coordinator.last_seen > first
# Past the window it moves again
freezer.tick(timedelta(seconds=LAST_SEEN_RESOLUTION))
coordinator._notification_handler(0, frame)
assert coordinator.data["last_seen"] > first
async def test_distances_are_rounded_to_100m(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""Four decimals of false precision would be a database row each."""
seed_storage(
hass_storage,
{
**STORED_STATE,
"ebm": {
**STORED_STATE["ebm"],
"odometry": 806.1488,
"autonomy": 14.6065,
"trip_odometry": 12.3456,
"trip_autonomy": 58.9876,
},
},
)
await setup_offline(hass, restore_config_entry)
assert hass.states.get(entity_id_for(hass, "_odometer")).state == "806.1"
assert hass.states.get(entity_id_for(hass, "_range")).state == "14.6"
assert hass.states.get(entity_id_for(hass, "_trip_distance")).state == "12.3"