Files
ha-mysmartbike_ble/custom_components/mysmartbike_ble/sensor.py
T
Rene NulschandClaude Opus 5 62bc1c150a Stop flooding the recorder with timestamp and false-precision noise
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
2026-08-28 08:36:09 +02:00

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