Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d52a3bdac | ||
|
|
4ad41e9977 | ||
|
|
62bc1c150a | ||
|
|
a38b2efef5 | ||
|
|
7c1a23a8b4 | ||
|
|
76c3d91ff9 | ||
|
|
5d658d6d9e | ||
|
|
ac0baffe74 | ||
|
|
c539823c1a |
@@ -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.
|
||||
@@ -23,16 +23,17 @@ This integration has been developed and tested with:
|
||||
|
||||
## 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** (%)
|
||||
@@ -40,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:
|
||||
@@ -56,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
|
||||
@@ -69,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
|
||||
@@ -78,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
|
||||
@@ -102,6 +112,7 @@ The integration will automatically discover iWoc and HUS devices in range via Bl
|
||||
- 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" 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
|
||||
|
||||
@@ -111,19 +122,37 @@ The integration will automatically discover iWoc and HUS devices in range via Bl
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ from homeassistant.components import bluetooth
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN, CONF_DEVICE_ADDRESS
|
||||
from .coordinator import MySmartBikeCoordinator
|
||||
from .const import CONF_DEVICE_ADDRESS, DOMAIN, STORAGE_VERSION
|
||||
from .coordinator import MySmartBikeCoordinator, storage_key
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,35 +18,36 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.S
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up MySmartBike BLE from a config entry."""
|
||||
"""Set up MySmartBike BLE from a config entry.
|
||||
|
||||
Setup never depends on the bike being in range. A bike that is switched off
|
||||
or parked out of reach is the normal case, so the entry loads with the
|
||||
persisted state and the coordinator connects whenever the bike shows up.
|
||||
"""
|
||||
address = entry.data[CONF_DEVICE_ADDRESS]
|
||||
|
||||
# Get BLE device
|
||||
ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True)
|
||||
if not ble_device:
|
||||
# Log warning only once per config entry
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
warning_key = f"warned_{entry.entry_id}"
|
||||
|
||||
if not hass.data[DOMAIN].get(warning_key):
|
||||
_LOGGER.warning(
|
||||
"MySmartBike device %s not found - ensure bike is powered on and in range",
|
||||
address
|
||||
)
|
||||
hass.data[DOMAIN][warning_key] = True
|
||||
raise ConfigEntryNotReady(f"Could not find MySmartBike device with address {address}")
|
||||
|
||||
# Clear warning flag when device is found
|
||||
if DOMAIN in hass.data:
|
||||
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
|
||||
|
||||
# Create and initialize coordinator
|
||||
coordinator = MySmartBikeCoordinator(hass, ble_device, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
coordinator = MySmartBikeCoordinator(hass, address, entry)
|
||||
# Restore before the platforms are set up so the entities' first state
|
||||
# write already carries the last known values and the serial number.
|
||||
await coordinator.async_restore()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Connect the moment the bike advertises instead of waiting for a poll tick.
|
||||
entry.async_on_unload(coordinator.async_start_bluetooth_watch())
|
||||
|
||||
if bluetooth.async_ble_device_from_address(hass, address, connectable=True) is None:
|
||||
_LOGGER.info(
|
||||
"MySmartBike device %s not in range - showing last known values, "
|
||||
"will connect automatically once the bike is powered on",
|
||||
address,
|
||||
)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass, coordinator.async_first_connect(), f"{DOMAIN} initial connect {address}"
|
||||
)
|
||||
|
||||
_LOGGER.debug("MySmartBike BLE setup completed for %s", address)
|
||||
return True
|
||||
|
||||
@@ -59,8 +60,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator: MySmartBikeCoordinator = entry.runtime_data
|
||||
await coordinator.async_shutdown()
|
||||
|
||||
# Clean up warning flag from hass.data
|
||||
if DOMAIN in hass.data:
|
||||
hass.data[DOMAIN].pop(f"warned_{entry.entry_id}", None)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Drop the persisted state when the bike is removed from Home Assistant."""
|
||||
await Store(hass, STORAGE_VERSION, storage_key(entry)).async_remove()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,24 +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
|
||||
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."""
|
||||
return self._is_connected
|
||||
|
||||
@property
|
||||
def last_seen(self) -> datetime | None:
|
||||
"""Return when the last BLE notification was received, if ever."""
|
||||
return self._last_seen
|
||||
|
||||
@property
|
||||
def vin(self) -> str | None:
|
||||
"""Return the VIN/serial number if available."""
|
||||
@@ -80,6 +118,245 @@ 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.
|
||||
|
||||
@@ -118,22 +395,26 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
del client
|
||||
if wait_for_slot:
|
||||
await asyncio.sleep(3.0) # Wait for BLE connection slot release
|
||||
# Let binary_sensor.connected drop immediately instead of at the next poll.
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_disconnect(self) -> None:
|
||||
"""Disconnect from the device (user initiated)."""
|
||||
_LOGGER.debug("User-initiated disconnect for %s", self._ble_device.address)
|
||||
_LOGGER.debug("User-initiated disconnect for %s", self._address)
|
||||
self._manual_disconnect = True
|
||||
self._schedule_save()
|
||||
await self._cleanup_client(send_close=True, wait_for_slot=True)
|
||||
|
||||
async def async_reconnect(self) -> None:
|
||||
"""Reconnect to the device (user initiated)."""
|
||||
_LOGGER.debug("User-initiated reconnect for %s", self._ble_device.address)
|
||||
_LOGGER.debug("User-initiated reconnect for %s", self._address)
|
||||
|
||||
# Clean up any existing client first
|
||||
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
||||
|
||||
# Clear manual disconnect flag to allow auto-reconnect
|
||||
self._manual_disconnect = False
|
||||
self._schedule_save()
|
||||
|
||||
try:
|
||||
await self._connect()
|
||||
@@ -144,67 +425,94 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
raise
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the device."""
|
||||
"""Refresh diagnostics and auto-reconnect; never fails the entities.
|
||||
|
||||
Values are last-known-good rather than live, so an unreachable bike must
|
||||
not mark the coordinator unsuccessful - that would take every entity to
|
||||
`unavailable` and throw away the restored state.
|
||||
"""
|
||||
# Auto-reconnect if not connected and not manually disconnected
|
||||
if not self._is_connected and not self._manual_disconnect:
|
||||
try:
|
||||
await self._connect()
|
||||
except Exception:
|
||||
pass # Connection errors are logged in _connect()
|
||||
await self._async_try_connect()
|
||||
|
||||
# Return current state from parser, ensure it's never None
|
||||
state = self._parser.state or {
|
||||
"battery_primary": None,
|
||||
"battery_secondary": None,
|
||||
"motor": None,
|
||||
"assist": None,
|
||||
"ebm": None,
|
||||
}
|
||||
state = self._parser.state
|
||||
|
||||
# Add RSSI (signal strength) to state
|
||||
# Add RSSI (signal strength) to state - None while out of range
|
||||
try:
|
||||
service_info = bluetooth.async_last_service_info(
|
||||
self.hass, self._ble_device.address, connectable=True
|
||||
self.hass, self._address, connectable=True
|
||||
)
|
||||
state["rssi"] = service_info.rssi if service_info else None
|
||||
except Exception:
|
||||
state["rssi"] = None
|
||||
|
||||
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."""
|
||||
# Clean up any existing client before connecting
|
||||
if self._client:
|
||||
_LOGGER.debug("Cleaning up existing client before new connection")
|
||||
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
||||
async with self._connect_lock:
|
||||
if self._is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
self._client = await establish_connection(
|
||||
BleakClientWithServiceCache,
|
||||
self._ble_device,
|
||||
self._ble_device.address,
|
||||
)
|
||||
ble_device = self._resolve_device()
|
||||
if ble_device is None:
|
||||
raise UpdateFailed(self._no_route_reason())
|
||||
|
||||
# Start notifications and request device info
|
||||
await self._client.start_notify(NOTIFY_UUID, self._notification_handler)
|
||||
await self._client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
|
||||
await asyncio.sleep(0.2)
|
||||
await self._client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
|
||||
# Clean up any existing client before connecting
|
||||
if self._client:
|
||||
_LOGGER.debug("Cleaning up existing client before new connection")
|
||||
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
||||
|
||||
self._is_connected = True
|
||||
_LOGGER.debug("Connected to %s", self._ble_device.address)
|
||||
try:
|
||||
# Held in a local: the handshake sleeps, and a concurrent
|
||||
# disconnect (switch off, dropped link) may clear self._client
|
||||
# underneath us - reading it back mid-handshake would crash.
|
||||
client = await establish_connection(
|
||||
BleakClientWithServiceCache,
|
||||
ble_device,
|
||||
self._address,
|
||||
disconnected_callback=self._async_client_disconnected,
|
||||
ble_device_callback=self._resolve_device,
|
||||
)
|
||||
self._client = client
|
||||
|
||||
except (BleakError, asyncio.TimeoutError) as ex:
|
||||
self._is_connected = False
|
||||
error_str = str(ex).lower()
|
||||
# Start notifications and request device info
|
||||
await client.start_notify(NOTIFY_UUID, self._notification_handler)
|
||||
await client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
|
||||
await asyncio.sleep(0.2)
|
||||
await client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
|
||||
|
||||
if "no longer reachable" in error_str or "out of connection slots" in error_str:
|
||||
_LOGGER.warning("Device %s not reachable - turn on the bike", self._ble_device.address)
|
||||
raise UpdateFailed(f"Device {self._ble_device.address} is not reachable") from ex
|
||||
else:
|
||||
_LOGGER.error("Failed to connect to %s: %s", self._ble_device.address, ex)
|
||||
raise UpdateFailed(f"Failed to connect to device: {ex}") from ex
|
||||
if self._client is not client:
|
||||
# Torn down while we were setting up - don't claim success.
|
||||
_LOGGER.debug("Connection to %s was cancelled", self._address)
|
||||
return
|
||||
|
||||
self._is_connected = True
|
||||
self._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."""
|
||||
@@ -219,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)
|
||||
|
||||
@@ -274,4 +586,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shutdown the coordinator."""
|
||||
_LOGGER.debug("Shutting down coordinator")
|
||||
await super().async_shutdown()
|
||||
await self._cleanup_client(send_close=True, wait_for_slot=False)
|
||||
# Flush any pending debounced write so an unload never loses the state.
|
||||
await self._store.async_save(self._persist_data())
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
"bleak>=0.21.0",
|
||||
"bleak-retry-connector>=3.1.0"
|
||||
],
|
||||
"version": "0.0.2"
|
||||
"version": "1.1.0"
|
||||
}
|
||||
@@ -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,7 @@ 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",
|
||||
@@ -126,7 +137,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
icon="mdi:bike",
|
||||
value_fn=lambda data: safe_get(data, "ebm", "trip_odometry"),
|
||||
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_odometry")),
|
||||
),
|
||||
MySmartBikeSensorEntityDescription(
|
||||
key="trip_range",
|
||||
@@ -136,7 +147,7 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:map-marker-distance",
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: safe_get(data, "ebm", "trip_autonomy"),
|
||||
value_fn=lambda data: round_km(safe_get(data, "ebm", "trip_autonomy")),
|
||||
),
|
||||
MySmartBikeSensorEntityDescription(
|
||||
key="light",
|
||||
@@ -163,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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -212,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."""
|
||||
|
||||
@@ -49,9 +49,18 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
|
||||
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
||||
self._attr_translation_key = "connection"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True - the connection wish can always be changed."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if connection is desired (not manually disconnected)."""
|
||||
"""Return True if connection is desired (not manually disconnected).
|
||||
|
||||
Restored from storage on startup, so a bike the user deliberately
|
||||
disconnected is not woken again by a Home Assistant restart.
|
||||
"""
|
||||
return not self.coordinator._manual_disconnect
|
||||
|
||||
@property
|
||||
@@ -60,9 +69,11 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
|
||||
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch - request connection to the bike."""
|
||||
self.async_write_ha_state()
|
||||
"""Turn on the switch - request connection to the bike.
|
||||
|
||||
The switch reflects the *wish* to be connected, so it stays on even when
|
||||
the bike is currently unreachable - the coordinator keeps retrying.
|
||||
"""
|
||||
try:
|
||||
await self.coordinator.async_reconnect()
|
||||
except Exception as ex:
|
||||
@@ -71,6 +82,8 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
|
||||
_LOGGER.warning("Cannot connect - bike not reachable. Will auto-connect when available.")
|
||||
else:
|
||||
_LOGGER.error("Failed to connect to bike: %s", ex)
|
||||
finally:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch - disconnect from the bike.
|
||||
|
||||
@@ -27,10 +27,15 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"connected": {
|
||||
"name": "Verbunden"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"connection": {
|
||||
"name": "Verbindung"
|
||||
"name": "Auto-Verbindung"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,15 @@
|
||||
}
|
||||
},
|
||||
"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
|
||||
|
||||
@@ -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