From 36d1e82baf6555f9c6430336f2252ff26ce8771d Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 29 Dec 2024 01:10:00 +0100 Subject: [PATCH 01/15] refactor: update sensor creation mechanism to use SensorEntityDescription Sensors now implement translation key for icons and name --- custom_components/linkytic/icons.json | 129 ++ custom_components/linkytic/sensor.py | 1910 ++++++----------- .../linkytic/translations/en.json | 370 +++- .../linkytic/translations/fr.json | 373 +++- 4 files changed, 1494 insertions(+), 1288 deletions(-) create mode 100644 custom_components/linkytic/icons.json diff --git a/custom_components/linkytic/icons.json b/custom_components/linkytic/icons.json new file mode 100644 index 0000000..3e20cb9 --- /dev/null +++ b/custom_components/linkytic/icons.json @@ -0,0 +1,129 @@ +{ + "entity": { + "sensor": { + "serial_number": { + "default": "mdi:meter-electric-outline" + }, + "tarif_option": { + "default": "mdi:cash-check" + }, + "subcription_current": { + "default": "mdi:cash-check" + }, + "current_tarif": { + "default": "mdi:calendar-expand-horizontal" + }, + "peak_notice": { + "default": "mdi:clock-outline" + }, + "tomorrow_color": { + "default": "mdi:palette" + }, + "peak_hour_schedule": { + "default": "mdi:clock-outline" + }, + "meter_state": { + "default": "mdi:file-word-box-outline" + }, + "overcurrent_warning": { + "default": "mdi:flash-triangle-outline" + }, + "overcurrent_warning_ph1": { + "default": "mdi:flash-triangle-outline" + }, + "overcurrent_warning_ph2": { + "default": "mdi:flash-triangle-outline" + }, + "overcurrent_warning_ph3": { + "default": "mdi:flash-triangle-outline" + }, + "tic_version": { + "default": "mdi:tag" + }, + "datetime": { + "default": "mdi:clock" + }, + "tarif_name": { + "default": "mdi:cash-check" + }, + "current_tarif_label": { + "default": "mdi:cash-check" + }, + "mobile_peak_start_1": { + "default": "mdi:clock-start" + }, + "mobile_peak_end_1": { + "default": "mdi:clock-end" + }, + "mobile_peak_start_2": { + "default": "mdi:clock-start" + }, + "mobile_peak_end_2": { + "default": "mdi:clock-end" + }, + "mobile_peak_start_3": { + "default": "mdi:clock-start" + }, + "mobile_peak_end_3": { + "default": "mdi:clock-end" + }, + "short_msg": { + "default": "mdi:message-text-outline" + }, + "ultra_short_msg": { + "default": "mdi:message-text-outline" + }, + "delivery_point": { + "default": "mdi:tag" + }, + "relays": { + "default": "mdi:electric-switch" + }, + "current_tarif_index": { + "default": "mdi:cash-check" + }, + "calendar_index_today": { + "default": "mdi:calendar-month-outline" + }, + "calendar_index_tomorrow": { + "default": "mdi:calendar-month-outline" + }, + "calendar_profile_tomorrow": { + "default": "mdi:calendar-month-outline" + }, + "next_peak_dat_profile": { + "default": "mdi:calendar-month-outline" + }, + "status_register": { + "default": "mdi:list-status" + }, + "status_trip_device": { + "default": "mdi:connection" + }, + "status_tarif_provider": { + "default": "mdi:cash-check" + }, + "status_tarif_distributor": { + "default": "mdi:cash-check" + }, + "status_euridis": { + "default": "mdi:tag" + }, + "status_cpl": { + "default": "mdi:tag" + }, + "status_tempo_color_today": { + "default": "mdi:palette" + }, + "status_tempo_color_tomorrow": { + "default": "mdi:palette" + }, + "status_mobile_peak_notice": { + "default": "mdi:clock-alert-outline" + }, + "status_mobile_peak": { + "default": "mdi:progress-clock" + } + } + } +} diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index e25f509..6998487 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -4,9 +4,10 @@ import logging from collections.abc import Callable -from typing import Generic, Iterable, Optional, TypeVar, cast +from dataclasses import dataclass +from typing import Generic, Optional, TypeVar, cast -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -18,7 +19,6 @@ UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -29,7 +29,6 @@ DID_TYPE_CODE, DID_YEAR, DOMAIN, - EXPERIMENTAL_DEVICES, SETUP_PRODUCER, SETUP_THREEPHASE, SETUP_TICMODE, @@ -41,6 +40,538 @@ _LOGGER = logging.getLogger(__name__) +REACTIVE_ENERGY = "VArh" + + +def _parse_timestamp(raw: str): + """Parse the raw timestamp string into human readable form.""" + return ( + f"{raw[5:7]}/{raw[3:5]}/{raw[1:3]} " + f"{raw[7:9]}:{raw[9:11]} " + f"({'Eté' if raw[0] == 'E' else 'Hiver'})" + ) + + +@dataclass(frozen=True, kw_only=True) +class LinkyTicSensorConfig(SensorEntityDescription): + """Sensor configuration dataclass.""" + + fallback_tags: tuple[str, ...] | None = ( + None # Multiple tags are allowed for non-standard linky tags support, see hekmon/linky#42 + ) + register_callback: bool = False + conversion: Callable | None = None + + +@dataclass(frozen=True, kw_only=True) +class SerialNumberSensorConfig(LinkyTicSensorConfig): + """Sensor configuration dataclass.""" + + translation_key: str | None = "serial_number" + entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + + +@dataclass(frozen=True, kw_only=True) +class ApparentPowerSensorConfig(LinkyTicSensorConfig): + """Configuration for apparent power sensors.""" + + device_class: SensorDeviceClass | None = SensorDeviceClass.APPARENT_POWER + native_unit_of_measurement: str | None = UnitOfApparentPower.VOLT_AMPERE + + +@dataclass(frozen=True, kw_only=True) +class ActivePowerSensorConfig(LinkyTicSensorConfig): + """Configuration for active power sensors.""" + + device_class: SensorDeviceClass | None = SensorDeviceClass.POWER + native_unit_of_measurement: str | None = UnitOfPower.WATT + + +@dataclass(frozen=True, kw_only=True) +class VoltageSensorConfig(LinkyTicSensorConfig): + """Configuration for voltage sensors.""" + + device_class: SensorDeviceClass | None = SensorDeviceClass.VOLTAGE + native_unit_of_measurement: str | None = UnitOfElectricPotential.VOLT + + +@dataclass(frozen=True, kw_only=True) +class ElectricalCurrentSensorConfig(LinkyTicSensorConfig): + """Configuration for electrical current sensors.""" + + device_class: SensorDeviceClass | None = SensorDeviceClass.CURRENT + native_unit_of_measurement: str | None = UnitOfElectricCurrent.AMPERE + + +@dataclass(frozen=True, kw_only=True) +class ActiveEnergySensorConfig(LinkyTicSensorConfig): + """Configuration for active energy sensors.""" + + device_class: SensorDeviceClass | None = SensorDeviceClass.ENERGY + native_unit_of_measurement: str | None = UnitOfEnergy.WATT_HOUR + state_class: SensorStateClass | str | None = SensorStateClass.TOTAL_INCREASING + + +@dataclass(frozen=True, kw_only=True) +class ReactiveEnergySensorConfig(LinkyTicSensorConfig): + """Configuration for reactive energy sensors.""" + + device_class: SensorDeviceClass | None = SensorDeviceClass.REACTIVE_POWER + native_unit_of_measurement: str | None = REACTIVE_ENERGY + + +@dataclass(frozen=True, kw_only=True) +class StatusRegisterSensorConfig(LinkyTicSensorConfig): + """Configuration for status register sensors.""" + + key: str = "STGE" + status_field: StatusRegister + + +REGISTRY: dict[type[LinkyTicSensorConfig], type[LinkyTICSensor]] = {} + + +def match(*configs: type[LinkyTicSensorConfig]): + """Associate one or more sensor config to a sensor class.""" + + def wrap(cls): + for config in configs: + REGISTRY[config] = cls + return cls + + return wrap + + +SENSORS_HISTORIC_COMMON: tuple[LinkyTicSensorConfig, ...] = ( + SerialNumberSensorConfig(key="ADCO"), + LinkyTicSensorConfig( + key="OPTARIF", + translation_key="tarif_option", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ElectricalCurrentSensorConfig( + key="ISOUSC", + translation_key="subcription_current", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ActiveEnergySensorConfig( + key="BASE", + translation_key="index_base", + ), + ActiveEnergySensorConfig( + key="HCHC", + translation_key="index_hchc", + ), + ActiveEnergySensorConfig( + key="HCHP", + translation_key="index_hchp", + ), + ActiveEnergySensorConfig( + key="EJPHN", + translation_key="index_ejp_normal", + ), + ActiveEnergySensorConfig( + key="EJPJPM", + translation_key="index_ejp_peak", + ), + ActiveEnergySensorConfig( + key="BBRHCJB", + translation_key="index_tempo_bluehc", + ), + ActiveEnergySensorConfig( + key="BBRHPJB", + translation_key="index_tempo_bluehp", + ), + ActiveEnergySensorConfig( + key="BBRHCJW", + translation_key="index_tempo_whitehc", + ), + ActiveEnergySensorConfig( + key="BBRHPJW", + translation_key="index_tempo_whitehp", + ), + ActiveEnergySensorConfig( + key="BBRHCJR", + translation_key="index_tempo_redhc", + ), + ActiveEnergySensorConfig( + key="BBRHPJR", + translation_key="index_tempo_redhp", + ), + LinkyTicSensorConfig( + key="PTEC", + translation_key="current_tarif", + ), + LinkyTicSensorConfig( + key="PEJP", + translation_key="peak_notice", + ), + LinkyTicSensorConfig( + key="DEMAIN", + translation_key="tomorrow_color", + ), + ApparentPowerSensorConfig( + key="PAPP", + translation_key="apparent_power", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ), + LinkyTicSensorConfig( + key="HHPHC", + translation_key="peak_hour_schedule", + ), + LinkyTicSensorConfig( + key="MOTDETAT", + translation_key="meter_state", + ), +) + +SENSORS_HISTORIC_SINGLEPHASE: tuple[LinkyTicSensorConfig, ...] = ( + ElectricalCurrentSensorConfig( + key="IINST", + translation_key="inst_current", + state_class=SensorStateClass.MEASUREMENT, + ), + ElectricalCurrentSensorConfig( + key="ADPS", + translation_key="overcurrent_warning", + state_class=SensorStateClass.MEASUREMENT, + ), + ElectricalCurrentSensorConfig( + key="IMAX", + translation_key="max_current", + ), +) + +SENSORS_HISTORIC_TREEPHASE: tuple[LinkyTicSensorConfig, ...] = ( + # IINST1, IINST2, IINST3 + *( + ElectricalCurrentSensorConfig( + key=f"IINST{phase}", + translation_key=f"inst_current_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + ) + for phase in (1, 2, 3) + ), + # ADIR1, ADIR2, ADIR3 + *( + ElectricalCurrentSensorConfig( + key=f"ADIR{phase}", + translation_key=f"overcurrent_warning_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + ) + for phase in (1, 2, 3) + ), + *( + ElectricalCurrentSensorConfig( + key=f"IMAX{phase}", + translation_key=f"max_current_ph{phase}", + ) + for phase in (1, 2, 3) + ), + ApparentPowerSensorConfig( + key="PMAX", + translation_key="max_power_n-1", + ), + LinkyTicSensorConfig( + key="PPOT", + translation_key="potentials_presence", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +SENSORS_STANDARD_COMMON: tuple[LinkyTicSensorConfig, ...] = ( + SerialNumberSensorConfig(key="ADSC"), + LinkyTicSensorConfig( + key="VTIC", + translation_key="tic_version", + ), + LinkyTicSensorConfig( + key="DATE", + translation_key="datetime", + conversion=_parse_timestamp, + ), # Useful in any way? + LinkyTicSensorConfig( + key="NGTF", + translation_key="tarif_name", + ), + LinkyTicSensorConfig( + key="LTARF", + translation_key="current_tarif_label", + ), + ActiveEnergySensorConfig( + key="EAST", + translation_key="active_energy_drawn_total", + ), + # EASF01, ... , EASF09 + *( + ActiveEnergySensorConfig( + key=f"EASF{index:02}", + translation_key=f"active_energy_provider_{index}", + ) + for index in range(1, 10) + ), + # EASD01, ... , EASD04 + *( + ActiveEnergySensorConfig( + key=f"EASD{index:02}", + translation_key=f"active_energy_distributor_{index}", + ) + for index in range(1, 5) + ), + ElectricalCurrentSensorConfig( + key="IRMS1", + translation_key="rms_current_ph1", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ), + VoltageSensorConfig( + key="URMS1", + translation_key="rms_voltage_ph1", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ), + ApparentPowerSensorConfig( + key="PREF", + translation_key="ref_power", + entity_category=EntityCategory.DIAGNOSTIC, + conversion=(lambda x: x * 1000), # kVA to VA conversion + ), + ApparentPowerSensorConfig( + key="PCOUP", + translation_key="trip_power", + entity_category=EntityCategory.DIAGNOSTIC, + conversion=(lambda x: x * 1000), # kVA to VA conversion + ), + ApparentPowerSensorConfig( + key="SINSTS", + translation_key="instantaneous_apparent_power", + fallback_tags=("SINST1",), # See hekmon/linkytic#42 + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ), + ApparentPowerSensorConfig( + key="SMAXSN", + translation_key="apparent_power_max_n", + fallback_tags=("SMAXN",), # See hekmon/linkytic#42 + register_callback=True, + ), + ApparentPowerSensorConfig( + key="SMAXSN-1", + translation_key="apparent_power_max_n-1", + fallback_tags=("SMAXN-1",), # See hekmon/linkytic#42 + register_callback=True, + ), + ActivePowerSensorConfig( + key="CCASN", + translation_key="power_load_curve_n", + ), + ActivePowerSensorConfig( + key="CCASN-1", + translation_key="power_load_curve_n-1", + ), + VoltageSensorConfig( + key="UMOY1", + translation_key="mean_voltage_ph1", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ), + # DPM1, DPM2, DPM3 + *( + LinkyTicSensorConfig( + translation_key=f"mobile_peak_start_{index}", + key=f"DPM{index}", + ) + for index in (1, 2, 3) + ), + # FPM1, FPM2, FPM3 + *( + LinkyTicSensorConfig( + translation_key=f"mobile_peak_end_{index}", + key=f"FPM{index}", + ) + for index in (1, 2, 3) + ), + LinkyTicSensorConfig( + key="MSG1", + translation_key="short_msg", + ), + LinkyTicSensorConfig( + key="MSG2", + translation_key="ultra_short_msg", + ), + LinkyTicSensorConfig( + key="PRM", + translation_key="delivery_point", + ), + LinkyTicSensorConfig( + key="RELAIS", + translation_key="relays", + ), + LinkyTicSensorConfig( + key="NTARF", + translation_key="current_tarif_index", + ), + LinkyTicSensorConfig( + key="NJOURF", + translation_key="calendar_index_today", + ), + LinkyTicSensorConfig( + key="NJOURF+1", + translation_key="calendar_index_tomorrow", + ), + LinkyTicSensorConfig( + key="PJOURF+1", + translation_key="calendar_profile_tomorrow", + conversion=(lambda x: str(x).replace("NONUTILE", "").strip()), + ), + LinkyTicSensorConfig( + key="PPOINTE", + translation_key="next_peak_dat_profile", + ), + LinkyTicSensorConfig( + key="STGE", + translation_key="status_register", + ), # Duplicate? All fields are exposed as sensors or binary sensors + StatusRegisterSensorConfig( + translation_key="status_trip_device", + status_field=StatusRegister.ORGANE_DE_COUPURE, + ), + StatusRegisterSensorConfig( + translation_key="status_tarif_provider", + status_field=StatusRegister.TARIF_CONTRAT_FOURNITURE, + ), + StatusRegisterSensorConfig( + translation_key="status_tarif_distributor", + status_field=StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR, + ), + StatusRegisterSensorConfig( + translation_key="status_euridis", + status_field=StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS, + ), + StatusRegisterSensorConfig( + translation_key="status_cpl", + status_field=StatusRegister.STATUS_CPL, + ), + StatusRegisterSensorConfig( + translation_key="status_tempo_color_today", + status_field=StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO, + ), + StatusRegisterSensorConfig( + translation_key="status_tempo_color_tomorrow", + status_field=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, + ), + StatusRegisterSensorConfig( + translation_key="status_mobile_peak_notice", + status_field=StatusRegister.PREAVIS_POINTES_MOBILES, + ), + StatusRegisterSensorConfig( + translation_key="status_mobile_peak", status_field=StatusRegister.POINTE_MOBILE + ), +) + +SENSORS_STANDARD_THREEPHASE: tuple[LinkyTicSensorConfig, ...] = ( + # IRMS2, IRMS3 + *( + ElectricalCurrentSensorConfig( + key=f"IRMS{phase}", + translation_key=f"rms_current_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ) + for phase in (2, 3) + ), + # URMS2, URMS3 + *( + VoltageSensorConfig( + key=f"URMS{phase}", + translation_key=f"rms_voltage_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ) + for phase in (2, 3) + ), + # SINSTS1, SINSTS2, SINSTS3 + *( + ApparentPowerSensorConfig( + key=f"SINSTS{phase}", + translation_key=f"instantaneous_apparent_power_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ) + for phase in (1, 2, 3) + ), + # SMAXSN1, SMAXSN2, SMAXSN3 + *( + ApparentPowerSensorConfig( + key=f"SMAXSN{phase}", + translation_key=f"apparent_power_max_n_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ) + for phase in (1, 2, 3) + ), + # SMAXSN1-1, SMAXSN2-1, SMAXSN3-1 + *( + ApparentPowerSensorConfig( + key=f"SMAXSN{phase}-1", + translation_key=f"apparent_power_max_n-1_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ) + for phase in (1, 2, 3) + ), + # UMOY2, UMOY3 + *( + VoltageSensorConfig( + key=f"UMOY{phase}", + translation_key=f"mean_voltage_ph{phase}", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ) + for phase in (2, 3) + ), +) + +SENSORS_STANDARD_PRODUCER: tuple[LinkyTicSensorConfig, ...] = ( + ActiveEnergySensorConfig( + key="EAIT", + translation_key="active_energy_injected_total", + ), + # ERQ1, ... , ERQ4 + *( + ReactiveEnergySensorConfig( + key=f"ERQ{index}", + translation_key=f"reactive_energy_q{index}", + ) + for index in range(1, 5) + ), + ApparentPowerSensorConfig( + key="SINSTI", + translation_key="instantaneous_apparent_power_injected", + state_class=SensorStateClass.MEASUREMENT, + register_callback=True, + ), + ApparentPowerSensorConfig( + key="SMAXIN", + translation_key="apparent_power_injected_max_n", + register_callback=True, + ), + ApparentPowerSensorConfig( + key="SMAXIN-1", + translation_key="apparent_power_injected_max_n-1", + register_callback=True, + ), + ActivePowerSensorConfig( + key="CCAIN", + translation_key="power_injected_load_curve_n", + ), + ActivePowerSensorConfig( + key="CCAIN-1", + translation_key="power_injected_load_curve_n-1", + ), +) + # config flow setup async def async_setup_entry( @@ -60,1009 +591,38 @@ async def async_setup_entry( ) return - # Flag for experimental counters which have slightly different tags. - is_pilot: bool = ( - serial_reader.device_identification[DID_TYPE_CODE] in EXPERIMENTAL_DEVICES - ) + is_standard = bool(config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD) + is_threephase = bool(config_entry.data.get(SETUP_THREEPHASE)) + is_producer = bool(config_entry.data.get(SETUP_PRODUCER)) - # Init sensors - sensors: Iterable[Entity] - if config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD: - # standard mode - sensors = [ - ADSSensor( - config_title=config_entry.title, - tag="ADSC", - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - LinkyTICStringSensor( - tag="VTIC", - name="Version de la TIC", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:tag", - ), - DateEtHeureSensor( - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - LinkyTICStringSensor( - tag="NGTF", - name="Nom du calendrier tarifaire fournisseur", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:cash-check", - ), - LinkyTICStringSensor( - tag="LTARF", - name="Libellé tarif fournisseur en cours", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:cash-check", - ), - EnergyIndexSensor( - tag="EAST", - name="Energie active soutirée totale", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF01", - name="Energie active soutirée fournisseur, index 01", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF02", - name="Energie active soutirée fournisseur, index 02", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF03", - name="Energie active soutirée fournisseur, index 03", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF04", - name="Energie active soutirée fournisseur, index 04", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF05", - name="Energie active soutirée fournisseur, index 05", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF06", - name="Energie active soutirée fournisseur, index 06", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF07", - name="Energie active soutirée fournisseur, index 07", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF08", - name="Energie active soutirée fournisseur, index 08", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASF09", - name="Energie active soutirée fournisseur, index 09", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASD01", - name="Energie active soutirée distributeur, index 01", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASD02", - name="Energie active soutirée distributeur, index 02", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASD03", - name="Energie active soutirée distributeur, index 03", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EASD04", - name="Energie active soutirée distributeur, index 04", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - CurrentSensor( - tag="IRMS1", - name="Courant efficace, phase 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ), - VoltageSensor( - tag="URMS1", - name="Tension efficace, phase 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ), - ApparentPowerSensor( - tag="PREF", - name="Puissance app. de référence", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - category=EntityCategory.DIAGNOSTIC, - conversion_function=(lambda x: x * 1000), # kVA conversion - ), - ApparentPowerSensor( - tag="PCOUP", - name="Puissance app. de coupure", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - category=EntityCategory.DIAGNOSTIC, - conversion_function=(lambda x: x * 1000), # kVA conversion - ), - ApparentPowerSensor( - tag="SINST1" if is_pilot else "SINSTS", - name="Puissance app. instantanée soutirée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ), - ApparentPowerSensor( - tag="SMAXN" if is_pilot else "SMAXSN", - name="Puissance app. max. soutirée n", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ), - ApparentPowerSensor( - tag="SMAXN-1" if is_pilot else "SMAXSN-1", - name="Puissance app. max. soutirée n-1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ), - PowerSensor( - tag="CCASN", - name="Point n de la courbe de charge active soutirée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - PowerSensor( - tag="CCASN-1", - name="Point n-1 de la courbe de charge active soutirée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - VoltageSensor( - tag="UMOY1", - name="Tension moy. ph. 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, # Should this be considered an instantaneous value? - register_callback=True, - ), - LinkyTICStringSensor( - tag="DPM1", - name="Début pointe mobile 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-start", - ), - LinkyTICStringSensor( - tag="FPM1", - name="Fin pointe mobile 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-end", - ), - LinkyTICStringSensor( - tag="DPM2", - name="Début pointe mobile 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-start", - ), - LinkyTICStringSensor( - tag="FPM2", - name="Fin pointe mobile 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-end", - ), - LinkyTICStringSensor( - tag="DPM3", - name="Début pointe mobile 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-start", - ), - LinkyTICStringSensor( - tag="FPM3", - name="Fin pointe mobile 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-end", - ), - LinkyTICStringSensor( - tag="MSG1", - name="Message court", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:message-text-outline", - ), - LinkyTICStringSensor( - tag="MSG2", - name="Message Ultra court", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:message-text-outline", - ), - LinkyTICStringSensor( - tag="PRM", - name="PRM", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:tag", - ), - LinkyTICStringSensor( - tag="RELAIS", - name="Relais", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:electric-switch", - ), - LinkyTICStringSensor( - tag="NTARF", - name="Numéro de l’index tarifaire en cours", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:cash-check", - ), - LinkyTICStringSensor( - tag="NJOURF", - name="Numéro du jour en cours calendrier fournisseur", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:calendar-month-outline", - ), - LinkyTICStringSensor( - tag="NJOURF+1", - name="Numéro du prochain jour calendrier fournisseur", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:calendar-month-outline", - ), - ProfilDuProchainJourCalendrierFournisseurSensor( - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - LinkyTICStringSensor( - tag="PPOINTE", - name="Profil du prochain jour de pointe", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:calendar-month-outline", - ), - LinkyTICStringSensor( - tag="STGE", - name="Registre de statuts", # codespell:ignore - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:list-status", - ), - LinkyTICStatusRegisterSensor( - name="Statut organe de coupure", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:connection", - field=StatusRegister.ORGANE_DE_COUPURE, - ), - LinkyTICStatusRegisterSensor( - name="Statut tarif contrat fourniture", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:cash-check", - field=StatusRegister.TARIF_CONTRAT_FOURNITURE, - ), - LinkyTICStatusRegisterSensor( - name="Statut tarif contrat distributeur", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:cash-check", - field=StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR, - ), - LinkyTICStatusRegisterSensor( - name="Statut sortie communication Euridis", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:tag", - field=StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS, - ), - LinkyTICStatusRegisterSensor( - name="Statut CPL", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:tag", - field=StatusRegister.STATUS_CPL, - ), - LinkyTICStatusRegisterSensor( - name="Statut couleur du jour tempo", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:palette", - field=StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO, - ), - LinkyTICStatusRegisterSensor( - name="Statut couleur du lendemain tempo", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:palette", - field=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, - ), - LinkyTICStatusRegisterSensor( - name="Statut préavis pointes mobiles", # codespell:ignore - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-alert-outline", - field=StatusRegister.PREAVIS_POINTES_MOBILES, - ), - LinkyTICStatusRegisterSensor( - name="Statut pointe mobile", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:progress-clock", - field=StatusRegister.POINTE_MOBILE, - ), - ] - # Add producer specific sensors - if bool(config_entry.data.get(SETUP_PRODUCER)): - sensors.append( - EnergyIndexSensor( - tag="EAIT", - name="Energie active injectée totale", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - EnergyIndexSensor( - tag="ERQ1", - name="Energie réactive Q1 totale", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - EnergyIndexSensor( - tag="ERQ2", - name="Energie réactive Q2 totale", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - EnergyIndexSensor( - tag="ERQ3", - name="Energie réactive Q3 totale", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - EnergyIndexSensor( - tag="ERQ4", - name="Energie réactive Q4 totale", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SINSTI", - name="Puissance app. instantanée injectée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXIN", - name="Puissance app. max. injectée n", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXIN-1", - name="Puissance app. max. injectée n-1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - PowerSensor( - tag="CCAIN", - name="Point n de la courbe de charge active injectée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower-import", - ) - ) - sensors.append( - PowerSensor( - tag="CCAIN-1", - name="Point n-1 de la courbe de charge active injectée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower-import", - ) - ) - # Add three-phase specific sensors - if bool(config_entry.data.get(SETUP_THREEPHASE)): - sensors.append( - CurrentSensor( - tag="IRMS2", - name="Courant efficace, phase 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="IRMS3", - name="Courant efficace, phase 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - VoltageSensor( - tag="URMS2", - name="Tension efficace, phase 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - VoltageSensor( - tag="URMS3", - name="Tension efficace, phase 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SINSTS1", - name="Puissance app. instantanée soutirée phase 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SINSTS2", - name="Puissance app. instantanée soutirée phase 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SINSTS3", - name="Puissance app. instantanée soutirée phase 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXSN1", - name="Puissance app max. soutirée n phase 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXSN2", - name="Puissance app max. soutirée n phase 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXSN3", - name="Puissance app max. soutirée n phase 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXSN1-1", - name="Puissance app max. soutirée n-1 phase 1", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXSN2-1", - name="Puissance app max. soutirée n-1 phase 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - ApparentPowerSensor( - tag="SMAXSN3-1", - name="Puissance app max. soutirée n-1 phase 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - VoltageSensor( - tag="UMOY2", - name="Tension moy. ph. 2", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - VoltageSensor( - tag="UMOY3", - name="Tension moy. ph. 3", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) + if is_standard: + # Standard mode + sensor_desc = [SENSORS_STANDARD_COMMON] + + if is_threephase: + sensor_desc.append(SENSORS_STANDARD_THREEPHASE) + + if is_producer: + sensor_desc.append(SENSORS_STANDARD_PRODUCER) else: - # historic mode - sensors = [ - ADSSensor( - config_title=config_entry.title, - tag="ADCO", - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - LinkyTICStringSensor( - tag="OPTARIF", - name="Option tarifaire choisie", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:cash-check", - category=EntityCategory.DIAGNOSTIC, - ), - CurrentSensor( - tag="ISOUSC", - name="Intensité souscrite", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - category=EntityCategory.DIAGNOSTIC, - ), - EnergyIndexSensor( - tag="BASE", - name="Index option Base", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="HCHC", - name="Index option Heures Creuses - Heures Creuses", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="HCHP", - name="Index option Heures Creuses - Heures Pleines", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EJPHN", - name="Index option EJP - Heures Normales", # codespell:ignore - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="EJPHPM", - name="Index option EJP - Heures de Pointe Mobile", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="BBRHCJB", - name="Index option Tempo - Heures Creuses Jours Bleus", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="BBRHPJB", - name="Index option Tempo - Heures Pleines Jours Bleus", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="BBRHCJW", - name="Index option Tempo - Heures Creuses Jours Blancs", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="BBRHPJW", - name="Index option Tempo - Heures Pleines Jours Blancs", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="BBRHCJR", - name="Index option Tempo - Heures Creuses Jours Rouges", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - EnergyIndexSensor( - tag="BBRHPJR", - name="Index option Tempo - Heures Pleines Jours Rouges", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - PEJPSensor( - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ), - LinkyTICStringSensor( - tag="PTEC", - name="Période Tarifaire en cours", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:calendar-expand-horizontal", - ), - LinkyTICStringSensor( - tag="DEMAIN", - name="Couleur du lendemain", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:palette", - ), - ApparentPowerSensor( - tag="PAPP", - name="Puissance apparente", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ), - LinkyTICStringSensor( - tag="HHPHC", - name="Horaire Heures Pleines Heures Creuses", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-outline", - enabled_by_default=False, - ), - LinkyTICStringSensor( - tag="MOTDETAT", - name="Mot d'état du compteur", # codespell:ignore - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:file-word-box-outline", - category=EntityCategory.DIAGNOSTIC, - enabled_by_default=False, - ), - ] - # Add specific sensors - if bool(config_entry.data.get(SETUP_THREEPHASE)): - # three-phase - concat specific sensors - sensors.append( - CurrentSensor( - tag="IINST1", - name="Intensité Instantanée (phase 1)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="IINST2", - name="Intensité Instantanée (phase 2)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="IINST3", - name="Intensité Instantanée (phase 3)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="IMAX1", - name="Intensité maximale appelée (phase 1)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ) - ) - sensors.append( - CurrentSensor( - tag="IMAX2", - name="Intensité maximale appelée (phase 2)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ) - ) - sensors.append( - CurrentSensor( - tag="IMAX3", - name="Intensité maximale appelée (phase 3)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ) - ) - sensors.append( - PowerSensor( # documentation says unit is Watt but description talks about VoltAmp :/ - tag="PMAX", - name="Puissance maximale triphasée atteinte (jour n-1)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ) - ) - sensors.append( - LinkyTICStringSensor( - tag="PPOT", - name="Présence des potentiels", # codespell:ignore - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - category=EntityCategory.DIAGNOSTIC, - ) - ) - # Burst sensors - sensors.append( - CurrentSensor( - tag="ADIR1", - name="Avertissement de Dépassement d'intensité de réglage (phase 1)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="ADIR2", - name="Avertissement de Dépassement d'intensité de réglage (phase 2)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="ADIR3", - name="Avertissement de Dépassement d'intensité de réglage (phase 3)", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - register_callback=True, - ) - ) - _LOGGER.info( - "Adding %d sensors for the three phase historic mode", len(sensors) - ) - else: - # single phase - concat specific sensors - sensors.append( - CurrentSensor( - tag="IINST", - name="Intensité Instantanée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="ADPS", - name="Avertissement de Dépassement De Puissance Souscrite", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, - register_callback=True, - ) - ) - sensors.append( - CurrentSensor( - tag="IMAX", - name="Intensité maximale appelée", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - ) - ) - _LOGGER.info( - "Adding %d sensors for the single phase historic mode", len(sensors) - ) - # Add the entities to HA - if len(sensors) > 0: - async_add_entities(sensors, True) + # Historic mode + sensor_desc = [SENSORS_HISTORIC_COMMON] + + sensor_desc.append( + SENSORS_HISTORIC_SINGLEPHASE + if is_threephase + else SENSORS_HISTORIC_SINGLEPHASE + ) + + async_add_entities( + ( + REGISTRY[type(config)](config, config_entry, serial_reader) + for descriptor in sensor_desc + for config in descriptor + ), + update_before_add=True, + ) T = TypeVar("T") @@ -1073,13 +633,30 @@ class LinkyTICSensor(LinkyTICEntity, SensorEntity, Generic[T]): _attr_should_poll = True _last_value: T | None + entity_description: LinkyTicSensorConfig - def __init__(self, tag: str, config_title: str, reader: LinkyTICReader) -> None: + def __init__( + self, + description: LinkyTicSensorConfig, + config_entry: ConfigEntry, + reader: LinkyTICReader, + ) -> None: """Init sensor entity.""" super().__init__(reader) + + self.entity_description = description self._last_value = None - self._tag = tag - self._config_title = config_title + self._tag = description.key + + # FIXME: Non-compliant to https://developers.home-assistant.io/docs/entity_registry_index/ + # it should not contain the DOMAIN + self._attr_unique_id = ( + f"{DOMAIN}_{config_entry.entry_id}_{description.key.lower()}" + ) + # But changing it would BREAK ALL EXISTING SENSORS (loss of history/statistics) + # self._attr_unique_id = f"{reader.serial_number}_{description.key.lower()}" + # And this method is not universal/compatible with status register sensors, which all use the same key + # and are differencied by their field @property def native_value(self) -> T | None: # type:ignore @@ -1091,12 +668,23 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: value, timestamp = self._serial_controller.get_values(self._tag) _LOGGER.debug( "%s: retrieved %s value from serial controller: (%s, %s)", - self._config_title, + self._serial_controller.name, self._tag, value, timestamp, ) + if ( + not value + and not timestamp + and self.entity_description.fallback_tags is not None + ): + # Fallback to other tags, if any + for tag in self.entity_description.fallback_tags: + value, timestamp = self._serial_controller.get_values(tag) + if value or timestamp: + break + if not value and not timestamp: # No data returned. if not self.available: # Sensor is already unavailable, no need to check why. @@ -1104,14 +692,14 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: if not self._serial_controller.is_connected: _LOGGER.debug( "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, + self._serial_controller.name, self._tag, ) self._attr_available = False elif self._serial_controller.has_read_full_frame: _LOGGER.info( "%s: marking the %s sensor as unavailable: a full frame has been read but %s has not been found", - self._config_title, + self._serial_controller.name, self._tag, self._tag, ) @@ -1127,13 +715,14 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: self._attr_available = True _LOGGER.info( "%s: marking the %s sensor as available now !", - self._config_title, + self._serial_controller.name, self._tag, ) return value, timestamp +@match(SerialNumberSensorConfig) class ADSSensor(LinkyTICSensor[str]): """Adresse du compteur entity.""" # codespell:ignore @@ -1142,22 +731,23 @@ class ADSSensor(LinkyTICSensor[str]): # Generic properties # https://developers.home-assistant.io/docs/core/entity#generic-properties + entity_description: SerialNumberSensorConfig + _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "Adresse du compteur" # codespell:ignore _attr_icon = "mdi:tag" def __init__( self, - config_title: str, - tag: str, - config_uniq_id: str, - serial_reader: LinkyTICReader, + description: SerialNumberSensorConfig, + config_entry: ConfigEntry, + reader: LinkyTICReader, ) -> None: """Initialize an ADCO/ADSC Sensor.""" - _LOGGER.debug("%s: initializing %s sensor", config_title, tag) - super().__init__(tag, config_title, serial_reader) - # Generic entity properties - self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_adco" + super().__init__(description, config_entry, reader) + + # Overwrite tag-based unique id for backward compatibility + self._attr_unique_id = f"{DOMAIN}_{config_entry.entry_id}_adco" self._extra: dict[str, str] = {} @property @@ -1186,35 +776,12 @@ def update(self): self._last_value = value +@match(LinkyTicSensorConfig) class LinkyTICStringSensor(LinkyTICSensor[str]): """Common class for text sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, - tag: str, - name: str, - config_title: str, - config_uniq_id: str, - serial_reader: LinkyTICReader, - icon: str | None = None, - category: EntityCategory | None = None, - enabled_by_default: bool = True, - ) -> None: - """Initialize a Regular Str Sensor.""" - _LOGGER.debug("%s: initializing %s sensor", config_title, tag.upper()) - super().__init__(tag, config_title, serial_reader) - - # Generic Entity properties - self._attr_name = name - self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{tag.lower()}" - if icon: - self._attr_icon = icon - if category: - self._attr_entity_category = category - self._attr_entity_registry_enabled_default = enabled_by_default - @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" @@ -1222,52 +789,30 @@ def update(self): value, _ = self._update() if not value: return - self._last_value = " ".join(value.split()) + + conversion = self.entity_description.conversion + + if conversion is not None: + try: + self._last_value = conversion(value) + except TypeError as e: + _LOGGER.debug("Couldn't convert value %s: %s", value, e) + self._last_value = value + else: + self._last_value = " ".join(value.split()) +@match( + ApparentPowerSensorConfig, + ActiveEnergySensorConfig, + ReactiveEnergySensorConfig, + ElectricalCurrentSensorConfig, + VoltageSensorConfig, + ActivePowerSensorConfig, +) class RegularIntSensor(LinkyTICSensor[int]): """Common class for int sensors.""" - def __init__( - self, - tag: str, - name: str, - config_title: str, - config_uniq_id: str, - serial_reader: LinkyTICReader, - icon: str | None = None, - category: EntityCategory | None = None, - device_class: SensorDeviceClass | None = None, - native_unit_of_measurement: str | None = None, - state_class: SensorStateClass | None = None, - register_callback: bool = False, - conversion_function: Callable[[int], int] | None = None, - ) -> None: - """Initialize a Regular Int Sensor.""" - _LOGGER.debug("%s: initializing %s sensor", config_title, tag.upper()) - super().__init__(tag, config_title, serial_reader) - self._attr_name = name - - if register_callback: - self._serial_controller.register_push_notif( - self._tag, self.update_notification - ) - # Generic Entity properties - if category: - self._attr_entity_category = category - self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{tag.lower()}" - if icon: - self._attr_icon = icon - # Sensor Entity Properties - if device_class: - self._attr_device_class = device_class - if native_unit_of_measurement: - self._attr_native_unit_of_measurement = native_unit_of_measurement - if state_class: - self._attr_state_class = state_class - - self._conversion_function = conversion_function - @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" @@ -1278,11 +823,17 @@ def update(self): value_int = int(value) except ValueError: return - self._last_value = ( - self._conversion_function(value_int) - if self._conversion_function - else value_int - ) + + conversion = self.entity_description.conversion + + if conversion is not None: + try: + self._last_value = conversion(value_int) + except TypeError as e: + _LOGGER.debug("Couldn't convert value %s: %s", value_int, e) + self._last_value = value_int + else: + self._last_value = value_int def update_notification(self, realtime_option: bool) -> None: """Receive a notification from the serial reader when our tag has been read on the wire.""" @@ -1307,185 +858,32 @@ def update_notification(self, realtime_option: bool) -> None: self.schedule_update_ha_state(force_refresh=True) -class EnergyIndexSensor(RegularIntSensor): - """Common class for energy index counters, in Watt-hours.""" - - _attr_device_class = SensorDeviceClass.ENERGY - _attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - -class VoltageSensor(RegularIntSensor): - """Common class for voltage sensors, in Volts.""" - - _attr_device_class = SensorDeviceClass.VOLTAGE - _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT - - -class CurrentSensor(RegularIntSensor): - """Common class for electric current sensors, in Amperes.""" - - _attr_device_class = SensorDeviceClass.CURRENT - _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE - - -class PowerSensor(RegularIntSensor): - """Common class for real power sensors, in Watts.""" - - _attr_device_class = SensorDeviceClass.POWER - _attr_native_unit_of_measurement = UnitOfPower.WATT - - -class ApparentPowerSensor(RegularIntSensor): - """Common class for apparent power sensors, in Volt-Amperes.""" - - _attr_device_class = SensorDeviceClass.APPARENT_POWER - _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE - - -class PEJPSensor(LinkyTICStringSensor): - """Préavis Début EJP (30 min) sensor.""" - - # - # This sensor could be improved I think (minutes as integer), but I do not have it to check and test its values - # Leaving it as it is to facilitate future modifications - # - _attr_icon = "mdi:clock-start" - - def __init__( - self, config_title: str, config_uniq_id: str, serial_reader: LinkyTICReader - ) -> None: - """Initialize a PEJP sensor.""" - _LOGGER.debug("%s: initializing PEJP sensor", config_title) - super().__init__( - tag="PEJP", - name="Préavis Début EJP", - config_title=config_title, - config_uniq_id=config_uniq_id, - serial_reader=serial_reader, - ) - - self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{self._tag.lower()}" - - -class DateEtHeureSensor(LinkyTICStringSensor): - """Date et heure courante sensor.""" - - _attr_icon = "mdi:clock-outline" - - def __init__( - self, - config_title: str, - config_uniq_id: str, - serial_reader: LinkyTICReader, - ) -> None: - """Initialize a Date et heure sensor.""" - _LOGGER.debug("%s: initializing Date et heure courante sensor", config_title) - super().__init__( - tag="DATE", - name="Date et heure courante", - config_title=config_title, - config_uniq_id=config_uniq_id, - serial_reader=serial_reader, - ) - - @callback - def update(self): - """Update the value of the sensor from the thread object memory cache.""" - # Get last seen value from controller - _, timestamp = self._update() - - if not timestamp: - return - # Save value - saison = "" - try: - if timestamp[0:1] == "E": - saison = " (Eté)" - elif timestamp[0:1] == "H": - saison = " (Hiver)" - self._last_value = ( - timestamp[5:7] - + "/" - + timestamp[3:5] - + "/" - + timestamp[1:3] - + " " - + timestamp[7:9] - + ":" - + timestamp[9:11] - + saison - ) - except IndexError: - return - - -class ProfilDuProchainJourCalendrierFournisseurSensor(LinkyTICStringSensor): - """Profil du prochain jour du calendrier fournisseur sensor.""" - - _attr_icon = "mdi:calendar-month-outline" - - def __init__( - self, - config_title: str, - config_uniq_id: str, - serial_reader: LinkyTICReader, - category: EntityCategory | None = None, - ) -> None: - """Initialize a Profil du prochain jour du calendrier fournisseur sensor.""" - _LOGGER.debug("%s: initializing Date et heure courante sensor", config_title) - super().__init__( - tag="PJOURF+1", - name="Profil du prochain jour calendrier fournisseur", - config_title=config_title, - config_uniq_id=config_uniq_id, - serial_reader=serial_reader, - ) - - @callback - def update(self): - """Update the value of the sensor from the thread object memory cache.""" - # Get last seen value from controller - value, _ = self._update() - if not value: - return - self._last_value = value.replace("NONUTILE", "").strip() - - +@match(StatusRegisterSensorConfig) class LinkyTICStatusRegisterSensor(LinkyTICStringSensor): """Data from status register.""" - _attr_has_entity_name = True - _attr_should_poll = True _attr_device_class = SensorDeviceClass.ENUM def __init__( self, - name: str, - config_title: str, - config_uniq_id: str, - serial_reader: LinkyTICReader, - field: StatusRegister, - enabled_by_default: bool = True, - icon: str | None = None, + description: StatusRegisterSensorConfig, + config_entry: ConfigEntry, + reader: LinkyTICReader, ) -> None: """Initialize a status register data sensor.""" - _LOGGER.debug("%s: initializing a status register data sensor", config_title) - self._field = field - super().__init__( - tag="STGE", - name=name, - config_title=config_title, - config_uniq_id=config_uniq_id, - serial_reader=serial_reader, - icon=icon, - enabled_by_default=enabled_by_default, - ) + super().__init__(description, config_entry, reader) + status_field = description.status_field + self._field = status_field + + # Breaking changes here. + # Overwrites the unique_id because all status register sensors are reading from the same tag. self._attr_unique_id = ( - f"{DOMAIN}_{config_uniq_id}_{field.name.lower()}" # Breaking changes here. + f"{DOMAIN}_{config_entry.entry_id}_{status_field.name.lower()}" ) # For SensorDeviceClass.ENUM, _attr_options contains all the possible values for the sensor. - self._attr_options = list(cast(dict[int, str], field.value.options).values()) + self._attr_options = list( + cast(dict[int, str], status_field.value.options).values() + ) @callback def update(self): diff --git a/custom_components/linkytic/translations/en.json b/custom_components/linkytic/translations/en.json index a9368ed..a279593 100644 --- a/custom_components/linkytic/translations/en.json +++ b/custom_components/linkytic/translations/en.json @@ -1,33 +1,373 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect (check the logs)", - "cannot_read": "Serial open successfully but an error occured while reading a line (check the logs)", - "unknown": "Unexpected error" - }, "step": { "user": { + "description": "If you need help to fill this form, please check the [readme](https://github.com/hekmon/linkytic/tree/v2).", "data": { "serial_device": "Path/URL to the serial device", - "three_phase": "Three-Phase", "tic_mode": "TIC mode", - "producer_mode": "Producer mode (standard mode only)" - }, - "description": "If you need help to fill this form, please check the [readme](https://github.com/hekmon/linkytic/tree/v2)." + "producer_mode": "Producer mode (standard mode only)", + "three_phase": "Three-Phase" + } } + }, + "error": { + "cannot_connect": "Failed to connect (check the logs)", + "cannot_read": "Serial open successfully but an error occured while reading a line (check the logs)", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" } }, "options": { "step": { "init": { + "title": "Linky TIC - Options", + "description": "Real time will update Home Assistant as soon as a new value is read and will not wait for Home Assistant to query the sensor: expect CPU usage and database size to increase !", "data": { "real_time": "Real time mode for compatible sensors \u26a0\ufe0f" - }, - "description": "Real time will update Home Assistant as soon as a new value is read and will not wait for Home Assistant to query the sensor: expect CPU usage and database size to increase !", - "title": "Linky TIC - Options" + } + } + } + }, + "entity": { + "sensor": { + "serial_number": { + "name": "Identifer" + }, + "tarif_option": { + "name": "Tarif Option" + }, + "subcription_current": { + "name": "Subscribed Current" + }, + "index_base": { + "name": "Index Base" + }, + "index_hchc": { + "name": "Index Off-peak Hours - Off-peak Hours" + }, + "index_hchp": { + "name": "Index Off-peak Hours - Peak Hours" + }, + "index_ejp_normal": { + "name": "Index EJP - Normal Hours" + }, + "index_ejp_peak": { + "name": "Index EJP - Mobile Peak Hours" + }, + "index_tempo_bluehc": { + "name": "Index Tempo - Blue Days Off-peak Hours" + }, + "index_tempo_bluehp": { + "name": "Index Tempo - Blue Days Peak Hours" + }, + "index_tempo_whitehc": { + "name": "Index Tempo - White Days Off-peak Hours" + }, + "index_tempo_whitehp": { + "name": "Index Tempo - White Days Peak Hours" + }, + "index_tempo_redhc": { + "name": "Index Tempo - Red Days Off-peak Hours" + }, + "index_tempo_redhp": { + "name": "Index Tempo - Red Days Peak Hours" + }, + "current_tarif": { + "name": "Current pricing period" + }, + "peak_notice": { + "name": "EJP mobile peak notice" + }, + "tomorrow_color": { + "name": "Next day color" + }, + "apparent_power": { + "name": "Apparent power" + }, + "peak_hour_schedule": { + "name": "Peak - Off-peak schedule" + }, + "meter_state": { + "name": "Meter word state" + }, + "inst_current": { + "name": "Instantaneous current" + }, + "overcurrent_warning": { + "name": "Overload warning" + }, + "max_current": { + "name": "Maximum current" + }, + "inst_current_ph1": { + "name": "L1 instantaneous current" + }, + "inst_current_ph2": { + "name": "L2 instantaneous current" + }, + "inst_current_ph3": { + "name": "L3 instantaneous current" + }, + "overcurrent_warning_ph1": { + "name": "L1 overloead warning" + }, + "overcurrent_warning_ph2": { + "name": "L2 overloead warning" + }, + "overcurrent_warning_ph3": { + "name": "L3 overloead warning" + }, + "max_current_ph1": { + "name": "L1 maximum current" + }, + "max_current_ph2": { + "name": "L2 maximum current" + }, + "max_current_ph3": { + "name": "L3 maximum current" + }, + "max_power_n-1": { + "name": "Previous day maximum power" + }, + "potentials_presence": { + "name": "Presence of potentials" + }, + "tic_version": { + "name": "TIC version" + }, + "datetime": { + "name": "Datetime" + }, + "tarif_name": { + "name": "Pricing option name" + }, + "current_tarif_label": { + "name": "Current pricing label" + }, + "active_energy_drawn_total": { + "name": "Total active energy consumption" + }, + "active_energy_provider_1": { + "name": "Index 1 provider active energy consumption" + }, + "active_energy_provider_2": { + "name": "Index 2 provider active energy consumption" + }, + "active_energy_provider_3": { + "name": "Index 3 provider active energy consumption" + }, + "active_energy_provider_4": { + "name": "Index 4 provider active energy consumption" + }, + "active_energy_provider_5": { + "name": "Index 5 provider active energy consumption" + }, + "active_energy_provider_6": { + "name": "Index 6 provider active energy consumption" + }, + "active_energy_provider_7": { + "name": "Index 7 provider active energy consumption" + }, + "active_energy_provider_8": { + "name": "Index 8 provider active energy consumption" + }, + "active_energy_provider_9": { + "name": "Index 9 provider active energy consumption" + }, + "active_energy_distributor_1": { + "name": "Index 1 distributor active energy consumption" + }, + "active_energy_distributor_2": { + "name": "Index 2 distributor active energy consumption" + }, + "active_energy_distributor_3": { + "name": "Index 3 distributor active energy consumption" + }, + "active_energy_distributor_4": { + "name": "Index 4 distributor active energy consumption" + }, + "rms_current_ph1": { + "name": "L1 RMS current" + }, + "rms_voltage_ph1": { + "name": "L1 RMS voltage" + }, + "ref_power": { + "name": "Reference power" + }, + "trip_power": { + "name": "Overload threshold" + }, + "instantaneous_apparent_power": { + "name": "Instantaneous apparent power" + }, + "apparent_power_max_n": { + "name": "Today peak apparent power" + }, + "apparent_power_max_n-1": { + "name": "Previous day peak apparent power" + }, + "power_load_curve_n": { + "name": "Power load curve point n" + }, + "power_load_curve_n-1": { + "name": "Power load curve point n-1" + }, + "mean_voltage_ph1": { + "name": "L1 average RMS voltage" + }, + "mobile_peak_start_1": { + "name": "Mobile peak 1 start" + }, + "mobile_peak_end_1": { + "name": "Mobile peak 1 end" + }, + "mobile_peak_start_2": { + "name": "Mobile peak 2 start" + }, + "mobile_peak_end_2": { + "name": "Mobile peak 2 end" + }, + "mobile_peak_start_3": { + "name": "Mobile peak 3 start" + }, + "mobile_peak_end_3": { + "name": "Mobile peak 3 end" + }, + "short_msg": { + "name": "Short message" + }, + "ultra_short_msg": { + "name": "Ultra short messages" + }, + "delivery_point": { + "name": "Delivery point (PRM)" + }, + "relays": { + "name": "Relays" + }, + "current_tarif_index": { + "name": "Current pricing index" + }, + "calendar_index_today": { + "name": "Today pricing calendar index" + }, + "calendar_index_tomorrow": { + "name": "Next day pricing calendar index" + }, + "calendar_profile_tomorrow": { + "name": "Next day provider calendar profile" + }, + "next_peak_dat_profile": { + "name": "Next peak day profile" + }, + "status_register": { + "name": "Status register" + }, + "status_trip_device": { + "name": "Trip device status" + }, + "status_tarif_provider": { + "name": "Provider contract pricing status" + }, + "status_tarif_distributor": { + "name": "Distributor contract pricing status" + }, + "status_euridis": { + "name": "Euridis communication status" + }, + "status_cpl": { + "name": "CPL Status" + }, + "status_tempo_color_today": { + "name": "Today color" + }, + "status_tempo_color_tomorrow": { + "name": "Next day color" + }, + "status_mobile_peak_notice": { + "name": "Mobile peak notice" + }, + "status_mobile_peak": { + "name": "Mobile peak" + }, + "rms_current_ph2": { + "name": "L2 RMS current" + }, + "rms_voltage_ph2": { + "name": "L2 RMS voltage" + }, + "rms_current_ph3": { + "name": "L3 RMS current" + }, + "rms_voltage_ph3": { + "name": "L3 RMS voltage" + }, + "instantaneous_apparent_power_ph1": { + "name": "L1 instantaneous apparent power" + }, + "apparent_power_max_n_ph1": { + "name": "L1 today peak apparent power" + }, + "apparent_power_max_n-1_ph1": { + "name": "L1 previous day peak apparent power" + }, + "instantaneous_apparent_power_ph2": { + "name": "L2 instantaneous apparent power" + }, + "apparent_power_max_n_ph2": { + "name": "L2 today peak apparent power" + }, + "apparent_power_max_n-1_ph2": { + "name": "L2 previous day peak apparent power" + }, + "instantaneous_apparent_power_ph3": { + "name": "L3 instantaneous apparent power" + }, + "apparent_power_max_n_ph3": { + "name": "L3 today peak apparent power" + }, + "apparent_power_max_n-1_ph3": { + "name": "L3 previous day peak apparent power" + }, + "mean_voltage_ph2": { + "name": "L2 average RMS voltage" + }, + "mean_voltage_ph3": { + "name": "L3 average RMS voltage" + }, + "active_energy_injected_total": { + "name": "Total active energy injected" + }, + "reactive_energy_q1": { + "name": "Q1 reactive energy" + }, + "reactive_energy_q2": { + "name": "Q2 reactive energy" + }, + "reactive_energy_q3": { + "name": "Q3 reactive energy" + }, + "reactive_energy_q4": { + "name": "Q4 reactive energy" + }, + "instantaneous_apparent_power_injected": { + "name": "Instantaneous injected apparent power" + }, + "apparent_power_injected_max_n": { + "name": "Today peak injected apparent power" + }, + "apparent_power_injected_max_n-1": { + "name": "Previous day peak injected apparent power" + }, + "power_injected_load_curve_n": { + "name": "Injected power load curve point n" + }, + "power_injected_load_curve_n-1": { + "name": "Injected power load curve point n-1" } } } diff --git a/custom_components/linkytic/translations/fr.json b/custom_components/linkytic/translations/fr.json index 857cc96..2cd4a97 100644 --- a/custom_components/linkytic/translations/fr.json +++ b/custom_components/linkytic/translations/fr.json @@ -1,34 +1,373 @@ { "config": { - "abort": { - "already_configured": "Le périphérique est déjà configuré" - }, - "error": { - "cannot_connect": "Erreur de connection (vérifiez les logs)", - "cannot_read": "La connection série a été ouverte avec succès mais une erreur est survenue pendant la lecture (vérifiez les logs)", - "unknown": "Erreur inattendue", - "unsupported_standard": "Le mode de transmission standard n'est pas encore supporté. Si vous souhaitez aider le développement, n'hésitez pas à ouvrir une issue: https://github.com/hekmon/linkytic/issues/new" - }, "step": { "user": { + "description": "Si vous avez besoin d'aide pour remplir ces champs de configuration, allez voir le fichier [lisezmoi](https://github.com/hekmon/linkytic/tree/v2).", "data": { "serial_device": "Chemin/Adresse vers le périphérique série", - "three_phase": "Triphasé", "tic_mode": "Mode TIC", - "producer_mode": "Mode producteur (seulement pour le mode standard)" - }, - "description": "Si vous avez besoin d'aide pour remplir ces champs de configuration, allez voir le fichier [lisezmoi](https://github.com/hekmon/linkytic/tree/v2)." + "producer_mode": "Mode producteur (seulement pour le mode standard)", + "three_phase": "Triphasé" + } } + }, + "error": { + "cannot_connect": "Erreur de connection (vérifiez les logs)", + "cannot_read": "La connection série a été ouverte avec succès mais une erreur est survenue pendant la lecture (vérifiez les logs)", + "unknown": "Erreur inattendue" + }, + "abort": { + "already_configured": "Le périphérique est déjà configuré" } }, "options": { "step": { "init": { - "data": { - "real_time": "Mode temps réel pour les senseurs compatibles \u26a0\ufe0f" - }, + "title": "Linky TIC - Options", "description": "Le mode temps réel poussera Home Assistant à mettre à jour certaines valeurs aussi tôt qu'elle seront lu sur le port série plutôt que de les stocker en mémoire puis d'attendre qu'Home Assistant viennent les récupérer: cela consommera plus de CPU et occupera plus d'espace disque !", - "title": "Linky TIC - Options" + "data": { + "real_time": "Mode temps réel pour les capteurs compatibles \u26a0\ufe0f" + } + } + } + }, + "entity": { + "sensor": { + "serial_number": { + "name": "Adresse du compteur" + }, + "tarif_option": { + "name": "Option tarifaire souscrite" + }, + "subcription_current": { + "name": "Intensité souscrite" + }, + "index_base": { + "name": "Index option Base" + }, + "index_hchc": { + "name": "Index option Heures Creuses - Heures Creuses" + }, + "index_hchp": { + "name": "Index option Heures Creuses - Heures Pleines" + }, + "index_ejp_normal": { + "name": "Index option EJP - Heures Normales" + }, + "index_ejp_peak": { + "name": "Index option EJP - Heures de Pointe Mobile" + }, + "index_tempo_bluehc": { + "name": "Index option Tempo - Heures Creuses Jours Bleus" + }, + "index_tempo_bluehp": { + "name": "Index option Tempo - Heures Pleines Jours Bleus" + }, + "index_tempo_whitehc": { + "name": "Index option Tempo - Heures Creuses Jours Blancs" + }, + "index_tempo_whitehp": { + "name": "Index option Tempo - Heures Pleines Jours Blancs" + }, + "index_tempo_redhc": { + "name": "Index option Tempo - Heures Creuses Jours Rouges" + }, + "index_tempo_redhp": { + "name": "Index option Tempo - Heures Pleines Jours Rouges" + }, + "current_tarif": { + "name": "Période Tarifaire en cours" + }, + "peak_notice": { + "name": "Préavis EJP" + }, + "tomorrow_color": { + "name": "Couleur du lendemain" + }, + "apparent_power": { + "name": "Puissance apparente" + }, + "peak_hour_schedule": { + "name": "Horaire Heures Pleines Heures Creuses" + }, + "meter_state": { + "name": "Mot d'état du compteur" + }, + "inst_current": { + "name": "Intensité Instantanée" + }, + "overcurrent_warning": { + "name": "Avertissement de Dépassement d'intensité de réglage" + }, + "max_current": { + "name": "Intensité maximale appelée" + }, + "inst_current_ph1": { + "name": "Intensité Instantanée (phase 1)" + }, + "inst_current_ph2": { + "name": "Intensité Instantanée (phase 2)" + }, + "inst_current_ph3": { + "name": "Intensité Instantanée (phase 3)" + }, + "overcurrent_warning_ph1": { + "name": "Avertissement de Dépassement d'intensité de réglage (phase 1)" + }, + "overcurrent_warning_ph2": { + "name": "Avertissement de Dépassement d'intensité de réglage (phase 2)" + }, + "overcurrent_warning_ph3": { + "name": "Avertissement de Dépassement d'intensité de réglage (phase 3)" + }, + "max_current_ph1": { + "name": "Intensité maximale appelée (phase 1)" + }, + "max_current_ph2": { + "name": "Intensité maximale appelée (phase 2)" + }, + "max_current_ph3": { + "name": "Intensité maximale appelée (phase 3)" + }, + "max_power_n-1": { + "name": "Puissance maximale triphasée atteinte (jour n-1)" + }, + "potentials_presence": { + "name": "Présence des potentiels" + }, + "tic_version": { + "name": "Version de la TIC" + }, + "datetime": { + "name": "Date" + }, + "tarif_name": { + "name": "Nom du calendrier tarifaire fournisseur" + }, + "current_tarif_label": { + "name": "Libellé tarif fournisseur en cours" + }, + "active_energy_drawn_total": { + "name": "Energie active soutirée totale" + }, + "active_energy_provider_1": { + "name": "Energie active soutirée fournisseur, index 01" + }, + "active_energy_provider_2": { + "name": "Energie active soutirée fournisseur, index 02" + }, + "active_energy_provider_3": { + "name": "Energie active soutirée fournisseur, index 03" + }, + "active_energy_provider_4": { + "name": "Energie active soutirée fournisseur, index 04" + }, + "active_energy_provider_5": { + "name": "Energie active soutirée fournisseur, index 05" + }, + "active_energy_provider_6": { + "name": "Energie active soutirée fournisseur, index 06" + }, + "active_energy_provider_7": { + "name": "Energie active soutirée fournisseur, index 07" + }, + "active_energy_provider_8": { + "name": "Energie active soutirée fournisseur, index 08" + }, + "active_energy_provider_9": { + "name": "Energie active soutirée fournisseur, index 09" + }, + "active_energy_distributor_1": { + "name": "Energie active soutirée distributeur, index 01" + }, + "active_energy_distributor_2": { + "name": "Energie active soutirée distributeur, index 02" + }, + "active_energy_distributor_3": { + "name": "Energie active soutirée distributeur, index 03" + }, + "active_energy_distributor_4": { + "name": "Energie active soutirée distributeur, index 04" + }, + "rms_current_ph1": { + "name": "Courant efficace, phase 1" + }, + "rms_voltage_ph1": { + "name": "Tension efficace, phase 1" + }, + "ref_power": { + "name": "Puissance app. de référence" + }, + "trip_power": { + "name": "Puissance app. de coupure" + }, + "instantaneous_apparent_power": { + "name": "Puissance app. instantanée soutirée" + }, + "apparent_power_max_n": { + "name": "Puissance app. max. soutirée n" + }, + "apparent_power_max_n-1": { + "name": "Puissance app. max. soutirée n-1" + }, + "power_load_curve_n": { + "name": "Point n de la courbe de charge active soutirée" + }, + "power_load_curve_n-1": { + "name": "Point n-1 de la courbe de charge active soutirée" + }, + "mean_voltage_ph1": { + "name": "Tension moyenne, phase 1" + }, + "mobile_peak_start_1": { + "name": "Début pointe mobile 1" + }, + "mobile_peak_end_1": { + "name": "Fin pointe mobile 1" + }, + "mobile_peak_start_2": { + "name": "Début pointe mobile 2" + }, + "mobile_peak_end_2": { + "name": "Fin pointe mobile 2" + }, + "mobile_peak_start_3": { + "name": "Début pointe mobile 3" + }, + "mobile_peak_end_3": { + "name": "Fin pointe mobile 3" + }, + "short_msg": { + "name": "Message court" + }, + "ultra_short_msg": { + "name": "Message ultra court" + }, + "delivery_point": { + "name": "Point de livraison (PRM)" + }, + "relays": { + "name": "Relais" + }, + "current_tarif_index": { + "name": "Numéro de l’index tarifaire en cours" + }, + "calendar_index_today": { + "name": "Numéro du jour en cours calendrier fournisseur" + }, + "calendar_index_tomorrow": { + "name": "Numéro du prochain jour calendrier fournisseur" + }, + "calendar_profile_tomorrow": { + "name": "Profil du prochain jour calendrier fournisseur" + }, + "next_peak_dat_profile": { + "name": "NProfil du prochain jour de pointe" + }, + "status_register": { + "name": "Registre de statuts" + }, + "status_trip_device": { + "name": "Statut organe de coupure" + }, + "status_tarif_provider": { + "name": "Statut tarif contrat fourniture" + }, + "status_tarif_distributor": { + "name": "Statut tarif contrat distributeur" + }, + "status_euridis": { + "name": "Statut communication Euridis" + }, + "status_cpl": { + "name": "Statut CPL" + }, + "status_tempo_color_today": { + "name": "Couleur du jour" + }, + "status_tempo_color_tomorrow": { + "name": "Couleur du lendemain" + }, + "status_mobile_peak_notice": { + "name": "Préavis pointes mobiles" + }, + "status_mobile_peak": { + "name": "Pointe mobile" + }, + "rms_current_ph2": { + "name": "Courant efficace, phase 2" + }, + "rms_voltage_ph2": { + "name": "Tension efficace, phase 2" + }, + "rms_current_ph3": { + "name": "Courant efficace, phase 3" + }, + "rms_voltage_ph3": { + "name": "Tension efficace, phase 3" + }, + "instantaneous_apparent_power_ph1": { + "name": "Puissance app. instantanée soutirée phase 1" + }, + "apparent_power_max_n_ph1": { + "name": "Puissance app max. soutirée n phase 1" + }, + "apparent_power_max_n-1_ph1": { + "name": "Puissance app max. soutirée n-1 phase 1" + }, + "instantaneous_apparent_power_ph2": { + "name": "Puissance app. instantanée soutirée phase 2" + }, + "apparent_power_max_n_ph2": { + "name": "Puissance app max. soutirée n phase 2" + }, + "apparent_power_max_n-1_ph2": { + "name": "Puissance app max. soutirée n-1 phase 2" + }, + "instantaneous_apparent_power_ph3": { + "name": "Puissance app. instantanée soutirée phase 3" + }, + "apparent_power_max_n_ph3": { + "name": "Puissance app max. soutirée n phase 3" + }, + "apparent_power_max_n-1_ph3": { + "name": "Puissance app max. soutirée n-1 phase 3" + }, + "mean_voltage_ph2": { + "name": "Tension moyenne, phase 2" + }, + "mean_voltage_ph3": { + "name": "Tension moyenne, phase 3" + }, + "active_energy_injected_total": { + "name": "Energie active injectée totale" + }, + "reactive_energy_q1": { + "name": "Energie réactive Q1 totale" + }, + "reactive_energy_q2": { + "name": "Energie réactive Q2 totale" + }, + "reactive_energy_q3": { + "name": "Energie réactive Q3 totale" + }, + "reactive_energy_q4": { + "name": "Energie réactive Q4 totale" + }, + "instantaneous_apparent_power_injected": { + "name": "Puissance app. instantanée injectée" + }, + "apparent_power_injected_max_n": { + "name": "Puissance app. max. injectée n" + }, + "apparent_power_injected_max_n-1": { + "name": "Puissance app. max. injectée n-1" + }, + "power_injected_load_curve_n": { + "name": "Point n de la courbe de charge active injectée" + }, + "power_injected_load_curve_n-1": { + "name": "Point n-1 de la courbe de charge active injectée" } } } From 8b518916d2c6c44af0262e9057a1271a69490a83 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 30 Dec 2024 11:54:38 +0100 Subject: [PATCH 02/15] refactor: update binary_sensor creation mechanism to use entity description breaking: status register unique id, renamed because of ambiguities --- custom_components/linkytic/binary_sensor.py | 210 ++++++++---------- custom_components/linkytic/icons.json | 50 +++++ custom_components/linkytic/sensor.py | 16 +- custom_components/linkytic/status_register.py | 101 ++++----- .../linkytic/translations/en.json | 144 +++++++++++- .../linkytic/translations/fr.json | 138 +++++++++++- tests/test_status_register.py | 119 +++++----- 7 files changed, 514 insertions(+), 264 deletions(-) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index b6aa86d..912dc86 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -3,11 +3,13 @@ from __future__ import annotations import logging -from typing import Optional, cast +from dataclasses import dataclass +from typing import cast from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -21,74 +23,70 @@ _LOGGER = logging.getLogger(__name__) -STATUS_REGISTER_SENSORS = ( - ( - StatusRegister.CONTACT_SEC, - "Contact sec", - BinarySensorDeviceClass.OPENING, - "mdi:electric-switch-closed", - "mdi:electric-switch", - False, + +@dataclass(frozen=True, kw_only=True) +class StatusRegisterBinarySensorDescription(BinarySensorEntityDescription): + """Binary sensor entity description for status register fields.""" + + key: str = "STGE" + entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + field: StatusRegister + inverted: bool = False + + +STATUS_REGISTER_SENSORS: tuple[StatusRegisterBinarySensorDescription, ...] = ( + StatusRegisterBinarySensorDescription( + translation_key="status_dry_contact", + field=StatusRegister.DRY_CONTACT, + device_class=BinarySensorDeviceClass.OPENING, + ), + StatusRegisterBinarySensorDescription( + translation_key="status_terminal_cover", + field=StatusRegister.TERMINAL_COVER_OFF, + device_class=BinarySensorDeviceClass.OPENING, ), - ( - StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, - "Cache-borne", - BinarySensorDeviceClass.OPENING, - "mdi:toy-brick", - "mdi:toy-brick-outline", - False, + StatusRegisterBinarySensorDescription( + translation_key="status_overvoltage", + field=StatusRegister.OVERVOLTAGE, + device_class=BinarySensorDeviceClass.PROBLEM, ), - ( - StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, - "Surtension", - BinarySensorDeviceClass.PRESENCE, - "mdi:flash-triangle-outline", - "mdi:flash-triangle", - False, + StatusRegisterBinarySensorDescription( + translation_key="status_power_over_ref", + field=StatusRegister.POWER_OVER_REF, + device_class=BinarySensorDeviceClass.PROBLEM, ), - ( - StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, - "Dépassement puissance", - BinarySensorDeviceClass.PRESENCE, - "mdi:transmission-tower", - "mdi:transmission-tower-off", - False, + StatusRegisterBinarySensorDescription( + translation_key="status_producer", + field=StatusRegister.IS_PRODUCER, ), - ( - StatusRegister.PRODUCTEUR_CONSOMMATEUR, - "Producteur", - None, - "mdi:transmission-tower-export", - None, - False, + StatusRegisterBinarySensorDescription( + translation_key="status_injection", + field=StatusRegister.IS_INJECTING, ), - ( - StatusRegister.SENS_ENERGIE_ACTIVE, - "Sens énergie active", - None, - "mdi:transmission-tower-export", - None, - False, + StatusRegisterBinarySensorDescription( + translation_key="status_rtc_sync", + field=StatusRegister.RTC_DEGRADED, + device_class=BinarySensorDeviceClass.LOCK, ), - ( - StatusRegister.MODE_DEGRADE_HORLOGE, - "Synchronisation horloge", - BinarySensorDeviceClass.LOCK, - "mdi:sync", - "mdi:sync-off", - False, + StatusRegisterBinarySensorDescription( + translation_key="status_mode_tic", + field=StatusRegister.TIC_STD, ), - (StatusRegister.MODE_TIC, "Mode historique", None, "mdi:tag", None, False), - ( - StatusRegister.SYNCHRO_CPL, - "Synchronisation CPL", - BinarySensorDeviceClass.LOCK, - "mdi:sync", - "mdi:sync-off", - True, + StatusRegisterBinarySensorDescription( + translation_key="status_cpl_sync", + field=StatusRegister.CPL_SYNC, + device_class=BinarySensorDeviceClass.LOCK, + inverted=True, ), ) +SERIAL_LINK_BINARY_SENSOR = BinarySensorEntityDescription( + key="serial_link", + translation_key="serial_link", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) + # config flow setup async def async_setup_entry( @@ -109,23 +107,15 @@ async def async_setup_entry( return # Init sensors sensors: list[BinarySensorEntity] = [ - SerialConnectivity(config_entry.title, config_entry.entry_id, serial_reader) + SerialConnectivity(SERIAL_LINK_BINARY_SENSOR, config_entry, serial_reader) ] if config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD: sensors.extend( StatusRegisterBinarySensor( - name=name, - config_title=config_entry.title, - field=field, - serial_reader=serial_reader, - unique_id=config_entry.entry_id, - device_class=devclass, - icon_off=icon_off, - icon_on=icon_on, - inverted=inverted, + description=description, config_entry=config_entry, reader=serial_reader ) - for field, name, devclass, icon_off, icon_on, inverted in STATUS_REGISTER_SENSORS + for description in STATUS_REGISTER_SENSORS ) async_add_entities(sensors, True) @@ -134,23 +124,16 @@ async def async_setup_entry( class SerialConnectivity(LinkyTICEntity, BinarySensorEntity): """Serial connectivity to the Linky TIC serial interface.""" - # Generic properties - # https://developers.home-assistant.io/docs/core/entity#generic-properties - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "Connectivité du lien série" - - # Binary sensor properties - # https://developers.home-assistant.io/docs/core/entity/binary-sensor/#properties - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - def __init__( - self, title: str, unique_id: str, serial_reader: LinkyTICReader + self, + description: BinarySensorEntityDescription, + config_entry: ConfigEntry, + reader: LinkyTICReader, ) -> None: """Initialize the SerialConnectivity binary sensor.""" - _LOGGER.debug("%s: initializing Serial Connectivity binary sensor", title) - super().__init__(serial_reader) - self._title = title - self._attr_unique_id = f"{DOMAIN}_{unique_id}_serial_connectivity" + super().__init__(reader) + self.entity_description = description + self._attr_unique_id = f"{DOMAIN}_{config_entry.entry_id}_serial_connectivity" @property def is_on(self) -> bool: @@ -161,55 +144,36 @@ def is_on(self) -> bool: class StatusRegisterBinarySensor(LinkyTICEntity, BinarySensorEntity): """Binary sensor for binary status register fields.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC - _binary_state: bool _tag = "STGE" def __init__( self, - name: str, - config_title: str, - unique_id: str, - serial_reader: LinkyTICReader, - field: StatusRegister, - device_class: BinarySensorDeviceClass | None = None, - icon_on: str | None = None, - icon_off: str | None = None, - inverted: bool = False, + description: StatusRegisterBinarySensorDescription, + config_entry: ConfigEntry, + reader: LinkyTICReader, ) -> None: """Initialize the status register binary sensor.""" - _LOGGER.debug("%s: initializing %s binary sensor", config_title, field.name) - super().__init__(serial_reader) + _LOGGER.debug( + "%s: initializing %s binary sensor", + config_entry.title, + description.field.name, + ) + super().__init__(reader) - self._config_title = config_title + self.entity_description = description self._binary_state = False # Default state. - self._inverted = inverted - self._field = field - self._attr_name = name - self._attr_unique_id = f"{DOMAIN}_{unique_id}_{field.name.lower()}" - if device_class: - self._attr_device_class = device_class - - self._icon_on = icon_on - self._icon_off = icon_off + self._inverted = description.inverted + self._field = description.field + self._attr_unique_id = ( + f"{DOMAIN}_{config_entry.entry_id}_{description.field.name.lower()}" + ) @property def is_on(self) -> bool: """Value of the sensor.""" return self._binary_state ^ self._inverted - @property - def icon(self) -> str | None: - """Return icon of the sensor.""" - if not self._icon_off or not self._icon_on: - return self._icon_on or self._icon_off or super().icon - - if self.is_on: - return self._icon_on - else: - return self._icon_off - def update(self) -> None: """Update the state of the sensor.""" value, _ = self._update() @@ -218,12 +182,12 @@ def update(self) -> None: self._binary_state = cast(bool, self._field.value.get_status(value)) # TODO: factor _update function to remove copy from sensors entities - def _update(self) -> tuple[Optional[str], Optional[str]]: + def _update(self) -> tuple[str | None, str | None]: """Get value and/or timestamp from cached data. Responsible for updating sensor availability.""" value, timestamp = self._serial_controller.get_values(self._tag) _LOGGER.debug( "%s: retrieved %s value from serial controller: (%s, %s)", - self._config_title, + self._serial_controller.name, self._tag, value, timestamp, @@ -236,14 +200,14 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: if not self._serial_controller.is_connected: _LOGGER.debug( "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, + self._serial_controller.name, self._tag, ) self._attr_available = False elif self._serial_controller.has_read_full_frame: _LOGGER.info( "%s: marking the %s sensor as unavailable: a full frame has been read but %s has not been found", - self._config_title, + self._serial_controller.name, self._tag, self._tag, ) @@ -259,7 +223,7 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: self._attr_available = True _LOGGER.info( "%s: marking the %s sensor as available now !", - self._config_title, + self._serial_controller.name, self._tag, ) diff --git a/custom_components/linkytic/icons.json b/custom_components/linkytic/icons.json index 3e20cb9..e614670 100644 --- a/custom_components/linkytic/icons.json +++ b/custom_components/linkytic/icons.json @@ -124,6 +124,56 @@ "status_mobile_peak": { "default": "mdi:progress-clock" } + }, + "binary_sensor": { + "status_dry_contact": { + "state": { + "off": "mdi:electric-switch-closed", + "on": "mdi:electric-switch" + } + }, + "status_terminal_cover": { + "state": { + "off": "mdi:toy-brick", + "on": "mdi:toy-brick-outline" + } + }, + "status_overvoltage": { + "state": { + "on": "mdi:flash-triangle", + "off": "mdi:flash-triangle-outline" + } + }, + "status_power_over_ref": { + "state": { + "on": "mdi:flash-alert", + "off": "mdi:flash" + } + }, + "status_producer": { + "default": "mdi:transmission-tower-import" + }, + "status_injection": { + "state": { + "on": "mdi:transmission-tower-import", + "off": "mdi:transmission-tower-export" + } + }, + "status_rtc_sync": { + "state": { + "on": "mdi:sync-off", + "off": "mdi:sync" + } + }, + "status_mode_tic": { + "default": "mdi:information-slab-box-outline" + }, + "status_cpl_sync": { + "state": { + "on": "mdi:sync-off", + "off": "mdi:sync" + } + } } } } diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 6998487..a8c8139 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -435,19 +435,19 @@ def wrap(cls): ), # Duplicate? All fields are exposed as sensors or binary sensors StatusRegisterSensorConfig( translation_key="status_trip_device", - status_field=StatusRegister.ORGANE_DE_COUPURE, + status_field=StatusRegister.TRIP_UNIT, ), StatusRegisterSensorConfig( translation_key="status_tarif_provider", - status_field=StatusRegister.TARIF_CONTRAT_FOURNITURE, + status_field=StatusRegister.PROVIDER_INDEX, ), StatusRegisterSensorConfig( translation_key="status_tarif_distributor", - status_field=StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR, + status_field=StatusRegister.DISTRIBUTOR_INDEX, ), StatusRegisterSensorConfig( translation_key="status_euridis", - status_field=StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS, + status_field=StatusRegister.EURIDIS, ), StatusRegisterSensorConfig( translation_key="status_cpl", @@ -455,18 +455,18 @@ def wrap(cls): ), StatusRegisterSensorConfig( translation_key="status_tempo_color_today", - status_field=StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO, + status_field=StatusRegister.COLOR_TODAY, ), StatusRegisterSensorConfig( translation_key="status_tempo_color_tomorrow", - status_field=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, + status_field=StatusRegister.COLOR_NEXT_DAY, ), StatusRegisterSensorConfig( translation_key="status_mobile_peak_notice", - status_field=StatusRegister.PREAVIS_POINTES_MOBILES, + status_field=StatusRegister.MOBILE_PEAK_NOTICE, ), StatusRegisterSensorConfig( - translation_key="status_mobile_peak", status_field=StatusRegister.POINTE_MOBILE + translation_key="status_mobile_peak", status_field=StatusRegister.MOBILE_PEAK ), ) diff --git a/custom_components/linkytic/status_register.py b/custom_components/linkytic/status_register.py index df8677e..218857d 100644 --- a/custom_components/linkytic/status_register.py +++ b/custom_components/linkytic/status_register.py @@ -4,7 +4,7 @@ from typing import NamedTuple -class StatusRegisterEnumValueType(NamedTuple): +class StatusRegisterField(NamedTuple): """Represent a field in the status register. lsb is the position of the least significant bit of the field in the status register. len if the length in bit of the field (default 1 bit). @@ -28,51 +28,44 @@ def get_status(self, register: str) -> str | bool: return self.options[val] # Let IndexError propagate if val is unknown. -organe_coupure = { - 0: "Fermé", - 1: "Ouvert sur surpuissance", - 2: "Ouvert sur surtension", - 3: "Ouvert sur délestage", - 4: "Ouvert sur ordre CPL ou Euridis", - 5: "Ouvert sur surchauffe (>Imax)", - 6: "Ouvert sur surchauffe (Imax)", + "overheat_below_imax": "Opened on overheat (Imax)", + "overheat_below_imax": "Ouvert sur surchauffe ( Date: Mon, 30 Dec 2024 15:37:06 +0100 Subject: [PATCH 03/15] fix: use ConfigEntry.runtime_data to pass the reader to the platforms --- custom_components/linkytic/__init__.py | 37 ++++++++------------- custom_components/linkytic/binary_sensor.py | 16 +++------ custom_components/linkytic/sensor.py | 13 ++------ 3 files changed, 22 insertions(+), 44 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index d6d7abc..a0f20b7 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -12,7 +12,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import ( - DOMAIN, LINKY_IO_ERRORS, OPTIONS_REALTIME, SETUP_PRODUCER, @@ -28,7 +27,9 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry[LinkyTICReader] +) -> bool: """Set up linkytic from a config entry.""" # Create the serial reader thread and start it port = entry.data.get(SETUP_SERIAL) @@ -70,39 +71,29 @@ async def read_serial_number(serial: LinkyTICReader): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, serial_reader.signalstop) # Add options callback entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.async_on_unload(lambda: serial_reader.signalstop("config_entry_unload")) - # Add the serial reader to HA and initialize sensors - try: - hass.data[DOMAIN][entry.entry_id] = serial_reader - except KeyError: - hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.entry_id] = serial_reader + + entry.runtime_data = serial_reader + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ConfigEntry[LinkyTICReader] +) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - # Remove the related entry - hass.data[DOMAIN].pop(entry.entry_id) + reader = entry.runtime_data + reader.signalstop("unload") return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry): """Handle options update.""" - # Retrieved the serial reader for this config entry - try: - serial_reader = hass.data[DOMAIN][entry.entry_id] - except KeyError: - _LOGGER.error( - "Can not update options for %s: failed to get the serial reader object", - entry.title, - ) - return - # Update its options - serial_reader.update_options(entry.options.get(OPTIONS_REALTIME)) + + reader = entry.runtime_data + reader.update_options(entry.options.get(OPTIONS_REALTIME)) async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 912dc86..fafef50 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -91,29 +91,23 @@ class StatusRegisterBinarySensorDescription(BinarySensorEntityDescription): # config flow setup async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ConfigEntry[LinkyTICReader], async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("%s: setting up binary sensor plateform", config_entry.title) # Retrieve the serial reader object - try: - serial_reader = hass.data[DOMAIN][config_entry.entry_id] - except KeyError: - _LOGGER.error( - "%s: can not init binaries sensors: failed to get the serial reader object", - config_entry.title, - ) - return + reader = config_entry.runtime_data + # Init sensors sensors: list[BinarySensorEntity] = [ - SerialConnectivity(SERIAL_LINK_BINARY_SENSOR, config_entry, serial_reader) + SerialConnectivity(SERIAL_LINK_BINARY_SENSOR, config_entry, reader) ] if config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD: sensors.extend( StatusRegisterBinarySensor( - description=description, config_entry=config_entry, reader=serial_reader + description=description, config_entry=config_entry, reader=reader ) for description in STATUS_REGISTER_SENSORS ) diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index a8c8139..f57f8c2 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -576,20 +576,13 @@ def wrap(cls): # config flow setup async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ConfigEntry[LinkyTICReader], async_add_entities: AddEntitiesCallback, ) -> None: """Modern (thru config entry) sensors setup.""" _LOGGER.debug("%s: setting up sensor plateform", config_entry.title) # Retrieve the serial reader object - try: - serial_reader: LinkyTICReader = hass.data[DOMAIN][config_entry.entry_id] - except KeyError: - _LOGGER.error( - "%s: can not init sensors: failed to get the serial reader object", - config_entry.title, - ) - return + reader = config_entry.runtime_data is_standard = bool(config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD) is_threephase = bool(config_entry.data.get(SETUP_THREEPHASE)) @@ -617,7 +610,7 @@ async def async_setup_entry( async_add_entities( ( - REGISTRY[type(config)](config, config_entry, serial_reader) + REGISTRY[type(config)](config, config_entry, reader) for descriptor in sensor_desc for config in descriptor ), From d59f901b74a140dbb3a0afe4a0654ddfcb5737f2 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 30 Dec 2024 19:57:50 +0100 Subject: [PATCH 04/15] refactor: simplify status register enum --- custom_components/linkytic/binary_sensor.py | 6 +++--- custom_components/linkytic/status_register.py | 6 +++--- tests/test_status_register.py | 18 +++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index fafef50..038337d 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -42,7 +42,7 @@ class StatusRegisterBinarySensorDescription(BinarySensorEntityDescription): ), StatusRegisterBinarySensorDescription( translation_key="status_terminal_cover", - field=StatusRegister.TERMINAL_COVER_OFF, + field=StatusRegister.TERMINAL_COVER, device_class=BinarySensorDeviceClass.OPENING, ), StatusRegisterBinarySensorDescription( @@ -57,11 +57,11 @@ class StatusRegisterBinarySensorDescription(BinarySensorEntityDescription): ), StatusRegisterBinarySensorDescription( translation_key="status_producer", - field=StatusRegister.IS_PRODUCER, + field=StatusRegister.PRODUCER, ), StatusRegisterBinarySensorDescription( translation_key="status_injection", - field=StatusRegister.IS_INJECTING, + field=StatusRegister.INJECTING, ), StatusRegisterBinarySensorDescription( translation_key="status_rtc_sync", diff --git a/custom_components/linkytic/status_register.py b/custom_components/linkytic/status_register.py index 218857d..4f3f3f5 100644 --- a/custom_components/linkytic/status_register.py +++ b/custom_components/linkytic/status_register.py @@ -76,12 +76,12 @@ class StatusRegister(Enum): DRY_CONTACT = StatusRegisterField(0) TRIP_UNIT = StatusRegisterField(1, 3, trip_unit) - TERMINAL_COVER_OFF = StatusRegisterField(4) + TERMINAL_COVER = StatusRegisterField(4) # bit 5 is reserved OVERVOLTAGE = StatusRegisterField(6) POWER_OVER_REF = StatusRegisterField(7) - IS_PRODUCER = StatusRegisterField(8) - IS_INJECTING = StatusRegisterField(9) + PRODUCER = StatusRegisterField(8) + INJECTING = StatusRegisterField(9) PROVIDER_INDEX = StatusRegisterField(10, 4, current_index) DISTRIBUTOR_INDEX = StatusRegisterField(14, 2, current_index) RTC_DEGRADED = StatusRegisterField(16) diff --git a/tests/test_status_register.py b/tests/test_status_register.py index 2c78e0b..5989041 100644 --- a/tests/test_status_register.py +++ b/tests/test_status_register.py @@ -17,11 +17,11 @@ def test_parse(): EXPECTED = { StatusRegister.DRY_CONTACT: 1, StatusRegister.TRIP_UNIT: trip_unit[0], - StatusRegister.TERMINAL_COVER_OFF: 0, + StatusRegister.TERMINAL_COVER: 0, StatusRegister.OVERVOLTAGE: 0, StatusRegister.POWER_OVER_REF: 0, - StatusRegister.IS_PRODUCER: 1, - StatusRegister.IS_INJECTING: 0, + StatusRegister.PRODUCER: 1, + StatusRegister.INJECTING: 0, StatusRegister.PROVIDER_INDEX: current_index[1], StatusRegister.DISTRIBUTOR_INDEX: current_index[3], StatusRegister.RTC_DEGRADED: 0, @@ -43,11 +43,11 @@ def test_parse(): EXPECTED = { StatusRegister.DRY_CONTACT: 0, StatusRegister.TRIP_UNIT: trip_unit[0], - StatusRegister.TERMINAL_COVER_OFF: 0, + StatusRegister.TERMINAL_COVER: 0, StatusRegister.OVERVOLTAGE: 0, StatusRegister.POWER_OVER_REF: 0, - StatusRegister.IS_PRODUCER: 0, - StatusRegister.IS_INJECTING: 0, + StatusRegister.PRODUCER: 0, + StatusRegister.INJECTING: 0, StatusRegister.PROVIDER_INDEX: current_index[0], StatusRegister.DISTRIBUTOR_INDEX: current_index[3], StatusRegister.RTC_DEGRADED: 0, @@ -69,11 +69,11 @@ def test_parse(): EXPECTED = { StatusRegister.DRY_CONTACT: 1, StatusRegister.TRIP_UNIT: trip_unit[6], - StatusRegister.TERMINAL_COVER_OFF: 1, + StatusRegister.TERMINAL_COVER: 1, StatusRegister.OVERVOLTAGE: 1, StatusRegister.POWER_OVER_REF: 1, - StatusRegister.IS_PRODUCER: 1, - StatusRegister.IS_INJECTING: 1, + StatusRegister.PRODUCER: 1, + StatusRegister.INJECTING: 1, StatusRegister.PROVIDER_INDEX: current_index[9], StatusRegister.DISTRIBUTOR_INDEX: current_index[3], StatusRegister.RTC_DEGRADED: 1, From 2c95941e1b4e8a0cbe7c2e49874a9128ecd70bb8 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 30 Dec 2024 21:52:08 +0100 Subject: [PATCH 05/15] refactor: update entity and config unique id to HA requirements with the meter serial number --- custom_components/linkytic/__init__.py | 126 ++++++++++++++++++-- custom_components/linkytic/binary_sensor.py | 9 +- custom_components/linkytic/config_flow.py | 68 +++++++---- custom_components/linkytic/sensor.py | 31 +---- custom_components/linkytic/serial_reader.py | 42 ------- 5 files changed, 171 insertions(+), 105 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index a0f20b7..468233e 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -10,8 +10,11 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify from .const import ( + DOMAIN, LINKY_IO_ERRORS, OPTIONS_REALTIME, SETUP_PRODUCER, @@ -96,16 +99,14 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry): reader.update_options(entry.options.get(OPTIONS_REALTIME)) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry): """Migrate old entry.""" - _LOGGER.info( - "Migrating from version %d.%d", config_entry.version, config_entry.minor_version - ) + _LOGGER.info("Migrating from version %d.%d", entry.version, entry.minor_version) - if config_entry.version == 1: - new = {**config_entry.data} + if entry.version == 1: + new = {**entry.data} - if config_entry.minor_version < 2: + if entry.minor_version < 2: # Migrate to serial by-id. serial_by_id = await hass.async_add_executor_job( usb.get_serial_by_id, new[SETUP_SERIAL] @@ -118,14 +119,117 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): else: new[SETUP_SERIAL] = serial_by_id - # config_entry.minor_version = 2 + # Migrate the unique ID to use the serial number, this is not backward compatible + try: + reader = LinkyTICReader( + title=entry.title, + port=new[SETUP_SERIAL], + std_mode=new[SETUP_TICMODE] == TICMODE_STANDARD, + producer_mode=new[SETUP_PRODUCER], + three_phase=new[SETUP_THREEPHASE], + ) + reader.start() + + async def read_serial_number(serial: LinkyTICReader): + while serial.serial_number is None: + await asyncio.sleep(1) + # Check for any serial error that occurred in the serial thread context + if serial.setup_error: + raise serial.setup_error + return serial.serial_number + + s_n = await asyncio.wait_for(read_serial_number(reader), timeout=5) + + except (*LINKY_IO_ERRORS, TimeoutError) as e: + _LOGGER.error( + "Could not migrate config entry to version 2, cannot read serial number", + exc_info=e, + ) + return False + + finally: + reader.signalstop("probe_end") + + serial_number = slugify(s_n) + + # Explicitly pass serial number as config entry is not updated yet + await _migrate_entities_unique_id(hass, entry, serial_number) + hass.config_entries.async_update_entry( - config_entry, data=new, minor_version=2, version=1 + entry, data=new, version=2, minor_version=0, unique_id=serial_number ) # type: ignore _LOGGER.info( "Migration to version %d.%d successful", - config_entry.version, - config_entry.minor_version, + entry.version, + entry.minor_version, ) return True + + +async def _migrate_entities_unique_id( + hass: HomeAssistant, entry: ConfigEntry, serial_number: str +): + """Migrate entities unique id to conform to HA specifications.""" + + # Old entries are of format f"{DOMAIN}_{entry.config_id}_suffix" + # which is not conform to HA unique ID requirements (https://developers.home-assistant.io/docs/entity_registry_index#unique-id-requirements) + # domain should not appear in the unique id + # ConfigEntry.config_id is a last resort unique id when no acceptable source is awailable + # the meter serial number is a valid (and better) device unique id + + # Since we are migrating unique id, might as well migrate some suffixes for consistency + _ENTITY_MIGRATION_SUFFIX = { + # non-standard tic tags that were implemented as different sensors + "smaxn": "smaxsn", + "smaxn-1": "smaxsn-1", + # status register sensors, due to field renaming + "contact_sec": "dry_contact", + "organe_de_coupure": "trip_unit", + "etat_du_cache_borne_distributeur": "terminal_cover", + "surtension_sur_une_des_phases": "overvoltage", + "depassement_puissance_reference": "power_over_ref", + "producteur_consommateur": "producer", + "sens_energie_active": "injecting", + "tarif_contrat_fourniture": "provider_index", + "tarif_contrat_distributeur": "distributor_index", + "mode_degrade_horloge": "rtc_degraded", + "mode_tic": "tic_std", + "etat_sortie_communication_euridis": "euridis", + "synchro_cpl": "cpl_sync", + "status_cpl": "cpl_status", + "couleur_jour_contrat_tempo": "color_today", + "couleur_lendemain_contrat_tempo": "color_next_day", + "preavis_pointes_mobiles": "mobile_peak_notice", + "pointe_mobile": "mobile_peak", + } + + entity_reg = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + + migrate_entities: dict[str, str] = {} + + for entity in entities: + old_unique_id = entity.unique_id + + # Old ids all start with `linkytic_` + if not old_unique_id.startswith(DOMAIN): + continue + + old_suffix = old_unique_id.split(entry.entry_id, maxsplit=1)[1] + + if (new_suffix := _ENTITY_MIGRATION_SUFFIX.get(old_suffix)) is None: + # entity is not in the migration table, just remove the domain prefix and update the entry_id + new_suffix = old_suffix + + migrate_entities[entity.entity_id] = slugify(f"{serial_number}_{new_suffix}") + + _LOGGER.debug( + "Updating entity %s from unique id `%s` to `%s`", + entity.entity_id, + old_unique_id, + migrate_entities[entity.entity_id], + ) + + for entity_id, unique_id in migrate_entities.items(): + entity_reg.async_update_entity(entity_id, new_unique_id=unique_id) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 038337d..9ccc055 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -15,8 +15,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify -from .const import DOMAIN, SETUP_TICMODE, TICMODE_STANDARD +from .const import SETUP_TICMODE, TICMODE_STANDARD from .entity import LinkyTICEntity from .serial_reader import LinkyTICReader from .status_register import StatusRegister @@ -127,7 +128,7 @@ def __init__( """Initialize the SerialConnectivity binary sensor.""" super().__init__(reader) self.entity_description = description - self._attr_unique_id = f"{DOMAIN}_{config_entry.entry_id}_serial_connectivity" + self._attr_unique_id = slugify(f"{reader.serial_number}_serial_connectivity") @property def is_on(self) -> bool: @@ -159,8 +160,8 @@ def __init__( self._binary_state = False # Default state. self._inverted = description.inverted self._field = description.field - self._attr_unique_id = ( - f"{DOMAIN}_{config_entry.entry_id}_{description.field.name.lower()}" + self._attr_unique_id = slugify( + f"{reader.serial_number}_{description.field.name}" ) @property diff --git a/custom_components/linkytic/config_flow.py b/custom_components/linkytic/config_flow.py index 80f90d9..c739ba2 100644 --- a/custom_components/linkytic/config_flow.py +++ b/custom_components/linkytic/config_flow.py @@ -22,6 +22,7 @@ from .const import ( DOMAIN, + LINKY_IO_ERRORS, OPTIONS_REALTIME, SETUP_PRODUCER, SETUP_PRODUCER_DEFAULT, @@ -35,7 +36,7 @@ TICMODE_STANDARD, TICMODE_STANDARD_LABEL, ) -from .serial_reader import CannotConnect, CannotRead, linky_tic_tester +from .serial_reader import LinkyTICReader _LOGGER = logging.getLogger(__name__) @@ -63,8 +64,8 @@ class LinkyTICConfigFlow(ConfigFlow, domain=DOMAIN): # type:ignore """Handle a config flow for linkytic.""" - VERSION = 1 - MINOR_VERSION = 2 + VERSION = 2 + MINOR_VERSION = 0 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -75,38 +76,59 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - # Validate input - await self.async_set_unique_id(DOMAIN + "_" + user_input[SETUP_SERIAL]) - self._abort_if_unique_id_configured() # Search for serial/by-id, which SHOULD be a persistent name to serial interface. _port = await self.hass.async_add_executor_job( usb.get_serial_by_id, user_input[SETUP_SERIAL] ) + user_input[SETUP_SERIAL] = _port + errors = {} - title = user_input[SETUP_SERIAL] + try: - # Encapsulate the tester function, pyserial rfc2217 implementation have blocking calls. - await asyncio.to_thread( - linky_tic_tester, - device=_port, - std_mode=user_input[SETUP_TICMODE] == TICMODE_STANDARD, + serial_reader = LinkyTICReader( + title="Probe", + port=_port, + std_mode=user_input.get(SETUP_TICMODE) == TICMODE_STANDARD, + producer_mode=user_input.get(SETUP_PRODUCER), + three_phase=user_input.get(SETUP_THREEPHASE), + real_time=False, ) - except CannotConnect as cannot_connect: - _LOGGER.error("%s: can not connect: %s", title, cannot_connect) + serial_reader.start() + + async def read_serial_number(serial: LinkyTICReader): + while serial.serial_number is None: + await asyncio.sleep(1) + # Check for any serial error that occurred in the serial thread context + if serial.setup_error: + raise serial.setup_error + return serial.serial_number + + s_n = await asyncio.wait_for(read_serial_number(serial_reader), timeout=5) + + # Error when opening serial port. + except LINKY_IO_ERRORS as cannot_connect: + _LOGGER.error("Could not connect to %s (%s)", _port, cannot_connect) errors["base"] = "cannot_connect" - except CannotRead as cannot_read: - _LOGGER.error( - "%s: can not read a line after connection: %s", title, cannot_read - ) + + # Timeout waiting for S/N to be read. + except TimeoutError: + _LOGGER.error("Could not read serial number at %s", _port) errors["base"] = "cannot_read" - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception: %s", exc) - errors["base"] = "unknown" + else: - user_input[SETUP_SERIAL] = _port - return self.async_create_entry(title=title, data=user_input) + _LOGGER.info("Found a device with serial number: %s", s_n) + + await self.async_set_unique_id(s_n) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[SETUP_SERIAL], data=user_input + ) + + finally: + serial_reader.signalstop("end_probe") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index f57f8c2..1c3b90d 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -20,6 +20,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify from .const import ( DID_CONSTRUCTOR, @@ -28,7 +29,6 @@ DID_TYPE, DID_TYPE_CODE, DID_YEAR, - DOMAIN, SETUP_PRODUCER, SETUP_THREEPHASE, SETUP_TICMODE, @@ -641,15 +641,7 @@ def __init__( self._last_value = None self._tag = description.key - # FIXME: Non-compliant to https://developers.home-assistant.io/docs/entity_registry_index/ - # it should not contain the DOMAIN - self._attr_unique_id = ( - f"{DOMAIN}_{config_entry.entry_id}_{description.key.lower()}" - ) - # But changing it would BREAK ALL EXISTING SENSORS (loss of history/statistics) - # self._attr_unique_id = f"{reader.serial_number}_{description.key.lower()}" - # And this method is not universal/compatible with status register sensors, which all use the same key - # and are differencied by their field + self._attr_unique_id = slugify(f"{reader.serial_number}_{description.key}") @property def native_value(self) -> T | None: # type:ignore @@ -719,17 +711,8 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: class ADSSensor(LinkyTICSensor[str]): """Adresse du compteur entity.""" # codespell:ignore - # ADSSensor is a subclass and not an instance of StringSensor because it binds to two tags. - - # Generic properties - # https://developers.home-assistant.io/docs/core/entity#generic-properties - entity_description: SerialNumberSensorConfig - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "Adresse du compteur" # codespell:ignore - _attr_icon = "mdi:tag" - def __init__( self, description: SerialNumberSensorConfig, @@ -739,8 +722,8 @@ def __init__( """Initialize an ADCO/ADSC Sensor.""" super().__init__(description, config_entry, reader) - # Overwrite tag-based unique id for backward compatibility - self._attr_unique_id = f"{DOMAIN}_{config_entry.entry_id}_adco" + # Overwrite tag-based unique id for compatibility between tic versions + self._attr_unique_id = slugify(f"{reader.serial_number}_adco") self._extra: dict[str, str] = {} @property @@ -868,10 +851,8 @@ def __init__( status_field = description.status_field self._field = status_field - # Breaking changes here. - # Overwrites the unique_id because all status register sensors are reading from the same tag. - self._attr_unique_id = ( - f"{DOMAIN}_{config_entry.entry_id}_{status_field.name.lower()}" + self._attr_unique_id = slugify( + f"{reader.serial_number}_{description.status_field.name}" ) # For SensorDeviceClass.ENUM, _attr_options contains all the possible values for the sensor. self._attr_options = list( diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index ba4bf3c..1ee415e 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -511,45 +511,3 @@ def msg(self): bin(int.from_bytes(self.expected, byteorder="big")), chr(ord(self.expected)), ) - - -def linky_tic_tester(device: str, std_mode: bool) -> None: - """Before starting the thread, this method can help validate configuration by opening the serial communication and read a line. It returns None if everything went well or a string describing the error.""" - # Open connection - try: - serial_reader = serial.serial_for_url( - url=device, - baudrate=MODE_STANDARD_BAUD_RATE if std_mode else MODE_HISTORIC_BAUD_RATE, - bytesize=BYTESIZE, - parity=PARITY, - stopbits=STOPBITS, - timeout=1, - ) - except serial.serialutil.SerialException as exc: - raise CannotConnect( - f"Unable to connect to the serial device {device}: {exc}" - ) from exc - # Try to read a line - try: - serial_reader.readline() - except serial.serialutil.SerialException as exc: - serial_reader.close() - raise CannotRead(f"Failed to read a line: {exc}") from exc - # All good - serial_reader.close() - - -class CannotConnect(Exception): - """Error to indicate we cannot connect.""" - - def __init__(self, message) -> None: - """Initialize the CannotConnect error with an explanation message.""" - super().__init__(message) - - -class CannotRead(Exception): - """Error to indicate that the serial connection was open successfully but an error occurred while reading a line.""" - - def __init__(self, message) -> None: - """Initialize the CannotRead error with an explanation message.""" - super().__init__(message) From cf639f0c6216f29e689dbe5e08a9dfe5ffa2def4 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 30 Dec 2024 22:48:07 +0100 Subject: [PATCH 06/15] feat: check serial number of device in platform setup --- custom_components/linkytic/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index 468233e..62258eb 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify @@ -69,6 +69,14 @@ async def read_serial_number(serial: LinkyTICReader): "Connected to serial port but coulnd't read serial number before timeout: check if TIC is connected and active." ) from e + # entry.unique_id is the serial number read during the config flow, all data correspond to this meter s/n + if s_n != entry.unique_id: + serial_reader.signalstop("serial_number_mismatch") + raise ConfigEntryError( + f"Connected to a different meter with S/N: `{s_n}`, expected `{entry.unique_id}`. " + "Aborting setup to prevent overwriting long term data." + ) + _LOGGER.info(f"Device connected with serial number: {s_n}") hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, serial_reader.signalstop) From 47005dc7f7060278913188f49004aa6af23ca88d Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 30 Dec 2024 23:03:03 +0100 Subject: [PATCH 07/15] feat: add serial number and tic version to device info --- custom_components/linkytic/entity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/linkytic/entity.py b/custom_components/linkytic/entity.py index 6f4f3f3..d8307a6 100644 --- a/custom_components/linkytic/entity.py +++ b/custom_components/linkytic/entity.py @@ -40,4 +40,7 @@ def device_info(self) -> DeviceInfo: manufacturer=did.get(DID_CONSTRUCTOR, DID_DEFAULT_MANUFACTURER), model=did.get(DID_TYPE, DID_DEFAULT_MODEL), name=DID_DEFAULT_NAME, + serial_number=self._serial_controller.serial_number, + sw_version="TIC " + + ("Standard" if self._serial_controller._std_mode else "Historique"), ) From cf651dffdacb48c4065de12b6815364d0d5b9b03 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Tue, 31 Dec 2024 12:41:34 +0100 Subject: [PATCH 08/15] build: run mypy in venv and update configuration to match homeassistant/core --- .pre-commit-config.yaml | 4 +--- mypy.ini | 23 +++++++++++++++++++++++ script/run-in-venv.sh | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 mypy.ini create mode 100755 script/run-in-venv.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a73a15a..f4bbe41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,10 +29,8 @@ repos: # shell. - id: mypy name: mypy - entry: mypy + entry: ./script/run-in-venv.sh mypy language: python types_or: [python, pyi] - args: - - --ignore-missing-imports require_serial: true files: ^(custom_components)/.+\.(py|pyi)$ diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..f0552c0 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,23 @@ +[mypy] +python_version = 3.12 +platform = linux +show_error_codes = true +follow_imports = normal +local_partial_types = true +strict_equality = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +enable_error_code = ignore-without-code, redundant-self, truthy-iterable +disable_error_code = annotation-unchecked, import-not-found, import-untyped +extra_checks = false +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true diff --git a/script/run-in-venv.sh b/script/run-in-venv.sh new file mode 100755 index 0000000..02f2f26 --- /dev/null +++ b/script/run-in-venv.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env sh +set -eu + +# Copied from homeassistant/core + +# Used in venv activate script. +# Would be an error if undefined. +OSTYPE="${OSTYPE-}" + +# Activate pyenv and virtualenv if present, then run the specified command + +# pyenv, pyenv-virtualenv +if [ -s .python-version ]; then + PYENV_VERSION=$(head -n 1 .python-version) + export PYENV_VERSION +fi + +if [ -n "${VIRTUAL_ENV-}" ] && [ -f "${VIRTUAL_ENV}/bin/activate" ]; then + . "${VIRTUAL_ENV}/bin/activate" +else + # other common virtualenvs + my_path=$(git rev-parse --show-toplevel) + + for venv in venv .venv .; do + if [ -f "${my_path}/${venv}/bin/activate" ]; then + . "${my_path}/${venv}/bin/activate" + break + fi + done +fi + +exec "$@" From 9b6d44ec7d8616854431443f8cd7eef6554312bd Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Tue, 31 Dec 2024 13:14:28 +0100 Subject: [PATCH 09/15] fix: update project typehints --- custom_components/linkytic/__init__.py | 20 +++--- custom_components/linkytic/binary_sensor.py | 3 +- custom_components/linkytic/config_flow.py | 18 ++--- custom_components/linkytic/sensor.py | 16 ++--- custom_components/linkytic/serial_reader.py | 76 ++++++++++++--------- 5 files changed, 71 insertions(+), 62 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index 62258eb..d531367 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -35,19 +35,19 @@ async def async_setup_entry( ) -> bool: """Set up linkytic from a config entry.""" # Create the serial reader thread and start it - port = entry.data.get(SETUP_SERIAL) + port = entry.data[SETUP_SERIAL] try: serial_reader = LinkyTICReader( title=entry.title, port=port, - std_mode=entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD, - producer_mode=entry.data.get(SETUP_PRODUCER), - three_phase=entry.data.get(SETUP_THREEPHASE), + std_mode=entry.data[SETUP_TICMODE] == TICMODE_STANDARD, + producer_mode=entry.data[SETUP_PRODUCER], + three_phase=entry.data[SETUP_THREEPHASE], real_time=entry.options.get(OPTIONS_REALTIME), ) serial_reader.start() - async def read_serial_number(serial: LinkyTICReader): + async def read_serial_number(serial: LinkyTICReader) -> str: while serial.serial_number is None: await asyncio.sleep(1) # Check for any serial error that occurred in the serial thread context @@ -100,14 +100,14 @@ async def async_unload_entry( return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" reader = entry.runtime_data reader.update_options(entry.options.get(OPTIONS_REALTIME)) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" _LOGGER.info("Migrating from version %d.%d", entry.version, entry.minor_version) @@ -138,7 +138,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry): ) reader.start() - async def read_serial_number(serial: LinkyTICReader): + async def read_serial_number(serial: LinkyTICReader) -> str: while serial.serial_number is None: await asyncio.sleep(1) # Check for any serial error that occurred in the serial thread context @@ -165,7 +165,7 @@ async def read_serial_number(serial: LinkyTICReader): hass.config_entries.async_update_entry( entry, data=new, version=2, minor_version=0, unique_id=serial_number - ) # type: ignore + ) _LOGGER.info( "Migration to version %d.%d successful", @@ -177,7 +177,7 @@ async def read_serial_number(serial: LinkyTICReader): async def _migrate_entities_unique_id( hass: HomeAssistant, entry: ConfigEntry, serial_number: str -): +) -> None: """Migrate entities unique id to conform to HA specifications.""" # Old entries are of format f"{DOMAIN}_{entry.config_id}_suffix" diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 9ccc055..630f910 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -13,7 +13,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -169,6 +169,7 @@ def is_on(self) -> bool: """Value of the sensor.""" return self._binary_state ^ self._inverted + @callback def update(self) -> None: """Update the state of the sensor.""" value, _ = self._update() diff --git a/custom_components/linkytic/config_flow.py b/custom_components/linkytic/config_flow.py index c739ba2..b9f0887 100644 --- a/custom_components/linkytic/config_flow.py +++ b/custom_components/linkytic/config_flow.py @@ -42,8 +42,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(SETUP_SERIAL, default=SETUP_SERIAL_DEFAULT): str, # type: ignore - vol.Required(SETUP_TICMODE, default=TICMODE_HISTORIC): selector.SelectSelector( # type: ignore + vol.Required(SETUP_SERIAL, default=SETUP_SERIAL_DEFAULT): str, + vol.Required(SETUP_TICMODE, default=TICMODE_HISTORIC): selector.SelectSelector( selector.SelectSelectorConfig( options=[ selector.SelectOptionDict( @@ -55,13 +55,13 @@ ] ), ), - vol.Required(SETUP_PRODUCER, default=SETUP_PRODUCER_DEFAULT): bool, # type: ignore - vol.Required(SETUP_THREEPHASE, default=SETUP_THREEPHASE_DEFAULT): bool, # type: ignore + vol.Required(SETUP_PRODUCER, default=SETUP_PRODUCER_DEFAULT): bool, + vol.Required(SETUP_THREEPHASE, default=SETUP_THREEPHASE_DEFAULT): bool, } ) -class LinkyTICConfigFlow(ConfigFlow, domain=DOMAIN): # type:ignore +class LinkyTICConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for linkytic.""" VERSION = 2 @@ -91,13 +91,13 @@ async def async_step_user( title="Probe", port=_port, std_mode=user_input.get(SETUP_TICMODE) == TICMODE_STANDARD, - producer_mode=user_input.get(SETUP_PRODUCER), - three_phase=user_input.get(SETUP_THREEPHASE), + producer_mode=user_input[SETUP_PRODUCER], + three_phase=user_input[SETUP_THREEPHASE], real_time=False, ) serial_reader.start() - async def read_serial_number(serial: LinkyTICReader): + async def read_serial_number(serial: LinkyTICReader) -> str: while serial.serial_number is None: await asyncio.sleep(1) # Check for any serial error that occurred in the serial thread context @@ -167,7 +167,7 @@ async def async_step_init( { vol.Required( OPTIONS_REALTIME, - default=self.config_entry.options.get(OPTIONS_REALTIME), # type: ignore + default=self.config_entry.options.get(OPTIONS_REALTIME), ): bool } ), diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 1c3b90d..fb4a09c 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -43,7 +43,7 @@ REACTIVE_ENERGY = "VArh" -def _parse_timestamp(raw: str): +def _parse_timestamp(raw: str) -> str: """Parse the raw timestamp string into human readable form.""" return ( f"{raw[5:7]}/{raw[3:5]}/{raw[1:3]} " @@ -131,10 +131,10 @@ class StatusRegisterSensorConfig(LinkyTicSensorConfig): REGISTRY: dict[type[LinkyTicSensorConfig], type[LinkyTICSensor]] = {} -def match(*configs: type[LinkyTicSensorConfig]): +def match(*configs: type[LinkyTicSensorConfig]) -> Callable: """Associate one or more sensor config to a sensor class.""" - def wrap(cls): + def wrap(cls: type) -> type: for config in configs: REGISTRY[config] = cls return cls @@ -644,7 +644,7 @@ def __init__( self._attr_unique_id = slugify(f"{reader.serial_number}_{description.key}") @property - def native_value(self) -> T | None: # type:ignore + def native_value(self) -> T | None: # type:ignore[override] """Value of the sensor.""" return self._last_value @@ -732,7 +732,7 @@ def extra_state_attributes(self) -> dict[str, str]: return self._extra @callback - def update(self): + def update(self) -> None: """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller value, _ = self._update() @@ -759,7 +759,7 @@ class LinkyTICStringSensor(LinkyTICSensor[str]): _attr_entity_category = EntityCategory.DIAGNOSTIC @callback - def update(self): + def update(self) -> None: """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller value, _ = self._update() @@ -790,7 +790,7 @@ class RegularIntSensor(LinkyTICSensor[int]): """Common class for int sensors.""" @callback - def update(self): + def update(self) -> None: """Update the value of the sensor from the thread object memory cache.""" value, _ = self._update() if not value: @@ -860,7 +860,7 @@ def __init__( ) @callback - def update(self): + def update(self) -> None: """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller value, _ = self._update() diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index 1ee415e..97bab7d 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -6,10 +6,11 @@ import threading import time from collections.abc import Callable +from typing import cast import serial import serial.serialutil -from homeassistant.core import callback +from homeassistant.core import Event, callback from .const import ( BYTESIZE, @@ -43,10 +44,10 @@ class LinkyTICReader(threading.Thread): def __init__( self, title: str, - port, - std_mode, - producer_mode, - three_phase, + port: str, + std_mode: bool, + producer_mode: bool, + three_phase: bool, real_time: bool | None = False, ) -> None: """Init the LinkyTIC thread serial reader.""" # Thread @@ -83,7 +84,7 @@ def __init__( self._serial_number = None super().__init__(name=f"LinkyTIC for {title}") - def get_values(self, tag) -> tuple[str | None, str | None]: + def get_values(self, tag: str) -> tuple[str | None, str | None]: """Get tag value and timestamp from the thread memory cache.""" if not self.is_connected: return None, None @@ -103,7 +104,7 @@ def is_connected(self) -> bool: """Use to know if the reader is actually connected to a serial connection.""" if self._reader is None: return False - return self._reader.is_open + return cast(bool, self._reader.is_open) @property def serial_number(self) -> str | None: @@ -120,7 +121,7 @@ def setup_error(self) -> BaseException | None: """If the reader thread terminates due to a serial exception, this property will contain the raised exception.""" return self._setup_error - def run(self): + def run(self) -> None: """Continuously read the the serial connection and extract TIC values.""" if not self._open_serial(): @@ -205,13 +206,15 @@ def run(self): if self._reader: self._reader.close() - def register_push_notif(self, tag: str, notif_callback: Callable[[bool], None]): + def register_push_notif( + self, tag: str, notif_callback: Callable[[bool], None] + ) -> None: """Call to register a callback notification when a certain tag is parsed.""" _LOGGER.debug("Registering a callback for %s tag", tag) self._notif_callbacks[tag] = notif_callback @callback - def signalstop(self, event): + def signalstop(self, event: Event | str) -> None: """Activate the stop flag in order to stop the thread from within.""" if self.is_alive(): _LOGGER.info( @@ -219,12 +222,12 @@ def signalstop(self, event): ) self._stopsignal = True - def update_options(self, real_time: bool): + def update_options(self, real_time: bool) -> None: """Setter to update serial reader options.""" _LOGGER.debug("%s: new real time option value: %s", self._title, real_time) self._realtime = real_time - def _cleanup_cache(self): + def _cleanup_cache(self) -> None: """Call to cleanup the data cache to allow some sensors to get back to undefined/unavailable if they are not present in the last frame.""" for cached_tag in list(self._values.keys()): # pylint: disable=consider-using-dict-items,consider-iterating-dictionary if cached_tag not in self._tags_seen: @@ -264,7 +267,7 @@ def _open_serial(self) -> bool: _LOGGER.info("Serial connection is now open at %s", self._port) return True - def _reset_state(self): + def _reset_state(self) -> None: """Reinitialize the controller (by nullifying it) and wait 5s for other methods to re start init after a pause.""" _LOGGER.debug("Resetting serial reader state and wait 10s") self._values = {} @@ -284,7 +287,7 @@ def _reset_state(self): DID_YEAR: None, } - def _parse_line(self, line) -> str | None: + def _parse_line(self, line: bytes) -> str | None: """Parse a line when a full line has been read from serial. It parses it as Linky TIC infos, validate its checksum and save internally the line infos.""" # there is a great chance that the first line is a partial line: skip it if self._first_line: @@ -352,17 +355,19 @@ def _parse_line(self, line) -> str | None: # transform and store the values payload: dict[str, str | None] = {"value": field_value.decode("ascii")} payload["timestamp"] = timestamp.decode("ascii") if timestamp else None - tag = tag.decode("ascii") - self._values[tag] = payload - _LOGGER.debug("read the following values: %s -> %s", tag, repr(payload)) + tag_str = tag.decode("ascii") + self._values[tag_str] = payload + _LOGGER.debug("read the following values: %s -> %s", tag_str, repr(payload)) # Parse ADS for device identification if necessary - if (self._std_mode and tag == "ADSC") or (not self._std_mode and tag == "ADCO"): + if (self._std_mode and tag_str == "ADSC") or ( + not self._std_mode and tag_str == "ADCO" + ): self.parse_ads(payload["value"]) - return tag + return tag_str def _validate_checksum( self, tag: bytes, timestamp: bytes | None, value: bytes, checksum: bytes - ): + ) -> None: # rebuild the frame if self._std_mode: sep = MODE_STANDARD_FIELD_SEPARATOR @@ -402,18 +407,18 @@ def _validate_checksum( ), # fake expected checksum to avoid type error on ord() ) from exc - def parse_ads(self, ads): + def parse_ads(self, ads: str | None) -> None: """Extract information contained in the ADS as EURIDIS.""" _LOGGER.debug( "%s: parsing ADS: %s", self._title, ads, ) - if len(ads) != 12: + if ads is None or len(ads) != 12: _LOGGER.error( "%s: ADS should be 12 char long, actually %d cannot parse: %s", self._title, - len(ads), + len(ads or ""), ads, ) return @@ -423,16 +428,21 @@ def parse_ads(self, ads): return # Save serial number - self._serial_number = ads + self._serial_number = ads # type: ignore[assignment] # mypy complains because we checked prior that self._serial_number is None # let's parse ADS as EURIDIS - device_identification = {DID_YEAR: ads[2:4], DID_REGNUMBER: ads[6:]} + device_identification: dict[str, str | None] = { + DID_YEAR: ads[2:4], + DID_REGNUMBER: ads[6:], + } + const_code = ads[0:2] + type_code = ads[4:6] + # # Parse constructor code - device_identification[DID_CONSTRUCTOR_CODE] = ads[0:2] + + device_identification[DID_CONSTRUCTOR_CODE] = const_code try: - device_identification[DID_CONSTRUCTOR] = CONSTRUCTORS_CODES[ - device_identification[DID_CONSTRUCTOR_CODE] - ] + device_identification[DID_CONSTRUCTOR] = CONSTRUCTORS_CODES[const_code] except KeyError: _LOGGER.warning( "%s: constructor code is unknown: %s", @@ -441,11 +451,9 @@ def parse_ads(self, ads): ) device_identification[DID_CONSTRUCTOR] = None # # Parse device type code - device_identification[DID_TYPE_CODE] = ads[4:6] + device_identification[DID_TYPE_CODE] = type_code try: - device_identification[DID_TYPE] = ( - f"{DEVICE_TYPES[device_identification[DID_TYPE_CODE]]}" - ) + device_identification[DID_TYPE] = f"{DEVICE_TYPES[type_code]}" except KeyError: _LOGGER.warning( "%s: ADS device type is unknown: %s", @@ -493,7 +501,7 @@ def __init__( self.expected = expected super().__init__(self.msg()) - def msg(self): + def msg(self) -> str: """Printable exception method.""" return "{} -> {} ({}) | s1 {} {} | truncated {} {} {} | computed {} {} {} | expected {} {} {}".format( self.tag, From 18dcc39465be7d3351d9d654eb9e2438e43103f4 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Tue, 31 Dec 2024 14:52:13 +0100 Subject: [PATCH 10/15] fix: fix entity suffix migration and update migration error log --- custom_components/linkytic/__init__.py | 8 +++++--- custom_components/linkytic/sensor.py | 2 +- custom_components/linkytic/status_register.py | 2 +- tests/test_status_register.py | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index d531367..29c4a68 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -150,9 +150,10 @@ async def read_serial_number(serial: LinkyTICReader) -> str: except (*LINKY_IO_ERRORS, TimeoutError) as e: _LOGGER.error( - "Could not migrate config entry to version 2, cannot read serial number", - exc_info=e, + "Error migrating config entry to version 2, could not read device serial number: (%s)", + e, ) + _LOGGER.warning("Restart Home Assistant to retry migration") return False finally: @@ -224,7 +225,8 @@ async def _migrate_entities_unique_id( if not old_unique_id.startswith(DOMAIN): continue - old_suffix = old_unique_id.split(entry.entry_id, maxsplit=1)[1] + # format `linkytic_ENTRYID_suffix + old_suffix = old_unique_id.split(entry.entry_id + "_", maxsplit=1)[1] if (new_suffix := _ENTITY_MIGRATION_SUFFIX.get(old_suffix)) is None: # entity is not in the migration table, just remove the domain prefix and update the entry_id diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index fb4a09c..a195764 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -451,7 +451,7 @@ def wrap(cls: type) -> type: ), StatusRegisterSensorConfig( translation_key="status_cpl", - status_field=StatusRegister.STATUS_CPL, + status_field=StatusRegister.CPL_STATUS, ), StatusRegisterSensorConfig( translation_key="status_tempo_color_today", diff --git a/custom_components/linkytic/status_register.py b/custom_components/linkytic/status_register.py index 4f3f3f5..165eb9b 100644 --- a/custom_components/linkytic/status_register.py +++ b/custom_components/linkytic/status_register.py @@ -88,7 +88,7 @@ class StatusRegister(Enum): TIC_STD = StatusRegisterField(17) # bit 18 is reserved EURIDIS = StatusRegisterField(19, 2, euridis_status) - STATUS_CPL = StatusRegisterField(21, 2, cpl_status) + CPL_STATUS = StatusRegisterField(21, 2, cpl_status) CPL_SYNC = StatusRegisterField(23) COLOR_TODAY = StatusRegisterField(24, 2, tempo_color) COLOR_NEXT_DAY = StatusRegisterField(26, 2, tempo_color) diff --git a/tests/test_status_register.py b/tests/test_status_register.py index 5989041..2ec84a1 100644 --- a/tests/test_status_register.py +++ b/tests/test_status_register.py @@ -27,7 +27,7 @@ def test_parse(): StatusRegister.RTC_DEGRADED: 0, StatusRegister.TIC_STD: 1, StatusRegister.EURIDIS: euridis_status[3], - StatusRegister.STATUS_CPL: cpl_status[1], + StatusRegister.CPL_STATUS: cpl_status[1], StatusRegister.CPL_SYNC: 0, StatusRegister.COLOR_TODAY: tempo_color[1], StatusRegister.COLOR_NEXT_DAY: tempo_color[0], @@ -53,7 +53,7 @@ def test_parse(): StatusRegister.RTC_DEGRADED: 0, StatusRegister.TIC_STD: 1, StatusRegister.EURIDIS: euridis_status[3], - StatusRegister.STATUS_CPL: cpl_status[1], + StatusRegister.CPL_STATUS: cpl_status[1], StatusRegister.CPL_SYNC: 0, StatusRegister.COLOR_TODAY: tempo_color[0], StatusRegister.COLOR_NEXT_DAY: tempo_color[0], @@ -79,7 +79,7 @@ def test_parse(): StatusRegister.RTC_DEGRADED: 1, StatusRegister.TIC_STD: 1, StatusRegister.EURIDIS: euridis_status[3], - StatusRegister.STATUS_CPL: cpl_status[2], + StatusRegister.CPL_STATUS: cpl_status[2], StatusRegister.CPL_SYNC: 1, StatusRegister.COLOR_TODAY: tempo_color[3], StatusRegister.COLOR_NEXT_DAY: tempo_color[3], From 1bb33389f839f80372c0dee045fd41412271ebac Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Tue, 31 Dec 2024 15:40:54 +0100 Subject: [PATCH 11/15] fix: fix date sensor and historic sensor creation --- custom_components/linkytic/sensor.py | 98 ++++++++++++++++------------ 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index a195764..ebd9475 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -2,10 +2,11 @@ from __future__ import annotations +import contextlib import logging -from collections.abc import Callable +from collections.abc import Callable, Generator from dataclasses import dataclass -from typing import Generic, Optional, TypeVar, cast +from typing import Generic, TypeVar, cast from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass @@ -43,15 +44,6 @@ REACTIVE_ENERGY = "VArh" -def _parse_timestamp(raw: str) -> str: - """Parse the raw timestamp string into human readable form.""" - return ( - f"{raw[5:7]}/{raw[3:5]}/{raw[1:3]} " - f"{raw[7:9]}:{raw[9:11]} " - f"({'Eté' if raw[0] == 'E' else 'Hiver'})" - ) - - @dataclass(frozen=True, kw_only=True) class LinkyTicSensorConfig(SensorEntityDescription): """Sensor configuration dataclass.""" @@ -71,6 +63,12 @@ class SerialNumberSensorConfig(LinkyTicSensorConfig): entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC +# FIXME: Do we really need a date sensor ? +@dataclass(frozen=True, kw_only=True) +class DateSensorConfig(LinkyTicSensorConfig): + """Config for datetime sensor.""" + + @dataclass(frozen=True, kw_only=True) class ApparentPowerSensorConfig(LinkyTicSensorConfig): """Configuration for apparent power sensors.""" @@ -286,10 +284,9 @@ def wrap(cls: type) -> type: key="VTIC", translation_key="tic_version", ), - LinkyTicSensorConfig( + DateSensorConfig( key="DATE", translation_key="datetime", - conversion=_parse_timestamp, ), # Useful in any way? LinkyTicSensorConfig( key="NGTF", @@ -588,31 +585,36 @@ async def async_setup_entry( is_threephase = bool(config_entry.data.get(SETUP_THREEPHASE)) is_producer = bool(config_entry.data.get(SETUP_PRODUCER)) - if is_standard: - # Standard mode - sensor_desc = [SENSORS_STANDARD_COMMON] + def sensor_to_create() -> Generator[LinkyTicSensorConfig]: + if is_standard: + # Standard mode + for desc in SENSORS_STANDARD_COMMON: + yield desc - if is_threephase: - sensor_desc.append(SENSORS_STANDARD_THREEPHASE) + if is_threephase: + for desc in SENSORS_STANDARD_THREEPHASE: + yield desc - if is_producer: - sensor_desc.append(SENSORS_STANDARD_PRODUCER) + if is_producer: + for desc in SENSORS_STANDARD_PRODUCER: + yield desc - else: - # Historic mode - sensor_desc = [SENSORS_HISTORIC_COMMON] + else: + # Historic mode + for desc in SENSORS_HISTORIC_COMMON: + yield desc - sensor_desc.append( - SENSORS_HISTORIC_SINGLEPHASE - if is_threephase - else SENSORS_HISTORIC_SINGLEPHASE - ) + for desc in ( + SENSORS_HISTORIC_TREEPHASE + if is_threephase + else SENSORS_HISTORIC_SINGLEPHASE + ): + yield desc async_add_entities( ( - REGISTRY[type(config)](config, config_entry, reader) - for descriptor in sensor_desc - for config in descriptor + REGISTRY[type(descriptor)](descriptor, reader) + for descriptor in sensor_to_create() ), update_before_add=True, ) @@ -631,7 +633,6 @@ class LinkyTICSensor(LinkyTICEntity, SensorEntity, Generic[T]): def __init__( self, description: LinkyTicSensorConfig, - config_entry: ConfigEntry, reader: LinkyTICReader, ) -> None: """Init sensor entity.""" @@ -648,7 +649,7 @@ def native_value(self) -> T | None: # type:ignore[override] """Value of the sensor.""" return self._last_value - def _update(self) -> tuple[Optional[str], Optional[str]]: + def _update(self) -> tuple[str | None, str | None]: """Get value and/or timestamp from cached data. Responsible for updating sensor availability.""" value, timestamp = self._serial_controller.get_values(self._tag) _LOGGER.debug( @@ -716,11 +717,10 @@ class ADSSensor(LinkyTICSensor[str]): def __init__( self, description: SerialNumberSensorConfig, - config_entry: ConfigEntry, reader: LinkyTICReader, ) -> None: """Initialize an ADCO/ADSC Sensor.""" - super().__init__(description, config_entry, reader) + super().__init__(description, reader) # Overwrite tag-based unique id for compatibility between tic versions self._attr_unique_id = slugify(f"{reader.serial_number}_adco") @@ -778,6 +778,27 @@ def update(self) -> None: self._last_value = " ".join(value.split()) +# FIXME: Do we really need a date sensor ? +@match(DateSensorConfig) +class DateSensor(LinkyTICStringSensor): + """Linky internal date sensor.""" + + @callback + def update(self) -> None: + """Update the value of the sensor from the thread object memory cache.""" + _, timestamp = self._update() + if not timestamp: + return + raw = timestamp + + with contextlib.suppress(IndexError): + self._last_value = ( + f"{raw[5:7]}/{raw[3:5]}/{raw[1:3]} " + f"{raw[7:9]}:{raw[9:11]} " + f"({'Eté' if raw[0] == 'E' else 'Hiver'})" + ) + + @match( ApparentPowerSensorConfig, ActiveEnergySensorConfig, @@ -843,11 +864,10 @@ class LinkyTICStatusRegisterSensor(LinkyTICStringSensor): def __init__( self, description: StatusRegisterSensorConfig, - config_entry: ConfigEntry, reader: LinkyTICReader, ) -> None: """Initialize a status register data sensor.""" - super().__init__(description, config_entry, reader) + super().__init__(description, reader) status_field = description.status_field self._field = status_field @@ -868,7 +888,5 @@ def update(self) -> None: if not value: return - try: + with contextlib.suppress(IndexError): self._last_value = cast(str, self._field.value.get_status(value)) - except IndexError: - pass # Failsafe, value is unchanged. From 7eb2dcfbbcd2fc9cb706a8ec6c331c3507b0c8a4 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Wed, 1 Jan 2025 18:09:28 +0100 Subject: [PATCH 12/15] fix: add opening binary sensor translation --- custom_components/linkytic/translations/fr.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/custom_components/linkytic/translations/fr.json b/custom_components/linkytic/translations/fr.json index f76f6e5..eea5cb7 100644 --- a/custom_components/linkytic/translations/fr.json +++ b/custom_components/linkytic/translations/fr.json @@ -437,10 +437,18 @@ }, "binary_sensor": { "status_dry_contact": { - "name": "Contact sec" + "name": "Contact sec", + "state": { + "on": "Ouvert", + "off": "Fermé" + } }, "status_terminal_cover": { - "name": "Cache borne distributeur" + "name": "Cache borne distributeur", + "state": { + "on": "Ouvert", + "off": "Fermé" + } }, "status_overvoltage": { "name": "Surtension", From 2bf2cf05698f4db509029a67ebb2464385e90485 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Wed, 1 Jan 2025 19:01:20 +0100 Subject: [PATCH 13/15] docs: update readme --- README.md | 26 +++++++++++++------------- custom_components/linkytic/sensor.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e123853..7ed9e84 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ Cependant, certaines sondes peuvent avoir de la valeur dans leur "instantanéit Suivant la configuration que vous choisirez pour votre installation vous trouverez dans ce fichier dans la liste des sondes avec les annotations suivantes: -- 1 sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série) -- 2 sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère +- ``1`` sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série) +- ``2`` sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère ### Mode historique @@ -62,10 +62,10 @@ Les 23 champs des compteurs mono-phasé configurés en mode historique sont supp - `PEJP` Préavis Début EJP (30 min) - `PTEC` Période Tarifaire en cours - `DEMAIN` Couleur du lendemain -- `IINST` Intensité Instantanée 1 -- `ADPS` Avertissement de Dépassement De Puissance Souscrite 2 +- `IINST` Intensité Instantanée ``1`` +- `ADPS` Avertissement de Dépassement De Puissance Souscrite ``2`` - `IMAX` Intensité maximale appelée -- `PAPP` Puissance apparente 1 +- `PAPP` Puissance apparente ``1`` - `HHPHC` Horaire Heures Pleines Heures Creuses - `MOTDETAT` Mot d'état du compteur @@ -92,23 +92,23 @@ Des retours de log en `DEBUG` pendant l'émission de trames courtes sont nécess - `PEJP` Préavis Début EJP (30 min) - `PTEC` Période Tarifaire en cours - `DEMAIN` Couleur du lendemain -- `IINST1` Intensité Instantanée (phase 1) 1 pour les trames longues 2 pour les trames courtes -- `IINST2` Intensité Instantanée (phase 2) 1 pour les trames longues 2 pour les trames courtes -- `IINST3` Intensité Instantanée (phase 3) 1 pour les trames longues 2 pour les trames courtes +- `IINST1` Intensité Instantanée (phase 1) ``1`` pour les trames longues ``2`` pour les trames courtes +- `IINST2` Intensité Instantanée (phase 2) ``1`` pour les trames longues ``2`` pour les trames courtes +- `IINST3` Intensité Instantanée (phase 3) ``1`` pour les trames longues ``2`` pour les trames courtes - `IMAX1` Intensité maximale (phase 1) - `IMAX2` Intensité maximale (phase 2) - `IMAX3` Intensité maximale (phase 3) - `PMAX` Puissance maximale triphasée atteinte -- `PAPP` Puissance apparente 1 +- `PAPP` Puissance apparente ``1`` - `HHPHC` Horaire Heures Pleines Heures Creuses - `MOTDETAT` Mot d'état du compteur -- `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) 2 trames courtes uniquement -- `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) 2 trames courtes uniquement -- `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) 2 trames courtes uniquement +- `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) ``2`` trames courtes uniquement +- `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) ``2`` trames courtes uniquement +- `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) ``2`` trames courtes uniquement ### Mode standard -Une beta est actuellement en cours pour la future v3 supportant le mode standard, vous la trouverez dans les [releases](https://github.com/hekmon/linkytic/releases). N'hésitez pas à faire vos retours dans [#19](https://github.com/hekmon/linkytic/pull/19) afin d'accélére la sortie de beta du mode standard ! +Une beta est actuellement en cours pour la future v3 supportant le mode standard, vous la trouverez dans les [releases](https://github.com/hekmon/linkytic/releases). N'hésitez pas à faire vos retours dans [#19](https://github.com/hekmon/linkytic/pull/19) afin d'accélére la sortie de beta du mode standard ! Si vous rencontrez un bug, vous pouvez aussi ouvrir une [issue](https://github.com/hekmon/linkytic/issues). ## Installation diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index ebd9475..672aa0a 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -49,7 +49,7 @@ class LinkyTicSensorConfig(SensorEntityDescription): """Sensor configuration dataclass.""" fallback_tags: tuple[str, ...] | None = ( - None # Multiple tags are allowed for non-standard linky tags support, see hekmon/linky#42 + None # Multiple tags are allowed for non-standard linky tags support, see hekmon/linkytic#42 ) register_callback: bool = False conversion: Callable | None = None From aac6fda145b23d3577fe180f69960ec7c4d13533 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Wed, 1 Jan 2025 19:30:11 +0100 Subject: [PATCH 14/15] fix: fix translation trailing whitespace --- custom_components/linkytic/translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/linkytic/translations/en.json b/custom_components/linkytic/translations/en.json index 8d01eff..b9aa204 100644 --- a/custom_components/linkytic/translations/en.json +++ b/custom_components/linkytic/translations/en.json @@ -341,7 +341,7 @@ "status_mobile_peak_notice": { "name": "Mobile peak notice", "state": { - "no_data": "No mobile peak notice ", + "no_data": "No mobile peak notice", "pm1": "PM1 notice", "pm2": "PM2 notice", "pm3": "PM3 notice" @@ -350,7 +350,7 @@ "status_mobile_peak": { "name": "Mobile peak", "state": { - "no_data": "No mobile peak ongoing ", + "no_data": "No mobile peak ongoing", "pm1": "PM1 ongoing", "pm2": "PM2 ongoing", "pm3": "PM3 ongoing" From 39dbb9219d532a4c683c6c67314628952630a381 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sat, 4 Jan 2025 00:45:39 +0100 Subject: [PATCH 15/15] fix: fix HPM index tag --- custom_components/linkytic/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 672aa0a..b3eb919 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -169,7 +169,7 @@ def wrap(cls: type) -> type: translation_key="index_ejp_normal", ), ActiveEnergySensorConfig( - key="EJPJPM", + key="EJPHPM", translation_key="index_ejp_peak", ), ActiveEnergySensorConfig(