diff --git a/custom_components/mysmartbike_ble/const.py b/custom_components/mysmartbike_ble/const.py index e2fa638..24348fa 100644 --- a/custom_components/mysmartbike_ble/const.py +++ b/custom_components/mysmartbike_ble/const.py @@ -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 diff --git a/custom_components/mysmartbike_ble/coordinator.py b/custom_components/mysmartbike_ble/coordinator.py index 6b0d0e7..84ca401 100644 --- a/custom_components/mysmartbike_ble/coordinator.py +++ b/custom_components/mysmartbike_ble/coordinator.py @@ -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 diff --git a/custom_components/mysmartbike_ble/sensor.py b/custom_components/mysmartbike_ble/sensor.py index 076f2d9..62b1b38 100644 --- a/custom_components/mysmartbike_ble/sensor.py +++ b/custom_components/mysmartbike_ble/sensor.py @@ -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", diff --git a/tests/components/mysmartbike_ble/test_restore.py b/tests/components/mysmartbike_ble/test_restore.py index 106ae50..60a09ac 100644 --- a/tests/components/mysmartbike_ble/test_restore.py +++ b/tests/components/mysmartbike_ble/test_restore.py @@ -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"