Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
# 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