Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d52a3bdac | ||
|
|
4ad41e9977 | ||
|
|
62bc1c150a | ||
|
|
a38b2efef5 | ||
|
|
7c1a23a8b4 | ||
|
|
76c3d91ff9 |
@@ -23,16 +23,17 @@ This integration has been developed and tested with:
|
||||
|
||||
## Features
|
||||
|
||||
This integration provides real-time monitoring of your E-Bike through Bluetooth LE connection with 11 sensors, 1 binary sensor, and 1 switch:
|
||||
This integration provides real-time monitoring of your E-Bike through Bluetooth LE connection with 14 sensors, 1 binary sensor, and 1 switch:
|
||||
|
||||
### Connection Control
|
||||
|
||||
- **Connection Switch** - Control the BLE connection to your E-Bike
|
||||
- Turning off this switch will disconnect from the bike and **the bike will shut down after approximately 5 minutes**. You must manually turn the bike back on or connect it to power to reconnect!
|
||||
- Use this switch to save energy when you don't need active monitoring
|
||||
- The bike will automatically turn off about 5 minutes after disconnection to conserve battery
|
||||
- **Auto-connect** (Switch) - Controls whether the integration may connect to your E-Bike
|
||||
- This is the connection *wish*, not the connection status. It stays on while the bike is away, so the integration reconnects on its own once the bike is switched on.
|
||||
- Turning it off disconnects from the bike and **the bike will shut down after approximately 5 minutes**. You must manually turn the bike back on or connect it to power to reconnect!
|
||||
- Use it to save energy when you don't need active monitoring
|
||||
- The position is remembered across Home Assistant restarts
|
||||
|
||||
- **Connected** (Binary Sensor) - Shows current BLE connection status to the bike
|
||||
- **Connected** (Binary Sensor) - The actual live BLE connection status
|
||||
|
||||
### Battery Sensors
|
||||
- **Battery State of Charge** (%)
|
||||
@@ -40,14 +41,19 @@ This integration provides real-time monitoring of your E-Bike through Bluetooth
|
||||
- **Battery Remaining Energy** (Wh)
|
||||
|
||||
### Motor Sensors
|
||||
- **Assist Level**
|
||||
- **Assist Level** - disabled by default
|
||||
- **Motor Temperature** (°C)
|
||||
- **Speed** (km/h)
|
||||
|
||||
### E-Bike Management (EBM)
|
||||
- **Odometer** (km)
|
||||
- **Range** (km)
|
||||
- **Trip A Distance** (km)
|
||||
- **Trip A Range** (km) - disabled by default
|
||||
- **Light Status**
|
||||
- **EBM Status**
|
||||
|
||||
Trip A values are only reported by newer X20 bikes (device names starting with `HUS`). On X25 / X35+ bikes they stay "Unknown".
|
||||
|
||||
### Diagnostics
|
||||
- **Last Seen** (Timestamp) - When the bike last sent data, so you can tell how fresh the values are
|
||||
@@ -60,9 +66,9 @@ The integration automatically retrieves and displays:
|
||||
|
||||
## Requirements
|
||||
|
||||
- Home Assistant 2024.1.6 or newer
|
||||
- Home Assistant 2024.6.0 or newer
|
||||
- Bluetooth adapter with BLE support
|
||||
- Bluetooth proxies with active connections are supported (ex. EspHome) - Shelly is not support as active direct connections are not possible.
|
||||
- Bluetooth proxies with **active connections** are supported (e.g. ESPHome). Shelly proxies are passive-only: they can see the bike but never connect to it.
|
||||
- E-Bike with Mahle SmartBike system (compatible with MySmartBike or ebikemotion app)
|
||||
|
||||
## Installation
|
||||
@@ -106,6 +112,7 @@ The integration will automatically discover iWoc and HUS devices in range via Bl
|
||||
- Make sure your E-Bike is turned on and in range
|
||||
- Check that Bluetooth is enabled on your Home Assistant host
|
||||
- Verify that the device name starts with "iWoc" or "HUS" (please report other device names)
|
||||
- Check the log. If it says the bike *is advertising but no adapter or proxy that supports active connections can reach it*, the bike is only being seen by a passive proxy (e.g. a Shelly). You need a local Bluetooth adapter or an ESPHome proxy with `active: true` in range of the bike.
|
||||
|
||||
### Connection issues
|
||||
|
||||
@@ -115,7 +122,8 @@ The integration will automatically discover iWoc and HUS devices in range via Bl
|
||||
|
||||
### Sensor values not updating
|
||||
|
||||
- The integration updates data every 30 seconds when connected
|
||||
- While connected, the bike pushes data continuously and the sensors update within a second
|
||||
- The 30-second interval is only the retry timer used while the bike is *not* connected
|
||||
- Some sensors may show "Unknown" until the bike sends that specific data
|
||||
- Check if the bike is actively transmitting data (try riding or using the display)
|
||||
|
||||
@@ -129,18 +137,22 @@ The integration keeps working when the bike is switched off or out of range:
|
||||
are **not** restored and show "Unknown" until the bike connects again — a
|
||||
stale speed reading would look like live data from a parked bike
|
||||
- Use **Connected** and **Last Seen** to tell live data from last known values
|
||||
- The connection switch keeps its position across restarts, so a bike you
|
||||
- The **Auto-connect** switch keeps its position across restarts, so a bike you
|
||||
deliberately disconnected is not woken up again by a Home Assistant restart
|
||||
|
||||
### Connection Switch
|
||||
Values are only stored from the moment the bike connects. Directly after
|
||||
installing or updating the integration, sensors read "Unknown" until the bike
|
||||
has been connected once — from then on they survive restarts.
|
||||
|
||||
- **To disconnect**: Turn off the "Connection" switch in Home Assistant
|
||||
### Auto-connect Switch
|
||||
|
||||
- **To disconnect**: Turn off the "Auto-connect" switch in Home Assistant
|
||||
- ⚠️ This will shut down your bike after approximately 5 minutes!
|
||||
- The bike will remain on for about 5 minutes before automatically powering off
|
||||
- **To reconnect**:
|
||||
1. First, manually turn on your bike OR connect it to power
|
||||
2. Wait for the bike to be fully powered on
|
||||
3. Turn on the "Connection" switch in Home Assistant
|
||||
3. Turn on the "Auto-connect" switch in Home Assistant
|
||||
- **Use case**: Turn off the connection when you don't need monitoring to save your bike's battery
|
||||
|
||||
|
||||
|
||||
@@ -53,3 +53,14 @@ VOLATILE_FIELDS: Final[dict[str, tuple[str, ...]]] = {
|
||||
"battery_secondary": ("current", "is_charging"),
|
||||
"ebm": ("status", "accel_y", "accel_z"),
|
||||
}
|
||||
|
||||
# A link that survived at least this long is worth reconnecting immediately when
|
||||
# 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,8 @@ from .const import (
|
||||
PROTOCOL_REQUEST_MESSAGE,
|
||||
CLOSE_MESSAGE,
|
||||
SCAN_INTERVAL,
|
||||
LAST_SEEN_RESOLUTION,
|
||||
MIN_LINK_SECONDS_FOR_FAST_RECONNECT,
|
||||
STORAGE_SAVE_DELAY,
|
||||
STORAGE_VERSION,
|
||||
RESTORE_STATE_KEYS,
|
||||
@@ -84,6 +86,9 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._last_seen: datetime | None = None
|
||||
self._save_armed = False
|
||||
self._unreachable_reason: str | None = None
|
||||
self._connecting = False
|
||||
self._advertisements_seen = 0
|
||||
self._connected_since: float | None = None
|
||||
self._store: Store[dict[str, Any]] = Store(
|
||||
hass, STORAGE_VERSION, storage_key(entry)
|
||||
)
|
||||
@@ -168,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 {
|
||||
@@ -213,15 +236,36 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_claim_connect(self) -> bool:
|
||||
"""Reserve the right to run one connect attempt.
|
||||
|
||||
Home Assistant invokes the bluetooth callback on *every* advertisement,
|
||||
and these bikes advertise several times a second. Without a claim, a
|
||||
disconnected bike would queue hundreds of connect tasks behind
|
||||
`_connect_lock`, each running a full `establish_connection` retry cycle
|
||||
against a proxy that is already struggling to hold the link.
|
||||
"""
|
||||
if self._is_connected or self._manual_disconnect or self._connecting:
|
||||
return False
|
||||
self._connecting = True
|
||||
return True
|
||||
|
||||
@callback
|
||||
def _async_device_appeared(
|
||||
self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange
|
||||
) -> None:
|
||||
"""Handle the bike showing up in range."""
|
||||
if self._is_connected or self._manual_disconnect:
|
||||
self._advertisements_seen += 1
|
||||
if not self._async_claim_connect():
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Advertisement from %s (%d seen) - connecting now",
|
||||
self._address,
|
||||
self._advertisements_seen,
|
||||
)
|
||||
self._entry.async_create_background_task(
|
||||
self.hass, self._async_try_connect(), f"{DOMAIN} connect {self._address}"
|
||||
self.hass, self._async_run_connect(), f"{DOMAIN} connect {self._address}"
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -239,6 +283,25 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._is_connected = False
|
||||
self.async_update_listeners()
|
||||
|
||||
# Don't sit out the poll interval. These bikes stop advertising after an
|
||||
# unexpected link loss, so the advertisement watch does not fire, and
|
||||
# every notification has just pushed the poll timer another 30s out -
|
||||
# leaving the bike disconnected far longer than necessary.
|
||||
held_for = (
|
||||
self.hass.loop.time() - self._connected_since
|
||||
if self._connected_since is not None
|
||||
else 0.0
|
||||
)
|
||||
self._connected_since = None
|
||||
if held_for < MIN_LINK_SECONDS_FOR_FAST_RECONNECT:
|
||||
# A link that died almost immediately would spin; let the poll retry.
|
||||
return
|
||||
if not self._async_claim_connect():
|
||||
return
|
||||
self._entry.async_create_background_task(
|
||||
self.hass, self._async_run_connect(), f"{DOMAIN} reconnect {self._address}"
|
||||
)
|
||||
|
||||
async def async_first_connect(self) -> None:
|
||||
"""Attempt the initial connection without blocking setup."""
|
||||
if self._manual_disconnect:
|
||||
@@ -250,6 +313,12 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
await self._async_try_connect()
|
||||
|
||||
async def _async_try_connect(self) -> None:
|
||||
"""Run a connect attempt unless one is already in flight."""
|
||||
if not self._async_claim_connect():
|
||||
return
|
||||
await self._async_run_connect()
|
||||
|
||||
async def _async_run_connect(self) -> None:
|
||||
"""Connect, reporting the expected 'bike is off' failures once each."""
|
||||
try:
|
||||
await self._connect()
|
||||
@@ -257,6 +326,8 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._async_report_unreachable(str(ex))
|
||||
except Exception as ex: # noqa: BLE001
|
||||
self._async_report_unreachable(f"Connection attempt failed: {ex}")
|
||||
finally:
|
||||
self._connecting = False
|
||||
|
||||
def _async_report_unreachable(self, reason: str) -> None:
|
||||
"""Log why we cannot connect - once per distinct reason.
|
||||
@@ -375,7 +446,14 @@ 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,
|
||||
self._is_connected,
|
||||
self._advertisements_seen,
|
||||
state["rssi"],
|
||||
)
|
||||
return state
|
||||
|
||||
async def _connect(self) -> None:
|
||||
@@ -418,6 +496,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return
|
||||
|
||||
self._is_connected = True
|
||||
self._connected_since = self.hass.loop.time()
|
||||
self._unreachable_reason = None
|
||||
_LOGGER.debug("Connected to %s", self._address)
|
||||
|
||||
@@ -449,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",
|
||||
|
||||
@@ -269,7 +269,12 @@ async def test_reconnects_when_bike_appears(
|
||||
"""An advertisement triggers a connect instead of waiting for the poll."""
|
||||
await setup_offline(hass, restore_config_entry)
|
||||
coordinator = restore_config_entry.runtime_data
|
||||
coordinator._is_connected = False
|
||||
await wait_connected(hass, coordinator)
|
||||
|
||||
# Drop the link the way bleak reports one
|
||||
coordinator._async_client_disconnected(coordinator._client)
|
||||
await hass.async_block_till_done()
|
||||
assert coordinator.is_connected is False
|
||||
|
||||
with patch.object(
|
||||
coordinator, "_connect", new_callable=AsyncMock
|
||||
@@ -446,3 +451,154 @@ async def test_unreachable_warning_is_not_repeated(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert "turn on the bike" not in caplog.text
|
||||
|
||||
|
||||
async def test_advertisement_storm_starts_one_connect_attempt(
|
||||
hass: HomeAssistant,
|
||||
restore_config_entry: MockConfigEntry,
|
||||
mock_bleak_client,
|
||||
mock_device_in_range,
|
||||
mock_bluetooth_service_info,
|
||||
) -> None:
|
||||
"""Home Assistant fires the callback on every advertisement.
|
||||
|
||||
These bikes advertise several times a second, so an unguarded callback
|
||||
would queue a connect task per advertisement, all serialising behind
|
||||
_connect_lock and each hammering the proxy with a full retry cycle.
|
||||
"""
|
||||
await setup_offline(hass, restore_config_entry)
|
||||
coordinator = restore_config_entry.runtime_data
|
||||
await wait_connected(hass, coordinator)
|
||||
coordinator._async_client_disconnected(coordinator._client)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
started = 0
|
||||
release = asyncio.Event()
|
||||
|
||||
async def slow_connect() -> None:
|
||||
nonlocal started
|
||||
started += 1
|
||||
await release.wait()
|
||||
|
||||
with patch.object(coordinator, "_connect", side_effect=slow_connect):
|
||||
for _ in range(50):
|
||||
coordinator._async_device_appeared(mock_bluetooth_service_info, None)
|
||||
await asyncio.sleep(0)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert started == 1, f"{started} connect attempts for 50 advertisements"
|
||||
|
||||
release.set()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# ...and the claim is released, so a later advertisement can still connect
|
||||
assert coordinator._connecting is False
|
||||
|
||||
|
||||
async def test_fast_reconnect_after_established_link_drops(
|
||||
hass: HomeAssistant,
|
||||
restore_config_entry: MockConfigEntry,
|
||||
mock_bleak_client,
|
||||
mock_device_in_range,
|
||||
) -> None:
|
||||
"""A dropped link reconnects at once instead of waiting out the poll.
|
||||
|
||||
These bikes stop advertising after an unexpected disconnect, so the
|
||||
advertisement watch never fires, and every notification has just pushed the
|
||||
poll timer another SCAN_INTERVAL out.
|
||||
"""
|
||||
await setup_offline(hass, restore_config_entry)
|
||||
coordinator = restore_config_entry.runtime_data
|
||||
await wait_connected(hass, coordinator)
|
||||
|
||||
# Pretend the link had been up comfortably longer than the spin guard
|
||||
coordinator._connected_since = hass.loop.time() - 120
|
||||
|
||||
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
|
||||
coordinator._async_client_disconnected(coordinator._client)
|
||||
await hass.async_block_till_done()
|
||||
mock_connect.assert_called_once()
|
||||
|
||||
|
||||
async def test_no_fast_reconnect_when_link_died_immediately(
|
||||
hass: HomeAssistant,
|
||||
restore_config_entry: MockConfigEntry,
|
||||
mock_bleak_client,
|
||||
mock_device_in_range,
|
||||
) -> None:
|
||||
"""A link that collapses at once must not spin; the poll retries instead."""
|
||||
await setup_offline(hass, restore_config_entry)
|
||||
coordinator = restore_config_entry.runtime_data
|
||||
await wait_connected(hass, coordinator)
|
||||
|
||||
coordinator._connected_since = hass.loop.time() # just connected
|
||||
|
||||
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
|
||||
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