From 7cf9e7ecb9288a4f9fbaab197ff843b4fb7daa13 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 5 Feb 2024 23:37:17 +0100 Subject: [PATCH 01/18] fix: change config saved port to its persistant by-id and enable connections in device info --- custom_components/linkytic/__init__.py | 26 ++++++++++++++ custom_components/linkytic/binary_sensor.py | 2 +- custom_components/linkytic/config_flow.py | 40 +++++++++------------ custom_components/linkytic/sensor.py | 8 ++--- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index 5616d82..d08d952 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -1,4 +1,5 @@ """The linkytic integration.""" + from __future__ import annotations import logging @@ -6,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant +from homeassistant.components import usb from .const import ( DOMAIN, @@ -71,3 +73,27 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry): return # Update its options serial_reader.update_options(entry.options.get(OPTIONS_REALTIME)) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.info("Migrating from version %d.%d", config_entry.version, config_entry.minor_version) + + if config_entry.version == 1: + new = {**config_entry.data} + + if config_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]) + if serial_by_id == new[SETUP_SERIAL]: + _LOGGER.warning( + f"Couldn't migrate from version {config_entry.version}.{config_entry.minor_version}: /dev/serial/by-id not found." + ) + return False + new[SETUP_SERIAL] = serial_by_id + + config_entry.minor_version = 2 + hass.config_entries.async_update_entry(config_entry, data=new) + + _LOGGER.info("Migration to version %d.%d successful", config_entry.version, config_entry.minor_version) + return True diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 79b9525..153525e 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -96,7 +96,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - # connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER])}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], diff --git a/custom_components/linkytic/config_flow.py b/custom_components/linkytic/config_flow.py index 8e57d6c..84a6a62 100644 --- a/custom_components/linkytic/config_flow.py +++ b/custom_components/linkytic/config_flow.py @@ -1,4 +1,5 @@ """Config flow for linkytic integration.""" + from __future__ import annotations # import dataclasses @@ -13,6 +14,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector +from homeassistant.components import usb from .const import ( DOMAIN, @@ -39,12 +41,8 @@ vol.Required(SETUP_TICMODE, default=TICMODE_HISTORIC): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - selector.SelectOptionDict( - value=TICMODE_HISTORIC, label=TICMODE_HISTORIC_LABEL - ), - selector.SelectOptionDict( - value=TICMODE_STANDARD, label=TICMODE_STANDARD_LABEL - ), + selector.SelectOptionDict(value=TICMODE_HISTORIC, label=TICMODE_HISTORIC_LABEL), + selector.SelectOptionDict(value=TICMODE_STANDARD, label=TICMODE_STANDARD_LABEL), ] ), ), @@ -58,43 +56,41 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for linkytic.""" VERSION = 1 + MINOR_VERSION = 2 - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" # No input if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + 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]) + await self.async_set_unique_id(DOMAIN + "_" + user_input[SETUP_SERIAL]) # TODO: switch to meter s/n 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]) + errors = {} title = user_input[SETUP_SERIAL] try: linky_tic_tester( - device=user_input[SETUP_SERIAL], + device=_port, std_mode=user_input[SETUP_TICMODE] == TICMODE_STANDARD, ) except CannotConnect as cannot_connect: _LOGGER.error("%s: can not connect: %s", title, 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 - ) + _LOGGER.error("%s: can not read a line after connection: %s", title, cannot_read) 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) - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) + return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors) # async def async_step_usb(self, discovery_info: UsbServiceInfo) -> FlowResult: # """Handle a flow initialized by USB discovery.""" @@ -116,9 +112,7 @@ def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 844dbd2..b5214a9 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -1344,7 +1344,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - # connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], @@ -1461,7 +1461,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - # connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], @@ -1572,7 +1572,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - # connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], @@ -1708,7 +1708,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - # connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], From dcff0228f4161a6f220a43f568b8d4722b93f76d Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 11 Feb 2024 19:18:36 +0100 Subject: [PATCH 02/18] feat: permit migration with no persistent by-id name, for installations that do not have by-id mechanism --- custom_components/linkytic/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index d08d952..bbfbaa9 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -87,10 +87,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): serial_by_id = await hass.async_add_executor_job(usb.get_serial_by_id, new[SETUP_SERIAL]) if serial_by_id == new[SETUP_SERIAL]: _LOGGER.warning( - f"Couldn't migrate from version {config_entry.version}.{config_entry.minor_version}: /dev/serial/by-id not found." + f"Couldn't find a persistent /dev/serial/by-id alias for {serial_by_id}." + "Problems might occur at startup if device names are not persistent." ) - return False - new[SETUP_SERIAL] = serial_by_id + else: + new[SETUP_SERIAL] = serial_by_id config_entry.minor_version = 2 hass.config_entries.async_update_entry(config_entry, data=new) From 3f58a7a27e410e9f4925cdf985d933e461fa2cf5 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 11 Feb 2024 20:10:07 +0100 Subject: [PATCH 03/18] feat: verify connection at setup to fix empty duplicate devices fixes hekmon/linkytic#27 --- custom_components/linkytic/__init__.py | 48 ++++- custom_components/linkytic/const.py | 5 +- custom_components/linkytic/serial_reader.py | 226 +++++++++----------- 3 files changed, 149 insertions(+), 130 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index bbfbaa9..441f5cd 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -1,10 +1,12 @@ """The linkytic integration.""" from __future__ import annotations +import asyncio import logging +import termios -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.components import usb @@ -17,6 +19,7 @@ SETUP_TICMODE, SETUP_PRODUCER, TICMODE_STANDARD, + LINKY_IO_ERRORS, ) from .serial_reader import LinkyTICReader @@ -28,15 +31,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up linkytic from a config entry.""" # Create the serial reader thread and start it - serial_reader = LinkyTICReader( - title=entry.title, - port=entry.data.get(SETUP_SERIAL), - std_mode=entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD, - producer_mode=entry.data.get(SETUP_PRODUCER), - three_phase=entry.data.get(SETUP_THREEPHASE), - real_time=entry.options.get(OPTIONS_REALTIME), - ) - serial_reader.start() + port = entry.data.get(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), + real_time=entry.options.get(OPTIONS_REALTIME), + ) + serial_reader.start() + + async def read_serial_number(serial: LinkyTICReader): + while serial.serial_number is None: + await asyncio.sleep(1) + return serial.serial_number + + s_n = await asyncio.wait_for(read_serial_number(serial_reader), timeout=5) + # TODO: check if S/N is the one saved in config entry, if not this is a different meter! + + # Error when opening serial port. + except LINKY_IO_ERRORS as e: + raise ConfigEntryNotReady(f"Couldn't open serial port {port}: {e}") from e + + # Timeout waiting for S/N to be read. + except TimeoutError as e: + serial_reader.signalstop("linkytic_timeout") + del serial_reader + raise ConfigEntryNotReady( + f"Connected to serial port but coulnd't read serial number before timeout: check if TIC is connected and active." + ) + + _LOGGER.info(f"Device connected with serial number: {s_n}") + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, serial_reader.signalstop) # Add options callback entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/custom_components/linkytic/const.py b/custom_components/linkytic/const.py index 7184126..47b08a3 100644 --- a/custom_components/linkytic/const.py +++ b/custom_components/linkytic/const.py @@ -1,9 +1,12 @@ """Constants for the linkytic integration.""" -import serial +from termios import error +from serial import SerialException DOMAIN = "linkytic" +# Some termios exceptions are uncaught by pyserial +LINKY_IO_ERRORS = (SerialException, error) # Config Flow diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index 4ffacbd..883980e 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -1,4 +1,5 @@ """The linkytic integration serial reader.""" + from __future__ import annotations from collections.abc import Callable @@ -23,6 +24,7 @@ DID_YEAR, FRAME_END, LINE_END, + LINKY_IO_ERRORS, MODE_HISTORIC_BAUD_RATE, MODE_HISTORIC_FIELD_SEPARATOR, MODE_STANDARD_BAUD_RATE, @@ -39,10 +41,8 @@ class LinkyTICReader(threading.Thread): """Implements the reading of a serial Linky TIC.""" - def __init__( - self, title: str, port, std_mode, producer_mode, three_phase, real_time: bool | None = False - ) -> None: - """Init the LinkyTIC thread serial reader.""" # Thread + def __init__(self, title: str, port, std_mode, producer_mode, three_phase, real_time: bool | None = False) -> None: + """Init the LinkyTIC thread serial reader.""" # Thread self._stopsignal = False self._title = title # Options @@ -51,13 +51,9 @@ def __init__( self._realtime = real_time # Build self._port = port - self._baudrate = ( - MODE_STANDARD_BAUD_RATE if std_mode else MODE_HISTORIC_BAUD_RATE - ) + self._baudrate = MODE_STANDARD_BAUD_RATE if std_mode else MODE_HISTORIC_BAUD_RATE self._std_mode = std_mode - self._producer_mode = ( - producer_mode if std_mode else False - ) + self._producer_mode = producer_mode if std_mode else False self._three_phase = three_phase # Run self._reader: serial.Serial | None = None @@ -74,8 +70,12 @@ def __init__( } # will be set by the ADCO/ADSC tag self._notif_callbacks: dict[str, Callable[[bool], None]] = {} # Init parent thread class + self._serial_number = None super().__init__(name=f"LinkyTIC for {title}") + # Open port: failure will be reported to async_setup_entry + self._open_serial() + def get_values(self, tag) -> tuple[str | None, str | None]: """Get tag value and timestamp from the thread memory cache.""" if not self.is_connected(): @@ -96,74 +96,78 @@ def is_connected(self) -> bool: return False return self._reader.is_open + @property + def serial_number(self) -> str | None: + """Returns meter serial number (ADS or ADSO tag).""" + return self._serial_number + def run(self): """Continuously read the the serial connection and extract TIC values.""" while not self._stopsignal: + # Reader should have been opened. + assert self._reader is not None try: - # Try to open a connection - if self._reader is None: - self._open_serial() - continue - # Now that we have a connection, read its output + line = self._reader.readline() + except LINKY_IO_ERRORS as exc: + _LOGGER.exception( + "Error while reading serial device %s: %s. Will retry in 5s", + self._port, + exc, + ) + self._reset_state() + self._reader.close() + time.sleep(5) # Cooldown to prevent spamming logs with errors. + # TODO: implement a maximum retry, and go in failure mode if the connection can't be renewed. try: - line = self._reader.readline() - except serial.SerialException as exc: - _LOGGER.exception( - "Error while reading serial device %s: %s. Will retry in 5s", - self._port, - exc, - ) - self._reset_state() + self._reader.open() + except LINKY_IO_ERRORS: + _LOGGER.debug("Could not reopen port") + finally: continue - # Parse the line - tag = self._parse_line(line) + # Parse the line if non empty (prevent errors from read timeout that returns empty byte string) + if not line: + continue + tag = self._parse_line(line) + if tag is not None: + # Mark this tag as seen for end of frame cache cleanup + self._tags_seen.append(tag) + # Handle short burst for tri-phase historic mode + if ( + not self._std_mode + and self._three_phase + and not self._within_short_frame + and tag in SHORT_FRAME_DETECTION_TAGS + ): + _LOGGER.warning( + "Short trame burst detected (%s): switching to forced update mode", + tag, + ) + self._within_short_frame = True + # If we have a notification callback for this tag, call it + try: + notif_callback = self._notif_callbacks[tag] + _LOGGER.debug("We have a notification callback for %s: executing", tag) + forced_update = self._realtime + # Special case for forced_update: historic tree-phase short frame + if self._within_short_frame and tag in SHORT_FRAME_FORCED_UPDATE_TAGS: + forced_update = True + # Special case for forced_update: historic single-phase ADPS + if tag == "ADPS": + forced_update = True + notif_callback(forced_update) + except KeyError: + pass + # Handle frame end + if FRAME_END in line: + if self._within_short_frame: + # burst / short frame (exceptional) + self._within_short_frame = False + else: + # regular long frame + self._frames_read += 1 + self._cleanup_cache() if tag is not None: - # Mark this tag as seen for end of frame cache cleanup - self._tags_seen.append(tag) - # Handle short burst for tri-phase historic mode - if ( - not self._std_mode - and self._three_phase - and not self._within_short_frame - and tag in SHORT_FRAME_DETECTION_TAGS - ): - _LOGGER.warning( - "Short trame burst detected (%s): switching to forced update mode", - tag, - ) - self._within_short_frame = True - # If we have a notification callback for this tag, call it - try: - notif_callback = self._notif_callbacks[tag] - _LOGGER.debug( - "We have a notification callback for %s: executing", tag - ) - forced_update = self._realtime - # Special case for forced_update: historic tree-phase short frame - if ( - self._within_short_frame - and tag in SHORT_FRAME_FORCED_UPDATE_TAGS - ): - forced_update = True - # Special case for forced_update: historic single-phase ADPS - if tag == "ADPS": - forced_update = True - notif_callback(forced_update) - except KeyError: - pass - # Handle frame end - if FRAME_END in line: - if self._within_short_frame: - # burst / short frame (exceptional) - self._within_short_frame = False - else: - # regular long frame - self._frames_read += 1 - self._cleanup_cache() - if tag is not None: - _LOGGER.debug("End of frame, last tag read: %s", tag) - except Exception as e: - _LOGGER.exception("encountered an unexpected exception on the serial thread, catching it to avoid thread crash: %s", e) + _LOGGER.debug("End of frame, last tag read: %s", tag) # Stop flag as been activated _LOGGER.info("Thread stop: closing the serial connection") if self._reader: @@ -178,9 +182,7 @@ def register_push_notif(self, tag: str, notif_callback: Callable[[bool], None]): def signalstop(self, event): """Activate the stop flag in order to stop the thread from within.""" if self.is_alive(): - _LOGGER.info( - "Stopping %s serial thread reader (received %s)", self._title, event - ) + _LOGGER.info("Stopping %s serial thread reader (received %s)", self._title, event) self._stopsignal = True def update_options(self, real_time: bool): @@ -190,9 +192,7 @@ def update_options(self, real_time: bool): def _cleanup_cache(self): """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 + 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: _LOGGER.debug( "tag %s was present in cache but has not been seen in previous frame: removing from cache", @@ -210,29 +210,22 @@ def _cleanup_cache(self): def _open_serial(self): """Create (and open) the serial connection.""" - try: - self._reader = serial.serial_for_url( - url=self._port, - baudrate=self._baudrate, - bytesize=BYTESIZE, - parity=PARITY, - stopbits=STOPBITS, - timeout=1, - ) - _LOGGER.info("Serial connection is now open") - except serial.serialutil.SerialException as exc: - _LOGGER.error( - "Unable to connect to the serial device %s: %s", - self._port, - exc, - ) - self._reset_state() + self._reset_state() + self._reader = serial.serial_for_url( + url=self._port, + baudrate=self._baudrate, + bytesize=BYTESIZE, + parity=PARITY, + stopbits=STOPBITS, + timeout=1, + ) + _LOGGER.info("Serial connection is now open at %s", self._port) def _reset_state(self): """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._reader = None self._values = {} + self._serial_number = None # Inform sensor in push mode to come fetch data (will get None and switch to unavailable) for notif_callback in self._notif_callbacks.values(): notif_callback(self._realtime) @@ -247,7 +240,6 @@ def _reset_state(self): DID_TYPE_CODE: None, DID_YEAR: None, } - time.sleep(10) def _parse_line(self, line) -> 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.""" @@ -260,6 +252,8 @@ def _parse_line(self, line) -> str | None: _LOGGER.debug("line to parse: %s", repr(line)) # cleanup the line line = line.rstrip(LINE_END).rstrip(FRAME_END) + if not line: + return None # extract the fields by parsing the line given the mode timestamp = None if self._std_mode: @@ -323,9 +317,7 @@ def _parse_line(self, line) -> str | None: self.parse_ads(payload["value"]) return tag - def _validate_checksum( - self, tag: bytes, timestamp: bytes | None, value: bytes, checksum: bytes - ): + def _validate_checksum(self, tag: bytes, timestamp: bytes | None, value: bytes, checksum: bytes): # rebuild the frame if self._std_mode: sep = MODE_STANDARD_FIELD_SEPARATOR @@ -345,14 +337,10 @@ def _validate_checksum( # validate try: if computed_checksum != ord(checksum): - raise InvalidChecksum( - tag, timestamp, value, sum1, truncated, computed_checksum, checksum - ) + raise InvalidChecksum(tag, timestamp, value, sum1, truncated, computed_checksum, checksum) except TypeError as exc: # see https://github.com/hekmon/linkytic/issues/9 - _LOGGER.exception( - "Encountered an unexpected checksum (%s): %s", exc, checksum - ) + _LOGGER.exception("Encountered an unexpected checksum (%s): %s", exc, checksum) raise InvalidChecksum( tag, timestamp, @@ -360,9 +348,7 @@ def _validate_checksum( sum1, truncated, computed_checksum, - bytes( - "0", encoding="ascii" - ), # fake expected checksum to avoid type error on ord() + bytes("0", encoding="ascii"), # fake expected checksum to avoid type error on ord() ) from exc def parse_ads(self, ads): @@ -380,14 +366,20 @@ def parse_ads(self, ads): ads, ) return + + # Because S/N is a device identifier, only parse it once. + if self.serial_number: + return + + # Save serial number + self._serial_number = ads + # let's parse ADS as EURIDIS device_identification = {DID_YEAR: ads[2:4], DID_REGNUMBER: ads[6:]} # # Parse constructor code device_identification[DID_CONSTRUCTOR_CODE] = ads[0:2] try: - device_identification[DID_CONSTRUCTOR] = CONSTRUCTORS_CODES[ - device_identification[DID_CONSTRUCTOR_CODE] - ] + device_identification[DID_CONSTRUCTOR] = CONSTRUCTORS_CODES[device_identification[DID_CONSTRUCTOR_CODE]] except KeyError: _LOGGER.warning( "%s: constructor code is unknown: %s", @@ -400,9 +392,7 @@ def parse_ads(self, ads): try: device_identification[DID_TYPE] = f"{DEVICE_TYPES[device_identification[DID_TYPE_CODE]]}" except KeyError: - _LOGGER.warning( - "%s: ADS device type is unknown: %s", self._title, device_identification[DID_TYPE_CODE] - ) + _LOGGER.warning("%s: ADS device type is unknown: %s", self._title, device_identification[DID_TYPE_CODE]) device_identification[DID_TYPE] = None # # Update device infos self.device_identification = device_identification @@ -475,9 +465,7 @@ def linky_tic_tester(device: str, std_mode: bool) -> None: timeout=1, ) except serial.serialutil.SerialException as exc: - raise CannotConnect( - f"Unable to connect to the serial device {device}: {exc}" - ) from exc + raise CannotConnect(f"Unable to connect to the serial device {device}: {exc}") from exc # Try to read a line try: serial_reader.readline() @@ -491,7 +479,7 @@ def linky_tic_tester(device: str, std_mode: bool) -> None: class CannotConnect(Exception): """Error to indicate we cannot connect.""" - def __init__(self, message): + def __init__(self, message) -> None: """Initialize the CannotConnect error with an explanation message.""" super().__init__(message) @@ -499,6 +487,6 @@ def __init__(self, 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): + def __init__(self, message) -> None: """Initialize the CannotRead error with an explanation message.""" super().__init__(message) From c0cb9394d05b5f21c7721e09bafbb2df37a708e9 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 11 Feb 2024 20:23:47 +0100 Subject: [PATCH 04/18] fix: update frame delimitor and imports --- custom_components/linkytic/const.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/linkytic/const.py b/custom_components/linkytic/const.py index 47b08a3..e863461 100644 --- a/custom_components/linkytic/const.py +++ b/custom_components/linkytic/const.py @@ -1,7 +1,7 @@ """Constants for the linkytic integration.""" from termios import error -from serial import SerialException +from serial import SerialException, SEVENBITS, PARITY_EVEN, STOPBITS_ONE DOMAIN = "linkytic" @@ -28,9 +28,9 @@ # Protocol configuration # # https://www.enedis.fr/media/2035/download -BYTESIZE = serial.SEVENBITS -PARITY = serial.PARITY_EVEN -STOPBITS = serial.STOPBITS_ONE +BYTESIZE = SEVENBITS +PARITY = PARITY_EVEN +STOPBITS = STOPBITS_ONE MODE_STANDARD_BAUD_RATE = 9600 MODE_STANDARD_FIELD_SEPARATOR = b"\x09" @@ -39,7 +39,7 @@ MODE_HISTORIC_FIELD_SEPARATOR = b"\x20" LINE_END = b"\r\n" -FRAME_END = b"\r\x03\x02\n" +FRAME_END = b"\x03\x02" SHORT_FRAME_DETECTION_TAGS = ["ADIR1", "ADIR2", "ADIR3"] SHORT_FRAME_FORCED_UPDATE_TAGS = [ From 1aac3396e922b76d2fcbe85d6ddeb831672238e8 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 11 Feb 2024 20:29:57 +0100 Subject: [PATCH 05/18] refactor: remove unused import --- custom_components/linkytic/__init__.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index 441f5cd..3f4b20b 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -4,7 +4,6 @@ import asyncio import logging -import termios from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform @@ -105,17 +104,21 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry): async def async_migrate_entry(hass: HomeAssistant, config_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", config_entry.version, config_entry.minor_version + ) if config_entry.version == 1: new = {**config_entry.data} if config_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]) + serial_by_id = await hass.async_add_executor_job( + usb.get_serial_by_id, new[SETUP_SERIAL] + ) if serial_by_id == new[SETUP_SERIAL]: _LOGGER.warning( - f"Couldn't find a persistent /dev/serial/by-id alias for {serial_by_id}." + f"Couldn't find a persistent /dev/serial/by-id alias for {serial_by_id}. " "Problems might occur at startup if device names are not persistent." ) else: @@ -124,5 +127,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): config_entry.minor_version = 2 hass.config_entries.async_update_entry(config_entry, data=new) - _LOGGER.info("Migration to version %d.%d successful", config_entry.version, config_entry.minor_version) + _LOGGER.info( + "Migration to version %d.%d successful", + config_entry.version, + config_entry.minor_version, + ) return True From bea9d5a187fd2b45b3d4a04b6a973917e537fc0c Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 12 Feb 2024 19:11:34 +0100 Subject: [PATCH 06/18] refactor: comply with ruff --- custom_components/linkytic/binary_sensor.py | 4 +- custom_components/linkytic/sensor.py | 63 +++++++++++---------- custom_components/linkytic/serial_reader.py | 5 ++ 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 153525e..e3675b8 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -96,8 +96,8 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, - identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER])}, + connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, + identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or 'Unknown')}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], name=DID_DEFAULT_NAME, diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index b5214a9..c5fda6f 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from enum import Enum import logging -from typing import Callable from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,9 +16,9 @@ EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, - UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -34,32 +34,34 @@ DID_TYPE_CODE, DID_YEAR, DOMAIN, + SETUP_PRODUCER, SETUP_THREEPHASE, SETUP_TICMODE, - SETUP_PRODUCER, TICMODE_STANDARD, ) - from .serial_reader import LinkyTICReader + class StatusRegister(Enum): - CONTACT_SEC = 1, - ORGANE_DE_COUPURE = 2, - ETAT_DU_CACHE_BORNE_DISTRIBUTEUR = 3, - SURTENSION_SUR_UNE_DES_PHASES = 4, - DEPASSEMENT_PUISSANCE_REFERENCE = 5, - PRODUCTEUR_CONSOMMATEUR = 6, - SENS_ENERGIE_ACTIVE = 7, - TARIF_CONTRAT_FOURNITURE = 8, - TARIF_CONTRAT_DISTRIBUTEUR = 9, - MODE_DEGRADE_HORLOGE = 10, - MODE_TIC = 11, - ETAT_SORTIE_COMMUNICATION_EURIDIS = 12, - STATUS_CPL = 13, - SYNCHRO_CPL = 14, - COULEUR_JOUR_CONTRAT_TEMPO = 15, - COULEUR_LENDEMAIN_CONTRAT_TEMPO = 16, - PREAVIS_POINTES_MOBILES = 17, + """Field provided by status register.""" + + CONTACT_SEC = 1 + ORGANE_DE_COUPURE = 2 + ETAT_DU_CACHE_BORNE_DISTRIBUTEUR = 3 + SURTENSION_SUR_UNE_DES_PHASES = 4 + DEPASSEMENT_PUISSANCE_REFERENCE = 5 + PRODUCTEUR_CONSOMMATEUR = 6 + SENS_ENERGIE_ACTIVE = 7 + TARIF_CONTRAT_FOURNITURE = 8 + TARIF_CONTRAT_DISTRIBUTEUR = 9 + MODE_DEGRADE_HORLOGE = 10 + MODE_TIC = 11 + ETAT_SORTIE_COMMUNICATION_EURIDIS = 12 + STATUS_CPL = 13 + SYNCHRO_CPL = 14 + COULEUR_JOUR_CONTRAT_TEMPO = 15 + COULEUR_LENDEMAIN_CONTRAT_TEMPO = 16 + PREAVIS_POINTES_MOBILES = 17 POINTE_MOBILE = 18 _LOGGER = logging.getLogger(__name__) @@ -1344,7 +1346,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], @@ -1461,7 +1463,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], @@ -1503,8 +1505,7 @@ def update(self): ) self._attr_available = False # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - else: - if not self._attr_available: + elif not self._attr_available: _LOGGER.info( "%s: marking the %s sensor as available now !", self._config_title, @@ -1565,14 +1566,14 @@ def __init__( self._attr_native_unit_of_measurement = native_unit_of_measurement if state_class: self._attr_state_class = state_class - + self._conversion_function = conversion_function @property def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], @@ -1708,7 +1709,7 @@ def __init__( def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller._port)}, + connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], model=self._serial_controller.device_identification[DID_TYPE], @@ -1749,9 +1750,8 @@ def update(self): self._tag, ) self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - else: - if not self._attr_available: + # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable + elif not self._attr_available: _LOGGER.info( "%s: marking the %s sensor as available now !", self._config_title, @@ -1879,6 +1879,7 @@ def update(self): class StatusRegisterData(RegularStrSensor): """Data from status register.""" + _attr_has_entity_name = True _attr_should_poll = True diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index 883980e..fba32b3 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -101,6 +101,11 @@ def serial_number(self) -> str | None: """Returns meter serial number (ADS or ADSO tag).""" return self._serial_number + @property + def port(self) -> str: + """Returns serial port.""" + return self._port + def run(self): """Continuously read the the serial connection and extract TIC values.""" while not self._stopsignal: From dcf92bae41fd636726166237f3b79c066619a742 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Fri, 16 Feb 2024 21:54:22 +0100 Subject: [PATCH 07/18] fix: docstring and format --- custom_components/linkytic/__init__.py | 13 ++++--------- custom_components/linkytic/serial_reader.py | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index 3f4b20b..f6685f3 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -57,10 +57,9 @@ async def read_serial_number(serial: LinkyTICReader): # Timeout waiting for S/N to be read. except TimeoutError as e: serial_reader.signalstop("linkytic_timeout") - del serial_reader raise ConfigEntryNotReady( - f"Connected to serial port but coulnd't read serial number before timeout: check if TIC is connected and active." - ) + "Connected to serial port but coulnd't read serial number before timeout: check if TIC is connected and active." + ) from e _LOGGER.info(f"Device connected with serial number: {s_n}") @@ -104,18 +103,14 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry): async def async_migrate_entry(hass: HomeAssistant, config_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", config_entry.version, config_entry.minor_version) if config_entry.version == 1: new = {**config_entry.data} if config_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] - ) + serial_by_id = await hass.async_add_executor_job(usb.get_serial_by_id, new[SETUP_SERIAL]) if serial_by_id == new[SETUP_SERIAL]: _LOGGER.warning( f"Couldn't find a persistent /dev/serial/by-id alias for {serial_by_id}. " diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index fba32b3..80633b0 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -98,7 +98,7 @@ def is_connected(self) -> bool: @property def serial_number(self) -> str | None: - """Returns meter serial number (ADS or ADSO tag).""" + """Returns meter serial number (ADSC or ADCO tag).""" return self._serial_number @property From a783e37c783d6d86d2c5b83c93935ea3444a3975 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 18 Feb 2024 11:32:02 +0100 Subject: [PATCH 08/18] refactor: add common entity and sensor class --- custom_components/linkytic/binary_sensor.py | 34 +-- custom_components/linkytic/entity.py | 43 +++ custom_components/linkytic/sensor.py | 298 +++++++++----------- 3 files changed, 187 insertions(+), 188 deletions(-) create mode 100644 custom_components/linkytic/entity.py diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index e3675b8..0285f7f 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -1,4 +1,5 @@ """Binary sensors for linkytic integration.""" + from __future__ import annotations import asyncio @@ -10,18 +11,12 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DID_CONNECTION_TYPE, - DID_CONSTRUCTOR, - DID_DEFAULT_NAME, - DID_REGNUMBER, - DID_TYPE, - DOMAIN, -) +from .const import DOMAIN from .serial_reader import LinkyTICReader +from .entity import LinkyTICEntity _LOGGER = logging.getLogger(__name__) @@ -68,12 +63,11 @@ async def async_setup_entry( ) -class SerialConnectivity(BinarySensorEntity): +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_has_entity_name = True _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "Connectivité du lien série" _attr_should_poll = True @@ -82,26 +76,12 @@ class SerialConnectivity(BinarySensorEntity): # https://developers.home-assistant.io/docs/core/entity/binary-sensor/#properties _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - def __init__( - self, title: str, uniq_id: str | None, serial_reader: LinkyTICReader - ) -> None: + def __init__(self, title: str, uniq_id: str | None, serial_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}_{uniq_id}_serial_connectivity" - self._serial_controller = serial_reader - self._device_uniq_id = uniq_id if uniq_id is not None else "yaml_legacy" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, - identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or 'Unknown')}, - manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], - model=self._serial_controller.device_identification[DID_TYPE], - name=DID_DEFAULT_NAME, - ) @property def is_on(self) -> bool: diff --git a/custom_components/linkytic/entity.py b/custom_components/linkytic/entity.py new file mode 100644 index 0000000..f8b3532 --- /dev/null +++ b/custom_components/linkytic/entity.py @@ -0,0 +1,43 @@ +"""Entity for linkytic integration.""" + +from __future__ import annotations +from typing import cast + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device_registry import DeviceInfo + +from custom_components.linkytic.const import ( + DID_DEFAULT_MANUFACTURER, + DID_CONSTRUCTOR, + DID_DEFAULT_MODEL, + DID_DEFAULT_NAME, + DID_REGNUMBER, + DID_TYPE, + DOMAIN, +) + +from .serial_reader import LinkyTICReader + + +class LinkyTICEntity(Entity): + """Base class for all linkytic entities.""" + + _serial_controller: LinkyTICReader + + _attr_has_entity_name = True + + def __init__(self, reader: LinkyTICReader): + """Init Linkytic entity.""" + self._serial_controller = reader + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + did = self._serial_controller.device_identification + + return DeviceInfo( + identifiers={(DOMAIN, cast(str, did.get(DID_REGNUMBER)))}, + manufacturer=did.get(DID_CONSTRUCTOR, DID_DEFAULT_MANUFACTURER), + model=did.get(DID_TYPE, DID_DEFAULT_MODEL), + name=DID_DEFAULT_NAME, + ) diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index c5fda6f..8946aef 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -1,4 +1,5 @@ """Sensors for Linky TIC integration.""" + from __future__ import annotations import asyncio @@ -21,14 +22,13 @@ UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from custom_components.linkytic.entity import LinkyTICEntity + from .const import ( - DID_CONNECTION_TYPE, DID_CONSTRUCTOR, DID_CONSTRUCTOR_CODE, - DID_DEFAULT_NAME, DID_REGNUMBER, DID_TYPE, DID_TYPE_CODE, @@ -64,6 +64,7 @@ class StatusRegister(Enum): PREAVIS_POINTES_MOBILES = 17 POINTE_MOBILE = 18 + _LOGGER = logging.getLogger(__name__) @@ -113,7 +114,7 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, ), - RegularStrSensor( + LinkyTICStringSensor( tag="VTIC", name="Version de la TIC", config_title=config_entry.title, @@ -128,7 +129,7 @@ async def async_setup_entry( serial_reader=serial_reader, category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="NGTF", name="Nom du calendrier tarifaire fournisseur", config_title=config_entry.title, @@ -137,7 +138,7 @@ async def async_setup_entry( icon="mdi:cash-check", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="LTARF", name="Libellé tarif fournisseur en cours", config_title=config_entry.title, @@ -276,7 +277,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, - conversion_function=(lambda x: x * 1000) # kVA conversion + conversion_function=(lambda x: x * 1000), # kVA conversion ), RegularIntSensor( tag="PCOUP", @@ -288,7 +289,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, - conversion_function=(lambda x: x * 1000) # kVA conversion + conversion_function=(lambda x: x * 1000), # kVA conversion ), RegularIntSensor( tag="SINSTS", @@ -352,7 +353,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), - RegularStrSensor( + LinkyTICStringSensor( tag="DPM1", name="Début pointe mobile 1", config_title=config_entry.title, @@ -361,7 +362,7 @@ async def async_setup_entry( icon="mdi:clock-start", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="FPM1", name="Fin pointe mobile 1", config_title=config_entry.title, @@ -370,7 +371,7 @@ async def async_setup_entry( icon="mdi:clock-end", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="DPM2", name="Début pointe mobile 2", config_title=config_entry.title, @@ -379,7 +380,7 @@ async def async_setup_entry( icon="mdi:clock-start", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="FPM2", name="Fin pointe mobile 2", config_title=config_entry.title, @@ -388,7 +389,7 @@ async def async_setup_entry( icon="mdi:clock-end", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="DPM3", name="Début pointe mobile 3", config_title=config_entry.title, @@ -397,7 +398,7 @@ async def async_setup_entry( icon="mdi:clock-start", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="FPM3", name="Fin pointe mobile 3", config_title=config_entry.title, @@ -406,7 +407,7 @@ async def async_setup_entry( icon="mdi:clock-end", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="MSG1", name="Message court", config_title=config_entry.title, @@ -415,7 +416,7 @@ async def async_setup_entry( icon="mdi:message-text-outline", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="MSG2", name="Message Ultra court", config_title=config_entry.title, @@ -424,7 +425,7 @@ async def async_setup_entry( icon="mdi:message-text-outline", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="PRM", name="PRM", config_title=config_entry.title, @@ -433,7 +434,7 @@ async def async_setup_entry( icon="mdi:tag", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="RELAIS", name="Relais", config_title=config_entry.title, @@ -442,7 +443,7 @@ async def async_setup_entry( icon="mdi:electric-switch", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="NTARF", name="Numéro de l’index tarifaire en cours", config_title=config_entry.title, @@ -451,7 +452,7 @@ async def async_setup_entry( icon="mdi:cash-check", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="NJOURF", name="Numéro du jour en cours calendrier fournisseur", config_title=config_entry.title, @@ -460,7 +461,7 @@ async def async_setup_entry( icon="mdi:calendar-month-outline", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="NJOURF+1", name="Numéro du prochain jour calendrier fournisseur", config_title=config_entry.title, @@ -475,7 +476,7 @@ async def async_setup_entry( serial_reader=serial_reader, category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="PPOINTE", name="Profil du prochain jour de pointe", config_title=config_entry.title, @@ -484,7 +485,7 @@ async def async_setup_entry( icon="mdi:calendar-month-outline", category=EntityCategory.DIAGNOSTIC, ), - RegularStrSensor( + LinkyTICStringSensor( tag="STGE", name="Registre de statuts", config_title=config_entry.title, @@ -493,7 +494,7 @@ async def async_setup_entry( icon="mdi:list-status", category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut contact sec", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -502,7 +503,7 @@ async def async_setup_entry( data=StatusRegister.CONTACT_SEC, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut organe de coupure", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -511,7 +512,7 @@ async def async_setup_entry( data=StatusRegister.ORGANE_DE_COUPURE, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut état du cache-bornes distributeur", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -520,7 +521,7 @@ async def async_setup_entry( data=StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut surtension sur une des phases", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -529,7 +530,7 @@ async def async_setup_entry( data=StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut dépassement de la puissance de référence", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -538,7 +539,7 @@ async def async_setup_entry( data=StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut producteur/consommateur", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -547,7 +548,7 @@ async def async_setup_entry( data=StatusRegister.PRODUCTEUR_CONSOMMATEUR, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut sens de l’énergie active", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -556,7 +557,7 @@ async def async_setup_entry( data=StatusRegister.SENS_ENERGIE_ACTIVE, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut tarif contrat fourniture", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -565,7 +566,7 @@ async def async_setup_entry( data=StatusRegister.TARIF_CONTRAT_FOURNITURE, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut tarif contrat distributeur", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -574,7 +575,7 @@ async def async_setup_entry( data=StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut mode dégradée de l'horloge", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -583,7 +584,7 @@ async def async_setup_entry( data=StatusRegister.MODE_DEGRADE_HORLOGE, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut sortie télé-information", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -592,7 +593,7 @@ async def async_setup_entry( data=StatusRegister.MODE_TIC, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut sortie communication Euridis", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -601,7 +602,7 @@ async def async_setup_entry( data=StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut CPL", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -610,7 +611,7 @@ async def async_setup_entry( data=StatusRegister.STATUS_CPL, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut synchronisation CPL", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -619,7 +620,7 @@ async def async_setup_entry( data=StatusRegister.SYNCHRO_CPL, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut couleur du jour tempo", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -628,7 +629,7 @@ async def async_setup_entry( data=StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut couleur du lendemain tempo", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -637,7 +638,7 @@ async def async_setup_entry( data=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut préavis pointes mobiles", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -646,7 +647,7 @@ async def async_setup_entry( data=StatusRegister.PREAVIS_POINTES_MOBILES, category=EntityCategory.DIAGNOSTIC, ), - StatusRegisterData( + LinkyTICStatusRegisterSensor( name="Statut pointe mobile", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, @@ -981,7 +982,7 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, ), - RegularStrSensor( + LinkyTICStringSensor( tag="OPTARIF", name="Option tarifaire choisie", config_title=config_entry.title, @@ -1023,8 +1024,7 @@ async def async_setup_entry( ), EnergyIndexSensor( tag="EJPHN", - name="Index option EJP - Heures Normal" - + "es", # workaround for codespell in HA pre commit hook + name="Index option EJP - Heures Normal" + "es", # workaround for codespell in HA pre commit hook config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, @@ -1083,7 +1083,7 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, ), - RegularStrSensor( + LinkyTICStringSensor( tag="PTEC", name="Période Tarifaire en cours", config_title=config_entry.title, @@ -1091,7 +1091,7 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:calendar-expand-horizontal", ), - RegularStrSensor( + LinkyTICStringSensor( tag="DEMAIN", name="Couleur du lendemain", config_title=config_entry.title, @@ -1110,7 +1110,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), - RegularStrSensor( + LinkyTICStringSensor( tag="HHPHC", name="Horaire Heures Pleines Heures Creuses", config_title=config_entry.title, @@ -1119,10 +1119,9 @@ async def async_setup_entry( icon="mdi:clock-outline", enabled_by_default=False, ), - RegularStrSensor( + LinkyTICStringSensor( tag="MOTDETAT", - name="Mo" - + "t d'état du compteur", # workaround for codespell in HA pre commit hook + name="Mo" + "t d'état du compteur", # workaround for codespell in HA pre commit hook config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, @@ -1218,7 +1217,7 @@ async def async_setup_entry( ) ) sensors.append( - RegularStrSensor( + LinkyTICStringSensor( tag="PPOT", name="Présence des potentiels", config_title=config_entry.title, @@ -1264,9 +1263,7 @@ async def async_setup_entry( register_callback=True, ) ) - _LOGGER.info( - "Adding %d sensors for the three phase historic mode", len(sensors) - ) + _LOGGER.info("Adding %d sensors for the three phase historic mode", len(sensors)) else: # single phase - concat specific sensors sensors.append( @@ -1306,53 +1303,44 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ) ) - _LOGGER.info( - "Adding %d sensors for the single phase historic mode", len(sensors) - ) + _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) -class ADSSensor(SensorEntity): +class LinkyTICSensor(LinkyTICEntity, SensorEntity): + """Base class for all Linky TIC sensor entities.""" + + def __init__(self, reader: LinkyTICReader): + """Init sensor entity.""" + super().__init__(reader) + + +class ADSSensor(LinkyTICSensor): """Ad resse du compteur entity.""" # Generic properties # https://developers.home-assistant.io/docs/core/entity#generic-properties - _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = ( - "A" + "dress" + "e du compteur" - ) # workaround for codespell in HA pre commit hook + _attr_name = "A" + "dress" + "e du compteur" # workaround for codespell in HA pre commit hook _attr_should_poll = True _attr_icon = "mdi:tag" - def __init__( - self, config_title: str, tag: str, config_uniq_id: str, serial_reader: LinkyTICReader - ) -> None: + def __init__(self, config_title: str, tag: str, config_uniq_id: str, serial_reader: LinkyTICReader) -> None: """Initialize an ADCO/ADSC Sensor.""" _LOGGER.debug("%s: initializing %s sensor", config_title, tag) + super().__init__(serial_reader) # Linky TIC sensor properties self._config_title = config_title self._config_uniq_id = config_uniq_id self._last_value: str | None = None - self._serial_controller = serial_reader self._tag = tag # Generic entity properties self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_adco" self._extra: dict[str, str] = {} - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, - identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, - manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], - model=self._serial_controller.device_identification[DID_TYPE], - name=DID_DEFAULT_NAME, - ) - @property def native_value(self) -> str | None: """Value of the sensor.""" @@ -1423,12 +1411,11 @@ def update(self): self._last_value = value -class RegularStrSensor(SensorEntity): +class LinkyTICStringSensor(LinkyTICSensor): """Common class for text sensor.""" # Generic entity properties # https://developers.home-assistant.io/docs/core/entity#generic-properties - _attr_has_entity_name = True _attr_should_poll = True def __init__( @@ -1444,11 +1431,11 @@ def __init__( ) -> None: """Initialize a Regular Str Sensor.""" _LOGGER.debug("%s: initializing %s sensor", config_title, tag.upper()) + super().__init__(serial_reader) # Linky TIC sensor properties self._config_title = config_title self._config_uniq_id = config_uniq_id self._last_value: str | None = None - self._serial_controller = serial_reader self._tag = tag.upper() # Generic Entity properties self._attr_name = name @@ -1459,17 +1446,6 @@ def __init__( self._attr_entity_category = category self._attr_entity_registry_enabled_default = enabled_by_default - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, - identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, - manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], - model=self._serial_controller.device_identification[DID_TYPE], - name=DID_DEFAULT_NAME, - ) - @property def native_value(self) -> str | None: """Value of the sensor.""" @@ -1506,17 +1482,17 @@ def update(self): self._attr_available = False # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable elif not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True + _LOGGER.info( + "%s: marking the %s sensor as available now !", + self._config_title, + self._tag, + ) + self._attr_available = True # Save value self._last_value = value -class RegularIntSensor(SensorEntity): +class RegularIntSensor(LinkyTICSensor): """Common class for energy index counters.""" # Generic entity properties @@ -1537,21 +1513,19 @@ def __init__( native_unit_of_measurement: str | None = None, state_class: SensorStateClass | None = None, register_callback: bool = False, - conversion_function: Callable[[int], int] | None = None + 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__(serial_reader) # Linky TIC sensor properties self._config_title = config_title self._config_uniq_id = config_uniq_id self._last_value: int | None = None - self._serial_controller = serial_reader self._tag = tag.upper() if register_callback: - self._serial_controller.register_push_notif( - self._tag, self.update_notification - ) + self._serial_controller.register_push_notif(self._tag, self.update_notification) # Generic Entity properties if category: self._attr_entity_category = category @@ -1569,17 +1543,6 @@ def __init__( self._conversion_function = conversion_function - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, - identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, - manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], - model=self._serial_controller.device_identification[DID_TYPE], - name=DID_DEFAULT_NAME, - ) - @property def native_value(self) -> int | None: """Value of the sensor.""" @@ -1636,9 +1599,7 @@ def update_notification(self, realtime_option: bool) -> None: self._tag, ) if not self._attr_should_poll: - self._attr_should_poll = ( - True # realtime option disable, HA should poll us - ) + self._attr_should_poll = True # realtime option disable, HA should poll us return # Realtime on _LOGGER.debug( @@ -1646,7 +1607,9 @@ def update_notification(self, realtime_option: bool) -> None: self._tag, ) if self._attr_should_poll: - self._attr_should_poll = False # now that user has activated realtime, we will push data, no need for HA to poll us + self._attr_should_poll = ( + False # now that user has activated realtime, we will push data, no need for HA to poll us + ) self.schedule_update_ha_state(force_refresh=True) @@ -1676,7 +1639,7 @@ def __init__( ) -class PEJPSensor(SensorEntity): +class PEJPSensor(LinkyTICSensor): """Préavis Début EJP (30 min) sensor.""" # @@ -1686,36 +1649,22 @@ class PEJPSensor(SensorEntity): # Generic properties # https://developers.home-assistant.io/docs/core/entity#generic-properties - _attr_has_entity_name = True _attr_name = "Préavis Début EJP" _attr_should_poll = True _attr_icon = "mdi:clock-start" - def __init__( - self, config_title: str, config_uniq_id: str, serial_reader: LinkyTICReader - ) -> None: + 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__(serial_reader) # Linky TIC sensor properties self._config_title = config_title self._config_uniq_id = config_uniq_id self._last_value: str | None = None - self._serial_controller = serial_reader self._tag = "PEJP" # Generic Entity properties self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{self._tag.lower()}" - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - connections={(DID_CONNECTION_TYPE, self._serial_controller.port)}, - identifiers={(DOMAIN, self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown")}, - manufacturer=self._serial_controller.device_identification[DID_CONSTRUCTOR], - model=self._serial_controller.device_identification[DID_TYPE], - name=DID_DEFAULT_NAME, - ) - @property def native_value(self) -> str | None: """Value of the sensor.""" @@ -1752,28 +1701,37 @@ def update(self): self._attr_available = False # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable elif not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True + _LOGGER.info( + "%s: marking the %s sensor as available now !", + self._config_title, + self._tag, + ) + self._attr_available = True # Save value self._last_value = value -class DateEtHeureSensor(RegularStrSensor): +class DateEtHeureSensor(LinkyTICStringSensor): """Date et heure courante sensor.""" def __init__( - self, config_title: str, config_uniq_id: str, serial_reader: LinkyTICReader, category: EntityCategory | None = None, + self, + config_title: str, + config_uniq_id: str, + serial_reader: LinkyTICReader, + category: EntityCategory | None = None, ) -> 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, icon="mdi:clock-outline", - category=category) + super().__init__( + tag="DATE", + name="Date et heure courante", + config_title=config_title, + config_uniq_id=config_uniq_id, + serial_reader=serial_reader, + icon="mdi:clock-outline", + category=category, + ) @callback def update(self): @@ -1815,25 +1773,36 @@ def update(self): self._attr_available = True # Save value saison = "" - if horodate[0:1] == 'E': + if horodate[0:1] == "E": saison = " (Eté)" - elif horodate[0:1] == 'H': + elif horodate[0:1] == "H": saison = " (Hiver)" - self._last_value = horodate[5:7] + "/" + horodate[3:5] + "/" + horodate[1:3] + " " + horodate[7:9] + ":" + horodate[9:11] + saison + self._last_value = ( + horodate[5:7] + "/" + horodate[3:5] + "/" + horodate[1:3] + " " + horodate[7:9] + ":" + horodate[9:11] + saison + ) -class ProfilDuProchainJourCalendrierFournisseurSensor(RegularStrSensor): +class ProfilDuProchainJourCalendrierFournisseurSensor(LinkyTICStringSensor): """Profil du prochain jour du calendrier fournisseur sensor.""" def __init__( - self, config_title: str, config_uniq_id: str, serial_reader: LinkyTICReader, category: EntityCategory | None = None, + 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, icon="mdi:calendar-month-outline", - category=category) + 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, + icon="mdi:calendar-month-outline", + category=category, + ) @callback def update(self): @@ -1877,7 +1846,7 @@ def update(self): self._last_value = value.replace(" NONUTILE", "") -class StatusRegisterData(RegularStrSensor): +class LinkyTICStatusRegisterSensor(LinkyTICStringSensor): """Data from status register.""" _attr_has_entity_name = True @@ -1897,9 +1866,16 @@ def __init__( """Initialize a status register data sensor.""" _LOGGER.debug("%s: initializing a status register data sensor", config_title) self._data = data - super().__init__(tag="STGE", name=name, config_title=config_title, - config_uniq_id=config_uniq_id, serial_reader=serial_reader, - icon=icon, category=category, enabled_by_default=enabled_by_default) + super().__init__( + tag="STGE", + name=name, + config_title=config_title, + config_uniq_id=config_uniq_id, + serial_reader=serial_reader, + icon=icon, + category=category, + enabled_by_default=enabled_by_default, + ) self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_stge_{data.value}" @callback @@ -2071,4 +2047,4 @@ def update(self): "%s: Invalid status register : %s", self._config_title, value, - ) + ) From dd5faca598ad930819bdc413081d18012145d6a9 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 18 Feb 2024 11:36:43 +0100 Subject: [PATCH 09/18] fix: update serial reader port control --- custom_components/linkytic/serial_reader.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index 80633b0..1793070 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -111,24 +111,27 @@ def run(self): while not self._stopsignal: # Reader should have been opened. assert self._reader is not None + if not self._reader.is_open: + # TODO: implement a maximum retry, and go in failure mode if the connection can't be renewed. + try: + self._reader.open() + except LINKY_IO_ERRORS: + time.sleep(5) # Cooldown to prevent spamming logs. + _LOGGER.warning("Could not open port") + finally: + continue try: line = self._reader.readline() except LINKY_IO_ERRORS as exc: - _LOGGER.exception( + _LOGGER.error( "Error while reading serial device %s: %s. Will retry in 5s", self._port, exc, ) self._reset_state() self._reader.close() - time.sleep(5) # Cooldown to prevent spamming logs with errors. - # TODO: implement a maximum retry, and go in failure mode if the connection can't be renewed. - try: - self._reader.open() - except LINKY_IO_ERRORS: - _LOGGER.debug("Could not reopen port") - finally: - continue + continue + # Parse the line if non empty (prevent errors from read timeout that returns empty byte string) if not line: continue From 4137a57fe7f04cdc91d3eaf2d520924b516a8087 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 19 Feb 2024 19:33:35 +0100 Subject: [PATCH 10/18] refactor: add specific sensors --- custom_components/linkytic/binary_sensor.py | 1 - custom_components/linkytic/entity.py | 2 +- custom_components/linkytic/sensor.py | 338 ++++++-------------- 3 files changed, 99 insertions(+), 242 deletions(-) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 0285f7f..ae3d507 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -70,7 +70,6 @@ class SerialConnectivity(LinkyTICEntity, BinarySensorEntity): # https://developers.home-assistant.io/docs/core/entity#generic-properties _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "Connectivité du lien série" - _attr_should_poll = True # Binary sensor properties # https://developers.home-assistant.io/docs/core/entity/binary-sensor/#properties diff --git a/custom_components/linkytic/entity.py b/custom_components/linkytic/entity.py index f8b3532..57d0df7 100644 --- a/custom_components/linkytic/entity.py +++ b/custom_components/linkytic/entity.py @@ -23,7 +23,7 @@ class LinkyTICEntity(Entity): """Base class for all linkytic entities.""" _serial_controller: LinkyTICReader - + _attr_should_poll = True _attr_has_entity_name = True def __init__(self, reader: LinkyTICReader): diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 8946aef..2c46597 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from enum import Enum import logging +from typing import Generic, TypeVar from homeassistant.components.sensor import ( SensorDeviceClass, @@ -121,13 +122,11 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:tag", - category=EntityCategory.DIAGNOSTIC, ), DateEtHeureSensor( config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="NGTF", @@ -136,7 +135,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:cash-check", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="LTARF", @@ -145,7 +143,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:cash-check", - category=EntityCategory.DIAGNOSTIC, ), EnergyIndexSensor( tag="EAST", @@ -245,112 +242,90 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, ), - RegularIntSensor( + CurrentSensor( tag="IRMS1", name="Courant efficace, phase 1", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), - RegularIntSensor( + VoltageSensor( tag="URMS1", name="Tension efficace, phase 1", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, + category=EntityCategory.DIAGNOSTIC, conversion_function=(lambda x: x * 1000), # kVA conversion ), - RegularIntSensor( + ApparentPowerSensor( tag="PCOUP", name="Puissance app. de coupure", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, + category=EntityCategory.DIAGNOSTIC, conversion_function=(lambda x: x * 1000), # kVA conversion ), - RegularIntSensor( + ApparentPowerSensor( tag="SINSTS", name="Puissance app. instantanée soutirée", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), - RegularIntSensor( + ApparentPowerSensor( tag="SMAXSN", name="Puissance app. max. soutirée n", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), - RegularIntSensor( + ApparentPowerSensor( tag="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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), - RegularIntSensor( + 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, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.WATT, ), - RegularIntSensor( + 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, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.WATT, ), - RegularIntSensor( + VoltageSensor( tag="UMOY1", name="Tension moy. ph. 1", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, # Is this a curent value? register_callback=True, ), LinkyTICStringSensor( @@ -360,7 +335,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:clock-start", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="FPM1", @@ -369,7 +343,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:clock-end", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="DPM2", @@ -378,7 +351,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:clock-start", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="FPM2", @@ -387,7 +359,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:clock-end", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="DPM3", @@ -396,7 +367,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:clock-start", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="FPM3", @@ -405,7 +375,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:clock-end", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="MSG1", @@ -414,7 +383,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:message-text-outline", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="MSG2", @@ -423,7 +391,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:message-text-outline", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="PRM", @@ -432,7 +399,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:tag", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="RELAIS", @@ -441,7 +407,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:electric-switch", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="NTARF", @@ -450,7 +415,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:cash-check", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="NJOURF", @@ -459,7 +423,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:calendar-month-outline", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="NJOURF+1", @@ -468,13 +431,11 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:calendar-month-outline", - category=EntityCategory.DIAGNOSTIC, ), ProfilDuProchainJourCalendrierFournisseurSensor( config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="PPOINTE", @@ -483,7 +444,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:calendar-month-outline", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStringSensor( tag="STGE", @@ -492,7 +452,6 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:list-status", - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut contact sec", @@ -501,7 +460,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:electric-switch", data=StatusRegister.CONTACT_SEC, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut organe de coupure", @@ -510,7 +468,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:connection", data=StatusRegister.ORGANE_DE_COUPURE, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut état du cache-bornes distributeur", @@ -519,7 +476,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:toy-brick-outline", data=StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut surtension sur une des phases", @@ -528,7 +484,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:flash-alert", data=StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut dépassement de la puissance de référence", @@ -537,7 +492,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:flash-alert", data=StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut producteur/consommateur", @@ -546,7 +500,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:transmission-tower", data=StatusRegister.PRODUCTEUR_CONSOMMATEUR, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut sens de l’énergie active", @@ -555,7 +508,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:transmission-tower", data=StatusRegister.SENS_ENERGIE_ACTIVE, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut tarif contrat fourniture", @@ -564,7 +516,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:cash-check", data=StatusRegister.TARIF_CONTRAT_FOURNITURE, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut tarif contrat distributeur", @@ -573,7 +524,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:cash-check", data=StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut mode dégradée de l'horloge", @@ -582,7 +532,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:clock-alert-outline", data=StatusRegister.MODE_DEGRADE_HORLOGE, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut sortie télé-information", @@ -591,7 +540,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:tag", data=StatusRegister.MODE_TIC, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut sortie communication Euridis", @@ -600,7 +548,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:tag", data=StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut CPL", @@ -609,7 +556,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:tag", data=StatusRegister.STATUS_CPL, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut synchronisation CPL", @@ -618,7 +564,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:sync", data=StatusRegister.SYNCHRO_CPL, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut couleur du jour tempo", @@ -627,7 +572,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:palette", data=StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut couleur du lendemain tempo", @@ -636,7 +580,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:palette", data=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut préavis pointes mobiles", @@ -645,7 +588,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:clock-alert-outline", data=StatusRegister.PREAVIS_POINTES_MOBILES, - category=EntityCategory.DIAGNOSTIC, ), LinkyTICStatusRegisterSensor( name="Statut pointe mobile", @@ -654,7 +596,6 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:progress-clock", data=StatusRegister.POINTE_MOBILE, - category=EntityCategory.DIAGNOSTIC, ), ] # Add producer specific sensors @@ -710,264 +651,216 @@ async def async_setup_entry( ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, icon="mdi:transmission-tower-import", ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, icon="mdi:transmission-tower-import", ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, icon="mdi:transmission-tower-import", ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.WATT, icon="mdi:transmission-tower-import", ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.WATT, icon="mdi:transmission-tower-import", ) ) # Add three-phase specific sensors if bool(config_entry.data.get(SETUP_THREEPHASE)): sensors.append( - RegularIntSensor( + CurrentSensor( tag="IRMS2", name="Courant efficace, phase 2", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + CurrentSensor( tag="IRMS3", name="Courant efficace, phase 3", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + VoltageSensor( tag="URMS2", name="Tension efficace, phase 2", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + VoltageSensor( tag="URMS3", name="Tension efficace, phase 3", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + VoltageSensor( tag="UMOY2", name="Tension moy. ph. 2", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + VoltageSensor( tag="UMOY3", name="Tension moy. ph. 3", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) @@ -991,15 +884,13 @@ async def async_setup_entry( icon="mdi:cash-check", category=EntityCategory.DIAGNOSTIC, ), - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), EnergyIndexSensor( tag="BASE", @@ -1099,14 +990,12 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:palette", ), - RegularIntSensor( + ApparentPowerSensor( tag="PAPP", name="Puissance apparente", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.APPARENT_POWER, - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ), @@ -1134,75 +1023,63 @@ async def async_setup_entry( if bool(config_entry.data.get(SETUP_THREEPHASE)): # three-phase - concat specific sensors sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ) ) sensors.append( @@ -1228,38 +1105,32 @@ async def async_setup_entry( ) # Burst sensors sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, register_callback=True, ) ) @@ -1267,40 +1138,34 @@ async def async_setup_entry( else: # single phase - concat specific sensors sensors.append( - RegularIntSensor( + CurrentSensor( tag="IINST", name="Intensité Instantanée", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, register_callback=True, ) ) sensors.append( - RegularIntSensor( + CurrentSensor( tag="IMAX", name="Intensité maximale appelée", config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ) ) _LOGGER.info("Adding %d sensors for the single phase historic mode", len(sensors)) @@ -1309,15 +1174,27 @@ async def async_setup_entry( async_add_entities(sensors, True) -class LinkyTICSensor(LinkyTICEntity, SensorEntity): +T = TypeVar("T") + + +class LinkyTICSensor(LinkyTICEntity, SensorEntity, Generic[T]): """Base class for all Linky TIC sensor entities.""" + _attr_should_poll = True + _last_value: T | None + def __init__(self, reader: LinkyTICReader): """Init sensor entity.""" super().__init__(reader) + self._last_value = None + @property + def native_value(self) -> T | None: + """Value of the sensor.""" + return self._last_value -class ADSSensor(LinkyTICSensor): + +class ADSSensor(LinkyTICSensor[str]): """Ad resse du compteur entity.""" # Generic properties @@ -1325,7 +1202,6 @@ class ADSSensor(LinkyTICSensor): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "A" + "dress" + "e du compteur" # workaround for codespell in HA pre commit hook - _attr_should_poll = True _attr_icon = "mdi:tag" def __init__(self, config_title: str, tag: str, config_uniq_id: str, serial_reader: LinkyTICReader) -> None: @@ -1335,17 +1211,11 @@ def __init__(self, config_title: str, tag: str, config_uniq_id: str, serial_read # Linky TIC sensor properties self._config_title = config_title self._config_uniq_id = config_uniq_id - self._last_value: str | None = None self._tag = tag # Generic entity properties self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_adco" self._extra: dict[str, str] = {} - @property - def native_value(self) -> str | None: - """Value of the sensor.""" - return self._last_value - @property def extra_state_attributes(self) -> dict[str, str]: """Get HA sensor extra attributes.""" @@ -1411,12 +1281,10 @@ def update(self): self._last_value = value -class LinkyTICStringSensor(LinkyTICSensor): +class LinkyTICStringSensor(LinkyTICSensor[str]): """Common class for text sensor.""" - # Generic entity properties - # https://developers.home-assistant.io/docs/core/entity#generic-properties - _attr_should_poll = True + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, @@ -1446,11 +1314,6 @@ def __init__( self._attr_entity_category = category self._attr_entity_registry_enabled_default = enabled_by_default - @property - def native_value(self) -> str | None: - """Value of the sensor.""" - return self._last_value - @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" @@ -1492,14 +1355,9 @@ def update(self): self._last_value = value -class RegularIntSensor(LinkyTICSensor): +class RegularIntSensor(LinkyTICSensor[int]): """Common class for energy index counters.""" - # Generic entity properties - # https://developers.home-assistant.io/docs/core/entity#generic-properties - _attr_has_entity_name = True - _attr_should_poll = True - def __init__( self, tag: str, @@ -1543,11 +1401,6 @@ def __init__( self._conversion_function = conversion_function - @property - def native_value(self) -> int | None: - """Value of the sensor.""" - return self._last_value - @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" @@ -1614,29 +1467,39 @@ def update_notification(self, realtime_option: bool) -> None: class EnergyIndexSensor(RegularIntSensor): - """Common class for energy index counters.""" + """Common class for energy index counters, in Watt-hours.""" - def __init__( - self, - tag: str, - name: str, - config_title: str, - config_uniq_id: str, - serial_reader: LinkyTICReader, - icon: str | None = "mdi:counter", - ) -> None: - """Initialize an Energy Index sensor.""" - super().__init__( - tag=tag, - name=name, - config_title=config_title, - config_uniq_id=config_uniq_id, - serial_reader=serial_reader, - icon=icon, - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - ) + _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 Vmperes.""" + + _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 electric current sensors, in Volt-Amperes.""" + + _attr_device_class = SensorDeviceClass.APPARENT_POWER + _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE class PEJPSensor(LinkyTICSensor): @@ -1650,7 +1513,6 @@ class PEJPSensor(LinkyTICSensor): # Generic properties # https://developers.home-assistant.io/docs/core/entity#generic-properties _attr_name = "Préavis Début EJP" - _attr_should_poll = True _attr_icon = "mdi:clock-start" def __init__(self, config_title: str, config_uniq_id: str, serial_reader: LinkyTICReader) -> None: @@ -1665,11 +1527,6 @@ def __init__(self, config_title: str, config_uniq_id: str, serial_reader: LinkyT # Generic Entity properties self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{self._tag.lower()}" - @property - def native_value(self) -> str | None: - """Value of the sensor.""" - return self._last_value - @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" @@ -2040,6 +1897,7 @@ def update(self): self._last_value = "PM3 en cours" else: + # AssertionError self._last_value = self._data.name except ValueError: From 4757e612eea701a47593033e532f772e0caaafe5 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 25 Feb 2024 19:23:45 +0100 Subject: [PATCH 11/18] refactor: suppress type warning --- custom_components/linkytic/config_flow.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/linkytic/config_flow.py b/custom_components/linkytic/config_flow.py index 84a6a62..970010d 100644 --- a/custom_components/linkytic/config_flow.py +++ b/custom_components/linkytic/config_flow.py @@ -37,8 +37,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(SETUP_SERIAL, default=SETUP_SERIAL_DEFAULT): str, - vol.Required(SETUP_TICMODE, default=TICMODE_HISTORIC): selector.SelectSelector( + vol.Required(SETUP_SERIAL, default=SETUP_SERIAL_DEFAULT): str, # type: ignore + vol.Required(SETUP_TICMODE, default=TICMODE_HISTORIC): selector.SelectSelector( # type: ignore selector.SelectSelectorConfig( options=[ selector.SelectOptionDict(value=TICMODE_HISTORIC, label=TICMODE_HISTORIC_LABEL), @@ -46,8 +46,8 @@ ] ), ), - vol.Required(SETUP_PRODUCER, default=SETUP_PRODUCER_DEFAULT): bool, - vol.Required(SETUP_THREEPHASE, default=SETUP_THREEPHASE_DEFAULT): bool, + vol.Required(SETUP_PRODUCER, default=SETUP_PRODUCER_DEFAULT): bool, # type: ignore + vol.Required(SETUP_THREEPHASE, default=SETUP_THREEPHASE_DEFAULT): bool, # type: ignore } ) @@ -123,7 +123,7 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Flo { vol.Required( OPTIONS_REALTIME, - default=self.config_entry.options.get(OPTIONS_REALTIME), + default=self.config_entry.options.get(OPTIONS_REALTIME), # type: ignore ): bool } ), From 35bdaedcaf75f284daec2f3bfa8b9c97db02f684 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Sun, 25 Feb 2024 19:39:05 +0100 Subject: [PATCH 12/18] refactor: refactor sensor update mechanism refactor status register handling fix absolute import remove unecessary waiting (now handled by async_setup) change serial function to property --- custom_components/linkytic/binary_sensor.py | 21 +- custom_components/linkytic/entity.py | 3 +- custom_components/linkytic/sensor.py | 806 +++++------------- custom_components/linkytic/serial_reader.py | 4 +- custom_components/linkytic/status_register.py | 91 ++ 5 files changed, 318 insertions(+), 607 deletions(-) create mode 100644 custom_components/linkytic/status_register.py diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index ae3d507..e5b54f9 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging from homeassistant.components.binary_sensor import ( @@ -38,24 +37,6 @@ async def async_setup_entry( config_entry.title, ) return - # Wait a bit for the controller to feed on serial frames (home assistant warns after 10s) - _LOGGER.debug( - "%s: waiting at most 9s before setting up binary sensor plateform in order for the async serial reader to have time to parse a full frame", - config_entry.title, - ) - for i in range(9): - await asyncio.sleep(1) - if serial_reader.has_read_full_frame(): - _LOGGER.debug( - "%s: a full frame has been read, initializing sensors", - config_entry.title, - ) - break - if i == 8: - _LOGGER.warning( - "%s: wait time is over but a full frame has yet to be read: initializing sensors anyway", - config_entry.title, - ) # Init sensors async_add_entities( [SerialConnectivity(config_entry.title, config_entry.entry_id, serial_reader)], @@ -85,4 +66,4 @@ def __init__(self, title: str, uniq_id: str | None, serial_reader: LinkyTICReade @property def is_on(self) -> bool: """Value of the sensor.""" - return self._serial_controller.is_connected() + return self._serial_controller.is_connected diff --git a/custom_components/linkytic/entity.py b/custom_components/linkytic/entity.py index 57d0df7..93543f7 100644 --- a/custom_components/linkytic/entity.py +++ b/custom_components/linkytic/entity.py @@ -6,7 +6,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.device_registry import DeviceInfo -from custom_components.linkytic.const import ( +from .const import ( DID_DEFAULT_MANUFACTURER, DID_CONSTRUCTOR, DID_DEFAULT_MODEL, @@ -15,7 +15,6 @@ DID_TYPE, DOMAIN, ) - from .serial_reader import LinkyTICReader diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 2c46597..2a217bd 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -2,11 +2,9 @@ from __future__ import annotations -import asyncio from collections.abc import Callable -from enum import Enum import logging -from typing import Generic, TypeVar +from typing import Generic, Optional, TypeVar, cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,8 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from custom_components.linkytic.entity import LinkyTICEntity - +from .entity import LinkyTICEntity from .const import ( DID_CONSTRUCTOR, DID_CONSTRUCTOR_CODE, @@ -41,30 +38,7 @@ TICMODE_STANDARD, ) from .serial_reader import LinkyTICReader - - -class StatusRegister(Enum): - """Field provided by status register.""" - - CONTACT_SEC = 1 - ORGANE_DE_COUPURE = 2 - ETAT_DU_CACHE_BORNE_DISTRIBUTEUR = 3 - SURTENSION_SUR_UNE_DES_PHASES = 4 - DEPASSEMENT_PUISSANCE_REFERENCE = 5 - PRODUCTEUR_CONSOMMATEUR = 6 - SENS_ENERGIE_ACTIVE = 7 - TARIF_CONTRAT_FOURNITURE = 8 - TARIF_CONTRAT_DISTRIBUTEUR = 9 - MODE_DEGRADE_HORLOGE = 10 - MODE_TIC = 11 - ETAT_SORTIE_COMMUNICATION_EURIDIS = 12 - STATUS_CPL = 13 - SYNCHRO_CPL = 14 - COULEUR_JOUR_CONTRAT_TEMPO = 15 - COULEUR_LENDEMAIN_CONTRAT_TEMPO = 16 - PREAVIS_POINTES_MOBILES = 17 - POINTE_MOBILE = 18 - +from .status_register import StatusRegister _LOGGER = logging.getLogger(__name__) @@ -86,24 +60,6 @@ async def async_setup_entry( config_entry.title, ) return - # Wait a bit for the controller to feed on serial frames (home assistant warns after 10s) - _LOGGER.debug( - "%s: waiting at most 9s before setting up sensor plateform in order for the async serial reader to have time to parse a full frame", - config_entry.title, - ) - for i in range(9): - await asyncio.sleep(1) - if serial_reader.has_read_full_frame(): - _LOGGER.debug( - "%s: a full frame has been read, initializing sensors", - config_entry.title, - ) - break - if i == 8: - _LOGGER.warning( - "%s: wait time is over but a full frame has yet to be read: initializing sensors anyway", - config_entry.title, - ) # Init sensors sensors = [] if config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD: @@ -453,69 +409,69 @@ async def async_setup_entry( serial_reader=serial_reader, icon="mdi:list-status", ), - LinkyTICStatusRegisterSensor( - name="Statut contact sec", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:electric-switch", - data=StatusRegister.CONTACT_SEC, - ), + # LinkyTICStatusRegisterSensor( + # name="Statut contact sec", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:electric-switch", + # field=StatusRegister.CONTACT_SEC, + # ), 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", - data=StatusRegister.ORGANE_DE_COUPURE, - ), - LinkyTICStatusRegisterSensor( - name="Statut état du cache-bornes distributeur", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:toy-brick-outline", - data=StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, - ), - LinkyTICStatusRegisterSensor( - name="Statut surtension sur une des phases", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:flash-alert", - data=StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, - ), - LinkyTICStatusRegisterSensor( - name="Statut dépassement de la puissance de référence", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:flash-alert", - data=StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, - ), - LinkyTICStatusRegisterSensor( - name="Statut producteur/consommateur", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower", - data=StatusRegister.PRODUCTEUR_CONSOMMATEUR, - ), - LinkyTICStatusRegisterSensor( - name="Statut sens de l’énergie active", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:transmission-tower", - data=StatusRegister.SENS_ENERGIE_ACTIVE, - ), + field=StatusRegister.ORGANE_DE_COUPURE, + ), + # LinkyTICStatusRegisterSensor( + # name="Statut état du cache-bornes distributeur", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:toy-brick-outline", + # field=StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, + # ), + # LinkyTICStatusRegisterSensor( + # name="Statut surtension sur une des phases", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:flash-alert", + # field=StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, + # ), + # LinkyTICStatusRegisterSensor( + # name="Statut dépassement de la puissance de référence", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:flash-alert", + # field=StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, + # ), + # LinkyTICStatusRegisterSensor( + # name="Statut producteur/consommateur", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:transmission-tower", + # field=StatusRegister.PRODUCTEUR_CONSOMMATEUR, + # ), + # LinkyTICStatusRegisterSensor( + # name="Statut sens de l’énergie active", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:transmission-tower", + # field=StatusRegister.SENS_ENERGIE_ACTIVE, + # ), 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", - data=StatusRegister.TARIF_CONTRAT_FOURNITURE, + field=StatusRegister.TARIF_CONTRAT_FOURNITURE, ), LinkyTICStatusRegisterSensor( name="Statut tarif contrat distributeur", @@ -523,31 +479,31 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:cash-check", - data=StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR, - ), - LinkyTICStatusRegisterSensor( - name="Statut mode dégradée de l'horloge", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:clock-alert-outline", - data=StatusRegister.MODE_DEGRADE_HORLOGE, - ), - LinkyTICStatusRegisterSensor( - name="Statut sortie télé-information", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:tag", - data=StatusRegister.MODE_TIC, - ), + field=StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR, + ), + # LinkyTICStatusRegisterSensor( + # name="Statut mode dégradée de l'horloge", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:clock-alert-outline", + # field=StatusRegister.MODE_DEGRADE_HORLOGE, + # ), + # LinkyTICStatusRegisterSensor( + # name="Statut sortie télé-information", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:tag", + # field=StatusRegister.MODE_TIC, + # ), 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", - data=StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS, + field=StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS, ), LinkyTICStatusRegisterSensor( name="Statut CPL", @@ -555,23 +511,23 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:tag", - data=StatusRegister.STATUS_CPL, - ), - LinkyTICStatusRegisterSensor( - name="Statut synchronisation CPL", - config_title=config_entry.title, - config_uniq_id=config_entry.entry_id, - serial_reader=serial_reader, - icon="mdi:sync", - data=StatusRegister.SYNCHRO_CPL, - ), + field=StatusRegister.STATUS_CPL, + ), + # LinkyTICStatusRegisterSensor( + # name="Statut synchronisation CPL", + # config_title=config_entry.title, + # config_uniq_id=config_entry.entry_id, + # serial_reader=serial_reader, + # icon="mdi:sync", + # field=StatusRegister.SYNCHRO_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", - data=StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO, + field=StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO, ), LinkyTICStatusRegisterSensor( name="Statut couleur du lendemain tempo", @@ -579,7 +535,7 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:palette", - data=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, + field=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, ), LinkyTICStatusRegisterSensor( name="Statut préavis pointes mobiles", @@ -587,7 +543,7 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:clock-alert-outline", - data=StatusRegister.PREAVIS_POINTES_MOBILES, + field=StatusRegister.PREAVIS_POINTES_MOBILES, ), LinkyTICStatusRegisterSensor( name="Statut pointe mobile", @@ -595,7 +551,7 @@ async def async_setup_entry( config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, icon="mdi:progress-clock", - data=StatusRegister.POINTE_MOBILE, + field=StatusRegister.POINTE_MOBILE, ), ] # Add producer specific sensors @@ -1083,14 +1039,12 @@ async def async_setup_entry( ) ) sensors.append( - RegularIntSensor( + 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, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.WATT, # documentation says unit is Watt but description talks about VoltAmp :/ ) ) sensors.append( @@ -1183,20 +1137,68 @@ class LinkyTICSensor(LinkyTICEntity, SensorEntity, Generic[T]): _attr_should_poll = True _last_value: T | None - def __init__(self, reader: LinkyTICReader): + def __init__(self, tag: str, config_title: str, reader: LinkyTICReader): """Init sensor entity.""" super().__init__(reader) self._last_value = None + self._tag = tag + self._config_title = config_title @property def native_value(self) -> T | None: """Value of the sensor.""" return self._last_value + def _update(self) -> tuple[Optional[str], Optional[str]]: + """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" if timestamp else "", + self._config_title, + self._tag, + repr(value), + repr(timestamp), + ) + + if not value and not timestamp: # No data returned. + if not self._serial_controller.is_connected: + _LOGGER.debug( + "%s: marking the %s sensor as unavailable: serial connection lost", + self._config_title, + 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._tag, + self._tag, + ) + self._attr_available = False + else: + # A frame has not been read yet (it should!) or is already unavailable and no new data was fetched. + # Let sensor in current availability state. + pass + return None, None + + if not self.available: + # Data is available, so is sensor + self._attr_available = True + _LOGGER.info( + "%s: marking the %s sensor as available now !", + self._config_title, + self._tag, + ) + + return value, timestamp + class ADSSensor(LinkyTICSensor[str]): """Ad resse du compteur entity.""" + # 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 @@ -1207,11 +1209,7 @@ class ADSSensor(LinkyTICSensor[str]): def __init__(self, config_title: str, tag: str, config_uniq_id: str, serial_reader: LinkyTICReader) -> None: """Initialize an ADCO/ADSC Sensor.""" _LOGGER.debug("%s: initializing %s sensor", config_title, tag) - super().__init__(serial_reader) - # Linky TIC sensor properties - self._config_title = config_title - self._config_uniq_id = config_uniq_id - self._tag = tag + super().__init__(tag, config_title, serial_reader) # Generic entity properties self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_adco" self._extra: dict[str, str] = {} @@ -1225,58 +1223,19 @@ def extra_state_attributes(self) -> dict[str, str]: def update(self): """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller - value, _ = self._serial_controller.get_values(self._tag) - _LOGGER.debug( - "%s: retrieved %s value from serial controller: %s", - self._config_title, - self._tag, - repr(value), - ) - # Handle entity availability - if value is None: - if self._attr_available: - self._extra = {} - if not self._serial_controller.is_connected(): - _LOGGER.debug( - "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, - 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._tag, - self._tag, - ) - self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - else: - # Set this sensor extra attributes - constructor_str = ( - f"{self._serial_controller.device_identification[DID_CONSTRUCTOR]} ({self._serial_controller.device_identification[DID_CONSTRUCTOR_CODE]})" - if self._serial_controller.device_identification[DID_CONSTRUCTOR] is not None - else f"Inconnu ({self._serial_controller.device_identification[DID_CONSTRUCTOR_CODE]})" - ) - type_str = ( - f"{self._serial_controller.device_identification[DID_TYPE]} ({self._serial_controller.device_identification[DID_TYPE_CODE]})" - if self._serial_controller.device_identification[DID_TYPE] is not None - else f"Inconnu ({self._serial_controller.device_identification[DID_TYPE_CODE]})" - ) - self._extra = { - "constructeur": constructor_str, - "année de construction": f"20{self._serial_controller.device_identification[DID_YEAR]}", - "type de l'appareil": type_str, - "matricule de l'appareil": self._serial_controller.device_identification[DID_REGNUMBER] or "Unknown", - } - if not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True + value, _ = self._update() + + if not value: + return + + # Set this sensor extra attributes + did = self._serial_controller.device_identification + self._extra = { + "constructeur": f"{did[DID_CONSTRUCTOR] or 'Inconnu'} ({did[DID_CONSTRUCTOR_CODE]})", + "année de construction": f"20{did[DID_YEAR]}", + "type de l'appareil": f"{did[DID_TYPE] or 'Inconnu'} ({did[DID_TYPE_CODE]})", + "matricule de l'appareil": did[DID_REGNUMBER] or "Inconnu", + } # Save value self._last_value = value @@ -1299,12 +1258,8 @@ def __init__( ) -> None: """Initialize a Regular Str Sensor.""" _LOGGER.debug("%s: initializing %s sensor", config_title, tag.upper()) - super().__init__(serial_reader) - # Linky TIC sensor properties - self._config_title = config_title - self._config_uniq_id = config_uniq_id - self._last_value: str | None = None - self._tag = 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()}" @@ -1318,45 +1273,14 @@ def __init__( def update(self): """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller - value, _ = self._serial_controller.get_values(self._tag) - _LOGGER.debug( - "%s: retrieved %s value from serial controller: %s", - self._config_title, - self._tag, - repr(value), - ) - # Handle entity availability - if value is None: - if self._attr_available: - if not self._serial_controller.is_connected(): - _LOGGER.debug( - "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, - 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._tag, - self._tag, - ) - self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - elif not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True - # Save value + value, _ = self._update() + if not value: + return self._last_value = value class RegularIntSensor(LinkyTICSensor[int]): - """Common class for energy index counters.""" + """Common class for int sensors.""" def __init__( self, @@ -1375,19 +1299,14 @@ def __init__( ) -> None: """Initialize a Regular Int Sensor.""" _LOGGER.debug("%s: initializing %s sensor", config_title, tag.upper()) - super().__init__(serial_reader) - # Linky TIC sensor properties - self._config_title = config_title - self._config_uniq_id = config_uniq_id - self._last_value: int | None = None - self._tag = 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_name = name self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{tag.lower()}" if icon: self._attr_icon = icon @@ -1404,44 +1323,14 @@ def __init__( @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" - # Get last seen value from controller - value, _ = self._serial_controller.get_values(self._tag) - _LOGGER.debug( - "%s: retrieved %s value from serial controller: %s", - self._config_title, - self._tag, - repr(value), - ) - # Handle entity availability and save value - if value is None: - if self._attr_available: - if not self._serial_controller.is_connected(): - _LOGGER.debug( - "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, - 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._tag, - self._tag, - ) - self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - # Nullify value - self._last_value = None - else: - self._last_value = int(value) if not self._conversion_function else self._conversion_function(int(value)) - if not self._attr_available: - _LOGGER.debug( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True + value, _ = self._update() + if not value: + return + try: + value_int = int(value) + except ValueError: + return + self._last_value = self._conversion_function(value_int) if self._conversion_function else 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.""" @@ -1482,7 +1371,7 @@ class VoltageSensor(RegularIntSensor): class CurrentSensor(RegularIntSensor): - """Common class for electric current sensors, in Vmperes.""" + """Common class for electric current sensors, in Amperes.""" _attr_device_class = SensorDeviceClass.CURRENT _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE @@ -1496,87 +1385,45 @@ class PowerSensor(RegularIntSensor): class ApparentPowerSensor(RegularIntSensor): - """Common class for electric current sensors, in Volt-Amperes.""" + """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(LinkyTICSensor): +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 # - - # Generic properties - # https://developers.home-assistant.io/docs/core/entity#generic-properties - _attr_name = "Préavis Début EJP" _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__(serial_reader) - # Linky TIC sensor properties - self._config_title = config_title - self._config_uniq_id = config_uniq_id - self._last_value: str | None = None - self._tag = "PEJP" - # Generic Entity properties - self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{self._tag.lower()}" - - @callback - def update(self): - """Update the value of the sensor from the thread object memory cache.""" - # Get last seen value from controller - value, _ = self._serial_controller.get_values(self._tag) - _LOGGER.debug( - "%s: retrieved %s value from serial controller: %s", - self._config_title, - self._tag, - repr(value), + super().__init__( + tag="PEJP", + name="Préavis Début EJP", + config_title=config_title, + config_uniq_id=config_uniq_id, + serial_reader=serial_reader, ) - # Handle entity availability - if value is None: - if self._attr_available: - if not self._serial_controller.is_connected(): - _LOGGER.debug( - "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, - 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._tag, - self._tag, - ) - self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - elif not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True - # Save value - self._last_value = value + + 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, - category: EntityCategory | None = None, ) -> None: """Initialize a Date et heure sensor.""" _LOGGER.debug("%s: initializing Date et heure courante sensor", config_title) @@ -1586,62 +1433,44 @@ def __init__( config_title=config_title, config_uniq_id=config_uniq_id, serial_reader=serial_reader, - icon="mdi:clock-outline", - category=category, ) @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller - _, horodate = self._serial_controller.get_values(self._tag) - _LOGGER.debug( - "%s: retrieved %s value from serial controller: %s", - self._config_title, - self._tag, - repr(horodate), - ) - # Handle entity availability - if horodate is None: - if self._attr_available: - if not self._serial_controller.is_connected(): - _LOGGER.debug( - "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, - 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._tag, - self._tag, - ) - self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - else: - if not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True - # Save value - saison = "" - if horodate[0:1] == "E": + _, timestamp = self._update() + + if not timestamp: + return + # Save value + saison = "" + try: + if timestamp[0:1] == "E": saison = " (Eté)" - elif horodate[0:1] == "H": + elif timestamp[0:1] == "H": saison = " (Hiver)" self._last_value = ( - horodate[5:7] + "/" + horodate[3:5] + "/" + horodate[1:3] + " " + horodate[7:9] + ":" + horodate[9:11] + saison + 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, @@ -1657,50 +1486,16 @@ def __init__( config_title=config_title, config_uniq_id=config_uniq_id, serial_reader=serial_reader, - icon="mdi:calendar-month-outline", - category=category, ) @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller - value, horodate = self._serial_controller.get_values(self._tag) - _LOGGER.debug( - "%s: retrieved %s value from serial controller: %s", - self._config_title, - self._tag, - repr(value), - ) - # Handle entity availability - if value is None: - if self._attr_available: - if not self._serial_controller.is_connected(): - _LOGGER.debug( - "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, - 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._tag, - self._tag, - ) - self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - else: - if not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True - # Save value - self._last_value = value.replace(" NONUTILE", "") + value, _ = self._update() + if not value: + return + self._last_value = value.replace("NONUTILE", "").strip() class LinkyTICStatusRegisterSensor(LinkyTICStringSensor): @@ -1708,6 +1503,7 @@ class LinkyTICStatusRegisterSensor(LinkyTICStringSensor): _attr_has_entity_name = True _attr_should_poll = True + _attr_device_class = SensorDeviceClass.ENUM def __init__( self, @@ -1715,14 +1511,13 @@ def __init__( config_title: str, config_uniq_id: str, serial_reader: LinkyTICReader, - data: StatusRegister, + field: StatusRegister, enabled_by_default: bool = True, icon: str | None = None, - category: EntityCategory | None = None, ) -> None: """Initialize a status register data sensor.""" _LOGGER.debug("%s: initializing a status register data sensor", config_title) - self._data = data + self._field = field super().__init__( tag="STGE", name=name, @@ -1730,179 +1525,22 @@ def __init__( config_uniq_id=config_uniq_id, serial_reader=serial_reader, icon=icon, - category=category, enabled_by_default=enabled_by_default, ) - self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_stge_{data.value}" + self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{field.name.lower()}" # Breaking changes here. + # 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()) @callback def update(self): """Update the value of the sensor from the thread object memory cache.""" # Get last seen value from controller - value, _ = self._serial_controller.get_values(self._tag) - _LOGGER.debug( - "%s: retrieved %s value from serial controller: %s", - self._config_title, - self._tag, - repr(value), - ) - # Handle entity availability - if value is None: - if self._attr_available: - if not self._serial_controller.is_connected(): - _LOGGER.debug( - "%s: marking the %s sensor as unavailable: serial connection lost", - self._config_title, - 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._tag, - self._tag, - ) - self._attr_available = False - # else: we are connected but a full frame has not been read yet, let's wait a little longer before marking it unavailable - else: - if not self._attr_available: - _LOGGER.info( - "%s: marking the %s sensor as available now !", - self._config_title, - self._tag, - ) - self._attr_available = True - - try: - val = int(value, 16) - - # Save value - if self._data == StatusRegister.CONTACT_SEC: - self._last_value = "Ouvert" if (val & 0x01) else "Fermé" - - elif self._data == StatusRegister.ORGANE_DE_COUPURE: - val_organe_de_coupure = (val >> 1) & 0x07 - if val_organe_de_coupure == 0: - self._last_value = "Fermé" - elif val_organe_de_coupure == 1: - self._last_value = "Ouvert sur surpuissance" - elif val_organe_de_coupure == 2: - self._last_value = "Ouvert sur surtension" - elif val_organe_de_coupure == 3: - self._last_value = "Ouvert sur délestage" - elif val_organe_de_coupure == 4: - self._last_value = "Ouvert sur ordre CPL ou Euridis" - elif val_organe_de_coupure == 5: - self._last_value = "Ouvert sur une surchauffe (>Imax)" - elif val_organe_de_coupure == 6: - self._last_value = "Ouvert sur une surchauffe (> 4) & 0x01) else "Fermé" - - elif self._data == StatusRegister.SURTENSION_SUR_UNE_DES_PHASES: - self._last_value = "Surtension" if ((val >> 6) & 0x01) else "Pas de surtension" - - elif self._data == StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE: - self._last_value = "Dépassement en cours" if ((val >> 7) & 0x01) else "Pas de dépassement" - - elif self._data == StatusRegister.PRODUCTEUR_CONSOMMATEUR: - self._last_value = "Producteur" if ((val >> 8) & 0x01) else "Consommateur" - - elif self._data == StatusRegister.SENS_ENERGIE_ACTIVE: - self._last_value = "Energie active négative" if ((val >> 9) & 0x01) else "Energie active positive" - - elif self._data == StatusRegister.TARIF_CONTRAT_FOURNITURE: - index = (val >> 10) & 0x0F - self._last_value = "Energie ventillée sur index " + str(index + 1) - - elif self._data == StatusRegister.TARIF_CONTRAT_DISTRIBUTEUR: - index = (val >> 14) & 0x03 - self._last_value = "Energie ventillée sur index " + str(index + 1) - - elif self._data == StatusRegister.MODE_DEGRADE_HORLOGE: - self._last_value = "Horloge en mode dégradée" if ((val >> 16) & 0x01) else "Horloge correcte" - - elif self._data == StatusRegister.MODE_TIC: - self._last_value = "Mode standard" if ((val >> 17) & 0x01) else "Mode historique" - - elif self._data == StatusRegister.ETAT_SORTIE_COMMUNICATION_EURIDIS: - etat = (val >> 19) & 0x03 - if etat == 0: - self._last_value = "Désactivée" - elif etat == 1: - self._last_value = "Activée sans sécurité" - elif etat == 3: - self._last_value = "Activée avec sécurité" - else: - self._last_value = "Inconnue" - - elif self._data == StatusRegister.STATUS_CPL: - etat = (val >> 21) & 0x03 - if etat == 0: - self._last_value = "New/Unlock" - elif etat == 1: - self._last_value = "New/Lock" - elif etat == 2: - self._last_value = "Registered" - else: - self._last_value = "Inconnue" - - elif self._data == StatusRegister.SYNCHRO_CPL: - self._last_value = "Compteur synchronisé" if ((val >> 23) & 0x01) else "Compteur non synchronisé" - - elif self._data == StatusRegister.COULEUR_JOUR_CONTRAT_TEMPO: - etat = (val >> 24) & 0x03 - if etat == 0: - self._last_value = "Pas d'annonce" - elif etat == 1: - self._last_value = "Bleu" - elif etat == 2: - self._last_value = "Blanc" - else: - self._last_value = "Rouge" - - elif self._data == StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO: - etat = (val >> 26) & 0x03 - if etat == 0: - self._last_value = "Pas d'annonce" - elif etat == 1: - self._last_value = "Bleu" - elif etat == 2: - self._last_value = "Blanc" - else: - self._last_value = "Rouge" - - elif self._data == StatusRegister.PREAVIS_POINTES_MOBILES: - etat = (val >> 28) & 0x03 - if etat == 0: - self._last_value = "Pas de préavis en cours" - elif etat == 1: - self._last_value = "Préavis PM1 en cours" - elif etat == 2: - self._last_value = "Préavis PM2 en cours" - else: - self._last_value = "Préavis PM3 en cours" - - elif self._data == StatusRegister.POINTE_MOBILE: - etat = (val >> 28) & 0x03 - if etat == 0: - self._last_value = "Pas de pointe mobile" - elif etat == 1: - self._last_value = "PM1 en cours" - elif etat == 2: - self._last_value = "PM2 en cours" - else: - self._last_value = "PM3 en cours" - - else: - # AssertionError - self._last_value = self._data.name - - except ValueError: - _LOGGER.error( - "%s: Invalid status register : %s", - self._config_title, - value, - ) + value, _ = self._update() + + if not value: + return + + try: + self._last_value = cast(str, self._field.value.get_status(value)) + except IndexError: + pass # Failsafe, value is unchanged. diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index 80633b0..d0dc66c 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -78,7 +78,7 @@ def __init__(self, title: str, port, std_mode, producer_mode, three_phase, real_ def get_values(self, tag) -> tuple[str | None, str | None]: """Get tag value and timestamp from the thread memory cache.""" - if not self.is_connected(): + if not self.is_connected: return None, None try: payload = self._values[tag] @@ -86,10 +86,12 @@ def get_values(self, tag) -> tuple[str | None, str | None]: except KeyError: return None, None + @property def has_read_full_frame(self) -> bool: """Use to known if at least one complete frame has been read on the serial connection.""" return self._frames_read >= 1 + @property def is_connected(self) -> bool: """Use to know if the reader is actually connected to a serial connection.""" if self._reader is None: diff --git a/custom_components/linkytic/status_register.py b/custom_components/linkytic/status_register.py new file mode 100644 index 0000000..2baec06 --- /dev/null +++ b/custom_components/linkytic/status_register.py @@ -0,0 +1,91 @@ +"""Definition of status register fields and handlers.""" + +from enum import Enum +from typing import NamedTuple + + +class StatusRegisterEnumValueType(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). + options is a dictionary mapping field int value to string. If no options, the value is treated as a boolean. + """ + + lsb: int + len: int = 1 + options: dict[int, str] | None = None + + def get_status(self, register: str) -> str | bool: + try: + int_register = int(register, base=16) + except TypeError: + return False + val = (int_register >> self.lsb) & (1 << (self.len - 1)) + + if self.options is None: + return bool(val) + + return self.options[val] # Let IndexError propagate if val is unknwon. + + +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 ( Date: Sun, 25 Feb 2024 21:16:33 +0100 Subject: [PATCH 13/18] feat: change binary status register field sensors to binary sensors --- custom_components/linkytic/binary_sensor.py | 126 +++++++++++++++++++- 1 file changed, 120 insertions(+), 6 deletions(-) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index e5b54f9..aee6dc4 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Optional, cast from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -16,9 +17,22 @@ from .const import DOMAIN from .serial_reader import LinkyTICReader from .entity import LinkyTICEntity +from .status_register import StatusRegister _LOGGER = logging.getLogger(__name__) +STATUS_REGISTER_SENSORS = ( + (StatusRegister.CONTACT_SEC, None, "mdi:electric-switch", "Contact sec"), + (StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, None, "mdi:toy-brick-outline", "Etat du cache-borne"), + (StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, BinarySensorDeviceClass.SAFETY, None, "Surtension"), + (StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, BinarySensorDeviceClass.PROBLEM, None, "Dépassement puissance"), + (StatusRegister.PRODUCTEUR_CONSOMMATEUR, None, "mdi:transmission-tower", "Etat producteur"), + (StatusRegister.SENS_ENERGIE_ACTIVE, None, "mdi:transmission-tower", "Sens énergie active"), + (StatusRegister.MODE_DEGRADE_HORLOGE, None, None, "Mode horloge dégradée"), + (StatusRegister.MODE_TIC, BinarySensorDeviceClass.PROBLEM, "mdi:tag", "Mode TIC"), + (StatusRegister.SYNCHRO_CPL, BinarySensorDeviceClass.CONNECTIVITY, None, "Etat synchronisation CPL"), +) + # config flow setup async def async_setup_entry( @@ -38,10 +52,20 @@ async def async_setup_entry( ) return # Init sensors - async_add_entities( - [SerialConnectivity(config_entry.title, config_entry.entry_id, serial_reader)], - True, - ) + sensors: list[BinarySensorEntity] = [ + StatusRegisterBinarySensor( + name=name, + config_title=config_entry.title, + field=field, + serial_reader=serial_reader, + unique_id=config_entry.entry_id, + device_class=devclass, + icon=icon, + ) + for field, devclass, icon, name in STATUS_REGISTER_SENSORS + ] + sensors.append(SerialConnectivity(config_entry.title, config_entry.entry_id, serial_reader)) + async_add_entities(sensors, True) class SerialConnectivity(LinkyTICEntity, BinarySensorEntity): @@ -56,14 +80,104 @@ class SerialConnectivity(LinkyTICEntity, BinarySensorEntity): # https://developers.home-assistant.io/docs/core/entity/binary-sensor/#properties _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - def __init__(self, title: str, uniq_id: str | None, serial_reader: LinkyTICReader) -> None: + def __init__(self, title: str, unique_id: str, serial_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}_{uniq_id}_serial_connectivity" + self._attr_unique_id = f"{DOMAIN}_{unique_id}_serial_connectivity" @property def is_on(self) -> bool: """Value of the sensor.""" return self._serial_controller.is_connected + + +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: str | None = None, + ) -> None: + """Initialize the status register binary sensor.""" + _LOGGER.debug("%s: initializing %s binary sensor", config_title, field.name) + super().__init__(serial_reader) + + self._config_title = config_title + self._binary_state = False # Default state. + 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 + if icon: + self._attr_icon = icon + + @property + def is_on(self) -> bool: + """Value of the sensor.""" + return self._binary_state + + def update(self) -> None: + """Update the state of the sensor.""" + value, _ = self._update() + if not value: + return + 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]]: + """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" if timestamp else "", + self._config_title, + self._tag, + repr(value), + repr(timestamp), + ) + + if not value and not timestamp: # No data returned. + if not self._serial_controller.is_connected: + _LOGGER.debug( + "%s: marking the %s sensor as unavailable: serial connection lost", + self._config_title, + 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._tag, + self._tag, + ) + self._attr_available = False + else: + # A frame has not been read yet (it should!) or is already unavailable and no new data was fetched. + # Let sensor in current availability state. + pass + return None, None + + if not self.available: + # Data is available, so is sensor + self._attr_available = True + _LOGGER.info( + "%s: marking the %s sensor as available now !", + self._config_title, + self._tag, + ) + + return value, timestamp From 06fa4bf3424226b09f271bfad99911e988b7ce93 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 26 Feb 2024 23:13:41 +0100 Subject: [PATCH 14/18] fix: revert unnecessary changes fix debug messages number of args fix log flood of availability infos --- custom_components/linkytic/binary_sensor.py | 9 ++++++--- custom_components/linkytic/config_flow.py | 2 +- custom_components/linkytic/const.py | 2 +- custom_components/linkytic/sensor.py | 11 +++++++---- custom_components/linkytic/serial_reader.py | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index aee6dc4..1f43a61 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -142,14 +142,17 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: """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" if timestamp else "", + "%s: retrieved %s value from serial controller: (%s, %s)", self._config_title, self._tag, - repr(value), - repr(timestamp), + value, + timestamp ) if not value and not timestamp: # No data returned. + if not self.available: + # Sensor is already unavailable, no need to check why. + return None, None if not self._serial_controller.is_connected: _LOGGER.debug( "%s: marking the %s sensor as unavailable: serial connection lost", diff --git a/custom_components/linkytic/config_flow.py b/custom_components/linkytic/config_flow.py index 970010d..79898fa 100644 --- a/custom_components/linkytic/config_flow.py +++ b/custom_components/linkytic/config_flow.py @@ -64,7 +64,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo if user_input is None: 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]) # TODO: switch to meter s/n + 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. diff --git a/custom_components/linkytic/const.py b/custom_components/linkytic/const.py index e863461..d76e33f 100644 --- a/custom_components/linkytic/const.py +++ b/custom_components/linkytic/const.py @@ -39,7 +39,7 @@ MODE_HISTORIC_FIELD_SEPARATOR = b"\x20" LINE_END = b"\r\n" -FRAME_END = b"\x03\x02" +FRAME_END = b"\r\x03\x02\n" SHORT_FRAME_DETECTION_TAGS = ["ADIR1", "ADIR2", "ADIR3"] SHORT_FRAME_FORCED_UPDATE_TAGS = [ diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 2a217bd..5b1b487 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -1137,7 +1137,7 @@ class LinkyTICSensor(LinkyTICEntity, SensorEntity, Generic[T]): _attr_should_poll = True _last_value: T | None - def __init__(self, tag: str, config_title: str, reader: LinkyTICReader): + def __init__(self, tag: str, config_title: str, reader: LinkyTICReader) -> None: """Init sensor entity.""" super().__init__(reader) self._last_value = None @@ -1153,14 +1153,17 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: """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" if timestamp else "", + "%s: retrieved %s value from serial controller: (%s, %s)", self._config_title, self._tag, - repr(value), - repr(timestamp), + value, + timestamp ) if not value and not timestamp: # No data returned. + if not self.available: + # Sensor is already unavailable, no need to check why. + return None, None if not self._serial_controller.is_connected: _LOGGER.debug( "%s: marking the %s sensor as unavailable: serial connection lost", diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index 89a1341..72ae33a 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -114,7 +114,7 @@ def run(self): # Reader should have been opened. assert self._reader is not None if not self._reader.is_open: - # TODO: implement a maximum retry, and go in failure mode if the connection can't be renewed. + # NOTE: implement a maximum retry, and go in failure mode if the connection can't be renewed? try: self._reader.open() except LINKY_IO_ERRORS: From 5093d22f62c39a0516a0d34cc1f983d69bcd1805 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Tue, 27 Feb 2024 15:53:30 +0100 Subject: [PATCH 15/18] fix: check ticmode before initializing binary sensors change device class for consistency add custom on/off icons --- custom_components/linkytic/binary_sensor.py | 113 ++++++++++++++------ 1 file changed, 82 insertions(+), 31 deletions(-) diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 1f43a61..2b003d6 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, SETUP_TICMODE, TICMODE_STANDARD from .serial_reader import LinkyTICReader from .entity import LinkyTICEntity from .status_register import StatusRegister @@ -22,15 +22,50 @@ _LOGGER = logging.getLogger(__name__) STATUS_REGISTER_SENSORS = ( - (StatusRegister.CONTACT_SEC, None, "mdi:electric-switch", "Contact sec"), - (StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, None, "mdi:toy-brick-outline", "Etat du cache-borne"), - (StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, BinarySensorDeviceClass.SAFETY, None, "Surtension"), - (StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, BinarySensorDeviceClass.PROBLEM, None, "Dépassement puissance"), - (StatusRegister.PRODUCTEUR_CONSOMMATEUR, None, "mdi:transmission-tower", "Etat producteur"), - (StatusRegister.SENS_ENERGIE_ACTIVE, None, "mdi:transmission-tower", "Sens énergie active"), - (StatusRegister.MODE_DEGRADE_HORLOGE, None, None, "Mode horloge dégradée"), - (StatusRegister.MODE_TIC, BinarySensorDeviceClass.PROBLEM, "mdi:tag", "Mode TIC"), - (StatusRegister.SYNCHRO_CPL, BinarySensorDeviceClass.CONNECTIVITY, None, "Etat synchronisation CPL"), + ( + StatusRegister.CONTACT_SEC, + "Contact sec", + BinarySensorDeviceClass.OPENING, + "mdi:electric-switch-closed", + "mdi-electric-switch", + False, + ), + ( + StatusRegister.ETAT_DU_CACHE_BORNE_DISTRIBUTEUR, + "Cache-borne", + BinarySensorDeviceClass.OPENING, + "mdi:toy-brick", + "mdi:toy-brick-outline", + False, + ), + ( + StatusRegister.SURTENSION_SUR_UNE_DES_PHASES, + "Surtension", + BinarySensorDeviceClass.PRESENCE, + "mdi:flash-triangle-outline", + "mdi:flash-triangle", + False, + ), + ( + StatusRegister.DEPASSEMENT_PUISSANCE_REFERENCE, + "Dépassement puissance", + BinarySensorDeviceClass.PRESENCE, + "mdi:transmission-tower", + "mdi:transmission-tower-off", + False, + ), + (StatusRegister.PRODUCTEUR_CONSOMMATEUR, "Producteur", None, "mdi:transmission-tower-export", None, False), + (StatusRegister.SENS_ENERGIE_ACTIVE, "Sens énergie active", None, "mdi:transmission-tower-export", None, False), + ( + StatusRegister.MODE_DEGRADE_HORLOGE, + "Synchronisation horloge", + BinarySensorDeviceClass.LOCK, + "mdi:sync", + "mdi:sync-off", + False, + ), + (StatusRegister.MODE_TIC, "Mode historique", None, "mdi:tag", None, False), + (StatusRegister.SYNCHRO_CPL, "Synchronisation CPL", BinarySensorDeviceClass.LOCK, "mdi:sync", "mdi:sync-off", True), ) @@ -52,19 +87,24 @@ async def async_setup_entry( ) return # Init sensors - sensors: list[BinarySensorEntity] = [ - StatusRegisterBinarySensor( - name=name, - config_title=config_entry.title, - field=field, - serial_reader=serial_reader, - unique_id=config_entry.entry_id, - device_class=devclass, - icon=icon, + sensors: list[BinarySensorEntity] = [SerialConnectivity(config_entry.title, config_entry.entry_id, 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, + ) + for field, name, devclass, icon_off, icon_on, inverted in STATUS_REGISTER_SENSORS ) - for field, devclass, icon, name in STATUS_REGISTER_SENSORS - ] - sensors.append(SerialConnectivity(config_entry.title, config_entry.entry_id, serial_reader)) + async_add_entities(sensors, True) @@ -109,7 +149,9 @@ def __init__( serial_reader: LinkyTICReader, field: StatusRegister, device_class: BinarySensorDeviceClass | None = None, - icon: str | None = None, + icon_on: str | None = None, + icon_off: str | None = None, + inverted: bool = False, ) -> None: """Initialize the status register binary sensor.""" _LOGGER.debug("%s: initializing %s binary sensor", config_title, field.name) @@ -117,18 +159,31 @@ def __init__( self._config_title = config_title 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 - if icon: - self._attr_icon = icon + + self._icon_on = icon_on + self._icon_off = icon_off @property def is_on(self) -> bool: """Value of the sensor.""" - return self._binary_state + 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.""" @@ -142,11 +197,7 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: """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._tag, - value, - timestamp + "%s: retrieved %s value from serial controller: (%s, %s)", self._config_title, self._tag, value, timestamp ) if not value and not timestamp: # No data returned. From 3ff7b3d09c67ce792f6d3cf6cab058ebbfff6578 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Tue, 27 Feb 2024 15:54:03 +0100 Subject: [PATCH 16/18] refactor: remove unused duplicate definition --- custom_components/linkytic/status_register.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/custom_components/linkytic/status_register.py b/custom_components/linkytic/status_register.py index 2baec06..8d93fd5 100644 --- a/custom_components/linkytic/status_register.py +++ b/custom_components/linkytic/status_register.py @@ -40,12 +40,6 @@ def get_status(self, register: str) -> str | bool: tarif_en_cours = {i: f"Index {i+1}" for i in range(0, 10)} -statut_sortie_euridis = { - 0: "Désactivée", - 1: "Activée dans sécurité", - 2: "Activée avec sécurité", -} - etat_euridis = { 0: "Désactivée", 1: "Activée sans sécurité", @@ -88,4 +82,4 @@ class StatusRegister(StatusRegisterEnumValueType, Enum): COULEUR_JOUR_CONTRAT_TEMPO = StatusRegisterEnumValueType(23, 2, tempo_color) COULEUR_LENDEMAIN_CONTRAT_TEMPO = StatusRegisterEnumValueType(25, 2, tempo_color) PREAVIS_POINTES_MOBILES = StatusRegisterEnumValueType(27, 2, preavis_pm) - POINTE_MOBILE = StatusRegisterEnumValueType(29, 2, pointe_mobile) \ No newline at end of file + POINTE_MOBILE = StatusRegisterEnumValueType(29, 2, pointe_mobile) From f26a7dea6dd9cda2e72cf66f0171f72e325c865a Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Tue, 27 Feb 2024 16:55:38 +0100 Subject: [PATCH 17/18] chore: add usb to dependencies --- custom_components/linkytic/manifest.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/linkytic/manifest.json b/custom_components/linkytic/manifest.json index 51fbc6c..f5a5642 100644 --- a/custom_components/linkytic/manifest.json +++ b/custom_components/linkytic/manifest.json @@ -5,7 +5,9 @@ "@hekmon" ], "config_flow": true, - "dependencies": [], + "dependencies": [ + "usb" + ], "documentation": "https://github.com/hekmon/linkytic/tree/v3.0.0-beta3", "iot_class": "local_polling", "issue_tracker": "https://github.com/hekmon/linkytic/issues", From c3695e09357117d721de6a7d5f6cd4c380ddbe6c Mon Sep 17 00:00:00 2001 From: Gilles Grandou Date: Sun, 7 Jul 2024 16:03:05 +0200 Subject: [PATCH 18/18] sanitize string sensor values * trim left and right spaces * remove duplicated spaces between words * example: " HP BLEU " -> "HP BLEU" --- custom_components/linkytic/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 844dbd2..6d486d1 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -1512,7 +1512,8 @@ def update(self): ) self._attr_available = True # Save value - self._last_value = value + # remove useless spaces + self._last_value = ' '.join(value.split()) class RegularIntSensor(SensorEntity):