diff --git a/.github/workflows/hacs.yaml b/.github/workflows/hacs.yaml index b8b0be0..0db318f 100644 --- a/.github/workflows/hacs.yaml +++ b/.github/workflows/hacs.yaml @@ -15,4 +15,4 @@ jobs: - name: HACS validation uses: "hacs/action@main" with: - category: "integration" \ No newline at end of file + category: "integration" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a73a15a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +# Adapted from https://github.com/home-assistant/core/blob/dev/.pre-commit-config.yaml +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.3 + hooks: + - id: ruff + args: [--fix, --select, I] + - id: ruff-format + files: ^((custom_components)/.+)?[^/]+\.(py|pyi)$ + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: + - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn + - --skip="./.*,*.csv,*.json,*.ambr,*.md" + - --quiet-level=2 + exclude_types: [csv, json, html, markdown] + exclude: ^serialserver/|res/ + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.0.3 + hooks: + - id: prettier + - repo: local + hooks: + # Run mypy through our wrapper script in order to get the possible + # pyenv and/or virtualenv activated; it may not have been e.g. if + # committing from a GUI tool that was not launched from an activated + # shell. + - id: mypy + name: mypy + entry: mypy + language: python + types_or: [python, pyi] + args: + - --ignore-missing-imports + require_serial: true + files: ^(custom_components)/.+\.(py|pyi)$ diff --git a/README.md b/README.md index e25bcb8..ab6629a 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ Cette intégration pour Home Assistant ajoute le support des Linky au travers de Par exemple: -* [Module série USB développé par LiXee](https://lixee.fr/produits/30-tic-din-3770014375070.html) (celui que j'utilise) -* [Téléinfo 1 compteur USB rail DIN de Cartelectronic](https://www.cartelectronic.fr/teleinfo-compteur-enedis/17-teleinfo-1-compteur-usb-rail-din-3760313520028.html) (validé par un [utilisateur](https://github.com/hekmon/linkytic/issues/2#issuecomment-1364535337)) -* [Circuit à faire soi-même](https://miniprojets.net/index.php/2019/06/28/recuperer-les-donnees-de-son-compteur-linky/), nécessitant peu de composants ([autre article avec un circuit similaire](https://hallard.me/pitinfov12/)). Validé par un [utilisateur](https://github.com/hekmon/linkytic/pull/4#issuecomment-1368877730). -* [Module Micro Téléinfo V3.0](https://github.com/hallard/uTeleinfo) à fabriquer soi-même ou pré-assemblé sur [Tindie](https://www.tindie.com/products/28873/) -* [Teleinfo ADTEK](https://doc.eedomus.com/view/T%C3%A9l%C3%A9info_USB_ADTEK) attention cependant [le baudrate ne semble pas standard](https://github.com/hekmon/linkytic/issues/40). -* et certainement bien d'autres ! (n'hésitez pas à m'ouvrir une issue pour rajouter le votre si vous avez validé que celui-ci fonctionne avec cette intégration afin d'aidez de potentiels futurs utilisateurs qui n'en auraient pas encore choisi un) +- [Module série USB développé par LiXee](https://lixee.fr/produits/30-tic-din-3770014375070.html) (celui que j'utilise) +- [Téléinfo 1 compteur USB rail DIN de Cartelectronic](https://www.cartelectronic.fr/teleinfo-compteur-enedis/17-teleinfo-1-compteur-usb-rail-din-3760313520028.html) (validé par un [utilisateur](https://github.com/hekmon/linkytic/issues/2#issuecomment-1364535337)) +- [Circuit à faire soi-même](https://miniprojets.net/index.php/2019/06/28/recuperer-les-donnees-de-son-compteur-linky/), nécessitant peu de composants ([autre article avec un circuit similaire](https://hallard.me/pitinfov12/)). Validé par un [utilisateur](https://github.com/hekmon/linkytic/pull/4#issuecomment-1368877730). +- [Module Micro Téléinfo V3.0](https://github.com/hallard/uTeleinfo) à fabriquer soi-même ou pré-assemblé sur [Tindie](https://www.tindie.com/products/28873/) +- [Teleinfo ADTEK](https://doc.eedomus.com/view/T%C3%A9l%C3%A9info_USB_ADTEK) attention cependant [le baudrate ne semble pas standard](https://github.com/hekmon/linkytic/issues/40). +- et certainement bien d'autres ! (n'hésitez pas à m'ouvrir une issue pour rajouter le votre si vous avez validé que celui-ci fonctionne avec cette intégration afin d'aidez de potentiels futurs utilisateurs qui n'en auraient pas encore choisi un) [Exemple sous Home Assistant](https://github.com/hekmon/linkytic/raw/v3.0.0-beta4/res/SCR-20221223-ink.png). @@ -34,8 +34,8 @@ Cependant, certaines sondes peuvent avoir de la valeur dans leur "instantanéit Suivant la configuration que vous choisirez pour votre installation vous trouverez dans ce fichier dans la liste des sondes avec les annotations suivantes: -* 1 sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série) -* 2 sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère +- 1 sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série) +- 2 sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère ### Mode historique @@ -45,29 +45,29 @@ Le mode historique est le plus commun (existant pré Linky) : il est activé par Les 23 champs des compteurs mono-phasé configurés en mode historique sont supportés: -* `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) -* `OPTARIF` Option tarifaire choisie -* `ISOUSC` Intensité souscrite -* `BASE` Index option Base -* `HCHC` Index option Heures Creuses - Heures Creuses -* `HCHP` Index option Heures Creuses - Heures Pleines -* `EJPHN` Index option EJP - Heures Normales -* `EJPHPM` Index option EJP - Heures de Pointe Mobile -* `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus -* `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus -* `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs -* `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs -* `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges -* `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges -* `PEJP` Préavis Début EJP (30 min) -* `PTEC` Période Tarifaire en cours -* `DEMAIN` Couleur du lendemain -* `IINST` Intensité Instantanée 1 -* `ADPS` Avertissement de Dépassement De Puissance Souscrite 2 -* `IMAX` Intensité maximale appelée -* `PAPP` Puissance apparente 1 -* `HHPHC` Horaire Heures Pleines Heures Creuses -* `MOTDETAT` Mot d'état du compteur +- `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) +- `OPTARIF` Option tarifaire choisie +- `ISOUSC` Intensité souscrite +- `BASE` Index option Base +- `HCHC` Index option Heures Creuses - Heures Creuses +- `HCHP` Index option Heures Creuses - Heures Pleines +- `EJPHN` Index option EJP - Heures Normales +- `EJPHPM` Index option EJP - Heures de Pointe Mobile +- `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus +- `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus +- `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs +- `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs +- `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges +- `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges +- `PEJP` Préavis Début EJP (30 min) +- `PTEC` Période Tarifaire en cours +- `DEMAIN` Couleur du lendemain +- `IINST` Intensité Instantanée 1 +- `ADPS` Avertissement de Dépassement De Puissance Souscrite 2 +- `IMAX` Intensité maximale appelée +- `PAPP` Puissance apparente 1 +- `HHPHC` Horaire Heures Pleines Heures Creuses +- `MOTDETAT` Mot d'état du compteur #### Compteurs tri-phasés @@ -75,36 +75,36 @@ Les 23 champs des compteurs mono-phasé configurés en mode historique sont supp Des retours de log en `DEBUG` pendant l'émission de trames courtes sont nécessaires pour valider le bon fonctionnement de l'intégration sur ces compteurs, n'hésitez pas à ouvrir une [issue](https://github.com/hekmon/linkytic/issues) si vous avec un compteur triphasé pour aider à sa finalisation ! -* `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) -* `OPTARIF` Option tarifaire choisie -* `ISOUSC` Intensité souscrite -* `BASE` Index option Base -* `HCHC` Index option Heures Creuses - Heures Creuses -* `HCHP` Index option Heures Creuses - Heures Pleines -* `EJPHN` Index option EJP - Heures Normales -* `EJPHPM` Index option EJP - Heures de Pointe Mobile -* `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus -* `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus -* `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs -* `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs -* `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges -* `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges -* `PEJP` Préavis Début EJP (30 min) -* `PTEC` Période Tarifaire en cours -* `DEMAIN` Couleur du lendemain -* `IINST1` Intensité Instantanée (phase 1) 1 pour les trames longues 2 pour les trames courtes -* `IINST2` Intensité Instantanée (phase 2) 1 pour les trames longues 2 pour les trames courtes -* `IINST3` Intensité Instantanée (phase 3) 1 pour les trames longues 2 pour les trames courtes -* `IMAX1` Intensité maximale (phase 1) -* `IMAX2` Intensité maximale (phase 2) -* `IMAX3` Intensité maximale (phase 3) -* `PMAX` Puissance maximale triphasée atteinte -* `PAPP` Puissance apparente 1 -* `HHPHC` Horaire Heures Pleines Heures Creuses -* `MOTDETAT` Mot d'état du compteur -* `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) 2 trames courtes uniquement -* `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) 2 trames courtes uniquement -* `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) 2 trames courtes uniquement +- `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) +- `OPTARIF` Option tarifaire choisie +- `ISOUSC` Intensité souscrite +- `BASE` Index option Base +- `HCHC` Index option Heures Creuses - Heures Creuses +- `HCHP` Index option Heures Creuses - Heures Pleines +- `EJPHN` Index option EJP - Heures Normales +- `EJPHPM` Index option EJP - Heures de Pointe Mobile +- `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus +- `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus +- `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs +- `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs +- `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges +- `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges +- `PEJP` Préavis Début EJP (30 min) +- `PTEC` Période Tarifaire en cours +- `DEMAIN` Couleur du lendemain +- `IINST1` Intensité Instantanée (phase 1) 1 pour les trames longues 2 pour les trames courtes +- `IINST2` Intensité Instantanée (phase 2) 1 pour les trames longues 2 pour les trames courtes +- `IINST3` Intensité Instantanée (phase 3) 1 pour les trames longues 2 pour les trames courtes +- `IMAX1` Intensité maximale (phase 1) +- `IMAX2` Intensité maximale (phase 2) +- `IMAX3` Intensité maximale (phase 3) +- `PMAX` Puissance maximale triphasée atteinte +- `PAPP` Puissance apparente 1 +- `HHPHC` Horaire Heures Pleines Heures Creuses +- `MOTDETAT` Mot d'état du compteur +- `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) 2 trames courtes uniquement +- `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) 2 trames courtes uniquement +- `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) 2 trames courtes uniquement ### Mode standard @@ -118,13 +118,13 @@ Une fois que votre module TIC est installé et connecté à votre compteur ainsi Exemple de configuration pour le module de [LiXee](https://faire-ca-soi-meme.fr/domotique/2016/09/12/module-teleinformation-tic/): -* Mode historique +- Mode historique ```bash stty -F /dev/ttyUSB0 1200 sane evenp parenb cs7 -crtscts ``` -* Mode standard +- Mode standard ```bash stty -F /dev/ttyUSB0 9600 sane evenp parenb cs7 -crtscts @@ -162,9 +162,9 @@ Une fois Home Assistant redémarré, allez dans: `Paramètres -> Appareils et se Vous devriez passer sur le formulaire d'installation vous présentant les 3 champs suivants: -* `Chemin/Adresse vers le périphérique série` Ici renseignez le path de votre périphérique USB testé précédement. Le champ est rempli par default avec la valeur `/dev/ttyUSB0`: Il ne s'agit pas d'une auto détection mais simplement de la valeure la plus probable dans 99% des installations. Il est aussi possible d'utiliser une URL supporté par [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html), ce qui peut s'avérer utile si le port série est connecté sur un appareil distant (support de la rfc2217 par exemple). -* `Mode TIC` Choississez entre `Standard` et `Historique`. Plus de détails sur ces 2 modes en début de ce document. -* `Triphasé` À cocher si votre compteur est un compteur... triphasé. À noter que cette option n'a d'effet que si vous êtes en mode historique (le mode standard gère le mono et le tri de manière indifférente). +- `Chemin/Adresse vers le périphérique série` Ici renseignez le path de votre périphérique USB testé précédement. Le champ est rempli par default avec la valeur `/dev/ttyUSB0`: Il ne s'agit pas d'une auto détection mais simplement de la valeure la plus probable dans 99% des installations. Il est aussi possible d'utiliser une URL supporté par [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html), ce qui peut s'avérer utile si le port série est connecté sur un appareil distant (support de la rfc2217 par exemple). +- `Mode TIC` Choississez entre `Standard` et `Historique`. Plus de détails sur ces 2 modes en début de ce document. +- `Triphasé` À cocher si votre compteur est un compteur... triphasé. À noter que cette option n'a d'effet que si vous êtes en mode historique (le mode standard gère le mono et le tri de manière indifférente). Validez et patientez pendant le temps du test. Celui-ci va tenter d'ouvrir une connection série sur le périphérique désigné et d'y lire au moins une ligne. En cas d'erreur, celle-ci vous sera retourné à l'écran de configuration. Sinon, votre nouvelle intégration est prête et disponible dans la liste des intégrations de la page où vous vous trouvez. diff --git a/custom_components/linkytic/__init__.py b/custom_components/linkytic/__init__.py index f6fb455..d6d7abc 100644 --- a/custom_components/linkytic/__init__.py +++ b/custom_components/linkytic/__init__.py @@ -1,24 +1,25 @@ """The linkytic integration.""" from __future__ import annotations -import asyncio +import asyncio import logging -from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady +from homeassistant.components import usb +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 homeassistant.exceptions import ConfigEntryNotReady from .const import ( DOMAIN, + LINKY_IO_ERRORS, OPTIONS_REALTIME, + SETUP_PRODUCER, SETUP_SERIAL, SETUP_THREEPHASE, SETUP_TICMODE, - SETUP_PRODUCER, TICMODE_STANDARD, - LINKY_IO_ERRORS, ) from .serial_reader import LinkyTICReader @@ -45,6 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def read_serial_number(serial: LinkyTICReader): while serial.serial_number is None: await asyncio.sleep(1) + # Check for any serial error that occurred in the serial thread context + if serial.setup_error: + raise serial.setup_error return serial.serial_number s_n = await asyncio.wait_for(read_serial_number(serial_reader), timeout=5) @@ -103,14 +107,18 @@ 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}. " @@ -120,7 +128,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): new[SETUP_SERIAL] = serial_by_id # config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry, data=new, minor_version=2, version=1) # type: ignore + hass.config_entries.async_update_entry( + config_entry, data=new, minor_version=2, version=1 + ) # type: ignore _LOGGER.info( "Migration to version %d.%d successful", diff --git a/custom_components/linkytic/binary_sensor.py b/custom_components/linkytic/binary_sensor.py index 3d7cf62..b6aa86d 100644 --- a/custom_components/linkytic/binary_sensor.py +++ b/custom_components/linkytic/binary_sensor.py @@ -10,13 +10,13 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, SETUP_TICMODE, TICMODE_STANDARD -from .serial_reader import LinkyTICReader from .entity import LinkyTICEntity +from .serial_reader import LinkyTICReader from .status_register import StatusRegister _LOGGER = logging.getLogger(__name__) @@ -54,8 +54,22 @@ "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.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", @@ -65,7 +79,14 @@ False, ), (StatusRegister.MODE_TIC, "Mode historique", None, "mdi:tag", None, False), - (StatusRegister.SYNCHRO_CPL, "Synchronisation CPL", BinarySensorDeviceClass.LOCK, "mdi:sync", "mdi:sync-off", True), + ( + StatusRegister.SYNCHRO_CPL, + "Synchronisation CPL", + BinarySensorDeviceClass.LOCK, + "mdi:sync", + "mdi:sync-off", + True, + ), ) @@ -87,7 +108,9 @@ async def async_setup_entry( ) return # Init sensors - sensors: list[BinarySensorEntity] = [SerialConnectivity(config_entry.title, config_entry.entry_id, serial_reader)] + sensors: list[BinarySensorEntity] = [ + SerialConnectivity(config_entry.title, config_entry.entry_id, serial_reader) + ] if config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD: sensors.extend( @@ -120,7 +143,9 @@ 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, unique_id: str, 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) @@ -197,7 +222,11 @@ 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. diff --git a/custom_components/linkytic/config_flow.py b/custom_components/linkytic/config_flow.py index 79898fa..80f90d9 100644 --- a/custom_components/linkytic/config_flow.py +++ b/custom_components/linkytic/config_flow.py @@ -3,29 +3,33 @@ from __future__ import annotations # import dataclasses +import asyncio import logging from typing import Any import voluptuous as vol - -from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) # from homeassistant.components.usb import UsbServiceInfo 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, OPTIONS_REALTIME, + SETUP_PRODUCER, + SETUP_PRODUCER_DEFAULT, SETUP_SERIAL, SETUP_SERIAL_DEFAULT, SETUP_THREEPHASE, SETUP_THREEPHASE_DEFAULT, SETUP_TICMODE, - SETUP_PRODUCER, - SETUP_PRODUCER_DEFAULT, TICMODE_HISTORIC, TICMODE_HISTORIC_LABEL, TICMODE_STANDARD, @@ -41,8 +45,12 @@ vol.Required(SETUP_TICMODE, default=TICMODE_HISTORIC): selector.SelectSelector( # type: ignore 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 + ), ] ), ), @@ -52,28 +60,36 @@ ) -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class LinkyTICConfigFlow(ConfigFlow, domain=DOMAIN): # type:ignore """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 + ) -> ConfigFlowResult: """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]) 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]) + _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( + # Encapsulate the tester function, pyserial rfc2217 implementation have blocking calls. + await asyncio.to_thread( + linky_tic_tester, device=_port, std_mode=user_input[SETUP_TICMODE] == TICMODE_STANDARD, ) @@ -81,7 +97,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo _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) @@ -90,7 +108,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo 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.""" @@ -99,20 +119,22 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo @staticmethod @callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: + config_entry: ConfigEntry, + ) -> OptionsFlow: """Create the options flow.""" return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(config_entries.OptionsFlow): +class OptionsFlowHandler(OptionsFlow): """Handles the options of a Linky TIC connection.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + def __init__(self, config_entry: 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 + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/custom_components/linkytic/const.py b/custom_components/linkytic/const.py index 7244aac..c95b41c 100644 --- a/custom_components/linkytic/const.py +++ b/custom_components/linkytic/const.py @@ -1,7 +1,8 @@ """Constants for the linkytic integration.""" from termios import error -from serial import SerialException, SEVENBITS, PARITY_EVEN, STOPBITS_ONE + +from serial import PARITY_EVEN, SEVENBITS, STOPBITS_ONE, SerialException DOMAIN = "linkytic" @@ -103,8 +104,8 @@ "32": "SIAME", "33": "LARSEN & TOUBRO Limited", "34": "ELSTER / HONEYWELL", - "35": "ELECTRONIC AFZAR AZMA", - "36": "ADVANCED ELECTRONIC COMPANY Ldt", # is actually COMPA G NY but codespell does not support inline ignore... https://github.com/codespell-project/codespell/issues/1212 + "35": "ELECTRONIC AFZAR AZMA", # codespell:ignore + "36": "ADVANCED ELECTRONIC COMPAGNY Ldt", # codespell:ignore "37": "AEM", "38": "ZHEJIANG CHINT INSTRUMENT & METER CO. Ldt", "39": "ZIV", @@ -157,4 +158,5 @@ "76": "Compteur triphasé 60 A généralisation Linky G3 - arrivée puissance basse", } -EXPERIMENTAL_DEVICES = ("67", "68") \ No newline at end of file +# Some early "pilote" linky have slightly different tags. +EXPERIMENTAL_DEVICES = ("67", "68") diff --git a/custom_components/linkytic/entity.py b/custom_components/linkytic/entity.py index 93543f7..6f4f3f3 100644 --- a/custom_components/linkytic/entity.py +++ b/custom_components/linkytic/entity.py @@ -1,14 +1,15 @@ """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 homeassistant.helpers.entity import Entity from .const import ( - DID_DEFAULT_MANUFACTURER, DID_CONSTRUCTOR, + DID_DEFAULT_MANUFACTURER, DID_DEFAULT_MODEL, DID_DEFAULT_NAME, DID_REGNUMBER, diff --git a/custom_components/linkytic/sensor.py b/custom_components/linkytic/sensor.py index 635c8ff..e25f509 100644 --- a/custom_components/linkytic/sensor.py +++ b/custom_components/linkytic/sensor.py @@ -2,15 +2,12 @@ from __future__ import annotations -from collections.abc import Callable import logging -from typing import Generic, Optional, TypeVar, cast +from collections.abc import Callable +from typing import Generic, Iterable, Optional, TypeVar, cast -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) +from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, @@ -21,9 +18,9 @@ UnitOfPower, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import LinkyTICEntity from .const import ( DID_CONSTRUCTOR, DID_CONSTRUCTOR_CODE, @@ -32,12 +29,13 @@ DID_TYPE_CODE, DID_YEAR, DOMAIN, + EXPERIMENTAL_DEVICES, SETUP_PRODUCER, SETUP_THREEPHASE, SETUP_TICMODE, TICMODE_STANDARD, - EXPERIMENTAL_DEVICES, ) +from .entity import LinkyTICEntity from .serial_reader import LinkyTICReader from .status_register import StatusRegister @@ -63,10 +61,12 @@ async def async_setup_entry( return # Flag for experimental counters which have slightly different tags. - is_pilot: bool = serial_reader.device_identification[DID_TYPE_CODE] in EXPERIMENTAL_DEVICES + is_pilot: bool = ( + serial_reader.device_identification[DID_TYPE_CODE] in EXPERIMENTAL_DEVICES + ) # Init sensors - sensors = [] + sensors: Iterable[Entity] if config_entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD: # standard mode sensors = [ @@ -286,7 +286,7 @@ async def async_setup_entry( config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, - state_class=SensorStateClass.MEASUREMENT, # Is this a curent value? + state_class=SensorStateClass.MEASUREMENT, # Should this be considered an instantaneous value? register_callback=True, ), LinkyTICStringSensor( @@ -408,7 +408,7 @@ async def async_setup_entry( ), LinkyTICStringSensor( tag="STGE", - name="Registre de statuts", + name="Registre de statuts", # codespell:ignore config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, @@ -471,7 +471,7 @@ async def async_setup_entry( field=StatusRegister.COULEUR_LENDEMAIN_CONTRAT_TEMPO, ), LinkyTICStatusRegisterSensor( - name="Statut préavis pointes mobiles", + name="Statut préavis pointes mobiles", # codespell:ignore config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, @@ -804,7 +804,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 Normales", # codespell:ignore config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, @@ -899,7 +899,7 @@ async def async_setup_entry( ), LinkyTICStringSensor( tag="MOTDETAT", - name="Mo" + "t d'état du compteur", # workaround for codespell in HA pre commit hook + name="Mot d'état du compteur", # codespell:ignore config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, @@ -983,7 +983,7 @@ async def async_setup_entry( sensors.append( LinkyTICStringSensor( tag="PPOT", - name="Présence des potentiels", + name="Présence des potentiels", # codespell:ignore config_title=config_entry.title, config_uniq_id=config_entry.entry_id, serial_reader=serial_reader, @@ -1021,7 +1021,9 @@ 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( @@ -1055,7 +1057,9 @@ async def async_setup_entry( serial_reader=serial_reader, ) ) - _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) @@ -1078,7 +1082,7 @@ def __init__(self, tag: str, config_title: str, reader: LinkyTICReader) -> None: self._config_title = config_title @property - def native_value(self) -> T | None: + def native_value(self) -> T | None: # type:ignore """Value of the sensor.""" return self._last_value @@ -1090,7 +1094,7 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: self._config_title, self._tag, value, - timestamp + timestamp, ) if not value and not timestamp: # No data returned. @@ -1131,7 +1135,7 @@ def _update(self) -> tuple[Optional[str], Optional[str]]: class ADSSensor(LinkyTICSensor[str]): - """Ad resse du compteur entity.""" + """Adresse du compteur entity.""" # codespell:ignore # ADSSensor is a subclass and not an instance of StringSensor because it binds to two tags. @@ -1139,10 +1143,16 @@ class ADSSensor(LinkyTICSensor[str]): # https://developers.home-assistant.io/docs/core/entity#generic-properties _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "A" + "dress" + "e du compteur" # workaround for codespell in HA pre commit hook + _attr_name = "Adresse du compteur" # codespell:ignore _attr_icon = "mdi:tag" - def __init__(self, config_title: str, tag: str, config_uniq_id: str, serial_reader: LinkyTICReader) -> 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__(tag, config_title, serial_reader) @@ -1239,7 +1249,9 @@ def __init__( self._attr_name = name 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 @@ -1266,7 +1278,11 @@ def update(self): value_int = int(value) except ValueError: return - self._last_value = self._conversion_function(value_int) if self._conversion_function else value_int + 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.""" @@ -1277,7 +1293,9 @@ 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( @@ -1285,9 +1303,7 @@ 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) @@ -1336,7 +1352,9 @@ class PEJPSensor(LinkyTICStringSensor): # _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__( @@ -1463,7 +1481,9 @@ def __init__( icon=icon, enabled_by_default=enabled_by_default, ) - self._attr_unique_id = f"{DOMAIN}_{config_uniq_id}_{field.name.lower()}" # Breaking changes here. + 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()) diff --git a/custom_components/linkytic/serial_reader.py b/custom_components/linkytic/serial_reader.py index 72ae33a..ba4bf3c 100644 --- a/custom_components/linkytic/serial_reader.py +++ b/custom_components/linkytic/serial_reader.py @@ -2,14 +2,13 @@ from __future__ import annotations -from collections.abc import Callable import logging import threading import time +from collections.abc import Callable import serial import serial.serialutil - from homeassistant.core import callback from .const import ( @@ -41,8 +40,17 @@ 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: + 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._setup_error: BaseException | None = None self._stopsignal = False self._title = title # Options @@ -51,7 +59,9 @@ def __init__(self, title: str, port, std_mode, producer_mode, three_phase, real_ 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._three_phase = three_phase @@ -73,9 +83,6 @@ def __init__(self, title: str, port, std_mode, producer_mode, three_phase, real_ 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: @@ -108,8 +115,18 @@ def port(self) -> str: """Returns serial port.""" return self._port + @property + def setup_error(self) -> BaseException | None: + """If the reader thread terminates due to a serial exception, this property will contain the raised exception.""" + return self._setup_error + def run(self): """Continuously read the the serial connection and extract TIC values.""" + + if not self._open_serial(): + # Serial error, do not start reader thread + return + while not self._stopsignal: # Reader should have been opened. assert self._reader is not None @@ -133,7 +150,7 @@ def run(self): self._reset_state() self._reader.close() continue - + # Parse the line if non empty (prevent errors from read timeout that returns empty byte string) if not line: continue @@ -156,10 +173,15 @@ def run(self): # 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) + _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: + 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": @@ -192,7 +214,9 @@ 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): @@ -218,18 +242,27 @@ def _cleanup_cache(self): pass self._tags_seen = [] - def _open_serial(self): + def _open_serial(self) -> bool: """Create (and open) the serial connection.""" 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) + + # Because we run in the thread context, we need to catch any exceptions and save them to report to the main thread. + try: + self._reader = serial.serial_for_url( + url=self._port, + baudrate=self._baudrate, + bytesize=BYTESIZE, + parity=PARITY, + stopbits=STOPBITS, + timeout=1, + ) + except BaseException as e: + self._setup_error = e + self._stopsignal = True + return False + else: + _LOGGER.info("Serial connection is now open at %s", self._port) + return True def _reset_state(self): """Reinitialize the controller (by nullifying it) and wait 5s for other methods to re start init after a pause.""" @@ -327,7 +360,9 @@ 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 @@ -347,10 +382,14 @@ def _validate_checksum(self, tag: bytes, timestamp: bytes | None, value: bytes, # 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, @@ -358,7 +397,9 @@ def _validate_checksum(self, tag: bytes, timestamp: bytes | None, value: bytes, 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): @@ -389,7 +430,9 @@ def parse_ads(self, ads): # # 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,14 +443,22 @@ def parse_ads(self, ads): # # Parse device type code device_identification[DID_TYPE_CODE] = ads[4:6] try: - device_identification[DID_TYPE] = f"{DEVICE_TYPES[device_identification[DID_TYPE_CODE]]}" + 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 # Parsing done - _LOGGER.debug("%s: parsed ADS: %s", self._title, repr(self.device_identification)) + _LOGGER.debug( + "%s: parsed ADS: %s", self._title, repr(self.device_identification) + ) class InvalidChecksum(Exception): @@ -475,7 +526,9 @@ 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() diff --git a/custom_components/linkytic/status_register.py b/custom_components/linkytic/status_register.py index 015c5cd..30b4f65 100644 --- a/custom_components/linkytic/status_register.py +++ b/custom_components/linkytic/status_register.py @@ -25,7 +25,7 @@ def get_status(self, register: str) -> str | bool: if self.options is None: return bool(val) - return self.options[val] # Let IndexError propagate if val is unknwon. + return self.options[val] # Let IndexError propagate if val is unknown. organe_coupure = { @@ -48,9 +48,19 @@ def get_status(self, register: str) -> str | bool: statut_cpl = {0: "New/Unlock", 1: "New/Lock", 2: "Registered"} -tempo_color = {0: "Pas d'annonce", 1: "Bleu", 2: "Blanc", 3: "Rouge"} +tempo_color = { + 0: "Pas d'annonce", + 1: "Bleu", + 2: "Blanc", + 3: "Rouge", # codespell:ignore +} -preavis_pm = {0: "Pas de préavis en cours", 1: "Préavis PM1 en cours", 2: "Préavis PM2 en cours", 3: "Préavis PM3 en cours"} +preavis_pm = { + 0: "Pas de préavis en cours", + 1: "Préavis PM1 en cours", + 2: "Préavis PM2 en cours", + 3: "Préavis PM3 en cours", +} pointe_mobile = { 0: "Pas de pointe mobile", diff --git a/custom_components/linkytic/strings.json b/custom_components/linkytic/strings.json index 1e434e2..5ecf036 100644 --- a/custom_components/linkytic/strings.json +++ b/custom_components/linkytic/strings.json @@ -32,4 +32,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/linkytic/translations/en.json b/custom_components/linkytic/translations/en.json index 40059b8..a9368ed 100644 --- a/custom_components/linkytic/translations/en.json +++ b/custom_components/linkytic/translations/en.json @@ -1,34 +1,34 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured" + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect (check the logs)", + "cannot_read": "Serial open successfully but an error occured while reading a line (check the logs)", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "serial_device": "Path/URL to the serial device", + "three_phase": "Three-Phase", + "tic_mode": "TIC mode", + "producer_mode": "Producer mode (standard mode only)" }, - "error": { - "cannot_connect": "Failed to connect (check the logs)", - "cannot_read": "Serial open successfully but an error occured while reading a line (check the logs)", - "unknown": "Unexpected error" + "description": "If you need help to fill this form, please check the [readme](https://github.com/hekmon/linkytic/tree/v2)." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "real_time": "Real time mode for compatible sensors \u26a0\ufe0f" }, - "step": { - "user": { - "data": { - "serial_device": "Path/URL to the serial device", - "three_phase": "Three-Phase", - "tic_mode": "TIC mode", - "producer_mode": "Producer mode (standard mode only)" - }, - "description": "If you need help to fill this form, please check the [readme](https://github.com/hekmon/linkytic/tree/v2)." - } - } - }, - "options": { - "step": { - "init": { - "data": { - "real_time": "Real time mode for compatible sensors \u26a0\ufe0f" - }, - "description": "Real time will update Home Assistant as soon as a new value is read and will not wait for Home Assistant to query the sensor: expect CPU usage and database size to increase !", - "title": "Linky TIC - Options" - } - } + "description": "Real time will update Home Assistant as soon as a new value is read and will not wait for Home Assistant to query the sensor: expect CPU usage and database size to increase !", + "title": "Linky TIC - Options" + } } + } } diff --git a/custom_components/linkytic/translations/fr.json b/custom_components/linkytic/translations/fr.json index 8965a04..857cc96 100644 --- a/custom_components/linkytic/translations/fr.json +++ b/custom_components/linkytic/translations/fr.json @@ -1,35 +1,35 @@ { - "config": { - "abort": { - "already_configured": "Le périphérique est déjà configuré" + "config": { + "abort": { + "already_configured": "Le périphérique est déjà configuré" + }, + "error": { + "cannot_connect": "Erreur de connection (vérifiez les logs)", + "cannot_read": "La connection série a été ouverte avec succès mais une erreur est survenue pendant la lecture (vérifiez les logs)", + "unknown": "Erreur inattendue", + "unsupported_standard": "Le mode de transmission standard n'est pas encore supporté. Si vous souhaitez aider le développement, n'hésitez pas à ouvrir une issue: https://github.com/hekmon/linkytic/issues/new" + }, + "step": { + "user": { + "data": { + "serial_device": "Chemin/Adresse vers le périphérique série", + "three_phase": "Triphasé", + "tic_mode": "Mode TIC", + "producer_mode": "Mode producteur (seulement pour le mode standard)" }, - "error": { - "cannot_connect": "Erreur de connection (vérifiez les logs)", - "cannot_read": "La connection série a été ouverte avec succès mais une erreur est survenue pendant la lecture (vérifiez les logs)", - "unknown": "Erreur inattendue", - "unsupported_standard": "Le mode de transmission standard n'est pas encore supporté. Si vous souhaitez aider le développement, n'hésitez pas à ouvrir une issue: https://github.com/hekmon/linkytic/issues/new" + "description": "Si vous avez besoin d'aide pour remplir ces champs de configuration, allez voir le fichier [lisezmoi](https://github.com/hekmon/linkytic/tree/v2)." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "real_time": "Mode temps réel pour les senseurs compatibles \u26a0\ufe0f" }, - "step": { - "user": { - "data": { - "serial_device": "Chemin/Adresse vers le périphérique série", - "three_phase": "Triphasé", - "tic_mode": "Mode TIC", - "producer_mode": "Mode producteur (seulement pour le mode standard)" - }, - "description": "Si vous avez besoin d'aide pour remplir ces champs de configuration, allez voir le fichier [lisezmoi](https://github.com/hekmon/linkytic/tree/v2)." - } - } - }, - "options": { - "step": { - "init": { - "data": { - "real_time": "Mode temps réel pour les senseurs compatibles \u26a0\ufe0f" - }, - "description": "Le mode temps réel poussera Home Assistant à mettre à jour certaines valeurs aussi tôt qu'elle seront lu sur le port série plutôt que de les stocker en mémoire puis d'attendre qu'Home Assistant viennent les récupérer: cela consommera plus de CPU et occupera plus d'espace disque !", - "title": "Linky TIC - Options" - } - } + "description": "Le mode temps réel poussera Home Assistant à mettre à jour certaines valeurs aussi tôt qu'elle seront lu sur le port série plutôt que de les stocker en mémoire puis d'attendre qu'Home Assistant viennent les récupérer: cela consommera plus de CPU et occupera plus d'espace disque !", + "title": "Linky TIC - Options" + } } + } } diff --git a/hacs.json b/hacs.json index ff06961..61ccd33 100644 --- a/hacs.json +++ b/hacs.json @@ -1,8 +1,6 @@ { - "name": "Linky TIC", - "hide_default_branch": true, - "homeassistant": "2023.11.0", - "country": [ - "FR" - ] -} \ No newline at end of file + "name": "Linky TIC", + "hide_default_branch": true, + "homeassistant": "2023.11.0", + "country": ["FR"] +} diff --git a/info.md b/info.md index 1f5b4d6..e2a7b58 100644 --- a/info.md +++ b/info.md @@ -4,12 +4,12 @@ Cette intégration pour Home Assistant ajoute le support des Linky au travers de Par exemple: -* [Module série USB développé par LiXee](https://lixee.fr/produits/30-tic-din-3770014375070.html) (celui que j'utilise) -* [Téléinfo 1 compteur USB rail DIN de Cartelectronic](https://www.cartelectronic.fr/teleinfo-compteur-enedis/17-teleinfo-1-compteur-usb-rail-din-3760313520028.html) (validé par un [utilisateur](https://github.com/hekmon/linkytic/issues/2#issuecomment-1364535337)) -* [Circuit à faire soi-même](https://miniprojets.net/index.php/2019/06/28/recuperer-les-donnees-de-son-compteur-linky/), nécessitant peu de composants ([autre article avec un circuit similaire](https://hallard.me/pitinfov12/)). Validé par un [utilisateur](https://github.com/hekmon/linkytic/pull/4#issuecomment-1368877730). -* [Module Micro Téléinfo V3.0](https://github.com/hallard/uTeleinfo) à fabriquer soi-même ou pré-assemblé sur [Tindie](https://www.tindie.com/products/28873/) -* [Teleinfo ADTEK](https://doc.eedomus.com/view/T%C3%A9l%C3%A9info_USB_ADTEK) attention cependant [le baudrate ne semble pas standard](https://github.com/hekmon/linkytic/issues/40). -* et certainement bien d'autres ! (n'hésitez pas à m'ouvrir une issue pour rajouter le votre si vous avez validé que celui-ci fonctionne avec cette intégration afin d'aidez de potentiels futurs utilisateurs qui n'en auraient pas encore choisi un) +- [Module série USB développé par LiXee](https://lixee.fr/produits/30-tic-din-3770014375070.html) (celui que j'utilise) +- [Téléinfo 1 compteur USB rail DIN de Cartelectronic](https://www.cartelectronic.fr/teleinfo-compteur-enedis/17-teleinfo-1-compteur-usb-rail-din-3760313520028.html) (validé par un [utilisateur](https://github.com/hekmon/linkytic/issues/2#issuecomment-1364535337)) +- [Circuit à faire soi-même](https://miniprojets.net/index.php/2019/06/28/recuperer-les-donnees-de-son-compteur-linky/), nécessitant peu de composants ([autre article avec un circuit similaire](https://hallard.me/pitinfov12/)). Validé par un [utilisateur](https://github.com/hekmon/linkytic/pull/4#issuecomment-1368877730). +- [Module Micro Téléinfo V3.0](https://github.com/hallard/uTeleinfo) à fabriquer soi-même ou pré-assemblé sur [Tindie](https://www.tindie.com/products/28873/) +- [Teleinfo ADTEK](https://doc.eedomus.com/view/T%C3%A9l%C3%A9info_USB_ADTEK) attention cependant [le baudrate ne semble pas standard](https://github.com/hekmon/linkytic/issues/40). +- et certainement bien d'autres ! (n'hésitez pas à m'ouvrir une issue pour rajouter le votre si vous avez validé que celui-ci fonctionne avec cette intégration afin d'aidez de potentiels futurs utilisateurs qui n'en auraient pas encore choisi un) [Exemple sous Home Assistant](https://github.com/hekmon/linkytic/raw/v3.0.0-beta4/res/SCR-20221223-ink.png). @@ -23,8 +23,8 @@ Cependant, certaines sondes peuvent avoir de la valeur dans leur "instantanéit Suivant la configuration que vous choisirez pour votre installation vous trouverez dans ce fichier dans la liste des sondes avec les annotations suivantes: -* 1 sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série) -* 2 sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère +- 1 sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série) +- 2 sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère ### Mode historique @@ -34,29 +34,29 @@ Le mode historique est le plus commun (existant pré Linky) : il est activé par Les 23 champs des compteurs mono-phasé configurés en mode historique sont supportés: -* `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) -* `OPTARIF` Option tarifaire choisie -* `ISOUSC` Intensité souscrite -* `BASE` Index option Base -* `HCHC` Index option Heures Creuses - Heures Creuses -* `HCHP` Index option Heures Creuses - Heures Pleines -* `EJPHN` Index option EJP - Heures Normales -* `EJPHPM` Index option EJP - Heures de Pointe Mobile -* `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus -* `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus -* `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs -* `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs -* `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges -* `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges -* `PEJP` Préavis Début EJP (30 min) -* `PTEC` Période Tarifaire en cours -* `DEMAIN` Couleur du lendemain -* `IINST` Intensité Instantanée 1 -* `ADPS` Avertissement de Dépassement De Puissance Souscrite 2 -* `IMAX` Intensité maximale appelée -* `PAPP` Puissance apparente 1 -* `HHPHC` Horaire Heures Pleines Heures Creuses -* `MOTDETAT` Mot d'état du compteur +- `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) +- `OPTARIF` Option tarifaire choisie +- `ISOUSC` Intensité souscrite +- `BASE` Index option Base +- `HCHC` Index option Heures Creuses - Heures Creuses +- `HCHP` Index option Heures Creuses - Heures Pleines +- `EJPHN` Index option EJP - Heures Normales +- `EJPHPM` Index option EJP - Heures de Pointe Mobile +- `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus +- `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus +- `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs +- `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs +- `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges +- `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges +- `PEJP` Préavis Début EJP (30 min) +- `PTEC` Période Tarifaire en cours +- `DEMAIN` Couleur du lendemain +- `IINST` Intensité Instantanée 1 +- `ADPS` Avertissement de Dépassement De Puissance Souscrite 2 +- `IMAX` Intensité maximale appelée +- `PAPP` Puissance apparente 1 +- `HHPHC` Horaire Heures Pleines Heures Creuses +- `MOTDETAT` Mot d'état du compteur #### Compteurs tri-phasés @@ -64,36 +64,36 @@ Les 23 champs des compteurs mono-phasé configurés en mode historique sont supp Des retours de log en `DEBUG` pendant l'émission de trames courtes sont nécessaires pour valider le bon fonctionnement de l'intégration sur ces compteurs, n'hésitez pas à ouvrir une [issue](https://github.com/hekmon/linkytic/issues) si vous avec un compteur triphasé pour aider à sa finalisation ! -* `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) -* `OPTARIF` Option tarifaire choisie -* `ISOUSC` Intensité souscrite -* `BASE` Index option Base -* `HCHC` Index option Heures Creuses - Heures Creuses -* `HCHP` Index option Heures Creuses - Heures Pleines -* `EJPHN` Index option EJP - Heures Normales -* `EJPHPM` Index option EJP - Heures de Pointe Mobile -* `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus -* `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus -* `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs -* `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs -* `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges -* `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges -* `PEJP` Préavis Début EJP (30 min) -* `PTEC` Période Tarifaire en cours -* `DEMAIN` Couleur du lendemain -* `IINST1` Intensité Instantanée (phase 1) 1 pour les trames longues 2 pour les trames courtes -* `IINST2` Intensité Instantanée (phase 2) 1 pour les trames longues 2 pour les trames courtes -* `IINST3` Intensité Instantanée (phase 3) 1 pour les trames longues 2 pour les trames courtes -* `IMAX1` Intensité maximale (phase 1) -* `IMAX2` Intensité maximale (phase 2) -* `IMAX3` Intensité maximale (phase 3) -* `PMAX` Puissance maximale triphasée atteinte -* `PAPP` Puissance apparente 1 -* `HHPHC` Horaire Heures Pleines Heures Creuses -* `MOTDETAT` Mot d'état du compteur -* `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) 2 trames courtes uniquement -* `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) 2 trames courtes uniquement -* `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) 2 trames courtes uniquement +- `ADCO` Adresse du compteur (avec parsing EURIDIS en attributs étendus et périphérique agrégateur sous Home Assistant) +- `OPTARIF` Option tarifaire choisie +- `ISOUSC` Intensité souscrite +- `BASE` Index option Base +- `HCHC` Index option Heures Creuses - Heures Creuses +- `HCHP` Index option Heures Creuses - Heures Pleines +- `EJPHN` Index option EJP - Heures Normales +- `EJPHPM` Index option EJP - Heures de Pointe Mobile +- `BBRHCJB` Index option Tempo - Heures Creuses Jours Bleus +- `BBRHPJB` Index option Tempo - Heures Pleines Jours Bleus +- `BBRHCJW` Index option Tempo - Heures Creuses Jours Blancs +- `BBRHPJW` Index option Tempo - Heures Pleines Jours Blancs +- `BBRHCJR` Index option Tempo - Heures Creuses Jours Rouges +- `BBRHPJR` Index option Tempo - Heures Pleines Jours Rouges +- `PEJP` Préavis Début EJP (30 min) +- `PTEC` Période Tarifaire en cours +- `DEMAIN` Couleur du lendemain +- `IINST1` Intensité Instantanée (phase 1) 1 pour les trames longues 2 pour les trames courtes +- `IINST2` Intensité Instantanée (phase 2) 1 pour les trames longues 2 pour les trames courtes +- `IINST3` Intensité Instantanée (phase 3) 1 pour les trames longues 2 pour les trames courtes +- `IMAX1` Intensité maximale (phase 1) +- `IMAX2` Intensité maximale (phase 2) +- `IMAX3` Intensité maximale (phase 3) +- `PMAX` Puissance maximale triphasée atteinte +- `PAPP` Puissance apparente 1 +- `HHPHC` Horaire Heures Pleines Heures Creuses +- `MOTDETAT` Mot d'état du compteur +- `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) 2 trames courtes uniquement +- `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) 2 trames courtes uniquement +- `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) 2 trames courtes uniquement ### Mode standard @@ -105,9 +105,9 @@ Une fois Home Assistant redémarré, allez dans: `Paramètres -> Appareils et se Vous devriez passer sur le formulaire d'installation vous présentant les 3 champs suivants: -* `Chemin/Adresse vers le périphérique série` Ici renseignez le path de votre périphérique USB testé précédement. Le champ est rempli par default avec la valeur `/dev/ttyUSB0`: Il ne s'agit pas d'une auto détection mais simplement de la valeure la plus probable dans 99% des installations. Il est aussi possible d'utiliser une URL supporté par [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html), ce qui peut s'avérer utile si le port série est connecté sur un appareil distant (support de la rfc2217 par exemple). -* `Mode TIC` Choississez entre `Standard` et `Historique`. Plus de détails sur ces 2 modes en début de ce document. -* `Triphasé` À cocher si votre compteur est un compteur... triphasé. À noter que cette option n'a d'effet que si vous êtes en mode historique (le mode standard gère le mono et le tri de manière indifférente). +- `Chemin/Adresse vers le périphérique série` Ici renseignez le path de votre périphérique USB testé précédement. Le champ est rempli par default avec la valeur `/dev/ttyUSB0`: Il ne s'agit pas d'une auto détection mais simplement de la valeure la plus probable dans 99% des installations. Il est aussi possible d'utiliser une URL supporté par [pyserial](https://pyserial.readthedocs.io/en/latest/url_handlers.html), ce qui peut s'avérer utile si le port série est connecté sur un appareil distant (support de la rfc2217 par exemple). +- `Mode TIC` Choississez entre `Standard` et `Historique`. Plus de détails sur ces 2 modes en début de ce document. +- `Triphasé` À cocher si votre compteur est un compteur... triphasé. À noter que cette option n'a d'effet que si vous êtes en mode historique (le mode standard gère le mono et le tri de manière indifférente). Validez et patientez pendant le temps du test. Celui-ci va tenter d'ouvrir une connection série sur le périphérique désigné et d'y lire au moins une ligne. En cas d'erreur, celle-ci vous sera retourné à l'écran de configuration. Sinon, votre nouvelle intégration est prête et disponible dans la liste des intégrations de la page où vous vous trouvez. diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..aeacbee --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,8 @@ +pyserial==3.5 +homeassistant>=2024.11.2 +voluptuous==0.15.2 + +# for pre-commit +codespell==2.3.0 +ruff==0.7.3 +mypy==1.13.0 \ No newline at end of file diff --git a/serialserver/README.md b/serialserver/README.md index 870fab7..6c3032d 100644 --- a/serialserver/README.md +++ b/serialserver/README.md @@ -9,6 +9,7 @@ Le code du serveur (`rfc2217_server.py`) n'est pas le miens mais est celui du pr ### Machine avec le module série Validez que vous avez bien votre module série d'accessible: + ```raw hekmon@nucsrv:~$ ls -l /dev/ttyUSB0 crw-rw---- 1 root dialout 188, 0 Jun 4 11:55 /dev/ttyUSB0 @@ -16,11 +17,13 @@ hekmon@nucsrv:~$ ``` Commencez par créer un utilisateur qui sera utilisé pour faire tourner le server: + ```bash sudo useradd --groups dialout --home-dir /usr/lib/serial --create-home --system --shell /usr/sbin/nologin serial ``` Copier les fichiers de ce dossier sur la machine cible: + ```raw /usr/lib/serial/rfc2217_server.py /etc/systemd/system/serialrfc2217server.service @@ -30,6 +33,7 @@ Copier les fichiers de ce dossier sur la machine cible: Assurez vous d'avoir `python3` d'installé, le service l'appellera depuis ce path : `/usr/bin/python3`. Une fois l'utilisateur créé et les fichiers copiés, lancez le serveur comme ceci: + ```bash # Enregistrement du nouveau service sudo systemctl daemon-reload diff --git a/serialserver/usr/lib/serial/rfc2217_server.py b/serialserver/usr/lib/serial/rfc2217_server.py index b42feb0..8d791e8 100644 --- a/serialserver/usr/lib/serial/rfc2217_server.py +++ b/serialserver/usr/lib/serial/rfc2217_server.py @@ -10,8 +10,9 @@ import logging import socket import sys -import time import threading +import time + import serial import serial.rfc2217