Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d52a3bdac | ||
|
|
4ad41e9977 | ||
|
|
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
|
||||
# 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
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
"bleak>=0.21.0",
|
||||
"bleak-retry-connector>=3.1.0"
|
||||
],
|
||||
"version": "0.0.2"
|
||||
"version": "1.1.0"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user