Measured on a real installation with two bikes: the two "Last Seen" sensors were the number one and number two writers in the entire Home Assistant database, ahead of a three-phase energy meter. The integration accounted for 26.5% of all recorder writes on that system. Last Seen moved on every BLE notification, about once a second, or 2834 rows an hour per bike. The entity exists to say how fresh the values are, which needs nothing near that resolution, so it now advances at LAST_SEEN_RESOLUTION granularity. `self._last_seen` stays exact - that is what gets persisted and restored - only the entity is throttled. Measured after: 120 rows an hour, the theoretical maximum for a 30s window. The distance sensors carried four decimals of false precision, because the ebikemotion frames divide by 10000: a range that is really "14.6" was reported as 14.6065, then 14.6529, then 14.6297, each one another state write and another database row. They are rounded to 0.1 km now. Home Assistant offers integrations no way to exclude an entity from the recorder - `entity_filter` comes from user configuration only, and `_unrecorded_attributes` covers attributes rather than states - so reducing how often the state changes is the only fix that works without asking every user to edit configuration.yaml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyabWZzd7HoLpyBwEa5Zzh
249 lines
8.8 KiB
Python
249 lines
8.8 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 round_km(value: Any) -> float | None:
|
|
"""Round a distance to 0.1 km.
|
|
|
|
The ebikemotion frames carry these as `read32 / 10000`, which yields four
|
|
decimals of false precision - 14.6065, 14.6529, 14.6297 for a range that is
|
|
really "14.6". Every flicker in the last digit would be another state write
|
|
and another database row.
|
|
"""
|
|
return None if value is None else round(float(value), 1)
|
|
|
|
|
|
def safe_get(data: dict[str, Any] | None, *keys: str) -> Any:
|
|
"""Safely get nested dictionary values."""
|
|
if data is None:
|
|
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: round_km(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: round_km(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: round_km(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: round_km(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
|