Keep showing last known values when the bike is out of range

Setup no longer depends on the bike being reachable. The coordinator is
built from an address and resolves the BLEDevice per connect attempt, so
a parked or switched-off bike loads the entry instead of raising
ConfigEntryNotReady and leaving every entity unavailable.

Parser state is persisted through helpers.storage.Store and restored
before the platforms are set up, so entities carry their last values and
the VIN on their first state write. What survives is a whitelist:
battery and EBM counters yes, motor and assist no - a restored speed
reading is indistinguishable from live data on a parked bike. The
connection switch position is restored too, so a restart no longer wakes
a bike the user deliberately disconnected.

Also fixes three defects found while building this:

- Store.async_delay_save debounces rather than throttles, so re-arming on
  every notification postponed the write for as long as the bike stayed
  connected and nothing reached disk except on a clean shutdown.
- establish_connection had no disconnected_callback, so a dropped link
  left _is_connected True: the connectivity sensor lied and the poll
  never retried.
- _connect read self._client back across the 200ms handshake, which a
  concurrent teardown could clear underneath it.

Connecting now starts on the bike's advertisement instead of the next
poll tick, and an unreachable bike says why - distinguishing "switched
off" from "seen only by a passive proxy that cannot connect".

Renames the connection switch to Auto-connect and drops the hardcoded
English name on the connectivity sensor, which had been defeating its
translation key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
This commit is contained in:
Rene Nulsch
2026-08-27 22:45:21 +02:00
co-authored by Claude Opus 5
parent c539823c1a
commit ac0baffe74
12 changed files with 1050 additions and 99 deletions
+70 -6
View File
@@ -75,21 +75,85 @@ def mock_ble_device() -> Generator[MagicMock]:
yield mock_devices
@pytest.fixture(autouse=True)
def mock_bluetooth_helpers() -> Generator[None]:
"""Stub the bluetooth helpers the coordinator calls into.
Keeps the tests independent of a real bluetooth integration setup; the
device-present/absent case is driven by `mock_device_in_range`.
"""
with (
patch(
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_register_callback",
return_value=lambda: None,
),
patch(
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_last_service_info",
return_value=_get_bluetooth_service_info(),
),
):
yield
@pytest.fixture
def mock_device_in_range() -> Generator[MagicMock]:
"""Report the bike as reachable. Set `return_value = None` to take it away."""
with (
patch(
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_ble_device_from_address",
return_value=_get_bluetooth_service_info(),
) as mock_resolve,
patch(
"custom_components.mysmartbike_ble.bluetooth.async_ble_device_from_address",
return_value=_get_bluetooth_service_info(),
),
):
yield mock_resolve
@pytest.fixture
def mock_device_out_of_range() -> Generator[MagicMock]:
"""Report the bike as out of range / switched off."""
with (
patch(
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_ble_device_from_address",
return_value=None,
) as mock_resolve,
patch(
"custom_components.mysmartbike_ble.bluetooth.async_ble_device_from_address",
return_value=None,
),
):
yield mock_resolve
@pytest.fixture
async def init_integration(
hass,
mock_config_entry: MockConfigEntry,
mock_bleak_client: MagicMock,
mock_device_in_range: MagicMock,
) -> MockConfigEntry:
"""Set up the MySmartBike BLE integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.bluetooth.async_ble_device_from_address"
) as mock_ble_device_from_address:
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@pytest.fixture
async def init_integration_offline(
hass,
mock_config_entry: MockConfigEntry,
mock_bleak_client: MagicMock,
mock_device_out_of_range: MagicMock,
) -> MockConfigEntry:
"""Set up the integration while the bike is out of range."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@@ -0,0 +1,448 @@
"""Test that the integration survives a restart with the bike out of range."""
import asyncio
from datetime import timedelta
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.mysmartbike_ble.const import (
CONF_DEVICE_ADDRESS,
CONF_DEVICE_NAME,
DOMAIN,
STORAGE_VERSION,
)
ENTRY_ID = "restoretestentry"
STORE_KEY = f"{DOMAIN}.{ENTRY_ID}"
STORED_STATE = {
"battery_primary": {
"voltage": 36.4,
"soc": 73,
"temperature": 21,
"temperature_mos": 24,
"current": -4.2,
"nominal_capacity": 248.0,
"remaining_wh": 181.0,
"cycles": 42,
"is_charging": True,
},
"battery_secondary": None,
"ebm": {
"odometry": 1234.5,
"autonomy": 61.0,
"trip_odometry": 12.3,
"trip_autonomy": 58.0,
"is_light_on": True,
"status": 3,
"accel_y": -5,
"accel_z": 61,
},
"vin": "WBS0000000RESTORE",
"protocol_version": "102",
"manual_disconnect": False,
"last_seen": "2026-08-26T18:30:00+00:00",
}
@pytest.fixture
def restore_config_entry() -> MockConfigEntry:
"""Config entry with a fixed entry_id so the storage key is predictable."""
return MockConfigEntry(
domain=DOMAIN,
title="iWoc1A36",
entry_id=ENTRY_ID,
data={
CONF_DEVICE_NAME: "iWoc1A36",
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
},
unique_id="AA:BB:CC:DD:EE:FF",
)
def seed_storage(hass_storage, data: dict) -> None:
"""Pre-populate .storage as if a previous run had written it."""
hass_storage[STORE_KEY] = {
"version": STORAGE_VERSION,
"minor_version": 1,
"key": STORE_KEY,
"data": data,
}
def entity_id_for(hass: HomeAssistant, suffix: str) -> str:
"""Look up an entity id by the tail of its unique id."""
registry = er.async_get(hass)
for entity in registry.entities.values():
if entity.unique_id.endswith(suffix):
return entity.entity_id
raise ValueError(f"No entity with unique_id ending in {suffix}")
async def setup_offline(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Set up the entry with the bike unreachable."""
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def test_setup_succeeds_without_device(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""The entry loads even when the bike is switched off or out of range."""
await setup_offline(hass, restore_config_entry)
assert restore_config_entry.state is ConfigEntryState.LOADED
# Entities exist rather than the whole entry being retried
assert hass.states.get(entity_id_for(hass, "_odometer")) is not None
async def test_restored_values_shown_while_offline(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""Persisted counters and battery values come back without a connection."""
seed_storage(hass_storage, STORED_STATE)
await setup_offline(hass, restore_config_entry)
assert hass.states.get(entity_id_for(hass, "_odometer")).state == "1234.5"
assert hass.states.get(entity_id_for(hass, "_trip_distance")).state == "12.3"
assert hass.states.get(entity_id_for(hass, "_range")).state == "61.0"
assert hass.states.get(entity_id_for(hass, "_battery_primary_soc")).state == "73"
assert (
hass.states.get(entity_id_for(hass, "_battery_primary_remaining_wh")).state
== "181.0"
)
assert hass.states.get(entity_id_for(hass, "_light")).state == "On"
async def test_restored_sensors_are_available_not_unavailable(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""An unreachable bike must not blank the sensors."""
seed_storage(hass_storage, STORED_STATE)
await setup_offline(hass, restore_config_entry)
for suffix in ("_odometer", "_battery_primary_soc", "_motor_speed"):
assert hass.states.get(entity_id_for(hass, suffix)).state != STATE_UNAVAILABLE
# ...but the connectivity sensor honestly reports "not connected"
assert hass.states.get(entity_id_for(hass, "_connected")).state == STATE_OFF
async def test_volatile_values_are_not_restored(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""Momentary readings would look like live data from a parked bike."""
seed_storage(hass_storage, STORED_STATE)
await setup_offline(hass, restore_config_entry)
# "motor" is not in RESTORE_STATE_KEYS at all
assert hass.states.get(entity_id_for(hass, "_motor_speed")).state == STATE_UNKNOWN
assert (
hass.states.get(entity_id_for(hass, "_motor_temperature")).state
== STATE_UNKNOWN
)
# ebm.status is restored as None
assert hass.states.get(entity_id_for(hass, "_ebm_status")).state == STATE_UNKNOWN
# battery current / charging flag are dropped from the restored dict
coordinator = restore_config_entry.runtime_data
assert coordinator.data["battery_primary"]["current"] is None
assert coordinator.data["battery_primary"]["is_charging"] is None
assert coordinator.data["battery_primary"]["soc"] == 73
async def test_restored_vin_populates_device_info(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""Serial number and protocol version survive a restart."""
seed_storage(hass_storage, STORED_STATE)
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
assert coordinator.vin == "WBS0000000RESTORE"
assert coordinator.protocol_version == "102"
from homeassistant.helpers import device_registry as dr
device = dr.async_get(hass).async_get_device(identifiers={(DOMAIN, ENTRY_ID)})
assert device is not None
assert device.serial_number == "WBS0000000RESTORE"
assert device.sw_version == "102"
async def test_manual_disconnect_survives_restart(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
) -> None:
"""A bike the user disconnected must not be woken by a restart."""
seed_storage(hass_storage, {**STORED_STATE, "manual_disconnect": True})
restore_config_entry.add_to_hass(hass)
with patch(
"custom_components.mysmartbike_ble.coordinator.MySmartBikeCoordinator._connect",
new_callable=AsyncMock,
) as mock_connect:
await hass.config_entries.async_setup(restore_config_entry.entry_id)
await hass.async_block_till_done()
mock_connect.assert_not_called()
assert restore_config_entry.runtime_data._manual_disconnect is True
assert hass.states.get(entity_id_for(hass, "_connection")).state == STATE_OFF
async def test_connection_switch_on_by_default(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""Without a stored preference the connection stays enabled."""
await setup_offline(hass, restore_config_entry)
assert hass.states.get(entity_id_for(hass, "_connection")).state == STATE_ON
async def test_state_is_persisted_on_unload(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
) -> None:
"""Parsed data reaches .storage, volatile keys excluded."""
restore_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(restore_config_entry.entry_id)
await hass.async_block_till_done()
coordinator = restore_config_entry.runtime_data
# A real 20-byte X20 EBM lifetime frame
coordinator._notification_handler(
0, bytearray.fromhex("246a245a2300008402e1000001f50148494a2340")
)
await hass.async_block_till_done()
await hass.config_entries.async_unload(restore_config_entry.entry_id)
await hass.async_block_till_done()
stored = hass_storage[STORE_KEY]["data"]
assert stored["ebm"]["odometry"] == pytest.approx(13.2, rel=1e-3)
assert stored["last_seen"] is not None
assert "motor" not in stored
assert "rssi" not in stored
async def test_reconnects_when_bike_appears(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
mock_bluetooth_service_info,
) -> None:
"""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
with patch.object(
coordinator, "_connect", new_callable=AsyncMock
) as mock_connect:
coordinator._async_device_appeared(mock_bluetooth_service_info, None)
await hass.async_block_till_done()
mock_connect.assert_called_once()
async def test_no_reconnect_on_advertisement_when_disconnected_manually(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
mock_bluetooth_service_info,
) -> None:
"""The advertisement watch must respect the user's disconnect."""
seed_storage(hass_storage, {**STORED_STATE, "manual_disconnect": True})
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
with patch.object(
coordinator, "_connect", new_callable=AsyncMock
) as mock_connect:
coordinator._async_device_appeared(mock_bluetooth_service_info, None)
await hass.async_block_till_done()
mock_connect.assert_not_called()
async def wait_connected(hass: HomeAssistant, coordinator) -> None:
"""Wait out the 200 ms VIN/protocol handshake in _connect()."""
for _ in range(20):
await asyncio.sleep(0.05)
await hass.async_block_till_done()
if coordinator.is_connected:
return
raise AssertionError("coordinator never reported a connection")
async def test_unexpected_disconnect_is_noticed(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
) -> None:
"""A dropped link flips the connectivity sensor and re-enables reconnect."""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
await wait_connected(hass, coordinator)
assert hass.states.get(entity_id_for(hass, "_connected")).state == STATE_ON
# bleak invokes the disconnected_callback with the client it handed out
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
assert coordinator.is_connected is False
assert hass.states.get(entity_id_for(hass, "_connected")).state == STATE_OFF
# Restored/last-known values stay visible
assert (
hass.states.get(entity_id_for(hass, "_odometer")).state != STATE_UNAVAILABLE
)
async def test_writes_are_throttled_not_debounced(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
freezer,
) -> None:
"""A continuous notification stream must not postpone the write forever.
`Store.async_delay_save` debounces: re-arming on every notification would
keep pushing the write out for as long as the bike stays connected.
"""
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import async_fire_time_changed
from custom_components.mysmartbike_ble.const import STORAGE_SAVE_DELAY
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
frame = bytearray.fromhex("246a245a2300008402e1000001f50148494a2340")
# Keep notifying across more than one save window, as a connected bike does
for _ in range(4):
coordinator._notification_handler(0, frame)
freezer.tick(timedelta(seconds=STORAGE_SAVE_DELAY // 2))
async_fire_time_changed(hass, dt_util.utcnow())
await hass.async_block_till_done()
# Written without ever unloading or shutting Home Assistant down
assert STORE_KEY in hass_storage
assert hass_storage[STORE_KEY]["data"]["ebm"]["odometry"] == pytest.approx(13.2)
async def test_entity_names_come_from_translations(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""The switch is the connection *wish*, the binary sensor the status.
Both used to read "Connection"/"Connected" side by side, and the binary
sensor hardcoded its English name, defeating its translation key.
"""
await setup_offline(hass, restore_config_entry)
switch = hass.states.get(entity_id_for(hass, "_connection"))
connected = hass.states.get(entity_id_for(hass, "_connected"))
assert switch.attributes["friendly_name"] == "iWoc1A36 Auto-connect"
assert connected.attributes["friendly_name"] == "iWoc1A36 Connected"
async def test_unreachable_reason_distinguishes_passive_only_proxy(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
caplog,
) -> None:
"""A bike seen only by a passive proxy must not read as "switched off"."""
with patch(
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_address_present",
return_value=True,
):
await setup_offline(hass, restore_config_entry)
assert "no Bluetooth adapter or proxy that supports active connections" in caplog.text
assert "turn on the bike" not in caplog.text
async def test_unreachable_reason_when_bike_is_off(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
caplog,
) -> None:
"""Nothing advertising at all still reads as "turn on the bike"."""
with patch(
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_address_present",
return_value=False,
):
await setup_offline(hass, restore_config_entry)
assert "turn on the bike" in caplog.text
async def test_unreachable_warning_is_not_repeated(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
caplog,
) -> None:
"""The 30s poll must not spam a warning for a parked bike."""
import logging
with patch(
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_address_present",
return_value=False,
):
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
caplog.clear()
with caplog.at_level(logging.WARNING):
for _ in range(3):
await coordinator.async_refresh()
await hass.async_block_till_done()
assert "turn on the bike" not in caplog.text