From 7cf9e7ecb9288a4f9fbaab197ff843b4fb7daa13 Mon Sep 17 00:00:00 2001 From: Tom Benezech Date: Mon, 5 Feb 2024 23:37:17 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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