17 Commits
Author SHA1 Message Date
Rene Nulsch 9d52a3bdac bump dev version 2026-08-28 08:50:35 +02:00
Rene Nulsch 4ad41e9977 Merge pull request #7 from ReneNulschDE/fix/reduce-recorder-load
Stop flooding the recorder with timestamp and false-precision noise
2026-08-28 08:42:51 +02:00
Rene NulschandClaude Opus 5 62bc1c150a Stop flooding the recorder with timestamp and false-precision noise
Measured on a real installation with two bikes: the two "Last Seen"
sensors were the number one and number two writers in the entire Home
Assistant database, ahead of a three-phase energy meter. The integration
accounted for 26.5% of all recorder writes on that system.

Last Seen moved on every BLE notification, about once a second, or 2834
rows an hour per bike. The entity exists to say how fresh the values are,
which needs nothing near that resolution, so it now advances at
LAST_SEEN_RESOLUTION granularity. `self._last_seen` stays exact - that is
what gets persisted and restored - only the entity is throttled. Measured
after: 120 rows an hour, the theoretical maximum for a 30s window.

The distance sensors carried four decimals of false precision, because
the ebikemotion frames divide by 10000: a range that is really "14.6" was
reported as 14.6065, then 14.6529, then 14.6297, each one another state
write and another database row. They are rounded to 0.1 km now.

Home Assistant offers integrations no way to exclude an entity from the
recorder - `entity_filter` comes from user configuration only, and
`_unrecorded_attributes` covers attributes rather than states - so
reducing how often the state changes is the only fix that works without
asking every user to edit configuration.yaml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
2026-08-28 08:36:09 +02:00
Rene Nulsch a38b2efef5 Merge pull request #6 from ReneNulschDE/fix/reconnect-latency-and-advertisement-storm
Reconnect right after a dropped link instead of waiting out the poll
2026-08-28 00:33:34 +02:00
Rene NulschandClaude Opus 5 7c1a23a8b4 Reconnect right after a dropped link instead of waiting out the poll
Measured on a real bike: after every unexpected disconnect the integration
sat idle for 26.5s before reconnecting, in a suspiciously tight band across
nine consecutive drops.

Two causes, both introduced with the restore work:

`async_set_updated_data` runs on every BLE notification, which reschedules
the coordinator's next poll to now + SCAN_INTERVAL. The last notification
lands just before the link dies, bleak reports the disconnect ~3.5s later,
so the poll that would reconnect is still ~26.5s out.

The advertisement watch was supposed to cover exactly this, but does not:
instrumenting the callback showed one advertisement in seven minutes across
five drop cycles. These bikes stop advertising after an unexpected link
loss, so the watch only helps when the bike is switched on fresh.

The disconnected callback now starts a reconnect itself. A link that did
not survive MIN_LINK_SECONDS_FOR_FAST_RECONNECT is left to the poll so a
bike that cannot hold a connection at all does not spin. Measured after
the change: 0.7s instead of 26.5s.

Also fixes an advertisement storm. Home Assistant invokes bluetooth
callbacks on every advertisement, and this bike advertises every 0.27s, so
while disconnected the callback queued a background task several times a
second - roughly a hundred per reconnect window - all serialising behind
_connect_lock, each running a full establish_connection retry cycle against
a proxy already struggling to hold the link. A claim guard reduces that to
one attempt in flight at a time; the test covers 50 advertisements.

Keeps the advertisement counter as debug output - it is what made the
second cause visible, and it is the first thing to look at when a bike
reconnects slowly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
2026-08-28 00:24:42 +02:00
Rene NulschandClaude Opus 5 76c3d91ff9 Bring README in line with v0.0.7
The connection switch is called Auto-connect now, the sensor list was
three entries short and counted 11 instead of 14, and the minimum Home
Assistant version disagreed with hacs.json.

