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
12 KiB
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:
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_entrynever depends on the bike being reachable. It constructs the coordinator from the address alone,awaitscoordinator.async_restore()before forwarding the platforms (so entities' first state write already carries restored values and the VIN), registers the advertisement watch viaentry.async_on_unload, and kicks off the first connect as a background task. There is noConfigEntryNotReadyand nohass.data[DOMAIN][entry_id].async_remove_entrydeletes theStore.coordinator.py—MySmartBikeCoordinatoris built from an address, not aBLEDevice;_resolve_device()re-resolves per connect attempt so a stale device object from an old adapter/proxy can't linger. It polls everySCAN_INTERVAL(30s), auto-reconnecting unless_manual_disconnectis set, and returns the parser's accumulatedstateplus a freshrssiandlast_seen._async_update_datadeliberately never raises: an unreachable bike must not fliplast_update_success, which would mark every entityunavailableand throw the restored state away. Real data flow is push-based:_notification_handlerruns on every BLE notification, parses, stampslast_seen, queues a debounced save, and callsasync_set_updated_datato wake entities immediately._connectis serialised by_connect_lockbecause 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 clearingself._clientmid-handshake would otherwise crash it.establish_connectiongets adisconnected_callbackso a dropped link flips_is_connectedimmediately; without it the coordinator would believe it was still connected and never retry.parsers.py—BikeDataParseris a pure, stateful message decoder. It dispatches viarecognize_message_type(sniffs the$X$Y#...#@framing) and accumulates intostate(battery_primary,battery_secondary,motor,assist,ebm) plusvin/protocol_version. All multibyte reads are big-endian. Secondary-battery presence is inferred: abattery_number == 2packet 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 filtersasync_discovered_service_infofor names starting withiWocorHUS. Unique ID is the BLE address. Options flow exposesCONF_LOG_BLE_MESSAGESonly.binary_sensor.py/sensor.py/switch.py—CoordinatorEntitysubclasses. Sensors use aMySmartBikeSensorEntityDescriptiondataclass with avalue_fnlambda that pulls fromcoordinator.datavia thesafe_gethelper (data dicts can beNonebefore first packet). All entities share one device (identified by(DOMAIN, entry.entry_id));serial_numberandsw_versioncome from the parser'svin/protocol_version, whichasync_restore()has already populated by construction time. Every entity overridesavailabletoTrue— the values are last-known-good, and liveness is reported bybinary_sensor.connectedand thelast_seentimestamp sensor instead.
BLE protocol specifics (in const.py):
- Write characteristic
0000FFE2-…, notify characteristic0000FFD1-…. - 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_connectionis used instead of rawBleakClient— 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.motorandassistare excluded on purpose — a restored speed or motor power reading is indistinguishable from live data on a parked bike, which is worse thanunknown.VOLATILE_FIELDSnulls individual fields inside the restored dicts: batterycurrent/is_charging, andebmstatus/accel_y/accel_z.vin,protocol_version,last_seenandmanual_disconnectare stored alongside. Restoringmanual_disconnectis 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—MockConfigEntrywith a fixed addressAA:BB:CC:DD:EE:FF.mock_bleak_client— patchesestablish_connectionin the coordinator module (notbleakitself).mock_bluetooth_helpers(autouse) — stubsasync_register_callback/async_last_service_infoin the coordinator module.mock_device_in_range/mock_device_out_of_range— control whatasync_ble_device_from_addressreturns in bothcoordinatorand__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.