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
128 lines
12 KiB
Markdown
128 lines
12 KiB
Markdown
# 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.<entry_id>`, `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/<device>_<YYYYMMDD>_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.<entry_id>`) 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.
|