Also documents that values are only stored once the bike has connected
at least once, and adds the passive-proxy case to troubleshooting - a
bike seen only by a Shelly looks identical to a bike that is switched
off unless you read the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
2026-08-27 23:27:58 +02:00
Rene Nulsch 5d658d6d9e Merge pull request #5 from ReneNulschDE/feat/restore-state-across-restarts
Keep showing last known values when the bike is out of range
2026-08-27 23:23:43 +02:00
Rene NulschandClaude Opus 5 ac0baffe74 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
2026-08-27 22:45:21 +02:00
Rene Nulsch c539823c1a Fix repository URL in README.md 2026-06-28 15:18:33 +02:00
Rene Nulsch 28bf64baad Update: message parser, trip handling 2026-04-30 13:00:56 +02:00
Rene Nulsch 52178a219b Add x20 parser logic 2026-04-30 10:00:59 +02:00
Rene Nulsch 0afd8243bc Discover HUS-prefixed BLE devices alongside iWoc 2026-04-30 07:40:52 +02:00
Rene Nulsch 0638c4b244 Add Orbea to tested bikes 2025-11-22 16:47:17 +01:00
Rene Nulsch 70fa0aa72d Add HACS badge to readme 2025-11-22 15:31:22 +01:00
Rene Nulsch 43e6c0d99e CLeanup logging 2025-11-22 14:39:28 +01:00
Rene Nulsch e8f91dc1cd add badges to readme 2025-11-22 14:08:57 +01:00
Rene Nulsch 0e459bec15 fix release badge 2025-11-22 09:09:15 +01:00
16 changed files with 1902 additions and 496 deletions
+127
View File
@@ -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.<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.
+53 -19
View File
@@ -1,6 +1,9 @@
# MySmartBike BLE Integration for Home Assistant # MySmartBike BLE Integration for Home Assistant
[![GitHub release](https://img.shields.io/github/release/renenulschde/ha-mysmartbike-ble.svg)](https://github.com/renenulschde/ha-mysmartbike-ble/releases) [![GitHub release](https://img.shields.io/github/release/renenulschde/ha-mysmartbike_ble.svg)](https://github.com/renenulschde/ha-mysmartbike_ble/releases)
![](https://img.shields.io/github/downloads/renenulschde/ha-mysmartbike_ble/latest/total) ![Validate with HACS](https://github.com/ReneNulschDE/mbapi2020/workflows/Validate%20with%20HACS/badge.svg)
![HassFest tests](https://github.com/renenulschde/ha-mysmartbike_ble/workflows/Validate%20with%20hassfest/badge.svg)
Home Assistant custom component for E-Bikes with Mahle SmartBike systems (X25, X35+, ebikemotion, ...) via Bluetooth Low Energy (BLE). Home Assistant custom component for E-Bikes with Mahle SmartBike systems (X25, X35+, ebikemotion, ...) via Bluetooth Low Energy (BLE).
@@ -12,22 +15,25 @@ This integration has been developed and tested with:
| Brand | Model | Status | | Brand | Model | Status |
|-------|-------|--------| |-------|-------|--------|
| Orbea | Vibe | Fully tested |
| Schindelhauer | Arthur IX | Fully tested | | Schindelhauer | Arthur IX | Fully tested |
**Your bike not listed?** If you have an E-Bike that uses the MySmartBike app (or ebikemotion app), it will likely work with this integration. Please open an issue to report compatibility! **Your bike not listed?** If you have an E-Bike that uses the MySmartBike app (or ebikemotion app), it will likely work with this integration. Please open an issue to report compatibility!
## Features ## Features
This integration provides real-time monitoring of your E-Bike through Bluetooth LE connection with 11 sensors, 1 binary sensor, and 1 switch: This integration provides real-time monitoring of your E-Bike through Bluetooth LE connection with 14 sensors, 1 binary sensor, and 1 switch:
### Connection Control ### Connection Control
- **Connection Switch** - Control the BLE connection to your E-Bike - **Auto-connect** (Switch) - Controls whether the integration may connect to your E-Bike
- Turning off this switch will disconnect from the bike and **the bike will shut down after approximately 5 minutes**. You must manually turn the bike back on or connect it to power to reconnect! - This is the connection *wish*, not the connection status. It stays on while the bike is away, so the integration reconnects on its own once the bike is switched on.
- Use this switch to save energy when you don't need active monitoring - Turning it off disconnects from the bike and **the bike will shut down after approximately 5 minutes**. You must manually turn the bike back on or connect it to power to reconnect!
- The bike will automatically turn off about 5 minutes after disconnection to conserve battery - Use it to save energy when you don't need active monitoring
- The position is remembered across Home Assistant restarts
- **Connected** (Binary Sensor) - Shows current BLE connection status to the bike - **Connected** (Binary Sensor) - The actual live BLE connection status
### Battery Sensors ### Battery Sensors
- **Battery State of Charge** (%) - **Battery State of Charge** (%)
@@ -35,14 +41,23 @@ This integration provides real-time monitoring of your E-Bike through Bluetooth
- **Battery Remaining Energy** (Wh) - **Battery Remaining Energy** (Wh)
### Motor Sensors ### Motor Sensors
- **Assist Level** - **Assist Level** - disabled by default
- **Motor Temperature** (°C) - **Motor Temperature** (°C)
- **Speed** (km/h) - **Speed** (km/h)
### E-Bike Management (EBM) ### E-Bike Management (EBM)
- **Odometer** (km) - **Odometer** (km)
- **Range** (km) - **Range** (km)
- **Trip A Distance** (km)
- **Trip A Range** (km) - disabled by default
- **Light Status** - **Light Status**
- **EBM Status**
Trip A values are only reported by newer X20 bikes (device names starting with `HUS`). On X25 / X35+ bikes they stay "Unknown".
### Diagnostics
- **Last Seen** (Timestamp) - When the bike last sent data, so you can tell how fresh the values are
- **Signal Strength** (dBm) - disabled by default
### Device Information ### Device Information
The integration automatically retrieves and displays: The integration automatically retrieves and displays:
@@ -51,9 +66,9 @@ The integration automatically retrieves and displays:
## Requirements ## Requirements
- Home Assistant 2024.1.6 or newer - Home Assistant 2024.6.0 or newer
- Bluetooth adapter with BLE support - Bluetooth adapter with BLE support
- Bluetooth proxies with active connections are supported (ex. EspHome) - Shelly is not support as active direct connections are not possible. - Bluetooth proxies with **active connections** are supported (e.g. ESPHome). Shelly proxies are passive-only: they can see the bike but never connect to it.
- E-Bike with Mahle SmartBike system (compatible with MySmartBike or ebikemotion app) - E-Bike with Mahle SmartBike system (compatible with MySmartBike or ebikemotion app)
## Installation ## Installation
@@ -64,7 +79,7 @@ The integration automatically retrieves and displays:
2. Click on "Integrations" 2. Click on "Integrations"
3. Click the three dots in the top right corner 3. Click the three dots in the top right corner
4. Select "Custom repositories" 4. Select "Custom repositories"
5. Add this repository URL: `https://github.com/renenulschde/ha-mysmartbike-ble` 5. Add this repository URL: `https://github.com/renenulschde/ha-mysmartbike_ble`
6. Select category "Integration" 6. Select category "Integration"
7. Click "Add" 7. Click "Add"
8. Search for "MySmartBike BLE" in HACS 8. Search for "MySmartBike BLE" in HACS
@@ -73,7 +88,7 @@ The integration automatically retrieves and displays:
### Manual Installation ### Manual Installation
1. Download the latest release from the [releases page](https://github.com/renenulschde/ha-mysmartbike-ble/releases) 1. Download the latest release from the [releases page](https://github.com/renenulschde/ha-mysmartbike_ble/releases)
2. Extract the files 2. Extract the files
3. Copy the `custom_components/mysmartbike_ble` folder to your Home Assistant `custom_components` directory 3. Copy the `custom_components/mysmartbike_ble` folder to your Home Assistant `custom_components` directory
4. Restart Home Assistant 4. Restart Home Assistant
@@ -85,10 +100,10 @@ The integration is configured through the Home Assistant UI:
1. Go to **Settings****Devices & Services** 1. Go to **Settings****Devices & Services**
2. Click **+ Add Integration** 2. Click **+ Add Integration**
3. Search for **MySmartBike BLE** 3. Search for **MySmartBike BLE**
4. Select your iWoc device from the list 4. Select your iWoc/HUS device from the list
5. Click **Submit** 5. Click **Submit**
The integration will automatically discover iWoc devices in range via Bluetooth. The integration will automatically discover iWoc and HUS devices in range via Bluetooth.
## Troubleshooting ## Troubleshooting
@@ -96,7 +111,8 @@ The integration will automatically discover iWoc devices in range via Bluetooth.
- Make sure your E-Bike is turned on and in range - Make sure your E-Bike is turned on and in range
- Check that Bluetooth is enabled on your Home Assistant host - Check that Bluetooth is enabled on your Home Assistant host
- Verify that the device name starts with "iWoc" (please report other device names) - Verify that the device name starts with "iWoc" or "HUS" (please report other device names)
- Check the log. If it says the bike *is advertising but no adapter or proxy that supports active connections can reach it*, the bike is only being seen by a passive proxy (e.g. a Shelly). You need a local Bluetooth adapter or an ESPHome proxy with `active: true` in range of the bike.
### Connection issues ### Connection issues
@@ -106,19 +122,37 @@ The integration will automatically discover iWoc devices in range via Bluetooth.
### Sensor values not updating ### Sensor values not updating
- The integration updates data every 30 seconds when connected - While connected, the bike pushes data continuously and the sensors update within a second
- The 30-second interval is only the retry timer used while the bike is *not* connected
- Some sensors may show "Unknown" until the bike sends that specific data - 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) - Check if the bike is actively transmitting data (try riding or using the display)
### Connection Switch ### Values after a Home Assistant restart
- **To disconnect**: Turn off the "Connection" switch in Home Assistant 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 **Auto-connect** switch keeps its position across restarts, so a bike you
deliberately disconnected is not woken up again by a Home Assistant restart
Values are only stored from the moment the bike connects. Directly after
installing or updating the integration, sensors read "Unknown" until the bike
has been connected once — from then on they survive restarts.
### Auto-connect Switch
- **To disconnect**: Turn off the "Auto-connect" switch in Home Assistant
- ⚠️ This will shut down your bike after approximately 5 minutes! - ⚠️ This will shut down your bike after approximately 5 minutes!
- The bike will remain on for about 5 minutes before automatically powering off - The bike will remain on for about 5 minutes before automatically powering off
- **To reconnect**: - **To reconnect**:
1. First, manually turn on your bike OR connect it to power 1. First, manually turn on your bike OR connect it to power
2. Wait for the bike to be fully powered on 2. Wait for the bike to be fully powered on
3. Turn on the "Connection" switch in Home Assistant 3. Turn on the "Auto-connect" switch in Home Assistant
- **Use case**: Turn off the connection when you don't need monitoring to save your bike's battery - **Use case**: Turn off the connection when you don't need monitoring to save your bike's battery
+32 -69
View File
@@ -2,16 +2,15 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.storage import Store
from .const import DOMAIN, CONF_DEVICE_ADDRESS from .const import CONF_DEVICE_ADDRESS, DOMAIN, STORAGE_VERSION
from .coordinator import MySmartBikeCoordinator from .coordinator import MySmartBikeCoordinator, storage_key
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -19,87 +18,51 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.S
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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.
_LOGGER.debug("Setting up MySmartBike BLE integration for entry_id: %s", entry.entry_id)
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] address = entry.data[CONF_DEVICE_ADDRESS]
_LOGGER.debug("Device address from config: %s", address)
# Get BLE device coordinator = MySmartBikeCoordinator(hass, address, entry)
_LOGGER.debug("Looking up BLE device with address: %s", address) # Restore before the platforms are set up so the entities' first state
ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True) # write already carries the last known values and the serial number.
if not ble_device: await coordinator.async_restore()
# Log warning only once per config entry
# Use hass.data for warning flag as it's separate from coordinator runtime_data
hass.data.setdefault(DOMAIN, {})
warning_key = f"warned_{entry.entry_id}"
if not hass.data[DOMAIN].get(warning_key):
_LOGGER.warning(
"MySmartBike device with address %s not found. "
"Make sure the bike is powered on and in range. "
"Home Assistant will retry automatically",
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)
_LOGGER.debug("Found BLE device: %s", ble_device)
# Create coordinator
_LOGGER.debug("Creating coordinator for device %s", address)
coordinator = MySmartBikeCoordinator(hass, ble_device, entry)
# Perform first refresh
_LOGGER.debug("Performing first coordinator refresh")
await coordinator.async_config_entry_first_refresh()
_LOGGER.debug(
"First refresh completed - coordinator state: is_connected=%s, manual_disconnect=%s",
coordinator.is_connected,
coordinator._manual_disconnect,
)
# Store coordinator in runtime_data
entry.runtime_data = coordinator entry.runtime_data = coordinator
_LOGGER.debug("Coordinator stored in entry.runtime_data")
# Forward entry setup to platforms
_LOGGER.debug("Forwarding entry setup to platforms: %s", PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug("Platform setup completed")
_LOGGER.debug("MySmartBike BLE integration setup completed successfully for entry_id: %s", entry.entry_id) # 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 return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
_LOGGER.debug("Unloading MySmartBike BLE integration for entry_id: %s", entry.entry_id)
# Unload platforms
_LOGGER.debug("Unloading platforms: %s", PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
_LOGGER.debug("Platform unload result: %s", unload_ok)
if unload_ok: if unload_ok:
coordinator: MySmartBikeCoordinator = entry.runtime_data coordinator: MySmartBikeCoordinator = entry.runtime_data
_LOGGER.debug(
"Coordinator retrieved from runtime_data - state: is_connected=%s, manual_disconnect=%s",
coordinator.is_connected,
coordinator._manual_disconnect,
)
await coordinator.async_shutdown() await coordinator.async_shutdown()
_LOGGER.debug("Coordinator shutdown completed")
# Clean up warning flag from hass.data
if DOMAIN in hass.data:
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
else:
_LOGGER.warning("Platform unload was not successful")
_LOGGER.debug("MySmartBike BLE integration unload completed for entry_id: %s (result: %s)", entry.entry_id, unload_ok)
return unload_ok 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()
@@ -52,16 +52,16 @@ class MySmartBikeConnectionSensor(CoordinatorEntity[MySmartBikeCoordinator], Bin
self._attr_device_info["sw_version"] = coordinator.protocol_version self._attr_device_info["sw_version"] = coordinator.protocol_version
self._attr_translation_key = "connected" 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 @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if connected to the bike.""" """Return True if connected to the bike."""
return self.coordinator.is_connected return self.coordinator.is_connected
@property
def name(self) -> str:
"""Return the name of the sensor."""
return "Connected"
@property @property
def icon(self) -> str: def icon(self) -> str:
"""Return the icon.""" """Return the icon."""
@@ -99,8 +99,7 @@ class MySmartBikeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
): ):
continue continue
# Check if device name starts with "iWoc" if discovery_info.name and discovery_info.name.startswith(("iWoc", "HUS")):
if discovery_info.name and discovery_info.name.startswith("iWoc"):
self._discovered_devices[discovery_info.address] = discovery_info self._discovered_devices[discovery_info.address] = discovery_info
if not self._discovered_devices: if not self._discovered_devices:
@@ -36,3 +36,31 @@ CONF_DEVICE_ADDRESS: Final = "device_address"
# Options # Options
CONF_LOG_BLE_MESSAGES: Final = "log_ble_messages" 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"),
}
# A link that survived at least this long is worth reconnecting immediately when
# it drops; anything shorter is left to the regular poll so a bike that cannot
# hold a connection does not spin in a reconnect loop.
MIN_LINK_SECONDS_FOR_FAST_RECONNECT: Final = 5.0
# Granularity of the "Last Seen" entity. Notifications arrive about once a
# second; publishing each one made this timestamp the single biggest recorder
# writer in a real installation. Its job - telling you how fresh the values are
# - needs nothing near that resolution. The precise value is still persisted.
LAST_SEEN_RESOLUTION: Final = 30 # seconds
+385 -256
View File
@@ -15,10 +15,17 @@ from bleak_retry_connector import (
) )
from homeassistant.components import bluetooth 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.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.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import ( from .const import (
DOMAIN, DOMAIN,
@@ -28,6 +35,12 @@ from .const import (
PROTOCOL_REQUEST_MESSAGE, PROTOCOL_REQUEST_MESSAGE,
CLOSE_MESSAGE, CLOSE_MESSAGE,
SCAN_INTERVAL, SCAN_INTERVAL,
LAST_SEEN_RESOLUTION,
MIN_LINK_SECONDS_FOR_FAST_RECONNECT,
STORAGE_SAVE_DELAY,
STORAGE_VERSION,
RESTORE_STATE_KEYS,
VOLATILE_FIELDS,
CONF_LOG_BLE_MESSAGES, CONF_LOG_BLE_MESSAGES,
CONF_DEVICE_NAME, CONF_DEVICE_NAME,
) )
@@ -36,13 +49,24 @@ from .parsers import BikeDataParser
_LOGGER = logging.getLogger(__name__) _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 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__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
ble_device: BluetoothServiceInfoBleak, address: str,
entry: ConfigEntry, entry: ConfigEntry,
) -> None: ) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
@@ -52,36 +76,38 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=SCAN_INTERVAL), update_interval=timedelta(seconds=SCAN_INTERVAL),
) )
self._ble_device = ble_device self._address = address
self._entry = entry self._entry = entry
self._client: BleakClient | None = None self._client: BleakClient | None = None
self._parser = BikeDataParser() self._parser = BikeDataParser()
self._is_connected = False 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._manual_disconnect = False # Track if user manually disconnected
_LOGGER.debug( self._last_seen: datetime | None = None
"Coordinator initialized: address=%s, is_connected=%s, manual_disconnect=%s, scan_interval=%s", self._save_armed = False
ble_device.address, self._unreachable_reason: str | None = None
self._is_connected, self._connecting = False
self._manual_disconnect, self._advertisements_seen = 0
SCAN_INTERVAL, self._connected_since: float | None = None
self._store: Store[dict[str, Any]] = Store(
hass, STORAGE_VERSION, storage_key(entry)
) )
@property @property
def address(self) -> str: def address(self) -> str:
"""Return the address of the device.""" """Return the address of the device."""
return self._ble_device.address return self._address
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Return connection status.""" """Return connection status."""
_LOGGER.debug(
"Coordinator.is_connected property called: returning %s (manual_disconnect: %s)",
self._is_connected,
self._manual_disconnect,
)
return self._is_connected 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 @property
def vin(self) -> str | None: def vin(self) -> str | None:
"""Return the VIN/serial number if available.""" """Return the VIN/serial number if available."""
@@ -92,260 +118,407 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Return the protocol version if available.""" """Return the protocol version if available."""
return self._parser.protocol_version return self._parser.protocol_version
async def async_disconnect(self) -> None: def _resolve_device(self) -> Any:
"""Disconnect from the device (user initiated).""" """Resolve the current BLEDevice for our address, or None if not in range.
_LOGGER.debug(
"Coordinator.async_disconnect called for %s (user initiated) - current state: is_connected=%s, manual_disconnect=%s, client=%s", Resolved per attempt rather than cached: a device object handed out by a
self._ble_device.address, previous scan goes stale when the adapter or the proxy serving it changes.
self._is_connected, """
self._manual_disconnect, return bluetooth.async_ble_device_from_address(
self._client is not None, self.hass, self._address, connectable=True
) )
# Mark as manually disconnected to prevent auto-reconnect # ------------------------------------------------------------------
self._manual_disconnect = True # Persistence
_LOGGER.debug("Coordinator.async_disconnect: Set manual_disconnect=True") # ------------------------------------------------------------------
if self._client: async def async_restore(self) -> None:
_LOGGER.debug("Coordinator.async_disconnect: Client exists, cleaning up connection") """Load the persisted state into the parser.
client_to_cleanup = self._client
self._client = None # Clear reference immediately 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",
)
@callback
def _publish_last_seen(self) -> None:
"""Move the entity-visible timestamp forward at most every 30 seconds.
`self._last_seen` stays exact - it is what gets persisted - but writing
it to the entity on every notification produced roughly 2800 database
rows per hour per bike, more than any other entity in a real install.
"""
if self._last_seen is None:
return
published = self._parser.state.get("last_seen")
if (
published is not None
and (self._last_seen - published).total_seconds() < LAST_SEEN_RESOLUTION
):
return
self._parser.state["last_seen"] = self._last_seen
def _persist_data(self) -> dict[str, Any]:
"""Build the payload written to .storage."""
return {
**{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_claim_connect(self) -> bool:
"""Reserve the right to run one connect attempt.
Home Assistant invokes the bluetooth callback on *every* advertisement,
and these bikes advertise several times a second. Without a claim, a
disconnected bike would queue hundreds of connect tasks behind
`_connect_lock`, each running a full `establish_connection` retry cycle
against a proxy that is already struggling to hold the link.
"""
if self._is_connected or self._manual_disconnect or self._connecting:
return False
self._connecting = True
return True
@callback
def _async_device_appeared(
self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange
) -> None:
"""Handle the bike showing up in range."""
self._advertisements_seen += 1
if not self._async_claim_connect():
return
_LOGGER.debug(
"Advertisement from %s (%d seen) - connecting now",
self._address,
self._advertisements_seen,
)
self._entry.async_create_background_task(
self.hass, self._async_run_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._is_connected = False
self.async_update_listeners()
try: # Don't sit out the poll interval. These bikes stop advertising after an
# Only send close message if still connected # unexpected link loss, so the advertisement watch does not fire, and
if client_to_cleanup.is_connected: # every notification has just pushed the poll timer another 30s out -
# Send close message to bike before disconnecting # leaving the bike disconnected far longer than necessary.
_LOGGER.debug("Coordinator.async_disconnect: Sending close message ($D$I#@)") held_for = (
try: self.hass.loop.time() - self._connected_since
await client_to_cleanup.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE) if self._connected_since is not None
_LOGGER.debug("Coordinator.async_disconnect: Close message sent") else 0.0
await asyncio.sleep(0.5) )
except Exception as ex: self._connected_since = None
_LOGGER.debug("Coordinator.async_disconnect: Error sending close message: %s", ex) if held_for < MIN_LINK_SECONDS_FOR_FAST_RECONNECT:
# A link that died almost immediately would spin; let the poll retry.
return
if not self._async_claim_connect():
return
self._entry.async_create_background_task(
self.hass, self._async_run_connect(), f"{DOMAIN} reconnect {self._address}"
)
# Stop notifications async def async_first_connect(self) -> None:
try: """Attempt the initial connection without blocking setup."""
await client_to_cleanup.stop_notify(NOTIFY_UUID) if self._manual_disconnect:
_LOGGER.debug("Coordinator.async_disconnect: Stopped notifications") _LOGGER.debug(
except Exception as ex: "Not connecting to %s - connection was switched off by the user",
_LOGGER.debug("Coordinator.async_disconnect: Error stopping notifications: %s", ex) self._address,
)
return
await self._async_try_connect()
# Disconnect from device async def _async_try_connect(self) -> None:
try: """Run a connect attempt unless one is already in flight."""
await client_to_cleanup.disconnect() if not self._async_claim_connect():
_LOGGER.debug("Coordinator.async_disconnect: Disconnected from device") return
except Exception as ex: await self._async_run_connect()
_LOGGER.debug("Coordinator.async_disconnect: Error during disconnect: %s", ex)
else:
_LOGGER.debug("Coordinator.async_disconnect: Client exists but not connected, skipping disconnect")
except Exception as ex: async def _async_run_connect(self) -> None:
_LOGGER.debug("Coordinator.async_disconnect: Unexpected error during disconnect: %s", ex, exc_info=True) """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}")
finally: finally:
# Force delete the client object to help garbage collection self._connecting = False
del client_to_cleanup
# Give BLE adapter significant time to release connection slot def _async_report_unreachable(self, reason: str) -> None:
_LOGGER.debug("Coordinator.async_disconnect: Waiting for connection slot release (3 seconds)") """Log why we cannot connect - once per distinct reason.
await asyncio.sleep(3.0)
_LOGGER.debug("Coordinator.async_disconnect: Cleaned up client (is_connected=%s)", self._is_connected) 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: else:
_LOGGER.debug("Coordinator.async_disconnect: No client to disconnect") _LOGGER.debug("%s", reason)
self._is_connected = False
_LOGGER.debug( def _no_route_reason(self) -> str:
"Coordinator.async_disconnect completed - final state: is_connected=%s, manual_disconnect=%s", """Explain why the address did not resolve to a connectable device.
self._is_connected,
self._manual_disconnect, 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 async_reconnect(self) -> None: async def _cleanup_client(self, send_close: bool = True, wait_for_slot: bool = True) -> None:
"""Reconnect to the device (user initiated).""" """Clean up BLE client connection.
_LOGGER.debug(
"Coordinator.async_reconnect called for %s (user initiated) - current state: is_connected=%s, manual_disconnect=%s, client=%s",
self._ble_device.address,
self._is_connected,
self._manual_disconnect,
self._client is not None,
)
# Clean up any existing client first Args:
if self._client: send_close: Whether to send close message to bike before disconnecting.
_LOGGER.debug("Coordinator.async_reconnect: Found existing client, cleaning up first") wait_for_slot: Whether to wait for BLE connection slot release.
old_client = self._client """
if not self._client:
return
client = self._client
self._client = None self._client = None
self._is_connected = False self._is_connected = False
try: try:
if old_client.is_connected: if client.is_connected:
await old_client.disconnect() if send_close:
_LOGGER.debug("Coordinator.async_reconnect: Disconnected existing client") try:
await client.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE)
await asyncio.sleep(0.5)
except Exception:
pass # Ignore close message errors
try:
await client.stop_notify(NOTIFY_UUID)
except Exception:
pass # Ignore notification stop errors
try:
await client.disconnect()
except Exception as ex: except Exception as ex:
_LOGGER.debug("Coordinator.async_reconnect: Error disconnecting old client: %s", ex) _LOGGER.debug("Error during BLE disconnect: %s", ex)
except Exception as ex:
_LOGGER.debug("Unexpected error during client cleanup: %s", ex)
finally: finally:
del old_client del client
# Wait longer for connection slot to be released if wait_for_slot:
_LOGGER.debug("Coordinator.async_reconnect: Waiting for connection slot release (3 seconds)") await asyncio.sleep(3.0) # Wait for BLE connection slot release
await asyncio.sleep(3.0) # Let binary_sensor.connected drop immediately instead of at the next poll.
_LOGGER.debug("Coordinator.async_reconnect: Cleaned up old client and waited for slot release") self.async_update_listeners()
async def async_disconnect(self) -> None:
"""Disconnect from the device (user initiated)."""
_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._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 # Clear manual disconnect flag to allow auto-reconnect
self._manual_disconnect = False self._manual_disconnect = False
_LOGGER.debug("Coordinator.async_reconnect: Set manual_disconnect=False") self._schedule_save()
try: try:
await self._connect() await self._connect()
_LOGGER.debug(
"Coordinator.async_reconnect completed - final state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
except Exception as ex: except Exception as ex:
# Only log as error if it's not a "device not reachable" issue
error_str = str(ex).lower() error_str = str(ex).lower()
if "not reachable" in error_str or "turn on the bike" in error_str: if "not reachable" not in error_str and "turn on the bike" not in error_str:
_LOGGER.debug("Coordinator.async_reconnect: Device not reachable, will retry later") _LOGGER.error("Reconnect failed: %s", ex)
else:
_LOGGER.error("Coordinator.async_reconnect failed: %s", ex, exc_info=True)
raise raise
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the device.""" """Refresh diagnostics and auto-reconnect; never fails the entities.
_LOGGER.debug(
"Coordinator._async_update_data called - current state: is_connected=%s, manual_disconnect=%s",
self._is_connected,
self._manual_disconnect,
)
# Don't auto-reconnect if user manually disconnected 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: if not self._is_connected and not self._manual_disconnect:
_LOGGER.debug("Coordinator._async_update_data: Not connected and not manual disconnect, attempting auto-reconnect") await self._async_try_connect()
try:
await self._connect()
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect successful (is_connected=%s)", self._is_connected)
except Exception as ex:
# Only log as warning if device is not reachable, otherwise debug
error_str = str(ex).lower()
if "not reachable" in error_str or "turn on the bike" in error_str:
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect skipped - device not reachable")
else:
_LOGGER.debug("Coordinator._async_update_data: Auto-reconnect failed: %s", ex)
elif not self._is_connected and self._manual_disconnect:
_LOGGER.debug("Coordinator._async_update_data: Not connected but manual_disconnect=True, skipping auto-reconnect")
else:
_LOGGER.debug("Coordinator._async_update_data: Already connected, no action needed")
# Return current state from parser, ensure it's never None state = self._parser.state
state = self._parser.state or {
"battery_primary": None,
"battery_secondary": None,
"motor": None,
"assist": None,
"ebm": None,
}
# Add RSSI (signal strength) to state # Add RSSI (signal strength) to state - None while out of range
# Get latest service info which contains current RSSI
try: try:
service_info = bluetooth.async_last_service_info( 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 state["rssi"] = service_info.rssi if service_info else None
except Exception as ex: except Exception:
_LOGGER.debug("Could not get RSSI: %s", ex)
state["rssi"] = None state["rssi"] = None
_LOGGER.debug("Coordinator._async_update_data: Returning state (has_data=%s, rssi=%s)", self._parser.state is not None, state.get("rssi")) self._publish_last_seen()
_LOGGER.debug(
"%s: connected=%s advertisements_seen=%s rssi=%s",
self._address,
self._is_connected,
self._advertisements_seen,
state["rssi"],
)
return state return state
async def _connect(self) -> None: async def _connect(self) -> None:
"""Connect to the device and start notifications.""" """Connect to the device and start notifications."""
_LOGGER.debug( async with self._connect_lock:
"Coordinator._connect: Attempting to connect to %s (current is_connected=%s, manual_disconnect=%s, client=%s)", if self._is_connected:
self._ble_device.address, return
self._is_connected,
self._manual_disconnect, ble_device = self._resolve_device()
self._client is not None, if ble_device is None:
) raise UpdateFailed(self._no_route_reason())
# Clean up any existing client before connecting # Clean up any existing client before connecting
if self._client: if self._client:
_LOGGER.warning("Coordinator._connect: Client already exists, cleaning up before new connection") _LOGGER.debug("Cleaning up existing client before new connection")
old_client = self._client await self._cleanup_client(send_close=False, wait_for_slot=True)
self._client = None
try:
if old_client.is_connected:
await old_client.disconnect()
except Exception as ex:
_LOGGER.debug("Coordinator._connect: Error cleaning up old client: %s", ex)
finally:
del old_client
_LOGGER.debug("Coordinator._connect: Waiting for connection slot release (3 seconds)")
await asyncio.sleep(3.0)
try: try:
_LOGGER.debug("Coordinator._connect: Calling establish_connection for %s", self._ble_device.address) # Held in a local: the handshake sleeps, and a concurrent
# disconnect (switch off, dropped link) may clear self._client
self._client = await establish_connection( # underneath us - reading it back mid-handshake would crash.
client = await establish_connection(
BleakClientWithServiceCache, BleakClientWithServiceCache,
self._ble_device, ble_device,
self._ble_device.address, self._address,
disconnected_callback=self._async_client_disconnected,
ble_device_callback=self._resolve_device,
) )
self._client = client
_LOGGER.debug("Coordinator._connect: Successfully connected to %s, client=%s", self._ble_device.address, self._client) # Start notifications and request device info
await client.start_notify(NOTIFY_UUID, self._notification_handler)
# Start notifications first await client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
_LOGGER.debug("Coordinator._connect: Starting notifications on UUID %s", NOTIFY_UUID)
await self._client.start_notify(NOTIFY_UUID, self._notification_handler)
_LOGGER.debug("Coordinator._connect: Started notifications successfully")
# Request VIN/serial number ($S$V#@)
_LOGGER.debug("Coordinator._connect: Requesting VIN/serial number")
await self._client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
_LOGGER.debug("Coordinator._connect: VIN request sent")
# Small delay between requests
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
await client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
# Request protocol version ($S$P#@) if self._client is not client:
_LOGGER.debug("Coordinator._connect: Requesting protocol version") # Torn down while we were setting up - don't claim success.
await self._client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE) _LOGGER.debug("Connection to %s was cancelled", self._address)
_LOGGER.debug("Coordinator._connect: Protocol request sent") return
self._is_connected = True self._is_connected = True
_LOGGER.debug("Coordinator._connect: Set is_connected=True") self._connected_since = self.hass.loop.time()
self._unreachable_reason = None
_LOGGER.debug("Connected to %s", self._address)
except (BleakError, asyncio.TimeoutError) as ex: except (BleakError, asyncio.TimeoutError) as ex:
self._is_connected = False self._is_connected = False
# Check if error is due to device not being reachable (turned off)
error_str = str(ex).lower() error_str = str(ex).lower()
if "no longer reachable" in error_str or "out of connection slots" in error_str: if "no longer reachable" in error_str or "out of connection slots" in error_str:
_LOGGER.warning( _LOGGER.warning("Device %s not reachable - turn on the bike", self._address)
"Coordinator._connect: Device %s is not reachable or powered off. " raise UpdateFailed(f"Device {self._address} is not reachable") from ex
"Turn on the bike to connect.",
self._ble_device.address
)
raise UpdateFailed(
f"Device {self._ble_device.address} is not reachable. "
"Please turn on the bike."
) from ex
else: else:
_LOGGER.error( _LOGGER.error("Failed to connect to %s: %s", self._address, ex)
"Coordinator._connect: Failed to connect to device %s: %s (is_connected set to False)",
self._ble_device.address,
ex,
exc_info=True,
)
raise UpdateFailed(f"Failed to connect to device: {ex}") from 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: def _notification_handler(self, sender: int, data: bytearray) -> None:
"""Handle notification data.""" """Handle notification data."""
_LOGGER.debug("Received notification from %s: %s", sender, data.hex())
# Recognize message type before saving # Recognize message type before saving
message_type = self._parser.recognize_message_type(bytes(data)) message_type = self._parser.recognize_message_type(bytes(data))
_LOGGER.debug("BLE notification [%s]: %s", message_type, data.hex())
# Save BLE message to file if option is enabled (run in executor to avoid blocking) # Save BLE message to file if option is enabled (run in executor to avoid blocking)
if self._entry.options.get(CONF_LOG_BLE_MESSAGES, False): if self._entry.options.get(CONF_LOG_BLE_MESSAGES, False):
@@ -354,6 +527,10 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Parse the message # Parse the message
self._parser.handle_message(bytes(data)) self._parser.handle_message(bytes(data))
self._last_seen = dt_util.utcnow()
self._publish_last_seen()
self._schedule_save()
# Update coordinator data # Update coordinator data
self.async_set_updated_data(self._parser.state) self.async_set_updated_data(self._parser.state)
@@ -403,61 +580,13 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
with open(filepath, "a", encoding="utf-8") as f: with open(filepath, "a", encoding="utf-8") as f:
f.write(message_line) f.write(message_line)
_LOGGER.debug("Saved BLE message to: %s", filepath)
except Exception as ex: except Exception as ex:
_LOGGER.error("Failed to save BLE message to file: %s", ex, exc_info=True) _LOGGER.error("Failed to save BLE message to file: %s", ex)
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
"""Shutdown the coordinator.""" """Shutdown the coordinator."""
_LOGGER.debug( _LOGGER.debug("Shutting down coordinator")
"Coordinator.async_shutdown called - current state: is_connected=%s, manual_disconnect=%s, client=%s", await super().async_shutdown()
self._is_connected, await self._cleanup_client(send_close=True, wait_for_slot=False)
self._manual_disconnect, # Flush any pending debounced write so an unload never loses the state.
self._client is not None, await self._store.async_save(self._persist_data())
)
if self._client:
_LOGGER.debug("Coordinator.async_shutdown: Client exists, cleaning up connection")
client_to_cleanup = self._client
self._client = None
self._is_connected = False
try:
# Only send close message if still connected
if client_to_cleanup.is_connected:
# Send close message to bike before disconnecting
try:
_LOGGER.debug("Coordinator.async_shutdown: Sending close message ($D$I#@)")
await client_to_cleanup.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE)
_LOGGER.debug("Coordinator.async_shutdown: Close message sent")
await asyncio.sleep(0.5)
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error sending close message: %s", ex)
# Stop notifications
try:
await client_to_cleanup.stop_notify(NOTIFY_UUID)
_LOGGER.debug("Coordinator.async_shutdown: Stopped notifications")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error stopping notifications: %s", ex)
# Disconnect from device
try:
await client_to_cleanup.disconnect()
_LOGGER.debug("Coordinator.async_shutdown: Disconnected from device")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Error during disconnect: %s", ex)
else:
_LOGGER.debug("Coordinator.async_shutdown: Client exists but not connected, skipping disconnect")
except Exception as ex:
_LOGGER.debug("Coordinator.async_shutdown: Unexpected error during shutdown: %s", ex, exc_info=True)
finally:
del client_to_cleanup
_LOGGER.debug("Coordinator.async_shutdown: Cleaned up client")
else:
_LOGGER.debug("Coordinator.async_shutdown: No client to clean up")
self._is_connected = False
_LOGGER.debug("Coordinator.async_shutdown completed")
@@ -4,6 +4,9 @@
"bluetooth": [ "bluetooth": [
{ {
"local_name": "iWoc*" "local_name": "iWoc*"
},
{
"local_name": "HUS*"
} }
], ],
"codeowners": [ "codeowners": [
@@ -21,5 +24,5 @@
"bleak>=0.21.0", "bleak>=0.21.0",
"bleak-retry-connector>=3.1.0" "bleak-retry-connector>=3.1.0"
], ],
"version": "0.0.2" "version": "1.1.0"
} }
+163 -32
View File
@@ -12,12 +12,25 @@ _LOGGER = logging.getLogger(__name__)
def read16(data: bytes, offset: int) -> int: def read16(data: bytes, offset: int) -> int:
"""Read 16-bit value from data at offset (big-endian, as per Mahle protocol).""" """Read 16-bit big-endian value from data at offset."""
return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF) return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF)
def read16_signed(data: bytes, offset: int) -> int:
"""Read 16-bit big-endian value as signed int."""
value = read16(data, offset)
if value & 0x8000:
value -= 0x10000
return value
def read_signed_byte(byte_val: int) -> int:
"""Read byte as signed int."""
return byte_val - 256 if byte_val & 0x80 else byte_val
def read24(data: bytes, offset: int) -> int: def read24(data: bytes, offset: int) -> int:
"""Read 24-bit value from data at offset (big-endian, as per Mahle protocol).""" """Read 24-bit big-endian value from data at offset."""
return ( return (
((data[offset] & 0xFF) << 16) ((data[offset] & 0xFF) << 16)
| ((data[offset + 1] & 0xFF) << 8) | ((data[offset + 1] & 0xFF) << 8)
@@ -26,7 +39,7 @@ def read24(data: bytes, offset: int) -> int:
def read32(data: bytes, offset: int) -> int: def read32(data: bytes, offset: int) -> int:
"""Read 32-bit value from data at offset (big-endian, as per Mahle protocol).""" """Read 32-bit big-endian value from data at offset."""
return ( return (
((data[offset] & 0xFF) << 24) ((data[offset] & 0xFF) << 24)
| ((data[offset + 1] & 0xFF) << 16) | ((data[offset + 1] & 0xFF) << 16)
@@ -57,11 +70,15 @@ class BikeDataParser:
self.protocol_version: Optional[str] = None self.protocol_version: Optional[str] = None
def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]: def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse battery message and update state.""" """Parse battery frame; dispatch by length to the right layout."""
if len(message) < BATTERY_MESSAGE_LENGTH: if len(message) == 20:
return self._parse_battery_x20(message)
if len(message) >= BATTERY_MESSAGE_LENGTH:
return self._parse_battery_ebm(message)
return None return None
# Read values def _parse_battery_ebm(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 19-byte battery frame (X25 / X35+ / ebikemotion)."""
voltage = read16(message, 5) / 10.0 voltage = read16(message, 5) / 10.0
soc = read_unsigned_byte(message[7]) soc = read_unsigned_byte(message[7])
temp_status = message[8] temp_status = message[8]
@@ -69,66 +86,102 @@ class BikeDataParser:
nominal_capacity = read16(message, 11) / 10.0 nominal_capacity = read16(message, 11) / 10.0
remaining_wh = read16(message, 13) / 10.0 remaining_wh = read16(message, 13) / 10.0
# Get battery number and cycles from combined field at offset 15
# Format: value = (battery_number * 10000) + cycles
# e.g., 10036 means battery 1, 36 cycles
combined_raw = read16(message, 15) if len(message) >= 19 else None combined_raw = read16(message, 15) if len(message) >= 19 else None
battery_number = (combined_raw // 10000) if combined_raw else 1 battery_number = (combined_raw // 10000) if combined_raw else 1
cycles = (combined_raw % 10000) if combined_raw else None cycles = (combined_raw % 10000) if combined_raw else None
# Construct battery data dictionary
data = { data = {
"voltage": voltage, "voltage": voltage,
"soc": soc, "soc": soc,
"temperature": temp_status, "temperature": temp_status,
"temperature_mos": None,
"current": current, "current": current,
"nominal_capacity": nominal_capacity, "nominal_capacity": nominal_capacity,
"remaining_wh": remaining_wh, "remaining_wh": remaining_wh,
"cycles": cycles, "cycles": cycles,
"is_charging": False,
} }
self._store_battery(data, battery_number)
return data
# Handle secondary vs primary battery def _parse_battery_x20(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 20-byte battery frame (X20 / HUS-prefixed devices)."""
voltage = read16(message, 5) / 100.0
soc_raw = read_unsigned_byte(message[7])
# Bit 7 of the SOC byte signals charging on the newer firmware variant;
# safe to read unconditionally — real SOC is always ≤ 100, so the bit
# would never be set by accident on older firmwares.
is_charging = bool(soc_raw & 0x80)
soc = soc_raw & 0x7F
temp_status = read_signed_byte(message[8])
current = read16_signed(message, 9) / 10.0
nominal_capacity = read16(message, 11) / 10.0
remaining_wh = read16(message, 13) / 10.0
temperature_mos = read_signed_byte(message[15])
combined_raw = read16(message, 16)
battery_number = combined_raw // 10000
cycles = combined_raw % 10000
data = {
"voltage": voltage,
"soc": soc,
"temperature": temp_status,
"temperature_mos": temperature_mos,
"current": current,
"nominal_capacity": nominal_capacity,
"remaining_wh": remaining_wh,
"cycles": cycles,
"is_charging": is_charging,
}
self._store_battery(data, battery_number)
return data
def _store_battery(self, data: Dict[str, Any], battery_number: int) -> None:
"""Update primary/secondary battery slots and the consecutive-primary counter."""
if battery_number == 2: if battery_number == 2:
# Secondary battery detected
self.battery_packet_counter = 0 self.battery_packet_counter = 0
self.state["battery_secondary"] = data self.state["battery_secondary"] = data
elif battery_number == 1: return
# Primary battery
# Anything that isn't an explicit secondary battery (number == 2) is
# treated as primary — a missing/zero battery_number on a single-battery
# bike would otherwise leave all sensors unavailable.
self.battery_packet_counter += 1 self.battery_packet_counter += 1
self.state["battery_primary"] = data self.state["battery_primary"] = data
# After 4 consecutive primary battery packets, reset secondary battery
if self.battery_packet_counter >= 4: if self.battery_packet_counter >= 4:
self.state["battery_secondary"] = { self.state["battery_secondary"] = {
"voltage": 0.0, "voltage": 0.0,
"soc": 0.0, "soc": 0.0,
"temperature": 0, "temperature": 0,
"temperature_mos": None,
"current": 0.0, "current": 0.0,
"nominal_capacity": 0.0, "nominal_capacity": 0.0,
"remaining_wh": 0.0, "remaining_wh": 0.0,
"cycles": None, "cycles": None,
"is_charging": False,
} }
return data
def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]: def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse motor message and update state.""" """Parse motor frame; dispatch by length to the right layout."""
if len(message) < MOTOR_MESSAGE_LENGTH: if len(message) >= 20:
return self._parse_motor_x20(message)
if len(message) >= MOTOR_MESSAGE_LENGTH:
return self._parse_motor_ebm(message)
return None return None
# Extract values from message def _parse_motor_ebm(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 18-byte motor frame (X25 / X35+ / ebikemotion)."""
assist_level = message[5] assist_level = message[5]
temperature_celsius = message[6] temperature_celsius = message[6]
power_amp = float(read16(message, 7)) / 10.0 power_amp = float(read16(message, 7)) / 10.0
speed_kmh = float(read16(message, 9)) / 10.0 speed_kmh = float(read16(message, 9)) / 10.0
# Additional values
wheel_speed = read_unsigned_byte(message[11]) wheel_speed = read_unsigned_byte(message[11])
torque_pct = message[12] torque_pct = message[12]
power_max = float(read16(message, 13)) / 10.0 power_max = float(read16(message, 13)) / 10.0
max_torque_pct = message[15] max_torque_pct = message[15]
# Update state with motor data
data = { data = {
"assist_level": assist_level, "assist_level": assist_level,
"temperature_celsius": temperature_celsius, "temperature_celsius": temperature_celsius,
@@ -138,8 +191,38 @@ class BikeDataParser:
"torque_motor_pct": torque_pct, "torque_motor_pct": torque_pct,
"power_max_amp": power_max, "power_max_amp": power_max,
"max_torque_motor_pct": max_torque_pct, "max_torque_motor_pct": max_torque_pct,
"motor_power_watts": None,
"rider_power_watts": None,
} }
self.state["motor"] = data
return data
def _parse_motor_x20(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 20-byte motor frame (X20 / HUS-prefixed devices)."""
assist_level = read_signed_byte(message[5])
# Temperature is signed: 0xD8 (= -40 °C) is the "no sensor data" sentinel
# the bike reports during the first packets after connect.
temperature_celsius = read_signed_byte(message[6])
motor_power_watts = read16(message, 7) / 100.0
speed_kmh = read16(message, 9) / 10.0
wheel_speed_raw = read_unsigned_byte(message[11])
rider_power_watts = read16(message, 12) / 10.0
power_max_amp = read16(message, 14) / 10.0
# max_torque doubles as a validity flag for wheel_speed (0 → no data).
max_torque = read16(message, 16)
data = {
"assist_level": assist_level,
"temperature_celsius": temperature_celsius,
"power_amp": None,
"speed_kmh": speed_kmh,
"wheel_speed_rpm": wheel_speed_raw if max_torque != 0 else None,
"torque_motor_pct": None,
"power_max_amp": power_max_amp,
"max_torque_motor_pct": max_torque,
"motor_power_watts": motor_power_watts,
"rider_power_watts": rider_power_watts,
}
self.state["motor"] = data self.state["motor"] = data
return data return data
@@ -214,29 +297,77 @@ class BikeDataParser:
return None return None
def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]: def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse EBM (E-Bike Management) message.""" """Parse EBM (E-Bike Management) frame; dispatch by length."""
if len(message) < EBM_MESSAGE_LENGTH: if len(message) >= 20:
return None return self._parse_ebm_x20(message)
if len(message) >= EBM_MESSAGE_LENGTH:
# EbmParserEbm format: 32-bit reads directly from message (big-endian) return self._parse_ebm_ebm(message)
# Raw values are in decimeters, divide by 10000 to get km
# (Mahle code divides by 10 to get meters, then displays as km by /1000)
if len(message) < 15:
return None return None
def _parse_ebm_ebm(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 17-byte EBM frame (X25 / X35+ / ebikemotion)."""
odometry_km = read32(message, 5) / 10000.0 odometry_km = read32(message, 5) / 10000.0
autonomy_km = read32(message, 9) / 10000.0 autonomy_km = read32(message, 9) / 10000.0
is_light_on = message[13] == 1 is_light_on = message[13] == 1
status = read_unsigned_byte(message[14]) status = read_unsigned_byte(message[14])
# EbmParserEbm only parses bytes 5-14, bytes 15-16 are suffix #@
data = { data = {
"odometry": odometry_km, "odometry": odometry_km,
"autonomy": autonomy_km, "autonomy": autonomy_km,
"trip_odometry": None,
"trip_autonomy": None,
"is_light_on": is_light_on, "is_light_on": is_light_on,
"status": status, "status": status,
"accel_y": None,
"accel_z": None,
}
self.state["ebm"] = data
return data
def _parse_ebm_x20(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 20-byte EBM frame (X20 / HUS-prefixed devices).
The bike alternates between two slot indicators in byte 14:
- slot == 1 → bytes 5-9 carry the LIFETIME odometer & range
- slot == 2 → same bytes carry the current TRIP A distance & range
Bytes 15-17 are a fixed `HIJ` (`0x48 0x49 0x4A`) marker before `#@`. A
device using protocol v200 puts an MPlatform error code and remote-SOC
info there instead — not yet supported.
"""
odometry_km = read24(message, 5) / 10.0
autonomy_km = read16(message, 8) / 10.0
is_light_on = message[10] == 1
status = read_unsigned_byte(message[11])
accel_z = read_signed_byte(message[12])
accel_y = read_signed_byte(message[13])
slot = read_unsigned_byte(message[14])
prev = self.state.get("ebm") or {}
if slot == 2:
data = {
"odometry": prev.get("odometry"),
"autonomy": prev.get("autonomy"),
"trip_odometry": odometry_km,
"trip_autonomy": autonomy_km,
}
else:
data = {
"odometry": odometry_km,
"autonomy": autonomy_km,
"trip_odometry": prev.get("trip_odometry"),
"trip_autonomy": prev.get("trip_autonomy"),
} }
data.update(
{
"is_light_on": is_light_on,
"status": status,
"accel_y": accel_y,
"accel_z": accel_z,
}
)
self.state["ebm"] = data self.state["ebm"] = data
return data return data
+49 -2
View File
@@ -28,6 +28,17 @@ from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
from .coordinator import MySmartBikeCoordinator from .coordinator import MySmartBikeCoordinator
def round_km(value: Any) -> float | None:
"""Round a distance to 0.1 km.
The ebikemotion frames carry these as `read32 / 10000`, which yields four
decimals of false precision - 14.6065, 14.6529, 14.6297 for a range that is
really "14.6". Every flicker in the last digit would be another state write
and another database row.
"""
return None if value is None else round(float(value), 1)
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any: def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
"""Safely get nested dictionary values.""" """Safely get nested dictionary values."""
if data is None: if data is None:
@@ -108,7 +119,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE, device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:counter", icon="mdi:counter",
value_fn=lambda data: safe_get(data, "ebm", "odometry"), value_fn=lambda data: round_km(safe_get(data, "ebm", "odometry")),
), ),
MySmartBikeSensorEntityDescription( MySmartBikeSensorEntityDescription(
key="range", key="range",
@@ -117,7 +128,26 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DISTANCE, device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance", icon="mdi:map-marker-distance",
value_fn=lambda data: safe_get(data, "ebm", "autonomy"), value_fn=lambda data: round_km(safe_get(data, "ebm", "autonomy")),
),
MySmartBikeSensorEntityDescription(
key="trip_distance",
name="Trip A Distance",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:bike",
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_odometry")),
),
MySmartBikeSensorEntityDescription(
key="trip_range",
name="Trip A Range",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
entity_registry_enabled_default=False,
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_autonomy")),
), ),
MySmartBikeSensorEntityDescription( MySmartBikeSensorEntityDescription(
key="light", key="light",
@@ -144,6 +174,14 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
icon="mdi:wifi", icon="mdi:wifi",
value_fn=lambda data: safe_get(data, "rssi"), 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"),
),
) )
@@ -193,6 +231,15 @@ class MySmartBikeSensor(CoordinatorEntity[MySmartBikeCoordinator], SensorEntity)
if coordinator.protocol_version: if coordinator.protocol_version:
self._attr_device_info["sw_version"] = 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 @property
def native_value(self) -> Any: def native_value(self) -> Any:
"""Return the state of the sensor.""" """Return the state of the sensor."""
+23 -53
View File
@@ -22,10 +22,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up MySmartBike BLE switch entities.""" """Set up MySmartBike BLE switch entities."""
_LOGGER.debug("Setting up switch platform for entry_id: %s", entry.entry_id)
coordinator: MySmartBikeCoordinator = entry.runtime_data coordinator: MySmartBikeCoordinator = entry.runtime_data
_LOGGER.debug("Adding connection switch entity (coordinator.is_connected: %s)", coordinator.is_connected)
async_add_entities([MySmartBikeConnectionSwitch(coordinator, entry)]) async_add_entities([MySmartBikeConnectionSwitch(coordinator, entry)])
@@ -51,25 +48,20 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
if coordinator.protocol_version: if coordinator.protocol_version:
self._attr_device_info["sw_version"] = coordinator.protocol_version self._attr_device_info["sw_version"] = coordinator.protocol_version
self._attr_translation_key = "connection" self._attr_translation_key = "connection"
_LOGGER.debug(
"Switch initialized: unique_id=%s, coordinator.is_connected=%s", @property
self._attr_unique_id, def available(self) -> bool:
coordinator.is_connected, """Return True - the connection wish can always be changed."""
) return True
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if connection is desired (not manually disconnected).""" """Return True if connection is desired (not manually disconnected).
# Switch represents the desired state, not the actual connection status
# If manual_disconnect is False, user wants to be connected Restored from storage on startup, so a bike the user deliberately
state = not self.coordinator._manual_disconnect disconnected is not woken again by a Home Assistant restart.
_LOGGER.debug( """
"Switch is_on property called: returning %s (manual_disconnect=%s, is_connected=%s)", return not self.coordinator._manual_disconnect
state,
self.coordinator._manual_disconnect,
self.coordinator.is_connected
)
return state
@property @property
def icon(self) -> str: def icon(self) -> str:
@@ -77,33 +69,21 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off" return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch - request connection to the bike.""" """Turn on the switch - request connection to the bike.
_LOGGER.debug(
"Switch.async_turn_on called (current coordinator.is_connected: %s, manual_disconnect: %s)",
self.coordinator.is_connected,
self.coordinator._manual_disconnect,
)
# Update state immediately - switch is now ON (connection desired)
self.async_write_ha_state()
The switch reflects the *wish* to be connected, so it stays on even when
the bike is currently unreachable - the coordinator keeps retrying.
"""
try: try:
await self.coordinator.async_reconnect() await self.coordinator.async_reconnect()
_LOGGER.debug(
"Switch.async_turn_on: reconnect completed (coordinator.is_connected: %s)",
self.coordinator.is_connected,
)
except Exception as ex: except Exception as ex:
# Provide user-friendly error message error_msg = str(ex).lower()
error_msg = str(ex) if "not reachable" in error_msg:
if "not reachable" in error_msg.lower(): _LOGGER.warning("Cannot connect - bike not reachable. Will auto-connect when available.")
_LOGGER.warning(
"Switch.async_turn_on: Cannot connect now - bike is not reachable. "
"Will auto-connect when bike is powered on."
)
else: else:
_LOGGER.error("Switch.async_turn_on: Failed to connect to bike: %s", ex, exc_info=True) _LOGGER.error("Failed to connect to bike: %s", ex)
# Switch stays ON - coordinator will auto-reconnect when bike becomes available finally:
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch - disconnect from the bike. """Turn off the switch - disconnect from the bike.
@@ -111,19 +91,9 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
WARNING: This will turn off the bike after ~5 minutes! It must be manually WARNING: This will turn off the bike after ~5 minutes! It must be manually
turned on again or connected to power. turned on again or connected to power.
""" """
_LOGGER.warning( _LOGGER.warning("Disconnecting from bike - it will turn off after ~5 minutes")
"Switch.async_turn_off called - Disconnecting from bike (current coordinator.is_connected: %s). "
"Bike will turn off after approximately 5 minutes and must be manually turned on again or connected to power",
self.coordinator.is_connected,
)
try: try:
await self.coordinator.async_disconnect() await self.coordinator.async_disconnect()
_LOGGER.debug(
"Switch.async_turn_off: disconnect completed (coordinator.is_connected: %s, manual_disconnect: %s)",
self.coordinator.is_connected,
self.coordinator._manual_disconnect,
)
self.async_write_ha_state() self.async_write_ha_state()
_LOGGER.debug("Switch.async_turn_off: state written to HA")
except Exception as ex: except Exception as ex:
_LOGGER.error("Switch.async_turn_off: Failed to disconnect from bike: %s", ex, exc_info=True) _LOGGER.error("Failed to disconnect from bike: %s", ex)
@@ -27,9 +27,14 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"connected": {
"name": "Verbunden"
}
},
"switch": { "switch": {
"connection": { "connection": {
"name": "Verbindung" "name": "Auto-Verbindung"
} }
} }
} }
@@ -27,9 +27,14 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"connected": {
"name": "Connected"
}
},
"switch": { "switch": {
"connection": { "connection": {
"name": "Connection" "name": "Auto-connect"
} }
} }
} }
+68 -4
View File
@@ -75,19 +75,83 @@ def mock_ble_device() -> Generator[MagicMock]:
yield mock_devices 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 @pytest.fixture
async def init_integration( async def init_integration(
hass, hass,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
mock_bleak_client: MagicMock, mock_bleak_client: MagicMock,
mock_device_in_range: MagicMock,
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up the MySmartBike BLE integration for testing.""" """Set up the MySmartBike BLE integration for testing."""
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
with patch( await hass.config_entries.async_setup(mock_config_entry.entry_id)
"homeassistant.components.bluetooth.async_ble_device_from_address" await hass.async_block_till_done()
) as mock_ble_device_from_address:
mock_ble_device_from_address.return_value = _get_bluetooth_service_info() 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.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -4,8 +4,10 @@ import pytest
from custom_components.mysmartbike_ble.parsers import ( from custom_components.mysmartbike_ble.parsers import (
BikeDataParser, BikeDataParser,
read16, read16,
read16_signed,
read24, read24,
read32, read32,
read_signed_byte,
read_unsigned_byte, read_unsigned_byte,
) )
@@ -35,6 +37,24 @@ class TestReadFunctions:
assert read_unsigned_byte(0x00) == 0 assert read_unsigned_byte(0x00) == 0
assert read_unsigned_byte(0x7F) == 127 assert read_unsigned_byte(0x7F) == 127
def test_read_signed_byte(self):
"""Test signed byte read."""
assert read_signed_byte(0x00) == 0
assert read_signed_byte(0x7F) == 127
assert read_signed_byte(0x80) == -128
assert read_signed_byte(0xFF) == -1
def test_read16_signed(self):
"""Test 16-bit signed read (big-endian)."""
# Positive: 0x0001 → 1
assert read16_signed(bytes([0x00, 0x01]), 0) == 1
# Boundary: 0x7FFF → 32767
assert read16_signed(bytes([0x7F, 0xFF]), 0) == 32767
# Negative: 0x8000 → -32768
assert read16_signed(bytes([0x80, 0x00]), 0) == -32768
# -1: 0xFFFF
assert read16_signed(bytes([0xFF, 0xFF]), 0) == -1
class TestEbmParser: class TestEbmParser:
"""Test EBM message parsing with real data.""" """Test EBM message parsing with real data."""
@@ -110,6 +130,183 @@ class TestMotorParser:
assert result["temperature_celsius"] == 23 assert result["temperature_celsius"] == 23
class TestMotorParserX20:
"""20-byte motor frame parsing (X20 / HUS-prefixed devices)."""
# Real frame at rest: assist 1, 22 °C, zero power/speed, max_torque 0x03FF
# (the bike's idle sentinel), power_max_amp 9.0 A.
MOTOR_MESSAGE = bytes.fromhex("246d245a23011600000000000000005a03ff2340")
# First packet after connect: temp byte 0xD8 = -40 signed (no-sensor sentinel).
MOTOR_MESSAGE_BOOT = bytes.fromhex("246d245a2301d800000000000000005a03ff2340")
def test_recognition(self):
parser = BikeDataParser()
assert parser.recognize_message_type(self.MOTOR_MESSAGE) == "motor"
def test_assist_level_and_temperature(self):
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["assist_level"] == 1
assert result["temperature_celsius"] == 22
def test_signed_temperature_handles_no_sensor_sentinel(self):
"""0xD8 must decode as -40 °C (signed), not 216 °C (unsigned)."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE_BOOT)
assert result["temperature_celsius"] == -40
def test_speed_and_power(self):
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["speed_kmh"] == 0.0
assert result["motor_power_watts"] == 0.0
assert result["rider_power_watts"] == 0.0
# power_max_amp = 0x005A / 10 = 9.0 A
assert abs(result["power_max_amp"] - 9.0) < 0.01
def test_max_torque_uses_offset_16(self):
"""max_torque is a 16-bit raw value at offset 16-17 (= 0x03FF)."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["max_torque_motor_pct"] == 0x03FF
def test_wheel_speed_returned_when_max_torque_nonzero(self):
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
# max_torque = 0x03FF != 0 → wheel_speed byte (0x00) is returned
assert result["wheel_speed_rpm"] == 0
def test_wheel_speed_nulled_when_max_torque_zero(self):
"""When max_torque == 0, wheel_speed must be None."""
msg = bytearray(self.MOTOR_MESSAGE)
msg[16] = 0x00
msg[17] = 0x00
parser = BikeDataParser()
result = parser.parse_motor_message(bytes(msg))
assert result["wheel_speed_rpm"] is None
def test_x20_specific_fields_replace_legacy(self):
"""The X20 frame doesn't carry power_amp / torque_motor_pct."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["power_amp"] is None
assert result["torque_motor_pct"] is None
assert "motor_power_watts" in result
assert "rider_power_watts" in result
class TestEbmParserX20:
"""20-byte EBM frame parsing (X20 / HUS-prefixed devices).
Field offsets verified against an app-confirmed capture:
- Odometer 0x000084 / 10 = 13.2 km (app shows 8.08 mi = 13.005 km)
- Range 0x02E1 / 10 = 73.7 km (app shows 45 mi = 72.42 km)
"""
# Slot 1 frame = lifetime values
EBM_LIFETIME = bytes.fromhex("246a245a2300008402e1000001f50148494a2340")
# Slot 2 frame = trip values; same bike, same odometer/autonomy bytes →
# trip A == lifetime (no reset since first ride).
EBM_TRIP = bytes.fromhex("246a245a2300008402e1000001f50248494a2340")
def test_message_length(self):
assert len(self.EBM_LIFETIME) == 20
def test_recognition(self):
parser = BikeDataParser()
assert parser.recognize_message_type(self.EBM_LIFETIME) == "ebm"
def test_lifetime_odometer(self):
"""Odometer is a 24-bit field at offset 5 with /10 km scaling."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# 0x000084 / 10 = 13.2 km (app: 8.08 mi = 13.005 km)
assert abs(result["odometry"] - 13.2) < 0.05
def test_lifetime_autonomy(self):
"""Range is a 16-bit field at offset 8 with /10 km scaling."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# 0x02E1 / 10 = 73.7 km (app: 45 mi = 72.42 km)
assert abs(result["autonomy"] - 73.7) < 0.05
def test_lights_off_at_offset_10(self):
"""Lights flag moved from offset 13 to offset 10 in the X20 layout."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# message[10] = 0x00 → off
assert result["is_light_on"] is False
def test_lights_on(self):
msg = bytearray(self.EBM_LIFETIME)
msg[10] = 0x01
parser = BikeDataParser()
result = parser.parse_ebm_message(bytes(msg))
assert result["is_light_on"] is True
def test_status_byte(self):
"""Status moved from offset 14 to offset 11."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# message[11] = 0x00
assert result["status"] == 0
def test_accelerometer_axes(self):
"""Bytes 12-13 carry accelerometer Z/Y as signed bytes."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# message[12] = 0x01, message[13] = 0xF5 (signed = -11)
assert result["accel_z"] == 1
assert result["accel_y"] == -11
def test_slot_2_updates_trip_only(self):
"""Slot 2 frames carry trip A values; lifetime fields stay at previous."""
parser = BikeDataParser()
# First ingest a slot 1 frame so we have a previous lifetime
parser.parse_ebm_message(self.EBM_LIFETIME)
prev_odo = parser.state["ebm"]["odometry"]
# Now a slot 2 frame with different bytes (mock a real trip distance)
msg = bytearray(self.EBM_TRIP)
# Set trip odometer to 0x000050 = 80 → 8.0 km
msg[5], msg[6], msg[7] = 0x00, 0x00, 0x50
result = parser.parse_ebm_message(bytes(msg))
assert result["trip_odometry"] == 8.0
assert result["odometry"] == prev_odo # lifetime preserved
def test_slot_1_updates_lifetime_preserves_trip(self):
"""A slot 1 frame must not clobber the previously seen trip values."""
parser = BikeDataParser()
# First a slot 2 frame to populate trip_*
msg2 = bytearray(self.EBM_TRIP)
msg2[5], msg2[6], msg2[7] = 0x00, 0x00, 0x50 # trip = 8.0 km
parser.parse_ebm_message(bytes(msg2))
assert parser.state["ebm"]["trip_odometry"] == 8.0
# Now a slot 1 frame
result = parser.parse_ebm_message(self.EBM_LIFETIME)
assert result["trip_odometry"] == 8.0 # preserved
assert abs(result["odometry"] - 13.2) < 0.05
class TestBatteryParser: class TestBatteryParser:
"""Test battery message parsing with real data.""" """Test battery message parsing with real data."""
@@ -195,6 +392,106 @@ class TestBatteryParser:
assert parser.state["battery_primary"]["cycles"] == 36 assert parser.state["battery_primary"]["cycles"] == 36
class TestBatteryParserX20:
"""20-byte battery frame parsing (X20 / HUS-prefixed devices)."""
# Real frame from a HUS device: voltage 37.78 V, SOC 57 %, temp 22 °C,
# current 0 A, nominal 352.8 Wh, remaining 200.3 Wh, MOSFET temp 24 °C,
# combined cycles 0x2715 = 10005 → battery 1, 5 cycles.
BATTERY_MESSAGE = bytes.fromhex("2462245a230ec2391600000dc807d31827152340")
def test_message_length(self):
assert len(self.BATTERY_MESSAGE) == 20
def test_recognition(self):
parser = BikeDataParser()
assert parser.recognize_message_type(self.BATTERY_MESSAGE) == "battery"
def test_voltage_uses_centi_volt_scaling(self):
"""Voltage on the 20-byte frame is encoded as raw / 100 (vs raw / 10 on 19-byte)."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
assert abs(result["voltage"] - 37.78) < 0.01
def test_soc(self):
"""SOC is the unsigned byte at offset 7 with bit 7 masked off."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result["soc"] == 57
assert result["is_charging"] is False
def test_temperature(self):
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result["temperature"] == 22
def test_current_is_zero_at_rest(self):
"""Current is signed read16 / 10."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result["current"] == 0.0
def test_capacity_and_remaining(self):
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert abs(result["nominal_capacity"] - 352.8) < 0.1
assert abs(result["remaining_wh"] - 200.3) < 0.1
def test_temperature_mos(self):
"""A BMS MOSFET temperature byte sits at offset 15."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
# 0x18 = 24°C
assert result["temperature_mos"] == 24
def test_cycles_at_offset_16(self):
"""(battery_number * 10000 + cycles) is read at offset 16-17, not 15-16."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
# 0x2715 = 10005 → battery 1, 5 cycles
assert result["cycles"] == 5
def test_primary_state_is_set(self):
"""Regression: reading the combined field at offset 15 would compute
battery_number == 0 here and silently drop the update."""
parser = BikeDataParser()
parser.parse_battery_message(self.BATTERY_MESSAGE)
assert parser.state["battery_primary"] is not None
assert parser.state["battery_primary"]["soc"] == 57
def test_signed_current_when_charging(self):
"""A negative raw current value should decode as negative amps."""
# Replace bytes 9-10 with 0xFFEC (= -20 raw → -2.0 A)
msg = bytearray(self.BATTERY_MESSAGE)
msg[9] = 0xFF
msg[10] = 0xEC
parser = BikeDataParser()
result = parser.parse_battery_message(bytes(msg))
assert abs(result["current"] - (-2.0)) < 0.001
def test_charging_bit_in_soc_byte(self):
"""When the SOC byte's bit 7 is set, is_charging is True and SOC is masked."""
msg = bytearray(self.BATTERY_MESSAGE)
msg[7] = 0x80 | 57 # charging flag + 57% SOC
parser = BikeDataParser()
result = parser.parse_battery_message(bytes(msg))
assert result["is_charging"] is True
assert result["soc"] == 57
class TestVinParser: class TestVinParser:
"""Test VIN/serial number message parsing.""" """Test VIN/serial number message parsing."""
@@ -0,0 +1,604 @@
"""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
await wait_connected(hass, coordinator)
# Drop the link the way bleak reports one
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
assert coordinator.is_connected is False
with patch.object(
coordinator, "_connect", new_callable=AsyncMock
) 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
async def test_advertisement_storm_starts_one_connect_attempt(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
mock_bluetooth_service_info,
) -> None:
"""Home Assistant fires the callback on every advertisement.
These bikes advertise several times a second, so an unguarded callback
would queue a connect task per advertisement, all serialising behind
_connect_lock and each hammering the proxy with a full retry cycle.
"""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
await wait_connected(hass, coordinator)
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
started = 0
release = asyncio.Event()
async def slow_connect() -> None:
nonlocal started
started += 1
await release.wait()
with patch.object(coordinator, "_connect", side_effect=slow_connect):
for _ in range(50):
coordinator._async_device_appeared(mock_bluetooth_service_info, None)
await asyncio.sleep(0)
await hass.async_block_till_done()
assert started == 1, f"{started} connect attempts for 50 advertisements"
release.set()
await hass.async_block_till_done()
# ...and the claim is released, so a later advertisement can still connect
assert coordinator._connecting is False
async def test_fast_reconnect_after_established_link_drops(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
) -> None:
"""A dropped link reconnects at once instead of waiting out the poll.
These bikes stop advertising after an unexpected disconnect, so the
advertisement watch never fires, and every notification has just pushed the
poll timer another SCAN_INTERVAL out.
"""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
await wait_connected(hass, coordinator)
# Pretend the link had been up comfortably longer than the spin guard
coordinator._connected_since = hass.loop.time() - 120
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
mock_connect.assert_called_once()
async def test_no_fast_reconnect_when_link_died_immediately(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
) -> None:
"""A link that collapses at once must not spin; the poll retries instead."""
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
await wait_connected(hass, coordinator)
coordinator._connected_since = hass.loop.time() # just connected
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
coordinator._async_client_disconnected(coordinator._client)
await hass.async_block_till_done()
mock_connect.assert_not_called()
async def test_last_seen_is_throttled(
hass: HomeAssistant,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_in_range,
freezer,
) -> None:
"""A timestamp that moves on every packet floods the recorder.
Notifications arrive about once a second; the entity only needs to say how
fresh the values are, so it moves at LAST_SEEN_RESOLUTION granularity while
the exact time is still what gets persisted.
"""
from custom_components.mysmartbike_ble.const import LAST_SEEN_RESOLUTION
await setup_offline(hass, restore_config_entry)
coordinator = restore_config_entry.runtime_data
frame = bytearray.fromhex("246a245a2300008402e1000001f50148494a2340")
coordinator._notification_handler(0, frame)
first = coordinator.data["last_seen"]
assert first is not None
# A burst of notifications inside the window must not move the entity
for _ in range(10):
freezer.tick(timedelta(seconds=1))
coordinator._notification_handler(0, frame)
assert coordinator.data["last_seen"] == first
# ...while the persisted timestamp keeps tracking reality
assert coordinator.last_seen > first
# Past the window it moves again
freezer.tick(timedelta(seconds=LAST_SEEN_RESOLUTION))
coordinator._notification_handler(0, frame)
assert coordinator.data["last_seen"] > first
async def test_distances_are_rounded_to_100m(
hass: HomeAssistant,
hass_storage,
restore_config_entry: MockConfigEntry,
mock_bleak_client,
mock_device_out_of_range,
) -> None:
"""Four decimals of false precision would be a database row each."""
seed_storage(
hass_storage,
{
**STORED_STATE,
"ebm": {
**STORED_STATE["ebm"],
"odometry": 806.1488,
"autonomy": 14.6065,
"trip_odometry": 12.3456,
"trip_autonomy": 58.9876,
},
},
)
await setup_offline(hass, restore_config_entry)
assert hass.states.get(entity_id_for(hass, "_odometer")).state == "806.1"
assert hass.states.get(entity_id_for(hass, "_range")).state == "14.6"
assert hass.states.get(entity_id_for(hass, "_trip_distance")).state == "12.3"