diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c9115d5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Home Assistant custom integration for E-Bikes using the Mahle SmartBike BLE protocol (X25, X35+, ebikemotion — Schindelhauer, Orbea, Bianchi, Pinarello, Scott, etc.). Distributed via HACS. Device names start with `iWoc*` or `HUS*`. Domain: `mysmartbike_ble`. + +## Commands + +Tests use `pytest_homeassistant_custom_component`, which requires a real Home Assistant install: + +```bash +pip install -r requirements_test.txt +pytest # all tests +pytest tests/components/mysmartbike_ble/test_parsers.py # one file +pytest tests/components/mysmartbike_ble/test_parsers.py::test_parse_battery_message_primary # one test +pytest --cov=custom_components.mysmartbike_ble # coverage +``` + +There is no lint/build step. CI runs HACS validation and `hassfest` (`.github/workflows/`). + +`manifest.json` `version` is rewritten at release time from the git tag by `.github/workflows/publish.yaml`, which then zips `custom_components/mysmartbike_ble/` and attaches it to the GitHub release. Don't bump the version by hand for releases — create a tag. + +## Architecture + +Single-device integration. One config entry == one bike. The `DataUpdateCoordinator` owns the BLE client and a stateful parser; entities are thin views over `coordinator.data`. + +**Layers:** +- `__init__.py` — `async_setup_entry` **never** depends on the bike being reachable. It constructs the coordinator from the address alone, `await`s `coordinator.async_restore()` *before* forwarding the platforms (so entities' first state write already carries restored values and the VIN), registers the advertisement watch via `entry.async_on_unload`, and kicks off the first connect as a background task. There is no `ConfigEntryNotReady` and no `hass.data[DOMAIN][entry_id]`. `async_remove_entry` deletes the `Store`. +- `coordinator.py` — `MySmartBikeCoordinator` is built from an *address*, not a `BLEDevice`; `_resolve_device()` re-resolves per connect attempt so a stale device object from an old adapter/proxy can't linger. It polls every `SCAN_INTERVAL` (30s), auto-reconnecting unless `_manual_disconnect` is set, and returns the parser's accumulated `state` plus a fresh `rssi` and `last_seen`. `_async_update_data` deliberately never raises: an unreachable bike must not flip `last_update_success`, which would mark every entity `unavailable` and throw the restored state away. Real data flow is push-based: `_notification_handler` runs on every BLE notification, parses, stamps `last_seen`, queues a debounced save, and calls `async_set_updated_data` to wake entities immediately. `_connect` is serialised by `_connect_lock` because both the poll tick and the Bluetooth callback can enter it, and it holds the client in a *local* through the 200ms VIN/protocol handshake — a concurrent teardown clearing `self._client` mid-handshake would otherwise crash it. `establish_connection` gets a `disconnected_callback` so a dropped link flips `_is_connected` immediately; without it the coordinator would believe it was still connected and never retry. +- `parsers.py` — `BikeDataParser` is a pure, stateful message decoder. It dispatches via `recognize_message_type` (sniffs the `$X$Y#...#@` framing) and accumulates into `state` (`battery_primary`, `battery_secondary`, `motor`, `assist`, `ebm`) plus `vin` / `protocol_version`. All multibyte reads are big-endian. Secondary-battery presence is inferred: a `battery_number == 2` packet sets it; 4 consecutive primary packets clear it. +- `config_flow.py` — Two entry paths: BLE auto-discovery (`async_step_bluetooth`) and manual user step that filters `async_discovered_service_info` for names starting with `iWoc` or `HUS`. Unique ID is the BLE address. Options flow exposes `CONF_LOG_BLE_MESSAGES` only. +- `binary_sensor.py` / `sensor.py` / `switch.py` — `CoordinatorEntity` subclasses. Sensors use a `MySmartBikeSensorEntityDescription` dataclass with a `value_fn` lambda that pulls from `coordinator.data` via the `safe_get` helper (data dicts can be `None` before first packet). All entities share one device (identified by `(DOMAIN, entry.entry_id)`); `serial_number` and `sw_version` come from the parser's `vin` / `protocol_version`, which `async_restore()` has already populated by construction time. Every entity overrides `available` to `True` — the values are last-known-good, and liveness is reported by `binary_sensor.connected` and the `last_seen` timestamp sensor instead. + +**BLE protocol specifics (in `const.py`):** +- Write characteristic `0000FFE2-…`, notify characteristic `0000FFD1-…`. +- ASCII command framing: `$S$V#@` (request VIN), `$S$P#@` (request protocol), `$D$I#@` (close/disconnect). On connect, the coordinator sends VIN then protocol requests with a 200ms gap. +- `bleak_retry_connector.establish_connection` is used instead of raw `BleakClient` — it handles slot contention with proxies (EsphomeBT). Shelly proxies are unsupported (no active connections). + +### Frame layouts — two variants per message type + +Each broadcast message comes in two on-the-wire shapes; the parser dispatches by length. Older "ebikemotion" devices (X25 / X35+, names start with `iWoc*`) use the shorter frames; newer X20 devices (names start with `HUS*`, protocol version `102`+) use the 20-byte frames. All multi-byte fields are big-endian. + +**Battery — `$b$Z#…#@`** (19 vs 20 bytes) + +| Offset | ebikemotion (19 B) | X20 (20 B) | +|---|---|---| +| 5-6 | voltage `read16 / 10` | voltage `read16 / 100` | +| 7 | SOC unsigned byte | SOC: bit 7 = `is_charging`, bits 0-6 = SOC % | +| 8 | temperature byte | temperature **signed** byte | +| 9-10 | current `read16 / 10` | current **signed** `read16 / 10` | +| 11-12 | nominal_capacity `read16 / 10` | nominal_capacity `read16 / 10` | +| 13-14 | remaining_wh `read16 / 10` | remaining_wh `read16 / 10` | +| 15 | combined cycles MSB | **MOSFET temperature** signed byte | +| 16 (-17) | combined LSB → `(batt# * 10000) + cycles` at offset 15-16 | combined `(batt# * 10000) + cycles` at offset 16-17 | + +The X20 parser's primary slot is also written when `battery_number == 0`, otherwise single-battery bikes whose firmware doesn't fill that field would have all sensors stuck on `unavailable`. + +**Motor — `$m$Z#…#@`** (18 vs 20 bytes) + +| Offset | ebikemotion (18 B) | X20 (20 B) | +|---|---|---| +| 5 | assist_level | assist_level (signed byte) | +| 6 | temperature_celsius | temperature_celsius **signed**; `0xD8` = -40 °C is the "no sensor data" sentinel during the first packets after connect | +| 7-8 | power_amp `read16 / 10` | motor_power_watts `read16 / 100` | +| 9-10 | speed_kmh `read16 / 10` | speed_kmh `read16 / 10` | +| 11 | wheel_speed_rpm | wheel_speed_rpm — only valid when `max_torque != 0` | +| 12 | torque_motor_pct | — (start of rider_power_watts) | +| 12-13 | — | rider_power_watts `read16 / 10` | +| 13-14 | power_max `read16 / 10` | — | +| 14-15 | — | power_max_amp `read16 / 10` | +| 15 | max_torque_motor_pct (byte) | — | +| 16-17 | — | max_torque raw `read16` (also acts as the wheel-speed validity flag) | + +X20 frames don't carry `power_amp` or `torque_motor_pct`. Conversely the ebikemotion frame doesn't carry `motor_power_watts` / `rider_power_watts`. Each layout fills the missing keys with `None` so `safe_get` paths in `sensor.py` keep working. + +**EBM — `$j$Z#…#@`** (17 vs 20 bytes) + +| Offset | ebikemotion (17 B) | X20 (20 B) | +|---|---|---| +| 5-7 | (part of odometer) | odometer `read24 / 10` (km) | +| 5-8 | odometer `read32 / 10000` (km) | — | +| 8-9 | (part of autonomy) | autonomy `read16 / 10` (km) | +| 9-12 | autonomy `read32 / 10000` (km) | — | +| 10 | — | lights `byte == 1` | +| 11 | — | status (unsigned byte) | +| 12 | — | accelerometer Z (signed byte) | +| 13 | lights `byte == 1` | accelerometer Y (signed byte) | +| 14 | status (unsigned byte) | **slot indicator**: `1` = lifetime values, `2` = trip A values; pair-alternates between consecutive frames | +| 15-17 | — | fixed `HIJ` (`0x48 0x49 0x4A`) marker before `#@` | + +The X20 frame multiplexes lifetime and trip A onto the same odometer/autonomy bytes. The parser keeps both: `state["ebm"]["odometry"]` / `["autonomy"]` reflect the lifetime values (slot 1 frames), `["trip_odometry"]` / `["trip_autonomy"]` mirror trip A (slot 2 frames). When the bike has not had trip A reset since manufacture they happen to coincide. + +Devices speaking protocol v200 use the same length but a different layout in bytes 11-17 (an MPlatform error code at 11-12, slot moves to 15, remote-SOC at 16-17). Not yet supported — detect by absence of the `HIJ` marker if it ever shows up in a capture. + +### Restore across restarts + +`helpers.storage.Store` (key `mysmartbike_ble.`, `STORAGE_VERSION`) persists the parser state so a bike that is off or out of range still shows its last values. `_schedule_save()` uses `Store.async_delay_save(..., STORAGE_SAVE_DELAY)` — BLE notifications arrive far too often for eager writes; `Store` flushes on HA shutdown, and `async_shutdown` does an explicit `async_save` so an unload can't lose state. + +`coordinator.data` **is** `self._parser.state` (same object identity, see `_async_update_data` and `_notification_handler`), so restoring means seeding the parser dict — every `value_fn` then works unchanged with no per-entity restore code. It also seeds the `prev` lookup in `_parse_ebm_x20`, which carries lifetime/trip values across the slot alternation. + +What is restored is a deliberate whitelist in `const.py`: +- `RESTORE_STATE_KEYS` = `battery_primary`, `battery_secondary`, `ebm`. **`motor` and `assist` are excluded on purpose** — a restored speed or motor power reading is indistinguishable from live data on a parked bike, which is worse than `unknown`. +- `VOLATILE_FIELDS` nulls individual fields inside the restored dicts: battery `current` / `is_charging`, and `ebm` `status` / `accel_y` / `accel_z`. +- `vin`, `protocol_version`, `last_seen` and `manual_disconnect` are stored alongside. Restoring `manual_disconnect` is what stops a Home Assistant restart from waking a bike the user deliberately disconnected. + +When adding a sensor, decide which bucket its source field belongs to before wiring the `value_fn`. + +**Instant reconnect:** `async_start_bluetooth_watch()` registers a `bluetooth.async_register_callback` on the address, so the coordinator connects the moment the bike advertises rather than up to 30s later. The callback must keep honouring `_manual_disconnect`. + +**Disconnect semantics matter:** turning the connection switch off sends `CLOSE_MESSAGE` and the bike powers itself off ~5 minutes later. `_manual_disconnect` gates auto-reconnect so the coordinator doesn't wake the bike again. Reconnecting requires the user to physically power the bike on first. Preserve this gating when touching the coordinator. + +**Optional BLE message logging:** when the `log_ble_messages` option is set, every notification is appended to `custom_components/mysmartbike_ble/messages/__ble_messages.log` via an executor job (file I/O off the event loop). The `messages/` directory is gitignored implicitly via standard patterns; do not commit captures. + +## Tests + +`tests/conftest.py` enables custom integrations globally. `tests/components/mysmartbike_ble/conftest.py` provides: +- `mock_config_entry` — `MockConfigEntry` with a fixed address `AA:BB:CC:DD:EE:FF`. +- `mock_bleak_client` — patches `establish_connection` in the coordinator module (not `bleak` itself). +- `mock_bluetooth_helpers` (autouse) — stubs `async_register_callback` / `async_last_service_info` in the coordinator module. +- `mock_device_in_range` / `mock_device_out_of_range` — control what `async_ble_device_from_address` returns in both `coordinator` and `__init__`. +- `init_integration` / `init_integration_offline` — full setup with the bike reachable or not. + +`test_restore.py` seeds `.storage` through the `hass_storage` fixture (key `mysmartbike_ble.`) and covers the offline-setup, whitelist, device-info and manual-disconnect paths. + +Parser tests in `test_parsers.py` exercise raw byte sequences directly — the cleanest place to add coverage for new message types. diff --git a/README.md b/README.md index f510b78..857a4aa 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ This integration provides real-time monitoring of your E-Bike through Bluetooth - **Range** (km) - **Light Status** +### Diagnostics +- **Last Seen** (Timestamp) - When the bike last sent data, so you can tell how fresh the values are +- **Signal Strength** (dBm) - disabled by default + ### Device Information The integration automatically retrieves and displays: - **Serial Number** (VIN) - 17-character bike serial number @@ -115,6 +119,19 @@ The integration will automatically discover iWoc and HUS devices in range via Bl - 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) +### Values after a Home Assistant restart + +The integration keeps working when the bike is switched off or out of range: + +- Counters and battery values (odometer, trip distance, range, state of charge, + remaining energy, light) are stored and shown again after a restart +- Momentary readings (speed, motor temperature, assist level, battery current) + 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 + deliberately disconnected is not woken up again by a Home Assistant restart + ### Connection Switch - **To disconnect**: Turn off the "Connection" switch in Home Assistant diff --git a/custom_components/mysmartbike_ble/__init__.py b/custom_components/mysmartbike_ble/__init__.py index 7dfccbe..45e6de9 100644 --- a/custom_components/mysmartbike_ble/__init__.py +++ b/custom_components/mysmartbike_ble/__init__.py @@ -7,10 +7,10 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.storage import Store -from .const import DOMAIN, CONF_DEVICE_ADDRESS -from .coordinator import MySmartBikeCoordinator +from .const import CONF_DEVICE_ADDRESS, DOMAIN, STORAGE_VERSION +from .coordinator import MySmartBikeCoordinator, storage_key _LOGGER = logging.getLogger(__name__) @@ -18,35 +18,36 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.S async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up MySmartBike BLE from a config entry.""" + """Set up MySmartBike BLE from a config entry. + + Setup never depends on the bike being in range. A bike that is switched off + or parked out of reach is the normal case, so the entry loads with the + persisted state and the coordinator connects whenever the bike shows up. + """ address = entry.data[CONF_DEVICE_ADDRESS] - # Get BLE device - ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True) - if not ble_device: - # Log warning only once per config entry - hass.data.setdefault(DOMAIN, {}) - warning_key = f"warned_{entry.entry_id}" - - if not hass.data[DOMAIN].get(warning_key): - _LOGGER.warning( - "MySmartBike device %s not found - ensure bike is powered on and in range", - address - ) - hass.data[DOMAIN][warning_key] = True - raise ConfigEntryNotReady(f"Could not find MySmartBike device with address {address}") - - # Clear warning flag when device is found - if DOMAIN in hass.data: - hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None) - - # Create and initialize coordinator - coordinator = MySmartBikeCoordinator(hass, ble_device, entry) - await coordinator.async_config_entry_first_refresh() - + coordinator = MySmartBikeCoordinator(hass, address, entry) + # Restore before the platforms are set up so the entities' first state + # write already carries the last known values and the serial number. + await coordinator.async_restore() entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Connect the moment the bike advertises instead of waiting for a poll tick. + entry.async_on_unload(coordinator.async_start_bluetooth_watch()) + + if bluetooth.async_ble_device_from_address(hass, address, connectable=True) is None: + _LOGGER.info( + "MySmartBike device %s not in range - showing last known values, " + "will connect automatically once the bike is powered on", + address, + ) + + entry.async_create_background_task( + hass, coordinator.async_first_connect(), f"{DOMAIN} initial connect {address}" + ) + _LOGGER.debug("MySmartBike BLE setup completed for %s", address) return True @@ -59,8 +60,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator: MySmartBikeCoordinator = entry.runtime_data await coordinator.async_shutdown() - # Clean up warning flag from hass.data - if DOMAIN in hass.data: - hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None) - return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the persisted state when the bike is removed from Home Assistant.""" + await Store(hass, STORAGE_VERSION, storage_key(entry)).async_remove() diff --git a/custom_components/mysmartbike_ble/binary_sensor.py b/custom_components/mysmartbike_ble/binary_sensor.py index 1b29fe4..76386aa 100644 --- a/custom_components/mysmartbike_ble/binary_sensor.py +++ b/custom_components/mysmartbike_ble/binary_sensor.py @@ -52,16 +52,16 @@ class MySmartBikeConnectionSensor(CoordinatorEntity[MySmartBikeCoordinator], Bin self._attr_device_info["sw_version"] = coordinator.protocol_version self._attr_translation_key = "connected" + @property + def available(self) -> bool: + """Return True - "not connected" is a state, not an absence of one.""" + return True + @property def is_on(self) -> bool: """Return True if connected to the bike.""" return self.coordinator.is_connected - @property - def name(self) -> str: - """Return the name of the sensor.""" - return "Connected" - @property def icon(self) -> str: """Return the icon.""" diff --git a/custom_components/mysmartbike_ble/const.py b/custom_components/mysmartbike_ble/const.py index d91d12e..32a09b9 100644 --- a/custom_components/mysmartbike_ble/const.py +++ b/custom_components/mysmartbike_ble/const.py @@ -36,3 +36,20 @@ CONF_DEVICE_ADDRESS: Final = "device_address" # Options CONF_LOG_BLE_MESSAGES: Final = "log_ble_messages" + +# Persistence +STORAGE_VERSION: Final = 1 +STORAGE_SAVE_DELAY: Final = 60 # seconds; BLE notifications arrive far too often to save eagerly + +# Top-level parser state keys that survive a restart. "motor" and "assist" are +# deliberately absent: a restored speed or power reading would look like live +# data from a bike that is actually parked. +RESTORE_STATE_KEYS: Final = ("battery_primary", "battery_secondary", "ebm") + +# Fields inside the restored dicts that describe an instantaneous condition and +# are therefore dropped (set to None) when reading the state back. +VOLATILE_FIELDS: Final[dict[str, tuple[str, ...]]] = { + "battery_primary": ("current", "is_charging"), + "battery_secondary": ("current", "is_charging"), + "ebm": ("status", "accel_y", "accel_z"), +} diff --git a/custom_components/mysmartbike_ble/coordinator.py b/custom_components/mysmartbike_ble/coordinator.py index 92cfa77..addf0c1 100644 --- a/custom_components/mysmartbike_ble/coordinator.py +++ b/custom_components/mysmartbike_ble/coordinator.py @@ -15,10 +15,17 @@ from bleak_retry_connector import ( ) from homeassistant.components import bluetooth -from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.bluetooth import ( + BluetoothCallbackMatcher, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( DOMAIN, @@ -28,6 +35,10 @@ from .const import ( PROTOCOL_REQUEST_MESSAGE, CLOSE_MESSAGE, SCAN_INTERVAL, + STORAGE_SAVE_DELAY, + STORAGE_VERSION, + RESTORE_STATE_KEYS, + VOLATILE_FIELDS, CONF_LOG_BLE_MESSAGES, CONF_DEVICE_NAME, ) @@ -36,13 +47,24 @@ from .parsers import BikeDataParser _LOGGER = logging.getLogger(__name__) +def storage_key(entry: ConfigEntry) -> str: + """Return the .storage key holding the persisted state for a config entry.""" + return f"{DOMAIN}.{entry.entry_id}" + + class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Class to manage fetching MySmartBike data.""" + """Class to manage fetching MySmartBike data. + + The coordinator is deliberately independent of the bike's availability: it + is constructed from an address, resolves the `BLEDevice` on every connect + attempt, and keeps serving the last known values while the bike is out of + range or switched off. + """ def __init__( self, hass: HomeAssistant, - ble_device: BluetoothServiceInfoBleak, + address: str, entry: ConfigEntry, ) -> None: """Initialize coordinator.""" @@ -52,24 +74,35 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]): name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL), ) - self._ble_device = ble_device + self._address = address self._entry = entry self._client: BleakClient | None = None self._parser = BikeDataParser() self._is_connected = False - self._notify_task: asyncio.Task | None = None + self._connect_lock = asyncio.Lock() self._manual_disconnect = False # Track if user manually disconnected + self._last_seen: datetime | None = None + self._save_armed = False + self._unreachable_reason: str | None = None + self._store: Store[dict[str, Any]] = Store( + hass, STORAGE_VERSION, storage_key(entry) + ) @property def address(self) -> str: """Return the address of the device.""" - return self._ble_device.address + return self._address @property def is_connected(self) -> bool: """Return connection status.""" return self._is_connected + @property + def last_seen(self) -> datetime | None: + """Return when the last BLE notification was received, if ever.""" + return self._last_seen + @property def vin(self) -> str | None: """Return the VIN/serial number if available.""" @@ -80,6 +113,179 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Return the protocol version if available.""" return self._parser.protocol_version + def _resolve_device(self) -> Any: + """Resolve the current BLEDevice for our address, or None if not in range. + + Resolved per attempt rather than cached: a device object handed out by a + previous scan goes stale when the adapter or the proxy serving it changes. + """ + return bluetooth.async_ble_device_from_address( + self.hass, self._address, connectable=True + ) + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + async def async_restore(self) -> None: + """Load the persisted state into the parser. + + Must run before the entity platforms are set up so entities see values + (and the device serial number) on their very first state write. + """ + try: + stored = await self._store.async_load() + except Exception as ex: # noqa: BLE001 - never let a bad store block setup + _LOGGER.warning("Could not read stored state for %s: %s", self._address, ex) + stored = None + + if not stored: + self.data = self._parser.state + return + + for key in RESTORE_STATE_KEYS: + value = stored.get(key) + if not isinstance(value, dict): + continue + restored = dict(value) + for field in VOLATILE_FIELDS.get(key, ()): + restored[field] = None + self._parser.state[key] = restored + + self._parser.vin = stored.get("vin") + self._parser.protocol_version = stored.get("protocol_version") + self._manual_disconnect = bool(stored.get("manual_disconnect", False)) + + if last_seen := stored.get("last_seen"): + self._last_seen = dt_util.parse_datetime(last_seen) + + self._parser.state["last_seen"] = self._last_seen + self.data = self._parser.state + _LOGGER.debug( + "Restored state for %s (last seen %s, connection %s)", + self._address, + self._last_seen, + "disabled" if self._manual_disconnect else "enabled", + ) + + def _persist_data(self) -> dict[str, Any]: + """Build the payload written to .storage.""" + return { + **{key: self._parser.state.get(key) for key in RESTORE_STATE_KEYS}, + "vin": self._parser.vin, + "protocol_version": self._parser.protocol_version, + "manual_disconnect": self._manual_disconnect, + "last_seen": self._last_seen.isoformat() if self._last_seen else None, + } + + def _schedule_save(self) -> None: + """Throttle writes to one per STORAGE_SAVE_DELAY. + + `Store.async_delay_save` debounces rather than throttles - it pushes + `_next_write_time` forward on every call. Notifications arrive about + once a second while connected, so re-arming on each one would postpone + the write for as long as the bike stays connected and nothing would + ever reach disk except on a clean shutdown. Arming only when no write + is outstanding turns that into a throttle; `_data_to_save` runs at + write time, so the persisted snapshot is still current. + """ + if self._save_armed: + return + self._save_armed = True + self._store.async_delay_save(self._data_to_save, STORAGE_SAVE_DELAY) + + def _data_to_save(self) -> dict[str, Any]: + """Store calls this at write time; re-arms the next throttle window.""" + self._save_armed = False + return self._persist_data() + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + @callback + def async_start_bluetooth_watch(self) -> CALLBACK_TYPE: + """Connect as soon as the bike advertises, instead of waiting for the poll.""" + return bluetooth.async_register_callback( + self.hass, + self._async_device_appeared, + BluetoothCallbackMatcher(address=self._address, connectable=True), + BluetoothScanningMode.ACTIVE, + ) + + @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: + return + self._entry.async_create_background_task( + self.hass, self._async_try_connect(), f"{DOMAIN} connect {self._address}" + ) + + @callback + def _async_client_disconnected(self, client: BleakClient) -> None: + """Handle the bike dropping the link (powered off, out of range, slot lost). + + Without this the coordinator would keep believing it is connected, so + `binary_sensor.connected` would lie and `_async_update_data` would never + retry - the values would silently stop updating. + """ + if self._client is not client: + return # our own _cleanup_client already took ownership + _LOGGER.debug("Lost connection to %s", self._address) + self._client = None + self._is_connected = False + self.async_update_listeners() + + async def async_first_connect(self) -> None: + """Attempt the initial connection without blocking setup.""" + if self._manual_disconnect: + _LOGGER.debug( + "Not connecting to %s - connection was switched off by the user", + self._address, + ) + return + await self._async_try_connect() + + async def _async_try_connect(self) -> None: + """Connect, reporting the expected 'bike is off' failures once each.""" + try: + await self._connect() + except UpdateFailed as ex: + self._async_report_unreachable(str(ex)) + except Exception as ex: # noqa: BLE001 + self._async_report_unreachable(f"Connection attempt failed: {ex}") + + def _async_report_unreachable(self, reason: str) -> None: + """Log why we cannot connect - once per distinct reason. + + The poll retries every SCAN_INTERVAL, so warning every time would spam + the log for a bike that is simply parked. Warning on change still tells + the user *why* nothing happens, which silence never did. + """ + if reason != self._unreachable_reason: + self._unreachable_reason = reason + _LOGGER.warning("%s", reason) + else: + _LOGGER.debug("%s", reason) + + def _no_route_reason(self) -> str: + """Explain why the address did not resolve to a connectable device. + + A bike seen only by passive proxies looks identical to a bike that is + switched off unless we say so - Shelly proxies never offer connections, + so the address is "present" but never connectable. + """ + if bluetooth.async_address_present(self.hass, self._address, connectable=False): + return ( + f"Device {self._address} is advertising, but no Bluetooth adapter or " + "proxy that supports active connections can reach it. Shelly proxies " + "are passive-only - a local adapter or an ESPHome proxy is required" + ) + return f"Device {self._address} is not reachable - turn on the bike" + async def _cleanup_client(self, send_close: bool = True, wait_for_slot: bool = True) -> None: """Clean up BLE client connection. @@ -118,22 +324,26 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]): del client if wait_for_slot: await asyncio.sleep(3.0) # Wait for BLE connection slot release + # Let binary_sensor.connected drop immediately instead of at the next poll. + self.async_update_listeners() async def async_disconnect(self) -> None: """Disconnect from the device (user initiated).""" - _LOGGER.debug("User-initiated disconnect for %s", self._ble_device.address) + _LOGGER.debug("User-initiated disconnect for %s", self._address) self._manual_disconnect = True + self._schedule_save() await self._cleanup_client(send_close=True, wait_for_slot=True) async def async_reconnect(self) -> None: """Reconnect to the device (user initiated).""" - _LOGGER.debug("User-initiated reconnect for %s", self._ble_device.address) + _LOGGER.debug("User-initiated reconnect for %s", self._address) # Clean up any existing client first await self._cleanup_client(send_close=False, wait_for_slot=True) # Clear manual disconnect flag to allow auto-reconnect self._manual_disconnect = False + self._schedule_save() try: await self._connect() @@ -144,67 +354,86 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise async def _async_update_data(self) -> dict[str, Any]: - """Fetch data from the device.""" + """Refresh diagnostics and auto-reconnect; never fails the entities. + + Values are last-known-good rather than live, so an unreachable bike must + not mark the coordinator unsuccessful - that would take every entity to + `unavailable` and throw away the restored state. + """ # Auto-reconnect if not connected and not manually disconnected if not self._is_connected and not self._manual_disconnect: - try: - await self._connect() - except Exception: - pass # Connection errors are logged in _connect() + await self._async_try_connect() - # Return current state from parser, ensure it's never None - state = self._parser.state or { - "battery_primary": None, - "battery_secondary": None, - "motor": None, - "assist": None, - "ebm": None, - } + state = self._parser.state - # Add RSSI (signal strength) to state + # Add RSSI (signal strength) to state - None while out of range try: service_info = bluetooth.async_last_service_info( - self.hass, self._ble_device.address, connectable=True + self.hass, self._address, connectable=True ) state["rssi"] = service_info.rssi if service_info else None except Exception: state["rssi"] = None + state["last_seen"] = self._last_seen return state async def _connect(self) -> None: """Connect to the device and start notifications.""" - # Clean up any existing client before connecting - if self._client: - _LOGGER.debug("Cleaning up existing client before new connection") - await self._cleanup_client(send_close=False, wait_for_slot=True) + async with self._connect_lock: + if self._is_connected: + return - try: - self._client = await establish_connection( - BleakClientWithServiceCache, - self._ble_device, - self._ble_device.address, - ) + ble_device = self._resolve_device() + if ble_device is None: + raise UpdateFailed(self._no_route_reason()) - # Start notifications and request device info - await self._client.start_notify(NOTIFY_UUID, self._notification_handler) - await self._client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE) - await asyncio.sleep(0.2) - await self._client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE) + # Clean up any existing client before connecting + if self._client: + _LOGGER.debug("Cleaning up existing client before new connection") + await self._cleanup_client(send_close=False, wait_for_slot=True) - self._is_connected = True - _LOGGER.debug("Connected to %s", self._ble_device.address) + try: + # Held in a local: the handshake sleeps, and a concurrent + # disconnect (switch off, dropped link) may clear self._client + # underneath us - reading it back mid-handshake would crash. + client = await establish_connection( + BleakClientWithServiceCache, + ble_device, + self._address, + disconnected_callback=self._async_client_disconnected, + ble_device_callback=self._resolve_device, + ) + self._client = client - except (BleakError, asyncio.TimeoutError) as ex: - self._is_connected = False - error_str = str(ex).lower() + # Start notifications and request device info + await client.start_notify(NOTIFY_UUID, self._notification_handler) + await client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE) + await asyncio.sleep(0.2) + await client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE) - if "no longer reachable" in error_str or "out of connection slots" in error_str: - _LOGGER.warning("Device %s not reachable - turn on the bike", self._ble_device.address) - raise UpdateFailed(f"Device {self._ble_device.address} is not reachable") from ex - else: - _LOGGER.error("Failed to connect to %s: %s", self._ble_device.address, ex) - raise UpdateFailed(f"Failed to connect to device: {ex}") from ex + if self._client is not client: + # Torn down while we were setting up - don't claim success. + _LOGGER.debug("Connection to %s was cancelled", self._address) + return + + self._is_connected = True + self._unreachable_reason = None + _LOGGER.debug("Connected to %s", self._address) + + except (BleakError, asyncio.TimeoutError) as ex: + self._is_connected = False + error_str = str(ex).lower() + + if "no longer reachable" in error_str or "out of connection slots" in error_str: + _LOGGER.warning("Device %s not reachable - turn on the bike", self._address) + raise UpdateFailed(f"Device {self._address} is not reachable") from ex + else: + _LOGGER.error("Failed to connect to %s: %s", self._address, ex) + raise UpdateFailed(f"Failed to connect to device: {ex}") from ex + + # Outside the lock: entities pick up the new connection state immediately. + self.async_update_listeners() def _notification_handler(self, sender: int, data: bytearray) -> None: """Handle notification data.""" @@ -219,6 +448,10 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Parse the message self._parser.handle_message(bytes(data)) + self._last_seen = dt_util.utcnow() + self._parser.state["last_seen"] = self._last_seen + self._schedule_save() + # Update coordinator data self.async_set_updated_data(self._parser.state) @@ -274,4 +507,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_shutdown(self) -> None: """Shutdown the coordinator.""" _LOGGER.debug("Shutting down coordinator") + await super().async_shutdown() await self._cleanup_client(send_close=True, wait_for_slot=False) + # Flush any pending debounced write so an unload never loses the state. + await self._store.async_save(self._persist_data()) diff --git a/custom_components/mysmartbike_ble/sensor.py b/custom_components/mysmartbike_ble/sensor.py index 4a07016..076f2d9 100644 --- a/custom_components/mysmartbike_ble/sensor.py +++ b/custom_components/mysmartbike_ble/sensor.py @@ -163,6 +163,14 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = ( icon="mdi:wifi", value_fn=lambda data: safe_get(data, "rssi"), ), + MySmartBikeSensorEntityDescription( + key="last_seen", + name="Last Seen", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:clock-outline", + value_fn=lambda data: safe_get(data, "last_seen"), + ), ) @@ -212,6 +220,15 @@ class MySmartBikeSensor(CoordinatorEntity[MySmartBikeCoordinator], SensorEntity) if coordinator.protocol_version: self._attr_device_info["sw_version"] = coordinator.protocol_version + @property + def available(self) -> bool: + """Return True - values are last-known-good, not live readings. + + An unreachable bike must not blank the sensors; `binary_sensor.connected` + and the "Last Seen" timestamp tell the user how fresh the values are. + """ + return True + @property def native_value(self) -> Any: """Return the state of the sensor.""" diff --git a/custom_components/mysmartbike_ble/switch.py b/custom_components/mysmartbike_ble/switch.py index 1298086..e7b85b2 100644 --- a/custom_components/mysmartbike_ble/switch.py +++ b/custom_components/mysmartbike_ble/switch.py @@ -49,9 +49,18 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi self._attr_device_info["sw_version"] = coordinator.protocol_version self._attr_translation_key = "connection" + @property + def available(self) -> bool: + """Return True - the connection wish can always be changed.""" + return True + @property def is_on(self) -> bool: - """Return True if connection is desired (not manually disconnected).""" + """Return True if connection is desired (not manually disconnected). + + Restored from storage on startup, so a bike the user deliberately + disconnected is not woken again by a Home Assistant restart. + """ return not self.coordinator._manual_disconnect @property @@ -60,9 +69,11 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off" async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the switch - request connection to the bike.""" - self.async_write_ha_state() + """Turn on the switch - request connection to the bike. + The switch reflects the *wish* to be connected, so it stays on even when + the bike is currently unreachable - the coordinator keeps retrying. + """ try: await self.coordinator.async_reconnect() except Exception as ex: @@ -71,6 +82,8 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi _LOGGER.warning("Cannot connect - bike not reachable. Will auto-connect when available.") else: _LOGGER.error("Failed to connect to bike: %s", ex) + finally: + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch - disconnect from the bike. diff --git a/custom_components/mysmartbike_ble/translations/de.json b/custom_components/mysmartbike_ble/translations/de.json index 27fb8c3..9aca6ef 100644 --- a/custom_components/mysmartbike_ble/translations/de.json +++ b/custom_components/mysmartbike_ble/translations/de.json @@ -27,10 +27,15 @@ } }, "entity": { + "binary_sensor": { + "connected": { + "name": "Verbunden" + } + }, "switch": { "connection": { - "name": "Verbindung" + "name": "Auto-Verbindung" } } } -} \ No newline at end of file +} diff --git a/custom_components/mysmartbike_ble/translations/en.json b/custom_components/mysmartbike_ble/translations/en.json index 22c4458..18e7a57 100644 --- a/custom_components/mysmartbike_ble/translations/en.json +++ b/custom_components/mysmartbike_ble/translations/en.json @@ -27,10 +27,15 @@ } }, "entity": { + "binary_sensor": { + "connected": { + "name": "Connected" + } + }, "switch": { "connection": { - "name": "Connection" + "name": "Auto-connect" } } } -} \ No newline at end of file +} diff --git a/tests/components/mysmartbike_ble/conftest.py b/tests/components/mysmartbike_ble/conftest.py index 8c903ba..0dab854 100644 --- a/tests/components/mysmartbike_ble/conftest.py +++ b/tests/components/mysmartbike_ble/conftest.py @@ -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 diff --git a/tests/components/mysmartbike_ble/test_restore.py b/tests/components/mysmartbike_ble/test_restore.py new file mode 100644 index 0000000..545348f --- /dev/null +++ b/tests/components/mysmartbike_ble/test_restore.py @@ -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