Files
ha-mysmartbike_ble/custom_components/mysmartbike_ble/sensor.py
T
Rene NulschandClaude Opus 5 ac0baffe74 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
2026-08-27 22:45:21 +02:00

238 lines
8.3 KiB
Python

"""Sensor platform for MySmartBike BLE integration."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfEnergy,
UnitOfLength,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, MODEL, CONF_DEVICE_NAME
from .coordinator import MySmartBikeCoordinator
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
"""Safely get nested dictionary values."""
if data is None:
return None
result = data
for key in keys:
if result is None or not isinstance(result, dict):
return None
result = result.get(key)
return result
@dataclass
class MySmartBikeSensorEntityDescription(SensorEntityDescription):
"""Describes MySmartBike sensor entity."""
value_fn: callable[[dict[str, Any]], Any] | None = None
SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
# Battery Primary Sensors
MySmartBikeSensorEntityDescription(
key="battery_primary_soc",
name="Battery SoC",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "soc"),
),
MySmartBikeSensorEntityDescription(
key="battery_primary_temperature",
name="Battery Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "temperature"),
),
MySmartBikeSensorEntityDescription(
key="battery_primary_remaining_wh",
name="Battery Remaining Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "battery_primary", "remaining_wh"),
),
# Motor Sensors
MySmartBikeSensorEntityDescription(
key="motor_assist_level",
name="Assist Level",
icon="mdi:speedometer",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: safe_get(data, "motor", "assist_level"),
),
MySmartBikeSensorEntityDescription(
key="motor_temperature",
name="Motor Temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: safe_get(data, "motor", "temperature_celsius"),
),
MySmartBikeSensorEntityDescription(
key="motor_speed",
name="Speed",
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:speedometer",
value_fn=lambda data: safe_get(data, "motor", "speed_kmh"),
),
# EBM Sensors
MySmartBikeSensorEntityDescription(
key="odometer",
name="Odometer",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:counter",
value_fn=lambda data: safe_get(data, "ebm", "odometry"),
),
MySmartBikeSensorEntityDescription(
key="range",
name="Range",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
value_fn=lambda data: safe_get(data, "ebm", "autonomy"),
),
MySmartBikeSensorEntityDescription(
key="trip_distance",
name="Trip A Distance",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:bike",
value_fn=lambda data: safe_get(data, "ebm", "trip_odometry"),
),
MySmartBikeSensorEntityDescription(
key="trip_range",
name="Trip A Range",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
entity_registry_enabled_default=False,
value_fn=lambda data: safe_get(data, "ebm", "trip_autonomy"),
),
MySmartBikeSensorEntityDescription(
key="light",
name="Light",
icon="mdi:lightbulb",
value_fn=lambda data: "On" if safe_get(data, "ebm", "is_light_on") else ("Off" if safe_get(data, "ebm") else None),
),
MySmartBikeSensorEntityDescription(
key="ebm_status",
name="EBM Status",
icon="mdi:information",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: safe_get(data, "ebm", "status"),
),
# Connection Diagnostic Sensors
MySmartBikeSensorEntityDescription(
key="rssi",
name="Signal Strength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
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"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up MySmartBike BLE sensors."""
coordinator: MySmartBikeCoordinator = entry.runtime_data
async_add_entities(
MySmartBikeSensor(coordinator, entry, description)
for description in SENSORS
)
class MySmartBikeSensor(CoordinatorEntity[MySmartBikeCoordinator], SensorEntity):
"""Representation of a MySmartBike sensor."""
entity_description: MySmartBikeSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: MySmartBikeCoordinator,
entry: ConfigEntry,
description: MySmartBikeSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
device_name = entry.data[CONF_DEVICE_NAME]
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
# Build device info with optional serial number (VIN)
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": device_name,
"manufacturer": MANUFACTURER,
"model": MODEL,
}
# Add serial number if available
if coordinator.vin:
self._attr_device_info["serial_number"] = coordinator.vin
# Add protocol version as software version
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."""
if self.entity_description.value_fn:
return self.entity_description.value_fn(self.coordinator.data)
return None