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:
Rene Nulsch
2026-08-27 22:45:21 +02:00
co-authored by Claude Opus 5
parent c539823c1a
commit ac0baffe74
12 changed files with 1050 additions and 99 deletions
+33 -31
View File
@@ -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()