Stop flooding the recorder with timestamp and false-precision noise
Measured on a real installation with two bikes: the two "Last Seen" sensors were the number one and number two writers in the entire Home Assistant database, ahead of a three-phase energy meter. The integration accounted for 26.5% of all recorder writes on that system. Last Seen moved on every BLE notification, about once a second, or 2834 rows an hour per bike. The entity exists to say how fresh the values are, which needs nothing near that resolution, so it now advances at LAST_SEEN_RESOLUTION granularity. `self._last_seen` stays exact - that is what gets persisted and restored - only the entity is throttled. Measured after: 120 rows an hour, the theoretical maximum for a 30s window. The distance sensors carried four decimals of false precision, because the ebikemotion frames divide by 10000: a range that is really "14.6" was reported as 14.6065, then 14.6529, then 14.6297, each one another state write and another database row. They are rounded to 0.1 km now. Home Assistant offers integrations no way to exclude an entity from the recorder - `entity_filter` comes from user configuration only, and `_unrecorded_attributes` covers attributes rather than states - so reducing how often the state changes is the only fix that works without asking every user to edit configuration.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
This commit is contained in:
co-authored by
Claude Opus 5
parent
a38b2efef5
commit
62bc1c150a
@@ -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
|
# 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.
|
# hold a connection does not spin in a reconnect loop.
|
||||||
MIN_LINK_SECONDS_FOR_FAST_RECONNECT: Final = 5.0
|
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,
|
PROTOCOL_REQUEST_MESSAGE,
|
||||||
CLOSE_MESSAGE,
|
CLOSE_MESSAGE,
|
||||||
SCAN_INTERVAL,
|
SCAN_INTERVAL,
|
||||||
|
LAST_SEEN_RESOLUTION,
|
||||||
MIN_LINK_SECONDS_FOR_FAST_RECONNECT,
|
MIN_LINK_SECONDS_FOR_FAST_RECONNECT,
|
||||||
STORAGE_SAVE_DELAY,
|
STORAGE_SAVE_DELAY,
|
||||||
STORAGE_VERSION,
|
STORAGE_VERSION,
|
||||||
@@ -172,6 +173,24 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"disabled" if self._manual_disconnect else "enabled",
|
"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]:
|
def _persist_data(self) -> dict[str, Any]:
|
||||||
"""Build the payload written to .storage."""
|
"""Build the payload written to .storage."""
|
||||||
return {
|
return {
|
||||||
@@ -427,7 +446,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
except Exception:
|
except Exception:
|
||||||
state["rssi"] = None
|
state["rssi"] = None
|
||||||
|
|
||||||
state["last_seen"] = self._last_seen
|
self._publish_last_seen()
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s: connected=%s advertisements_seen=%s rssi=%s",
|
"%s: connected=%s advertisements_seen=%s rssi=%s",
|
||||||
self._address,
|
self._address,
|
||||||
@@ -509,7 +528,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._parser.handle_message(bytes(data))
|
self._parser.handle_message(bytes(data))
|
||||||
|
|
||||||
self._last_seen = dt_util.utcnow()
|
self._last_seen = dt_util.utcnow()
|
||||||
self._parser.state["last_seen"] = self._last_seen
|
self._publish_last_seen()
|
||||||
self._schedule_save()
|
self._schedule_save()
|
||||||
|
|
||||||
# Update coordinator data
|
# Update coordinator data
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
|
|||||||
from .coordinator import MySmartBikeCoordinator
|
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:
|
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
|
||||||
"""Safely get nested dictionary values."""
|
"""Safely get nested dictionary values."""
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -108,7 +119,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
|||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
icon="mdi:counter",
|
icon="mdi:counter",
|
||||||
value_fn=lambda data: safe_get(data, "ebm", "odometry"),
|
value_fn=lambda data: round_km(safe_get(data, "ebm", "odometry")),
|
||||||
),
|
),
|
||||||
MySmartBikeSensorEntityDescription(
|
MySmartBikeSensorEntityDescription(
|
||||||
key="range",
|
key="range",
|
||||||
@@ -117,7 +128,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
|||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
icon="mdi:map-marker-distance",
|
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(
|
MySmartBikeSensorEntityDescription(
|
||||||
key="trip_distance",
|
key="trip_distance",
|
||||||
@@ -126,7 +137,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
|||||||
device_class=SensorDeviceClass.DISTANCE,
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
icon="mdi:bike",
|
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(
|
MySmartBikeSensorEntityDescription(
|
||||||
key="trip_range",
|
key="trip_range",
|
||||||
@@ -136,7 +147,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
|||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
icon="mdi:map-marker-distance",
|
icon="mdi:map-marker-distance",
|
||||||
entity_registry_enabled_default=False,
|
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(
|
MySmartBikeSensorEntityDescription(
|
||||||
key="light",
|
key="light",
|
||||||
|
|||||||
@@ -537,3 +537,68 @@ async def test_no_fast_reconnect_when_link_died_immediately(
|
|||||||
coordinator._async_client_disconnected(coordinator._client)
|
coordinator._async_client_disconnected(coordinator._client)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
mock_connect.assert_not_called()
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user