Keep showing last known values when the bike is out of range
Setup no longer depends on the bike being reachable. The coordinator is built from an address and resolves the BLEDevice per connect attempt, so a parked or switched-off bike loads the entry instead of raising ConfigEntryNotReady and leaving every entity unavailable. Parser state is persisted through helpers.storage.Store and restored before the platforms are set up, so entities carry their last values and the VIN on their first state write. What survives is a whitelist: battery and EBM counters yes, motor and assist no - a restored speed reading is indistinguishable from live data on a parked bike. The connection switch position is restored too, so a restart no longer wakes a bike the user deliberately disconnected. Also fixes three defects found while building this: - Store.async_delay_save debounces rather than throttles, so re-arming on every notification postponed the write for as long as the bike stayed connected and nothing reached disk except on a clean shutdown. - establish_connection had no disconnected_callback, so a dropped link left _is_connected True: the connectivity sensor lied and the poll never retried. - _connect read self._client back across the 200ms handshake, which a concurrent teardown could clear underneath it. Connecting now starts on the bike's advertisement instead of the next poll tick, and an unreachable bike says why - distinguishing "switched off" from "seen only by a passive proxy that cannot connect". Renames the connection switch to Auto-connect and drops the hardcoded English name on the connectivity sensor, which had been defeating its translation key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
This commit is contained in:
co-authored by
Claude Opus 5
parent
c539823c1a
commit
ac0baffe74
@@ -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.
|
||||||
@@ -49,6 +49,10 @@ This integration provides real-time monitoring of your E-Bike through Bluetooth
|
|||||||
- **Range** (km)
|
- **Range** (km)
|
||||||
- **Light Status**
|
- **Light Status**
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
- **Last Seen** (Timestamp) - When the bike last sent data, so you can tell how fresh the values are
|
||||||
|
- **Signal Strength** (dBm) - disabled by default
|
||||||
|
|
||||||
### Device Information
|
### Device Information
|
||||||
The integration automatically retrieves and displays:
|
The integration automatically retrieves and displays:
|
||||||
- **Serial Number** (VIN) - 17-character bike serial number
|
- **Serial Number** (VIN) - 17-character bike serial number
|
||||||
@@ -115,6 +119,19 @@ The integration will automatically discover iWoc and HUS devices in range via Bl
|
|||||||
- Some sensors may show "Unknown" until the bike sends that specific data
|
- Some sensors may show "Unknown" until the bike sends that specific data
|
||||||
- Check if the bike is actively transmitting data (try riding or using the display)
|
- Check if the bike is actively transmitting data (try riding or using the display)
|
||||||
|
|
||||||
|
### Values after a Home Assistant restart
|
||||||
|
|
||||||
|
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 connection switch keeps its position across restarts, so a bike you
|
||||||
|
deliberately disconnected is not woken up again by a Home Assistant restart
|
||||||
|
|
||||||
### Connection Switch
|
### Connection Switch
|
||||||
|
|
||||||
- **To disconnect**: Turn off the "Connection" switch in Home Assistant
|
- **To disconnect**: Turn off the "Connection" switch in Home Assistant
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from homeassistant.components import bluetooth
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from .const import DOMAIN, CONF_DEVICE_ADDRESS
|
from .const import CONF_DEVICE_ADDRESS, DOMAIN, STORAGE_VERSION
|
||||||
from .coordinator import MySmartBikeCoordinator
|
from .coordinator import MySmartBikeCoordinator, storage_key
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,35 +18,36 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.S
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up MySmartBike BLE from a config entry."""
|
"""Set up MySmartBike BLE from a config entry.
|
||||||
|
|
||||||
|
Setup never depends on the bike being in range. A bike that is switched off
|
||||||
|
or parked out of reach is the normal case, so the entry loads with the
|
||||||
|
persisted state and the coordinator connects whenever the bike shows up.
|
||||||
|
"""
|
||||||
address = entry.data[CONF_DEVICE_ADDRESS]
|
address = entry.data[CONF_DEVICE_ADDRESS]
|
||||||
|
|
||||||
# Get BLE device
|
coordinator = MySmartBikeCoordinator(hass, address, entry)
|
||||||
ble_device = bluetooth.async_ble_device_from_address(hass, address, connectable=True)
|
# Restore before the platforms are set up so the entities' first state
|
||||||
if not ble_device:
|
# write already carries the last known values and the serial number.
|
||||||
# Log warning only once per config entry
|
await coordinator.async_restore()
|
||||||
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()
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
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)
|
_LOGGER.debug("MySmartBike BLE setup completed for %s", address)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -59,8 +60,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
coordinator: MySmartBikeCoordinator = entry.runtime_data
|
coordinator: MySmartBikeCoordinator = entry.runtime_data
|
||||||
await coordinator.async_shutdown()
|
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
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Drop the persisted state when the bike is removed from Home Assistant."""
|
||||||
|
await Store(hass, STORAGE_VERSION, storage_key(entry)).async_remove()
|
||||||
|
|||||||
@@ -52,16 +52,16 @@ class MySmartBikeConnectionSensor(CoordinatorEntity[MySmartBikeCoordinator], Bin
|
|||||||
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
||||||
self._attr_translation_key = "connected"
|
self._attr_translation_key = "connected"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True - "not connected" is a state, not an absence of one."""
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return True if connected to the bike."""
|
"""Return True if connected to the bike."""
|
||||||
return self.coordinator.is_connected
|
return self.coordinator.is_connected
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
"""Return the name of the sensor."""
|
|
||||||
return "Connected"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
"""Return the icon."""
|
"""Return the icon."""
|
||||||
|
|||||||
@@ -36,3 +36,20 @@ CONF_DEVICE_ADDRESS: Final = "device_address"
|
|||||||
|
|
||||||
# Options
|
# Options
|
||||||
CONF_LOG_BLE_MESSAGES: Final = "log_ble_messages"
|
CONF_LOG_BLE_MESSAGES: Final = "log_ble_messages"
|
||||||
|
|
||||||
|
# Persistence
|
||||||
|
STORAGE_VERSION: Final = 1
|
||||||
|
STORAGE_SAVE_DELAY: Final = 60 # seconds; BLE notifications arrive far too often to save eagerly
|
||||||
|
|
||||||
|
# Top-level parser state keys that survive a restart. "motor" and "assist" are
|
||||||
|
# deliberately absent: a restored speed or power reading would look like live
|
||||||
|
# data from a bike that is actually parked.
|
||||||
|
RESTORE_STATE_KEYS: Final = ("battery_primary", "battery_secondary", "ebm")
|
||||||
|
|
||||||
|
# Fields inside the restored dicts that describe an instantaneous condition and
|
||||||
|
# are therefore dropped (set to None) when reading the state back.
|
||||||
|
VOLATILE_FIELDS: Final[dict[str, tuple[str, ...]]] = {
|
||||||
|
"battery_primary": ("current", "is_charging"),
|
||||||
|
"battery_secondary": ("current", "is_charging"),
|
||||||
|
"ebm": ("status", "accel_y", "accel_z"),
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,17 @@ from bleak_retry_connector import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.components import bluetooth
|
from homeassistant.components import bluetooth
|
||||||
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
|
from homeassistant.components.bluetooth import (
|
||||||
|
BluetoothCallbackMatcher,
|
||||||
|
BluetoothChange,
|
||||||
|
BluetoothScanningMode,
|
||||||
|
BluetoothServiceInfoBleak,
|
||||||
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -28,6 +35,10 @@ from .const import (
|
|||||||
PROTOCOL_REQUEST_MESSAGE,
|
PROTOCOL_REQUEST_MESSAGE,
|
||||||
CLOSE_MESSAGE,
|
CLOSE_MESSAGE,
|
||||||
SCAN_INTERVAL,
|
SCAN_INTERVAL,
|
||||||
|
STORAGE_SAVE_DELAY,
|
||||||
|
STORAGE_VERSION,
|
||||||
|
RESTORE_STATE_KEYS,
|
||||||
|
VOLATILE_FIELDS,
|
||||||
CONF_LOG_BLE_MESSAGES,
|
CONF_LOG_BLE_MESSAGES,
|
||||||
CONF_DEVICE_NAME,
|
CONF_DEVICE_NAME,
|
||||||
)
|
)
|
||||||
@@ -36,13 +47,24 @@ from .parsers import BikeDataParser
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def storage_key(entry: ConfigEntry) -> str:
|
||||||
|
"""Return the .storage key holding the persisted state for a config entry."""
|
||||||
|
return f"{DOMAIN}.{entry.entry_id}"
|
||||||
|
|
||||||
|
|
||||||
class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
"""Class to manage fetching MySmartBike data."""
|
"""Class to manage fetching MySmartBike data.
|
||||||
|
|
||||||
|
The coordinator is deliberately independent of the bike's availability: it
|
||||||
|
is constructed from an address, resolves the `BLEDevice` on every connect
|
||||||
|
attempt, and keeps serving the last known values while the bike is out of
|
||||||
|
range or switched off.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
ble_device: BluetoothServiceInfoBleak,
|
address: str,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize coordinator."""
|
"""Initialize coordinator."""
|
||||||
@@ -52,24 +74,35 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||||
)
|
)
|
||||||
self._ble_device = ble_device
|
self._address = address
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._client: BleakClient | None = None
|
self._client: BleakClient | None = None
|
||||||
self._parser = BikeDataParser()
|
self._parser = BikeDataParser()
|
||||||
self._is_connected = False
|
self._is_connected = False
|
||||||
self._notify_task: asyncio.Task | None = None
|
self._connect_lock = asyncio.Lock()
|
||||||
self._manual_disconnect = False # Track if user manually disconnected
|
self._manual_disconnect = False # Track if user manually disconnected
|
||||||
|
self._last_seen: datetime | None = None
|
||||||
|
self._save_armed = False
|
||||||
|
self._unreachable_reason: str | None = None
|
||||||
|
self._store: Store[dict[str, Any]] = Store(
|
||||||
|
hass, STORAGE_VERSION, storage_key(entry)
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def address(self) -> str:
|
def address(self) -> str:
|
||||||
"""Return the address of the device."""
|
"""Return the address of the device."""
|
||||||
return self._ble_device.address
|
return self._address
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Return connection status."""
|
"""Return connection status."""
|
||||||
return self._is_connected
|
return self._is_connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_seen(self) -> datetime | None:
|
||||||
|
"""Return when the last BLE notification was received, if ever."""
|
||||||
|
return self._last_seen
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vin(self) -> str | None:
|
def vin(self) -> str | None:
|
||||||
"""Return the VIN/serial number if available."""
|
"""Return the VIN/serial number if available."""
|
||||||
@@ -80,6 +113,179 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"""Return the protocol version if available."""
|
"""Return the protocol version if available."""
|
||||||
return self._parser.protocol_version
|
return self._parser.protocol_version
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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_device_appeared(
|
||||||
|
self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange
|
||||||
|
) -> None:
|
||||||
|
"""Handle the bike showing up in range."""
|
||||||
|
if self._is_connected or self._manual_disconnect:
|
||||||
|
return
|
||||||
|
self._entry.async_create_background_task(
|
||||||
|
self.hass, self._async_try_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()
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""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}")
|
||||||
|
|
||||||
|
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:
|
async def _cleanup_client(self, send_close: bool = True, wait_for_slot: bool = True) -> None:
|
||||||
"""Clean up BLE client connection.
|
"""Clean up BLE client connection.
|
||||||
|
|
||||||
@@ -118,22 +324,26 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
del client
|
del client
|
||||||
if wait_for_slot:
|
if wait_for_slot:
|
||||||
await asyncio.sleep(3.0) # Wait for BLE connection slot release
|
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:
|
async def async_disconnect(self) -> None:
|
||||||
"""Disconnect from the device (user initiated)."""
|
"""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._manual_disconnect = True
|
||||||
|
self._schedule_save()
|
||||||
await self._cleanup_client(send_close=True, wait_for_slot=True)
|
await self._cleanup_client(send_close=True, wait_for_slot=True)
|
||||||
|
|
||||||
async def async_reconnect(self) -> None:
|
async def async_reconnect(self) -> None:
|
||||||
"""Reconnect to the device (user initiated)."""
|
"""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
|
# Clean up any existing client first
|
||||||
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
||||||
|
|
||||||
# Clear manual disconnect flag to allow auto-reconnect
|
# Clear manual disconnect flag to allow auto-reconnect
|
||||||
self._manual_disconnect = False
|
self._manual_disconnect = False
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._connect()
|
await self._connect()
|
||||||
@@ -144,67 +354,86 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Fetch data from the device."""
|
"""Refresh diagnostics and auto-reconnect; never fails the entities.
|
||||||
|
|
||||||
|
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
|
# Auto-reconnect if not connected and not manually disconnected
|
||||||
if not self._is_connected and not self._manual_disconnect:
|
if not self._is_connected and not self._manual_disconnect:
|
||||||
try:
|
await self._async_try_connect()
|
||||||
await self._connect()
|
|
||||||
except Exception:
|
|
||||||
pass # Connection errors are logged in _connect()
|
|
||||||
|
|
||||||
# Return current state from parser, ensure it's never None
|
state = self._parser.state
|
||||||
state = self._parser.state or {
|
|
||||||
"battery_primary": None,
|
|
||||||
"battery_secondary": None,
|
|
||||||
"motor": None,
|
|
||||||
"assist": None,
|
|
||||||
"ebm": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add RSSI (signal strength) to state
|
# Add RSSI (signal strength) to state - None while out of range
|
||||||
try:
|
try:
|
||||||
service_info = bluetooth.async_last_service_info(
|
service_info = bluetooth.async_last_service_info(
|
||||||
self.hass, self._ble_device.address, connectable=True
|
self.hass, self._address, connectable=True
|
||||||
)
|
)
|
||||||
state["rssi"] = service_info.rssi if service_info else None
|
state["rssi"] = service_info.rssi if service_info else None
|
||||||
except Exception:
|
except Exception:
|
||||||
state["rssi"] = None
|
state["rssi"] = None
|
||||||
|
|
||||||
|
state["last_seen"] = self._last_seen
|
||||||
return state
|
return state
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
async def _connect(self) -> None:
|
||||||
"""Connect to the device and start notifications."""
|
"""Connect to the device and start notifications."""
|
||||||
# Clean up any existing client before connecting
|
async with self._connect_lock:
|
||||||
if self._client:
|
if self._is_connected:
|
||||||
_LOGGER.debug("Cleaning up existing client before new connection")
|
return
|
||||||
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
|
||||||
|
|
||||||
try:
|
ble_device = self._resolve_device()
|
||||||
self._client = await establish_connection(
|
if ble_device is None:
|
||||||
BleakClientWithServiceCache,
|
raise UpdateFailed(self._no_route_reason())
|
||||||
self._ble_device,
|
|
||||||
self._ble_device.address,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start notifications and request device info
|
# Clean up any existing client before connecting
|
||||||
await self._client.start_notify(NOTIFY_UUID, self._notification_handler)
|
if self._client:
|
||||||
await self._client.write_gatt_char(WRITE_UUID, VIN_REQUEST_MESSAGE)
|
_LOGGER.debug("Cleaning up existing client before new connection")
|
||||||
await asyncio.sleep(0.2)
|
await self._cleanup_client(send_close=False, wait_for_slot=True)
|
||||||
await self._client.write_gatt_char(WRITE_UUID, PROTOCOL_REQUEST_MESSAGE)
|
|
||||||
|
|
||||||
self._is_connected = True
|
try:
|
||||||
_LOGGER.debug("Connected to %s", 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,
|
||||||
|
)
|
||||||
|
self._client = client
|
||||||
|
|
||||||
except (BleakError, asyncio.TimeoutError) as ex:
|
# Start notifications and request device info
|
||||||
self._is_connected = False
|
await client.start_notify(NOTIFY_UUID, self._notification_handler)
|
||||||
error_str = str(ex).lower()
|
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:
|
if self._client is not client:
|
||||||
_LOGGER.warning("Device %s not reachable - turn on the bike", self._ble_device.address)
|
# Torn down while we were setting up - don't claim success.
|
||||||
raise UpdateFailed(f"Device {self._ble_device.address} is not reachable") from ex
|
_LOGGER.debug("Connection to %s was cancelled", self._address)
|
||||||
else:
|
return
|
||||||
_LOGGER.error("Failed to connect to %s: %s", self._ble_device.address, ex)
|
|
||||||
raise UpdateFailed(f"Failed to connect to device: {ex}") from ex
|
self._is_connected = True
|
||||||
|
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:
|
def _notification_handler(self, sender: int, data: bytearray) -> None:
|
||||||
"""Handle notification data."""
|
"""Handle notification data."""
|
||||||
@@ -219,6 +448,10 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# Parse the message
|
# Parse the message
|
||||||
self._parser.handle_message(bytes(data))
|
self._parser.handle_message(bytes(data))
|
||||||
|
|
||||||
|
self._last_seen = dt_util.utcnow()
|
||||||
|
self._parser.state["last_seen"] = self._last_seen
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
# Update coordinator data
|
# Update coordinator data
|
||||||
self.async_set_updated_data(self._parser.state)
|
self.async_set_updated_data(self._parser.state)
|
||||||
|
|
||||||
@@ -274,4 +507,7 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
async def async_shutdown(self) -> None:
|
async def async_shutdown(self) -> None:
|
||||||
"""Shutdown the coordinator."""
|
"""Shutdown the coordinator."""
|
||||||
_LOGGER.debug("Shutting down coordinator")
|
_LOGGER.debug("Shutting down coordinator")
|
||||||
|
await super().async_shutdown()
|
||||||
await self._cleanup_client(send_close=True, wait_for_slot=False)
|
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())
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
|
|||||||
icon="mdi:wifi",
|
icon="mdi:wifi",
|
||||||
value_fn=lambda data: safe_get(data, "rssi"),
|
value_fn=lambda data: safe_get(data, "rssi"),
|
||||||
),
|
),
|
||||||
|
MySmartBikeSensorEntityDescription(
|
||||||
|
key="last_seen",
|
||||||
|
name="Last Seen",
|
||||||
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
icon="mdi:clock-outline",
|
||||||
|
value_fn=lambda data: safe_get(data, "last_seen"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -212,6 +220,15 @@ class MySmartBikeSensor(CoordinatorEntity[MySmartBikeCoordinator], SensorEntity)
|
|||||||
if coordinator.protocol_version:
|
if coordinator.protocol_version:
|
||||||
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True - values are last-known-good, not live readings.
|
||||||
|
|
||||||
|
An unreachable bike must not blank the sensors; `binary_sensor.connected`
|
||||||
|
and the "Last Seen" timestamp tell the user how fresh the values are.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> Any:
|
def native_value(self) -> Any:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
|
|||||||
@@ -49,9 +49,18 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
|
|||||||
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
self._attr_device_info["sw_version"] = coordinator.protocol_version
|
||||||
self._attr_translation_key = "connection"
|
self._attr_translation_key = "connection"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True - the connection wish can always be changed."""
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return True if connection is desired (not manually disconnected)."""
|
"""Return True if connection is desired (not manually disconnected).
|
||||||
|
|
||||||
|
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
|
return not self.coordinator._manual_disconnect
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -60,9 +69,11 @@ class MySmartBikeConnectionSwitch(CoordinatorEntity[MySmartBikeCoordinator], Swi
|
|||||||
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
|
return "mdi:bluetooth-connect" if self.is_on else "mdi:bluetooth-off"
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn on the switch - request connection to the bike."""
|
"""Turn on the switch - request connection to the bike.
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
|
The switch reflects the *wish* to be connected, so it stays on even when
|
||||||
|
the bike is currently unreachable - the coordinator keeps retrying.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await self.coordinator.async_reconnect()
|
await self.coordinator.async_reconnect()
|
||||||
except Exception as ex:
|
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.")
|
_LOGGER.warning("Cannot connect - bike not reachable. Will auto-connect when available.")
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("Failed to connect to bike: %s", ex)
|
_LOGGER.error("Failed to connect to bike: %s", ex)
|
||||||
|
finally:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off the switch - disconnect from the bike.
|
"""Turn off the switch - disconnect from the bike.
|
||||||
|
|||||||
@@ -27,9 +27,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"connected": {
|
||||||
|
"name": "Verbunden"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"connection": {
|
"connection": {
|
||||||
"name": "Verbindung"
|
"name": "Auto-Verbindung"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"connected": {
|
||||||
|
"name": "Connected"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"connection": {
|
"connection": {
|
||||||
"name": "Connection"
|
"name": "Auto-connect"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,21 +75,85 @@ def mock_ble_device() -> Generator[MagicMock]:
|
|||||||
yield mock_devices
|
yield mock_devices
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_bluetooth_helpers() -> Generator[None]:
|
||||||
|
"""Stub the bluetooth helpers the coordinator calls into.
|
||||||
|
|
||||||
|
Keeps the tests independent of a real bluetooth integration setup; the
|
||||||
|
device-present/absent case is driven by `mock_device_in_range`.
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_register_callback",
|
||||||
|
return_value=lambda: None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_last_service_info",
|
||||||
|
return_value=_get_bluetooth_service_info(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_device_in_range() -> Generator[MagicMock]:
|
||||||
|
"""Report the bike as reachable. Set `return_value = None` to take it away."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_ble_device_from_address",
|
||||||
|
return_value=_get_bluetooth_service_info(),
|
||||||
|
) as mock_resolve,
|
||||||
|
patch(
|
||||||
|
"custom_components.mysmartbike_ble.bluetooth.async_ble_device_from_address",
|
||||||
|
return_value=_get_bluetooth_service_info(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield mock_resolve
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_device_out_of_range() -> Generator[MagicMock]:
|
||||||
|
"""Report the bike as out of range / switched off."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"custom_components.mysmartbike_ble.coordinator.bluetooth.async_ble_device_from_address",
|
||||||
|
return_value=None,
|
||||||
|
) as mock_resolve,
|
||||||
|
patch(
|
||||||
|
"custom_components.mysmartbike_ble.bluetooth.async_ble_device_from_address",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield mock_resolve
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def init_integration(
|
async def init_integration(
|
||||||
hass,
|
hass,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_bleak_client: MagicMock,
|
mock_bleak_client: MagicMock,
|
||||||
|
mock_device_in_range: MagicMock,
|
||||||
) -> MockConfigEntry:
|
) -> MockConfigEntry:
|
||||||
"""Set up the MySmartBike BLE integration for testing."""
|
"""Set up the MySmartBike BLE integration for testing."""
|
||||||
mock_config_entry.add_to_hass(hass)
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
with patch(
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
"homeassistant.components.bluetooth.async_ble_device_from_address"
|
await hass.async_block_till_done()
|
||||||
) as mock_ble_device_from_address:
|
|
||||||
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
|
|
||||||
|
|
||||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
return mock_config_entry
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
|
@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
|
return mock_config_entry
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""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
|
||||||
|
coordinator._is_connected = 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
|
||||||
Reference in New Issue
Block a user