"""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