mirror of
https://github.com/home-assistant/core.git
synced 2026-09-08 14:41:38 +01:00
273 lines
8.9 KiB
Python
273 lines
8.9 KiB
Python
"""Sensor platform for the Silla Prism integration."""
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
from typing import override
|
|
|
|
from pysillaprism import PortError, PortState, PrismStatus
|
|
|
|
from homeassistant.components.sensor import (
|
|
SensorDeviceClass,
|
|
SensorEntity,
|
|
SensorEntityDescription,
|
|
SensorStateClass,
|
|
)
|
|
from homeassistant.const import (
|
|
EntityCategory,
|
|
UnitOfElectricCurrent,
|
|
UnitOfElectricPotential,
|
|
UnitOfEnergy,
|
|
UnitOfPower,
|
|
UnitOfTemperature,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
from homeassistant.helpers.typing import StateType
|
|
from homeassistant.util.dt import utcnow
|
|
|
|
from .const import PORT
|
|
from .coordinator import PrismConfigEntry, PrismCoordinator
|
|
from .entity import PrismEntity
|
|
|
|
PARALLEL_UPDATES = 0
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True)
|
|
class PrismSensorEntityDescription(SensorEntityDescription):
|
|
"""Describes a Prism sensor."""
|
|
|
|
value_fn: Callable[[PrismStatus], StateType]
|
|
|
|
|
|
# The Home Assistant state vocabulary differs from the protocol naming.
|
|
PORT_STATE_OPTIONS = {
|
|
PortState.IDLE: "idle",
|
|
PortState.WAITING: "waiting",
|
|
PortState.CHARGING: "charging",
|
|
PortState.PAUSE: "paused",
|
|
}
|
|
|
|
|
|
# The MQTT protocol only documents 0 as "no error". The fault codes are not
|
|
# specified, so they are reported as unknown rather than guessed, and logged so
|
|
# that this mapping can grow with the codes seen in the field.
|
|
ERROR_OPTIONS = {
|
|
PortError.NONE: "none",
|
|
}
|
|
|
|
|
|
def _port_state(status: PrismStatus) -> str | None:
|
|
state = status.port(PORT).state
|
|
return PORT_STATE_OPTIONS.get(state) if state is not None else None
|
|
|
|
|
|
SENSORS: tuple[PrismSensorEntityDescription, ...] = (
|
|
PrismSensorEntityDescription(
|
|
key="status",
|
|
translation_key="status",
|
|
device_class=SensorDeviceClass.ENUM,
|
|
options=list(PORT_STATE_OPTIONS.values()),
|
|
value_fn=_port_state,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="power",
|
|
device_class=SensorDeviceClass.POWER,
|
|
native_unit_of_measurement=UnitOfPower.WATT,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda status: status.port(PORT).power,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="current",
|
|
device_class=SensorDeviceClass.CURRENT,
|
|
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
|
|
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda status: status.port(PORT).current,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="voltage",
|
|
device_class=SensorDeviceClass.VOLTAGE,
|
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
entity_category=EntityCategory.DIAGNOSTIC,
|
|
value_fn=lambda status: status.port(PORT).voltage,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="pilot",
|
|
translation_key="pilot",
|
|
device_class=SensorDeviceClass.CURRENT,
|
|
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
entity_category=EntityCategory.DIAGNOSTIC,
|
|
value_fn=lambda status: status.port(PORT).pilot,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="session_energy",
|
|
translation_key="session_energy",
|
|
device_class=SensorDeviceClass.ENERGY,
|
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
|
state_class=SensorStateClass.TOTAL,
|
|
value_fn=lambda status: status.port(PORT).session_energy,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="total_energy",
|
|
translation_key="total_energy",
|
|
device_class=SensorDeviceClass.ENERGY,
|
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
value_fn=lambda status: status.port(PORT).total_energy,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="temperature",
|
|
device_class=SensorDeviceClass.TEMPERATURE,
|
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
entity_category=EntityCategory.DIAGNOSTIC,
|
|
value_fn=lambda status: status.temperature,
|
|
),
|
|
PrismSensorEntityDescription(
|
|
key="grid_power",
|
|
translation_key="grid_power",
|
|
device_class=SensorDeviceClass.POWER,
|
|
native_unit_of_measurement=UnitOfPower.WATT,
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
value_fn=lambda status: status.energy.power_grid,
|
|
),
|
|
)
|
|
|
|
ERROR = PrismSensorEntityDescription(
|
|
key="error",
|
|
translation_key="error",
|
|
device_class=SensorDeviceClass.ENUM,
|
|
options=list(ERROR_OPTIONS.values()),
|
|
entity_category=EntityCategory.DIAGNOSTIC,
|
|
value_fn=lambda status: status.port(PORT).error,
|
|
)
|
|
|
|
SESSION_START = PrismSensorEntityDescription(
|
|
key="session_start",
|
|
translation_key="session_start",
|
|
device_class=SensorDeviceClass.TIMESTAMP,
|
|
entity_category=EntityCategory.DIAGNOSTIC,
|
|
value_fn=lambda status: status.port(PORT).session_time,
|
|
)
|
|
|
|
# Prism reports the elapsed session time once a minute, and its counter runs
|
|
# about a second off the Home Assistant clock, so the derived start time jitters
|
|
# between messages. Deviations below this threshold keep the previous value.
|
|
SESSION_START_DEVIATION = timedelta(seconds=5)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: PrismConfigEntry,
|
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
) -> None:
|
|
"""Set up Prism sensors."""
|
|
coordinator = entry.runtime_data
|
|
entities: list[PrismSensor] = [
|
|
PrismSensor(coordinator, description) for description in SENSORS
|
|
]
|
|
entities.append(PrismErrorSensor(coordinator, ERROR))
|
|
entities.append(PrismSessionStartSensor(coordinator, SESSION_START))
|
|
async_add_entities(entities)
|
|
|
|
|
|
class PrismSensor(PrismEntity, SensorEntity):
|
|
"""A Prism sensor backed by an accumulated status field."""
|
|
|
|
entity_description: PrismSensorEntityDescription
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: PrismCoordinator,
|
|
description: PrismSensorEntityDescription,
|
|
) -> None:
|
|
"""Initialize the sensor."""
|
|
super().__init__(coordinator, description.key)
|
|
self.entity_description = description
|
|
|
|
@property
|
|
@override
|
|
def native_value(self) -> StateType | datetime:
|
|
"""Return the current value from the accumulated status."""
|
|
return self.entity_description.value_fn(self.coordinator.device.status)
|
|
|
|
|
|
class PrismErrorSensor(PrismSensor):
|
|
"""Reports the port error code.
|
|
|
|
Codes missing from the enum read as unknown and are logged once each, so
|
|
that undocumented fault codes can be reported and mapped.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: PrismCoordinator,
|
|
description: PrismSensorEntityDescription,
|
|
) -> None:
|
|
"""Initialize the sensor."""
|
|
super().__init__(coordinator, description)
|
|
self._logged_codes: set[int] = set()
|
|
|
|
@property
|
|
@override
|
|
def native_value(self) -> str | None:
|
|
"""Return the mapped error code."""
|
|
code = self.coordinator.device.status.port(PORT).error
|
|
if code is None:
|
|
return None
|
|
|
|
try:
|
|
state = ERROR_OPTIONS[PortError(code)]
|
|
except KeyError, ValueError:
|
|
if code not in self._logged_codes:
|
|
self._logged_codes.add(code)
|
|
_LOGGER.warning(
|
|
"Unknown error code: %s, please report at https://github.com/home-assistant/core/issues",
|
|
code,
|
|
)
|
|
return None
|
|
return state
|
|
|
|
|
|
class PrismSessionStartSensor(PrismSensor):
|
|
"""Reports when the running charging session started.
|
|
|
|
Prism only publishes the elapsed session time, which is stale as soon as it
|
|
is received. Reporting the derived start time instead keeps the value
|
|
accurate between messages.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: PrismCoordinator,
|
|
description: PrismSensorEntityDescription,
|
|
) -> None:
|
|
"""Initialize the sensor."""
|
|
super().__init__(coordinator, description)
|
|
self._session_start: datetime | None = None
|
|
|
|
@property
|
|
@override
|
|
def native_value(self) -> datetime | None:
|
|
"""Return the start time derived from the elapsed session time."""
|
|
elapsed = self.entity_description.value_fn(self.coordinator.device.status)
|
|
# Prism reports zero while no vehicle is connected.
|
|
if not elapsed:
|
|
self._session_start = None
|
|
return None
|
|
|
|
start = utcnow() - timedelta(seconds=float(elapsed))
|
|
if (
|
|
self._session_start is None
|
|
or abs(start - self._session_start) > SESSION_START_DEVIATION
|
|
):
|
|
self._session_start = start
|
|
|
|
return self._session_start
|