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()
@@ -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,20 @@ 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"),
}
+286 -50
View File
@@ -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,10 @@ from .const import (
PROTOCOL_REQUEST_MESSAGE,
CLOSE_MESSAGE,
SCAN_INTERVAL,
STORAGE_SAVE_DELAY,
STORAGE_VERSION,
RESTORE_STATE_KEYS,
VOLATILE_FIELDS,
CONF_LOG_BLE_MESSAGES,
CONF_DEVICE_NAME,
)
@@ -36,13 +47,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 +74,35 @@ 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._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 +113,179 @@ 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",
)
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:
"""Clean up BLE client connection.
@@ -118,22 +324,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 +354,86 @@ 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
state["last_seen"] = self._last_seen
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._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 +448,10 @@ class MySmartBikeCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Parse the message
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
self.async_set_updated_data(self._parser.state)
@@ -274,4 +507,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())
@@ -163,6 +163,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 +220,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."""
+16 -3
View File
@@ -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"
}
}
}
}
}