Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d52a3bdac | ||
|
|
4ad41e9977 | ||
|
|
62bc1c150a | ||
|
|
a38b2efef5 | ||
|
|
7c1a23a8b4 | ||
|
|
76c3d91ff9 | ||
|
|
5d658d6d9e | ||
|
|
ac0baffe74 | ||
|
|
c539823c1a | ||
|
|
28bf64baad | ||
|
|
52178a219b | ||
|
|
0afd8243bc | ||
|
|
0638c4b244 | ||
|
|
70fa0aa72d | ||
|
|
43e6c0d99e | ||
|
|
e8f91dc1cd | ||
|
|
0e459bec15 |
@@ -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.
|
||||
@@ -1,6 +1,9 @@
|
||||
# MySmartBike BLE Integration for Home Assistant
|
||||
|
||||
[](https://github.com/renenulschde/ha-mysmartbike-ble/releases)
|
||||
[](https://github.com/renenulschde/ha-mysmartbike_ble/releases)
|
||||
 
|
||||

|
||||
|
||||
|
||||
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 |
|
||||
|-------|-------|--------|
|
||||
| Orbea | Vibe | 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!
|
||||
|
||||
## 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 Switch** - Control the BLE connection 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!
|
||||
- Use this switch to save energy when you don't need active monitoring
|
||||
- The bike will automatically turn off about 5 minutes after disconnection to conserve battery
|
||||
- **Auto-connect** (Switch) - Controls whether the integration may connect to your E-Bike
|
||||
- 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.
|
||||
- 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!
|
||||
- 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 State of Charge** (%)
|
||||
@@ -35,14 +41,23 @@ This integration provides real-time monitoring of your E-Bike through Bluetooth
|
||||
- **Battery Remaining Energy** (Wh)
|
||||
|
||||
### Motor Sensors
|
||||
- **Assist Level**
|
||||
- **Assist Level** - disabled by default
|
||||
- **Motor Temperature** (°C)
|
||||
- **Speed** (km/h)
|
||||
|
||||
### E-Bike Management (EBM)
|
||||
- **Odometer** (km)
|
||||
- **Range** (km)
|
||||
- **Trip A Distance** (km)
|
||||
- **Trip A Range** (km) - disabled by default
|
||||
- **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
|
||||
The integration automatically retrieves and displays:
|
||||
@@ -51,9 +66,9 @@ The integration automatically retrieves and displays:
|
||||
|
||||
## Requirements
|
||||
|
||||
- Home Assistant 2024.1.6 or newer
|
||||
- Home Assistant 2024.6.0 or newer
|
||||
- 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)
|
||||
|
||||
## Installation
|
||||
@@ -64,7 +79,7 @@ The integration automatically retrieves and displays:
|
||||
2. Click on "Integrations"
|
||||
3. Click the three dots in the top right corner
|
||||
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"
|
||||
7. Click "Add"
|
||||
8. Search for "MySmartBike BLE" in HACS
|
||||
@@ -73,7 +88,7 @@ The integration automatically retrieves and displays:
|
||||
|
||||
### 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
|
||||
3. Copy the `custom_components/mysmartbike_ble` folder to your Home Assistant `custom_components` directory
|
||||
4. Restart Home Assistant
|
||||
@@ -85,10 +100,10 @@ The integration is configured through the Home Assistant UI:
|
||||
1. Go to **Settings** → **Devices & Services**
|
||||
2. Click **+ Add Integration**
|
||||
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**
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
|
||||
@@ -106,19 +122,37 @@ The integration will automatically discover iWoc devices in range via Bluetooth.
|
||||
|
||||
### 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
|
||||
- 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!
|
||||
- The bike will remain on for about 5 minutes before automatically powering off
|
||||
- **To reconnect**:
|
||||
1. First, manually turn on your bike OR connect it to power
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN, CONF_DEVICE_ADDRESS
|
||||
from .coordinator import MySmartBikeCoordinator
|
||||
from .const import CONF_DEVICE_ADDRESS, DOMAIN, STORAGE_VERSION
|
||||
from .coordinator import MySmartBikeCoordinator, storage_key
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,87 +18,51 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.S
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up MySmartBike BLE from a config entry."""
|
||||
_LOGGER.debug("Setting up MySmartBike BLE integration for entry_id: %s", entry.entry_id)
|
||||
"""Set up MySmartBike BLE from a config entry.
|
||||
|
||||
Setup never depends on the bike being in range. A bike that is switched off
|
||||
or parked out of reach is the normal case, so the entry loads with the
|
||||
persisted state and the coordinator connects whenever the bike shows up.
|
||||
"""
|
||||
address = entry.data[CONF_DEVICE_ADDRESS]
|
||||
_LOGGER.debug("Device address from config: %s", address)
|
||||
|
||||
# Get BLE device
|
||||
_LOGGER.debug("Looking up BLE device with address: %s", address)
|
||||
ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True)
|
||||
if not ble_device:
|
||||
# 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}"
|
||||
coordinator = MySmartBikeCoordinator(hass, address, entry)
|
||||
# Restore before the platforms are set up so the entities' first state
|
||||
# write already carries the last known values and the serial number.
|
||||
await coordinator.async_restore()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
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}")
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Clear warning flag when device is found
|
||||
if DOMAIN in hass.data:
|
||||
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
|
||||
# Connect the moment the bike advertises instead of waiting for a poll tick.
|
||||
entry.async_on_unload(coordinator.async_start_bluetooth_watch())
|
||||
|
||||
_LOGGER.debug("Found BLE device: %s", ble_device)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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,
|
||||
entry.async_create_background_task(
|
||||
hass, coordinator.async_first_connect(), f"{DOMAIN} initial connect {address}"
|
||||
)
|
||||
|
||||
# Store coordinator in runtime_data
|
||||
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)
|
||||
_LOGGER.debug("Platform setup completed")
|
||||
|
||||
_LOGGER.debug("MySmartBike BLE integration setup completed successfully for entry_id: %s", entry.entry_id)
|
||||
_LOGGER.debug("MySmartBike BLE setup completed for %s", address)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""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)
|
||||
_LOGGER.debug("Platform unload result: %s", unload_ok)
|
||||
|
||||
if unload_ok:
|
||||
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()
|
||||
_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
|
||||
|
||||
|
||||
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_translation_key = "connected"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True - "not connected" is a state, not an absence of one."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if connected to the bike."""
|
||||
return self.coordinator.is_connected
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the sensor."""
|
||||
return "Connected"
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the icon."""
|
||||
|
||||
@@ -99,8 +99,7 @@ class MySmartBikeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
):
|
||||
continue
|
||||
|
||||
# Check if device name starts with "iWoc"
|
||||
if discovery_info.name and discovery_info.name.startswith("iWoc"):
|
||||
if discovery_info.name and discovery_info.name.startswith(("iWoc", "HUS")):
|
||||
self._discovered_devices[discovery_info.address] = discovery_info
|
||||
|
||||
if not self._discovered_devices:
|
||||
|
||||
@@ -36,3 +36,31 @@ CONF_DEVICE_ADDRESS: Final = "device_address"
|
||||
|
||||
# Options
|
||||
CONF_LOG_BLE_MESSAGES: Final = "log_ble_messages"
|
||||
|
||||
# Persistence
|
||||
STORAGE_VERSION: Final = 1
|
||||
STORAGE_SAVE_DELAY: Final = 60 # seconds; BLE notifications arrive far too often to save eagerly
|
||||
|
||||
# Top-level parser state keys that survive a restart. "motor" and "assist" are
|
||||
# deliberately absent: a restored speed or power reading would look like live
|
||||
# data from a bike that is actually parked.
|
||||
RESTORE_STATE_KEYS: Final = ("battery_primary", "battery_secondary", "ebm")
|
||||
|
||||
# Fields inside the restored dicts that describe an instantaneous condition and
|
||||
# are therefore dropped (set to None) when reading the state back.
|
||||
VOLATILE_FIELDS: Final[dict[str, tuple[str, ...]]] = {
|
||||
"battery_primary": ("current", "is_charging"),
|
||||
"battery_secondary": ("current", "is_charging"),
|
||||
"ebm": ("status", "accel_y", "accel_z"),
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
@@ -15,10 +15,17 @@ from bleak_retry_connector import (
|
||||
)
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothCallbackMatcher,
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@@ -28,6 +35,12 @@ from .const import (
|
||||
PROTOCOL_REQUEST_MESSAGE,
|
||||
CLOSE_MESSAGE,
|
||||
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_DEVICE_NAME,
|
||||
)
|
||||
@@ -36,13 +49,24 @@ from .parsers import BikeDataParser
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def storage_key(entry: ConfigEntry) -> str:
|
||||
"""Return the .storage key holding the persisted state for a config entry."""
|
||||
return f"{DOMAIN}.{entry.entry_id}"
|
||||
|
||||
|
||||
class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Class to manage fetching MySmartBike data."""
|
||||
"""Class to manage fetching MySmartBike data.
|
||||
|
||||
The coordinator is deliberately independent of the bike's availability: it
|
||||
is constructed from an address, resolves the `BLEDevice` on every connect
|
||||
attempt, and keeps serving the last known values while the bike is out of
|
||||
range or switched off.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
ble_device: BluetoothServiceInfoBleak,
|
||||
address: str,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
@@ -52,36 +76,38 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
)
|
||||
self._ble_device = ble_device
|
||||
self._address = address
|
||||
self._entry = entry
|
||||
self._client: BleakClient | None = None
|
||||
self._parser = BikeDataParser()
|
||||
self._is_connected = False
|
||||
self._notify_task: asyncio.Task | None = None
|
||||
self._connect_lock = asyncio.Lock()
|
||||
self._manual_disconnect = False # Track if user manually disconnected
|
||||
_LOGGER.debug(
|
||||
"Coordinator initialized: address=%s, is_connected=%s, manual_disconnect=%s, scan_interval=%s",
|
||||
ble_device.address,
|
||||
self._is_connected,
|
||||
self._manual_disconnect,
|
||||
SCAN_INTERVAL,
|
||||
self._last_seen: datetime | None = None
|
||||
self._save_armed = False
|
||||
self._unreachable_reason: str | None = None
|
||||
self._connecting = False
|
||||
self._advertisements_seen = 0
|
||||
self._connected_since: float | None = None
|
||||
self._store: Store[dict[str, Any]] = Store(
|
||||
hass, STORAGE_VERSION, storage_key(entry)
|
||||
)
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
"""Return the address of the device."""
|
||||
return self._ble_device.address
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return connection status."""
|
||||
_LOGGER.debug(
|
||||
"Coordinator.is_connected property called: returning %s (manual_disconnect: %s)",
|
||||
self._is_connected,
|
||||
self._manual_disconnect,
|
||||
)
|
||||
return self._is_connected
|
||||
|
||||
@property
|
||||
def last_seen(self) -> datetime | None:
|
||||
"""Return when the last BLE notification was received, if ever."""
|
||||
return self._last_seen
|
||||
|
||||
@property
|
||||
def vin(self) -> str | None:
|
||||
"""Return the VIN/serial number if available."""
|
||||
@@ -92,260 +118,407 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Return the protocol version if available."""
|
||||
return self._parser.protocol_version
|
||||
|
||||
def _resolve_device(self) -> Any:
|
||||
"""Resolve the current BLEDevice for our address, or None if not in range.
|
||||
|
||||
Resolved per attempt rather than cached: a device object handed out by a
|
||||
previous scan goes stale when the adapter or the proxy serving it changes.
|
||||
"""
|
||||
return bluetooth.async_ble_device_from_address(
|
||||
self.hass, self._address, connectable=True
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def async_restore(self) -> None:
|
||||
"""Load the persisted state into the parser.
|
||||
|
||||
Must run before the entity platforms are set up so entities see values
|
||||
(and the device serial number) on their very first state write.
|
||||
"""
|
||||
try:
|
||||
stored = await self._store.async_load()
|
||||
except Exception as ex: # noqa: BLE001 - never let a bad store block setup
|
||||
_LOGGER.warning("Could not read stored state for %s: %s", self._address, ex)
|
||||
stored = None
|
||||
|
||||
if not stored:
|
||||
self.data = self._parser.state
|
||||
return
|
||||
|
||||
for key in RESTORE_STATE_KEYS:
|
||||
value = stored.get(key)
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
restored = dict(value)
|
||||
for field in VOLATILE_FIELDS.get(key, ()):
|
||||
restored[field] = None
|
||||
self._parser.state[key] = restored
|
||||
|
||||
self._parser.vin = stored.get("vin")
|
||||
self._parser.protocol_version = stored.get("protocol_version")
|
||||
self._manual_disconnect = bool(stored.get("manual_disconnect", False))
|
||||
|
||||
if last_seen := stored.get("last_seen"):
|
||||
self._last_seen = dt_util.parse_datetime(last_seen)
|
||||
|
||||
self._parser.state["last_seen"] = self._last_seen
|
||||
self.data = self._parser.state
|
||||
_LOGGER.debug(
|
||||
"Restored state for %s (last seen %s, connection %s)",
|
||||
self._address,
|
||||
self._last_seen,
|
||||
"disabled" if self._manual_disconnect else "enabled",
|
||||
)
|
||||
|
||||
@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.async_update_listeners()
|
||||
|
||||
# Don't sit out the poll interval. These bikes stop advertising after an
|
||||
# unexpected link loss, so the advertisement watch does not fire, and
|
||||
# every notification has just pushed the poll timer another 30s out -
|
||||
# leaving the bike disconnected far longer than necessary.
|
||||
held_for = (
|
||||
self.hass.loop.time() - self._connected_since
|
||||
if self._connected_since is not None
|
||||
else 0.0
|
||||
)
|
||||
self._connected_since = None
|
||||
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}"
|
||||
)
|
||||
|
||||
async def async_first_connect(self) -> None:
|
||||
"""Attempt the initial connection without blocking setup."""
|
||||
if self._manual_disconnect:
|
||||
_LOGGER.debug(
|
||||
"Not connecting to %s - connection was switched off by the user",
|
||||
self._address,
|
||||
)
|
||||
return
|
||||
await self._async_try_connect()
|
||||
|
||||
async def _async_try_connect(self) -> None:
|
||||
"""Run a connect attempt unless one is already in flight."""
|
||||
if not self._async_claim_connect():
|
||||
return
|
||||
await self._async_run_connect()
|
||||
|
||||
async def _async_run_connect(self) -> None:
|
||||
"""Connect, reporting the expected 'bike is off' failures once each."""
|
||||
try:
|
||||
await self._connect()
|
||||
except UpdateFailed as ex:
|
||||
self._async_report_unreachable(str(ex))
|
||||
except Exception as ex: # noqa: BLE001
|
||||
self._async_report_unreachable(f"Connection attempt failed: {ex}")
|
||||
finally:
|
||||
self._connecting = False
|
||||
|
||||
def _async_report_unreachable(self, reason: str) -> None:
|
||||
"""Log why we cannot connect - once per distinct reason.
|
||||
|
||||
The poll retries every SCAN_INTERVAL, so warning every time would spam
|
||||
the log for a bike that is simply parked. Warning on change still tells
|
||||
the user *why* nothing happens, which silence never did.
|
||||
"""
|
||||
if reason != self._unreachable_reason:
|
||||
self._unreachable_reason = reason
|
||||
_LOGGER.warning("%s", reason)
|
||||
else:
|
||||
_LOGGER.debug("%s", reason)
|
||||
|
||||
def _no_route_reason(self) -> str:
|
||||
"""Explain why the address did not resolve to a connectable device.
|
||||
|
||||
A bike seen only by passive proxies looks identical to a bike that is
|
||||
switched off unless we say so - Shelly proxies never offer connections,
|
||||
so the address is "present" but never connectable.
|
||||
"""
|
||||
if bluetooth.async_address_present(self.hass, self._address, connectable=False):
|
||||
return (
|
||||
f"Device {self._address} is advertising, but no Bluetooth adapter or "
|
||||
"proxy that supports active connections can reach it. Shelly proxies "
|
||||
"are passive-only - a local adapter or an ESPHome proxy is required"
|
||||
)
|
||||
return f"Device {self._address} is not reachable - turn on the bike"
|
||||
|
||||
async def _cleanup_client(self, send_close: bool = True, wait_for_slot: bool = True) -> None:
|
||||
"""Clean up BLE client connection.
|
||||
|
||||
Args:
|
||||
send_close: Whether to send close message to bike before disconnecting.
|
||||
wait_for_slot: Whether to wait for BLE connection slot release.
|
||||
"""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
client = self._client
|
||||
self._client = None
|
||||
self._is_connected = False
|
||||
|
||||
try:
|
||||
if client.is_connected:
|
||||
if send_close:
|
||||
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:
|
||||
_LOGGER.debug("Error during BLE disconnect: %s", ex)
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Unexpected error during client cleanup: %s", ex)
|
||||
finally:
|
||||
del client
|
||||
if wait_for_slot:
|
||||
await asyncio.sleep(3.0) # Wait for BLE connection slot release
|
||||
# Let binary_sensor.connected drop immediately instead of at the next poll.
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_disconnect(self) -> None:
|
||||
"""Disconnect from the device (user initiated)."""
|
||||
_LOGGER.debug(
|
||||
"Coordinator.async_disconnect 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,
|
||||
)
|
||||
|
||||
# Mark as manually disconnected to prevent auto-reconnect
|
||||
_LOGGER.debug("User-initiated disconnect for %s", self._address)
|
||||
self._manual_disconnect = True
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Set manual_disconnect=True")
|
||||
|
||||
if self._client:
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Client exists, cleaning up connection")
|
||||
client_to_cleanup = self._client
|
||||
self._client = None # Clear reference immediately
|
||||
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
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Sending close message ($D$I#@)")
|
||||
try:
|
||||
await client_to_cleanup.write_gatt_char(WRITE_UUID, CLOSE_MESSAGE)
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Close message sent")
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Error sending close message: %s", ex)
|
||||
|
||||
# Stop notifications
|
||||
try:
|
||||
await client_to_cleanup.stop_notify(NOTIFY_UUID)
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Stopped notifications")
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Error stopping notifications: %s", ex)
|
||||
|
||||
# Disconnect from device
|
||||
try:
|
||||
await client_to_cleanup.disconnect()
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Disconnected from device")
|
||||
except Exception as ex:
|
||||
_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:
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Unexpected error during disconnect: %s", ex, exc_info=True)
|
||||
finally:
|
||||
# Force delete the client object to help garbage collection
|
||||
del client_to_cleanup
|
||||
|
||||
# Give BLE adapter significant time to release connection slot
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Waiting for connection slot release (3 seconds)")
|
||||
await asyncio.sleep(3.0)
|
||||
_LOGGER.debug("Coordinator.async_disconnect: Cleaned up client (is_connected=%s)", self._is_connected)
|
||||
else:
|
||||
_LOGGER.debug("Coordinator.async_disconnect: No client to disconnect")
|
||||
self._is_connected = False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Coordinator.async_disconnect completed - final state: is_connected=%s, manual_disconnect=%s",
|
||||
self._is_connected,
|
||||
self._manual_disconnect,
|
||||
)
|
||||
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(
|
||||
"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,
|
||||
)
|
||||
_LOGGER.debug("User-initiated reconnect for %s", self._address)
|
||||
|
||||
# Clean up any existing client first
|
||||
if self._client:
|
||||
_LOGGER.debug("Coordinator.async_reconnect: Found existing client, cleaning up first")
|
||||
old_client = self._client
|
||||
self._client = None
|
||||
self._is_connected = False
|
||||
|
||||
try:
|
||||
if old_client.is_connected:
|
||||
await old_client.disconnect()
|
||||
_LOGGER.debug("Coordinator.async_reconnect: Disconnected existing client")
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Coordinator.async_reconnect: Error disconnecting old client: %s", ex)
|
||||
finally:
|
||||
del old_client
|
||||
# Wait longer for connection slot to be released
|
||||
_LOGGER.debug("Coordinator.async_reconnect: Waiting for connection slot release (3 seconds)")
|
||||
await asyncio.sleep(3.0)
|
||||
_LOGGER.debug("Coordinator.async_reconnect: Cleaned up old client and waited for slot release")
|
||||
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
||||
|
||||
# Clear manual disconnect flag to allow auto-reconnect
|
||||
self._manual_disconnect = False
|
||||
_LOGGER.debug("Coordinator.async_reconnect: Set manual_disconnect=False")
|
||||
self._schedule_save()
|
||||
|
||||
try:
|
||||
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:
|
||||
# Only log as error if it's not a "device not reachable" issue
|
||||
error_str = str(ex).lower()
|
||||
if "not reachable" in error_str or "turn on the bike" in error_str:
|
||||
_LOGGER.debug("Coordinator.async_reconnect: Device not reachable, will retry later")
|
||||
else:
|
||||
_LOGGER.error("Coordinator.async_reconnect failed: %s", ex, exc_info=True)
|
||||
if "not reachable" not in error_str and "turn on the bike" not in error_str:
|
||||
_LOGGER.error("Reconnect failed: %s", ex)
|
||||
raise
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the device."""
|
||||
_LOGGER.debug(
|
||||
"Coordinator._async_update_data called - current state: is_connected=%s, manual_disconnect=%s",
|
||||
self._is_connected,
|
||||
self._manual_disconnect,
|
||||
)
|
||||
"""Refresh diagnostics and auto-reconnect; never fails the entities.
|
||||
|
||||
# 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:
|
||||
_LOGGER.debug("Coordinator._async_update_data: Not connected and not manual disconnect, attempting auto-reconnect")
|
||||
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")
|
||||
await self._async_try_connect()
|
||||
|
||||
# Return current state from parser, ensure it's never None
|
||||
state = self._parser.state or {
|
||||
"battery_primary": None,
|
||||
"battery_secondary": None,
|
||||
"motor": None,
|
||||
"assist": None,
|
||||
"ebm": None,
|
||||
}
|
||||
state = self._parser.state
|
||||
|
||||
# Add RSSI (signal strength) to state
|
||||
# Get latest service info which contains current RSSI
|
||||
# Add RSSI (signal strength) to state - None while out of range
|
||||
try:
|
||||
service_info = bluetooth.async_last_service_info(
|
||||
self.hass, self._ble_device.address, connectable=True
|
||||
self.hass, self._address, connectable=True
|
||||
)
|
||||
state["rssi"] = service_info.rssi if service_info else None
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Could not get RSSI: %s", ex)
|
||||
except Exception:
|
||||
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
|
||||
|
||||
async def _connect(self) -> None:
|
||||
"""Connect to the device and start notifications."""
|
||||
_LOGGER.debug(
|
||||
"Coordinator._connect: Attempting to connect to %s (current is_connected=%s, manual_disconnect=%s, client=%s)",
|
||||
self._ble_device.address,
|
||||
self._is_connected,
|
||||
self._manual_disconnect,
|
||||
self._client is not None,
|
||||
)
|
||||
async with self._connect_lock:
|
||||
if self._is_connected:
|
||||
return
|
||||
|
||||
ble_device = self._resolve_device()
|
||||
if ble_device is None:
|
||||
raise UpdateFailed(self._no_route_reason())
|
||||
|
||||
# Clean up any existing client before connecting
|
||||
if self._client:
|
||||
_LOGGER.debug("Cleaning up existing client before new connection")
|
||||
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
||||
|
||||
# Clean up any existing client before connecting
|
||||
if self._client:
|
||||
_LOGGER.warning("Coordinator._connect: Client already exists, cleaning up before new connection")
|
||||
old_client = self._client
|
||||
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:
|
||||
_LOGGER.debug("Coordinator._connect: Calling establish_connection for %s", self._ble_device.address)
|
||||
|
||||
self._client = await establish_connection(
|
||||
BleakClientWithServiceCache,
|
||||
self._ble_device,
|
||||
self._ble_device.address,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Coordinator._connect: Successfully connected to %s, client=%s", self._ble_device.address, self._client)
|
||||
|
||||
# Start notifications first
|
||||
_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)
|
||||
|
||||
# Request protocol version ($S$P#@)
|
||||
_LOGGER.debug("Coordinator._connect: Requesting protocol version")
|
||||
await self._client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
|
||||
_LOGGER.debug("Coordinator._connect: Protocol request sent")
|
||||
|
||||
self._is_connected = True
|
||||
_LOGGER.debug("Coordinator._connect: Set is_connected=True")
|
||||
|
||||
except (BleakError, asyncio.TimeoutError) as ex:
|
||||
self._is_connected = False
|
||||
|
||||
# Check if error is due to device not being reachable (turned off)
|
||||
error_str = str(ex).lower()
|
||||
if "no longer reachable" in error_str or "out of connection slots" in error_str:
|
||||
_LOGGER.warning(
|
||||
"Coordinator._connect: Device %s is not reachable or powered off. "
|
||||
"Turn on the bike to connect.",
|
||||
self._ble_device.address
|
||||
# Held in a local: the handshake sleeps, and a concurrent
|
||||
# disconnect (switch off, dropped link) may clear self._client
|
||||
# underneath us - reading it back mid-handshake would crash.
|
||||
client = await establish_connection(
|
||||
BleakClientWithServiceCache,
|
||||
ble_device,
|
||||
self._address,
|
||||
disconnected_callback=self._async_client_disconnected,
|
||||
ble_device_callback=self._resolve_device,
|
||||
)
|
||||
raise UpdateFailed(
|
||||
f"Device {self._ble_device.address} is not reachable. "
|
||||
"Please turn on the bike."
|
||||
) from ex
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"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
|
||||
self._client = client
|
||||
|
||||
# Start notifications and request device info
|
||||
await client.start_notify(NOTIFY_UUID, self._notification_handler)
|
||||
await client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
|
||||
await asyncio.sleep(0.2)
|
||||
await client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
|
||||
|
||||
if self._client is not client:
|
||||
# Torn down while we were setting up - don't claim success.
|
||||
_LOGGER.debug("Connection to %s was cancelled", self._address)
|
||||
return
|
||||
|
||||
self._is_connected = True
|
||||
self._connected_since = self.hass.loop.time()
|
||||
self._unreachable_reason = None
|
||||
_LOGGER.debug("Connected to %s", self._address)
|
||||
|
||||
except (BleakError, asyncio.TimeoutError) as ex:
|
||||
self._is_connected = False
|
||||
error_str = str(ex).lower()
|
||||
|
||||
if "no longer reachable" in error_str or "out of connection slots" in error_str:
|
||||
_LOGGER.warning("Device %s not reachable - turn on the bike", self._address)
|
||||
raise UpdateFailed(f"Device {self._address} is not reachable") from ex
|
||||
else:
|
||||
_LOGGER.error("Failed to connect to %s: %s", self._address, ex)
|
||||
raise UpdateFailed(f"Failed to connect to device: {ex}") from ex
|
||||
|
||||
# Outside the lock: entities pick up the new connection state immediately.
|
||||
self.async_update_listeners()
|
||||
|
||||
def _notification_handler(self, sender: int, data: bytearray) -> None:
|
||||
"""Handle notification data."""
|
||||
_LOGGER.debug("Received notification from %s: %s", sender, data.hex())
|
||||
|
||||
# Recognize message type before saving
|
||||
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)
|
||||
if self._entry.options.get(CONF_LOG_BLE_MESSAGES, False):
|
||||
@@ -354,6 +527,10 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Parse the message
|
||||
self._parser.handle_message(bytes(data))
|
||||
|
||||
self._last_seen = dt_util.utcnow()
|
||||
self._publish_last_seen()
|
||||
self._schedule_save()
|
||||
|
||||
# Update coordinator data
|
||||
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:
|
||||
f.write(message_line)
|
||||
|
||||
_LOGGER.debug("Saved BLE message to: %s", filepath)
|
||||
|
||||
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:
|
||||
"""Shutdown the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Coordinator.async_shutdown called - current state: is_connected=%s, manual_disconnect=%s, client=%s",
|
||||
self._is_connected,
|
||||
self._manual_disconnect,
|
||||
self._client is not None,
|
||||
)
|
||||
|
||||
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")
|
||||
_LOGGER.debug("Shutting down coordinator")
|
||||
await super().async_shutdown()
|
||||
await self._cleanup_client(send_close=True, wait_for_slot=False)
|
||||
# Flush any pending debounced write so an unload never loses the state.
|
||||
await self._store.async_save(self._persist_data())
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "iWoc*"
|
||||
},
|
||||
{
|
||||
"local_name": "HUS*"
|
||||
}
|
||||
],
|
||||
"codeowners": [
|
||||
@@ -21,5 +24,5 @@
|
||||
"bleak>=0.21.0",
|
||||
"bleak-retry-connector>=3.1.0"
|
||||
],
|
||||
"version": "0.0.2"
|
||||
"version": "1.1.0"
|
||||
}
|
||||
@@ -12,12 +12,25 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""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 (
|
||||
((data[offset] & 0xFF) << 16)
|
||||
| ((data[offset + 1] & 0xFF) << 8)
|
||||
@@ -26,7 +39,7 @@ def read24(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 (
|
||||
((data[offset] & 0xFF) << 24)
|
||||
| ((data[offset + 1] & 0xFF) << 16)
|
||||
@@ -57,11 +70,15 @@ class BikeDataParser:
|
||||
self.protocol_version: Optional[str] = None
|
||||
|
||||
def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""Parse battery message and update state."""
|
||||
if len(message) < BATTERY_MESSAGE_LENGTH:
|
||||
return None
|
||||
"""Parse battery frame; dispatch by length to the right layout."""
|
||||
if len(message) == 20:
|
||||
return self._parse_battery_x20(message)
|
||||
if len(message) >= BATTERY_MESSAGE_LENGTH:
|
||||
return self._parse_battery_ebm(message)
|
||||
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
|
||||
soc = read_unsigned_byte(message[7])
|
||||
temp_status = message[8]
|
||||
@@ -69,66 +86,102 @@ class BikeDataParser:
|
||||
nominal_capacity = read16(message, 11) / 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
|
||||
battery_number = (combined_raw // 10000) if combined_raw else 1
|
||||
cycles = (combined_raw % 10000) if combined_raw else None
|
||||
|
||||
# Construct battery data dictionary
|
||||
data = {
|
||||
"voltage": voltage,
|
||||
"soc": soc,
|
||||
"temperature": temp_status,
|
||||
"temperature_mos": None,
|
||||
"current": current,
|
||||
"nominal_capacity": nominal_capacity,
|
||||
"remaining_wh": remaining_wh,
|
||||
"cycles": cycles,
|
||||
"is_charging": False,
|
||||
}
|
||||
|
||||
# Handle secondary vs primary battery
|
||||
if battery_number == 2:
|
||||
# Secondary battery detected
|
||||
self.battery_packet_counter = 0
|
||||
self.state["battery_secondary"] = data
|
||||
elif battery_number == 1:
|
||||
# Primary battery
|
||||
self.battery_packet_counter += 1
|
||||
self.state["battery_primary"] = data
|
||||
|
||||
# After 4 consecutive primary battery packets, reset secondary battery
|
||||
if self.battery_packet_counter >= 4:
|
||||
self.state["battery_secondary"] = {
|
||||
"voltage": 0.0,
|
||||
"soc": 0.0,
|
||||
"temperature": 0,
|
||||
"current": 0.0,
|
||||
"nominal_capacity": 0.0,
|
||||
"remaining_wh": 0.0,
|
||||
"cycles": None,
|
||||
}
|
||||
|
||||
self._store_battery(data, battery_number)
|
||||
return data
|
||||
|
||||
def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""Parse motor message and update state."""
|
||||
if len(message) < MOTOR_MESSAGE_LENGTH:
|
||||
return None
|
||||
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])
|
||||
|
||||
# Extract values from message
|
||||
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:
|
||||
self.battery_packet_counter = 0
|
||||
self.state["battery_secondary"] = data
|
||||
return
|
||||
|
||||
# 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.state["battery_primary"] = data
|
||||
|
||||
if self.battery_packet_counter >= 4:
|
||||
self.state["battery_secondary"] = {
|
||||
"voltage": 0.0,
|
||||
"soc": 0.0,
|
||||
"temperature": 0,
|
||||
"temperature_mos": None,
|
||||
"current": 0.0,
|
||||
"nominal_capacity": 0.0,
|
||||
"remaining_wh": 0.0,
|
||||
"cycles": None,
|
||||
"is_charging": False,
|
||||
}
|
||||
|
||||
def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""Parse motor frame; dispatch by length to the right layout."""
|
||||
if len(message) >= 20:
|
||||
return self._parse_motor_x20(message)
|
||||
if len(message) >= MOTOR_MESSAGE_LENGTH:
|
||||
return self._parse_motor_ebm(message)
|
||||
return None
|
||||
|
||||
def _parse_motor_ebm(self, message: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""Parse 18-byte motor frame (X25 / X35+ / ebikemotion)."""
|
||||
assist_level = message[5]
|
||||
temperature_celsius = message[6]
|
||||
power_amp = float(read16(message, 7)) / 10.0
|
||||
speed_kmh = float(read16(message, 9)) / 10.0
|
||||
|
||||
# Additional values
|
||||
wheel_speed = read_unsigned_byte(message[11])
|
||||
torque_pct = message[12]
|
||||
power_max = float(read16(message, 13)) / 10.0
|
||||
max_torque_pct = message[15]
|
||||
|
||||
# Update state with motor data
|
||||
data = {
|
||||
"assist_level": assist_level,
|
||||
"temperature_celsius": temperature_celsius,
|
||||
@@ -138,8 +191,38 @@ class BikeDataParser:
|
||||
"torque_motor_pct": torque_pct,
|
||||
"power_max_amp": power_max,
|
||||
"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
|
||||
return data
|
||||
|
||||
@@ -214,29 +297,77 @@ class BikeDataParser:
|
||||
return None
|
||||
|
||||
def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""Parse EBM (E-Bike Management) message."""
|
||||
if len(message) < EBM_MESSAGE_LENGTH:
|
||||
return None
|
||||
|
||||
# EbmParserEbm format: 32-bit reads directly from message (big-endian)
|
||||
# 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
|
||||
"""Parse EBM (E-Bike Management) frame; dispatch by length."""
|
||||
if len(message) >= 20:
|
||||
return self._parse_ebm_x20(message)
|
||||
if len(message) >= EBM_MESSAGE_LENGTH:
|
||||
return self._parse_ebm_ebm(message)
|
||||
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
|
||||
autonomy_km = read32(message, 9) / 10000.0
|
||||
is_light_on = message[13] == 1
|
||||
status = read_unsigned_byte(message[14])
|
||||
|
||||
# EbmParserEbm only parses bytes 5-14, bytes 15-16 are suffix #@
|
||||
data = {
|
||||
"odometry": odometry_km,
|
||||
"autonomy": autonomy_km,
|
||||
"trip_odometry": None,
|
||||
"trip_autonomy": None,
|
||||
"is_light_on": is_light_on,
|
||||
"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
|
||||
return data
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
|
||||
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:
|
||||
"""Safely get nested dictionary values."""
|
||||
if data is None:
|
||||
@@ -108,7 +119,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
icon="mdi:counter",
|
||||
value_fn=lambda data: safe_get(data, "ebm", "odometry"),
|
||||
value_fn=lambda data: round_km(safe_get(data, "ebm", "odometry")),
|
||||
),
|
||||
MySmartBikeSensorEntityDescription(
|
||||
key="range",
|
||||
@@ -117,7 +128,26 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
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(
|
||||
key="light",
|
||||
@@ -144,6 +174,14 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
||||
icon="mdi:wifi",
|
||||
value_fn=lambda data: safe_get(data, "rssi"),
|
||||
),
|
||||
MySmartBikeSensorEntityDescription(
|
||||
key="last_seen",
|
||||
name="Last Seen",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
icon="mdi:clock-outline",
|
||||
value_fn=lambda data: safe_get(data, "last_seen"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -193,6 +231,15 @@ class MySmartBikeSensor(CoordinatorEntity[MySmartBikeCoordinator], SensorEntity)
|
||||
if coordinator.protocol_version:
|
||||
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True - values are last-known-good, not live readings.
|
||||
|
||||
An unreachable bike must not blank the sensors; `binary_sensor.connected`
|
||||
and the "Last Seen" timestamp tell the user how fresh the values are.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def native_value(self) -> Any:
|
||||
"""Return the state of the sensor."""
|
||||
|
||||
@@ -22,10 +22,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MySmartBike BLE switch entities."""
|
||||
_LOGGER.debug("Setting up switch platform for entry_id: %s", entry.entry_id)
|
||||
coordinator: MySmartBikeCoordinator = entry.runtime_data
|
||||
|
||||
_LOGGER.debug("Adding connection switch entity (coordinator.is_connected: %s)", coordinator.is_connected)
|
||||
async_add_entities([MySmartBikeConnectionSwitch(coordinator, entry)])
|
||||
|
||||
|
||||
@@ -51,25 +48,20 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
|
||||
if coordinator.protocol_version:
|
||||
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
||||
self._attr_translation_key = "connection"
|
||||
_LOGGER.debug(
|
||||
"Switch initialized: unique_id=%s, coordinator.is_connected=%s",
|
||||
self._attr_unique_id,
|
||||
coordinator.is_connected,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True - the connection wish can always be changed."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if connection is desired (not manually disconnected)."""
|
||||
# Switch represents the desired state, not the actual connection status
|
||||
# If manual_disconnect is False, user wants to be connected
|
||||
state = not self.coordinator._manual_disconnect
|
||||
_LOGGER.debug(
|
||||
"Switch is_on property called: returning %s (manual_disconnect=%s, is_connected=%s)",
|
||||
state,
|
||||
self.coordinator._manual_disconnect,
|
||||
self.coordinator.is_connected
|
||||
)
|
||||
return state
|
||||
"""Return True if connection is desired (not manually disconnected).
|
||||
|
||||
Restored from storage on startup, so a bike the user deliberately
|
||||
disconnected is not woken again by a Home Assistant restart.
|
||||
"""
|
||||
return not self.coordinator._manual_disconnect
|
||||
|
||||
@property
|
||||
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"
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""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()
|
||||
"""Turn on the switch - request connection to the bike.
|
||||
|
||||
The switch reflects the *wish* to be connected, so it stays on even when
|
||||
the bike is currently unreachable - the coordinator keeps retrying.
|
||||
"""
|
||||
try:
|
||||
await self.coordinator.async_reconnect()
|
||||
_LOGGER.debug(
|
||||
"Switch.async_turn_on: reconnect completed (coordinator.is_connected: %s)",
|
||||
self.coordinator.is_connected,
|
||||
)
|
||||
except Exception as ex:
|
||||
# Provide user-friendly error message
|
||||
error_msg = str(ex)
|
||||
if "not reachable" in error_msg.lower():
|
||||
_LOGGER.warning(
|
||||
"Switch.async_turn_on: Cannot connect now - bike is not reachable. "
|
||||
"Will auto-connect when bike is powered on."
|
||||
)
|
||||
error_msg = str(ex).lower()
|
||||
if "not reachable" in error_msg:
|
||||
_LOGGER.warning("Cannot connect - bike not reachable. Will auto-connect when available.")
|
||||
else:
|
||||
_LOGGER.error("Switch.async_turn_on: Failed to connect to bike: %s", ex, exc_info=True)
|
||||
# Switch stays ON - coordinator will auto-reconnect when bike becomes available
|
||||
_LOGGER.error("Failed to connect to bike: %s", ex)
|
||||
finally:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch - disconnect from the bike.
|
||||
@@ -111,19 +91,9 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
|
||||
WARNING: This will turn off the bike after ~5 minutes! It must be manually
|
||||
turned on again or connected to power.
|
||||
"""
|
||||
_LOGGER.warning(
|
||||
"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,
|
||||
)
|
||||
_LOGGER.warning("Disconnecting from bike - it will turn off after ~5 minutes")
|
||||
try:
|
||||
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()
|
||||
_LOGGER.debug("Switch.async_turn_off: state written to HA")
|
||||
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": {
|
||||
"binary_sensor": {
|
||||
"connected": {
|
||||
"name": "Verbunden"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"connection": {
|
||||
"name": "Verbindung"
|
||||
"name": "Auto-Verbindung"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,14 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"connected": {
|
||||
"name": "Connected"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"connection": {
|
||||
"name": "Connection"
|
||||
"name": "Auto-connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,21 +75,85 @@ def mock_ble_device() -> Generator[MagicMock]:
|
||||
yield mock_devices
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_bluetooth_helpers() -> Generator[None]:
|
||||
"""Stub the bluetooth helpers the coordinator calls into.
|
||||
|
||||
Keeps the tests independent of a real bluetooth integration setup; the
|
||||
device-present/absent case is driven by `mock_device_in_range`.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_register_callback",
|
||||
return_value=lambda: None,
|
||||
),
|
||||
patch(
|
||||
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_last_service_info",
|
||||
return_value=_get_bluetooth_service_info(),
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_in_range() -> Generator[MagicMock]:
|
||||
"""Report the bike as reachable. Set `return_value = None` to take it away."""
|
||||
with (
|
||||
patch(
|
||||
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_ble_device_from_address",
|
||||
return_value=_get_bluetooth_service_info(),
|
||||
) as mock_resolve,
|
||||
patch(
|
||||
"custom_components.mysmartbike_ble.bluetooth.async_ble_device_from_address",
|
||||
return_value=_get_bluetooth_service_info(),
|
||||
),
|
||||
):
|
||||
yield mock_resolve
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_out_of_range() -> Generator[MagicMock]:
|
||||
"""Report the bike as out of range / switched off."""
|
||||
with (
|
||||
patch(
|
||||
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
) as mock_resolve,
|
||||
patch(
|
||||
"custom_components.mysmartbike_ble.bluetooth.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
yield mock_resolve
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_bleak_client: MagicMock,
|
||||
mock_device_in_range: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the MySmartBike BLE integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address"
|
||||
) as mock_ble_device_from_address:
|
||||
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration_offline(
|
||||
hass,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_bleak_client: MagicMock,
|
||||
mock_device_out_of_range: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration while the bike is out of range."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
|
||||
@@ -4,8 +4,10 @@ import pytest
|
||||
from custom_components.mysmartbike_ble.parsers import (
|
||||
BikeDataParser,
|
||||
read16,
|
||||
read16_signed,
|
||||
read24,
|
||||
read32,
|
||||
read_signed_byte,
|
||||
read_unsigned_byte,
|
||||
)
|
||||
|
||||
@@ -35,6 +37,24 @@ class TestReadFunctions:
|
||||
assert read_unsigned_byte(0x00) == 0
|
||||
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:
|
||||
"""Test EBM message parsing with real data."""
|
||||
@@ -110,6 +130,183 @@ class TestMotorParser:
|
||||
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:
|
||||
"""Test battery message parsing with real data."""
|
||||
|
||||
@@ -195,6 +392,106 @@ class TestBatteryParser:
|
||||
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:
|
||||
"""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"
|
||||
Reference in New Issue
Block a user