diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d338c70 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 2025-12-24 +- Ajout d'un scheduler precis par entite avec frequences en secondes/minutes. +- Lectures Modbus optimisees par batch + publication sur changement uniquement. +- Reconnexion Modbus avec backoff + backoff par entite en cas d'erreurs. +- LWT MQTT et publication d'availability automatique. +- Logs enrichis (niveau configurable, sortie fichier optionnelle) + metrics MQTT. +- Prise en charge de `state_topic` et `value_map` dans la config. +- Validation de configuration au demarrage. +- Ajout d'entites texte pour SystemStatus et FurnaceStatus. +- Ajout d'un capteur calcule "Stock energie" (kWh) avec volume et Tmin configurables. diff --git a/Dockerfile b/Dockerfile index 45b3a0a..85baa73 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Copier seulement le fichier main.py et config.yaml dans le conteneur COPY ./src/main.py /usr/src/app/main.py -COPY ./config/config.yaml /usr/src/app/config.yaml +COPY ./src/config.yaml /usr/src/app/config.yaml # Commande pour exécuter l'application -CMD ["python", "./main.py"] \ No newline at end of file +CMD ["python", "./main.py"] diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..dd178b5 --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,37 @@ +# Prompt - Modbus to MQTT App + +Use these development guidelines for this repository. + +## Goals +- Keep the app config-driven and backward compatible. +- Avoid breaking MQTT topics or HA discovery behavior. +- Prefer readable logs and safe defaults. + +## Core behavior +- Read Modbus registers and publish to MQTT. +- Support per-entity refresh intervals (seconds or minutes). +- Use batching for contiguous Modbus reads. +- Publish only on change by default (configurable). +- Publish availability (LWT) and metrics. + +## Configuration rules +- `src/config.yaml` is the active config file. +- Validate config on startup (required fields, unique_id). +- Support `state_topic` override per entity. +- Support `value_map` for text mapping. +- Allow computed entities (`input_type: computed`). + +## Reliability +- Reconnect Modbus with exponential backoff. +- Backoff per entity after read errors. +- Keep MQTT connection stable and log failures. + +## Observability +- Log at INFO by default, allow DEBUG via config. +- Optional file logging via `log_file`. +- Metrics to `/metrics`. + +## Changes checklist +- Update README and CHANGELOG when behavior changes. +- Keep ASCII where possible (avoid accents in new files). +- Add minimal comments only when logic is not obvious. diff --git a/README.md b/README.md index 42c3e1e..dd6e97d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,59 @@ # froling_modbus2mqtt +Passerelle Modbus -> MQTT pour chaudiere Froling, avec autodiscovery Home Assistant. + +## Fonctionnement +- Lit des registres Modbus (input/holding/coils/discrete) et publie sur MQTT. +- Chaque entite peut avoir sa frequence (secondes ou minutes). +- Autodiscovery HA publie automatiquement les capteurs. +- Optimisations: lectures Modbus par batch, publication sur changement uniquement (optionnel). +- Fiabilite: LWT MQTT + reconnexion Modbus avec backoff. + +## Fichier de configuration +L'application lit `src/config.yaml` (monte dans le conteneur). + +Parametres globaux utiles: +- `refresh_rate`: intervalle par defaut. +- `log_level` / `log_file`: logs console et fichier optionnel. +- `publish_on_change_only`: publier uniquement si la valeur change. +- `metrics_interval`: envoi des metrics MQTT. +- `buffer_volume_l`, `buffer_min_temp_c`: calcul Stock energie. +- `modbus.reconnect_min` / `modbus.reconnect_max`: backoff reconnexion. + +Parametres par entite: +- `refresh` + `refresh_unit` (`s` ou `m`) +- `state_topic` (sinon genere avec `topic_prefix` et `name`) +- `value_map` (SystemStatus, FurnaceStatus, etc.) + +## Entites calculees +Type `input_type: computed` pour les valeurs derivees. + +Exemple Stock energie (kWh) base sur Tampon Haut/Bas: +```yaml +buffer_volume_l: 1000 +buffer_min_temp_c: 25 +... + - name: "Stock energie" + unique_id: "stock_energie_sensor" + type: "sensor" + unit_of_measurement: "kWh" + state_topic: "froling/S3Turbo/STOCK_ENERGIE/state" + input_type: "computed" + formula: "buffer_energy_kwh" + sources: + - "tampon_haut_sensor" + - "tampon_bas_sensor" + precision: 2 +``` + +## MQTT +- Availability: `/availability` +- Metrics: `/metrics` (reads, errors, published, uptime) + +## Lancer en Docker +```bash +docker compose up -d --build +``` + +## Changelog +Voir `CHANGELOG.md`. diff --git a/config/config.yaml b/config/config.yaml.old similarity index 89% rename from config/config.yaml rename to config/config.yaml.old index 84a10e6..c3b5b3a 100755 --- a/config/config.yaml +++ b/config/config.yaml.old @@ -1,22 +1,3 @@ -# Digital Outputs -# Function: Read Coil Status (FC=01) -# Address Range: 00001-00158 - -# Digital Inputs -# Function: Read Input Status (FC=02) -# Address Range: 10001-10086 - -# Actual Values -# Function: Read Input Registers(FC=04) -# Address Range: 30001-30272 -# input_type : input - -# Parameters -# Function: Read Holding Registers(FC=03) -# Address Range: 40001-41094 -# input_type: holding - - mqtt: host: "10.0.0.3" port: 1883 @@ -84,8 +65,8 @@ entities: - name: "Etats systeme" unique_id: "etats_systeme_sensor" type: "sensor" - #device_class: "enum" - #state_class: "measurement" + # device_class: "enum" + # state_class: "measurement" icon: "mdi:radiator" unit_of_measurement: "" state_topic: "froling/S3Turbo/ETATS_SYSTEME/state" @@ -97,11 +78,13 @@ entities: precision: 1 value_map: "SystemStatus" signed: false + refresh: 1 + refresh_unit: "m" - name: "Etat Chaudiere" unique_id: "etats_chaudiere_sensor" type: "sensor" - #device_class: "enum" - #state_class: "measurement" + # device_class: "enum" + # state_class: "measurement" icon: "mdi:water-boiler-alert" unit_of_measurement: "" state_topic: "froling/S3Turbo/ETATS_CHAUDIERE/state" @@ -113,6 +96,8 @@ entities: precision: 0 value_map: "FurnaceStatus" signed: false + refresh: 1 + refresh_unit: "m" - name: "T° Chaudiere" unique_id: "temperature_chaudiere_sensor" type: "sensor" @@ -129,6 +114,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "T° Fumee" unique_id: "temperature_fumee_sensor" type: "sensor" @@ -145,6 +132,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "T° Board" unique_id: "temperature_board_sensor" type: "sensor" @@ -161,6 +150,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "O2 residuel" unique_id: "o2_residuel_sensor" type: "sensor" @@ -177,6 +168,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "T° Exterieur" unique_id: "temp_exterieur_sensor" type: "sensor" @@ -193,6 +186,8 @@ entities: precision: 0 value_map: null signed: true + refresh: 1 + refresh_unit: "m" - name: "Porte chaudiere" unique_id: "porte_chaudiere_sensor" type: "binary_sensor" @@ -209,6 +204,8 @@ entities: offset: 10001 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "Air primaire" @@ -226,6 +223,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "Air Secondaire" unique_id: "air_secondaire_sensor" type: "sensor" @@ -241,6 +240,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "Vitesse ventilateur" unique_id: "vitesse_ventilateur_sensor" type: "sensor" @@ -256,6 +257,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "Commande tirage" unique_id: "commande_tirage_sensor" type: "sensor" @@ -271,6 +274,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "Consigne T° fumée" unique_id: "consigne_temperature_fumee_sensor" type: "sensor" @@ -287,6 +292,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" - name: "Consigne T° chauffage" unique_id: "consigne_temperature_chauffage_sensor" type: "sensor" @@ -303,6 +310,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" - name: "Heure fonctionnement" unique_id: "heure_fonctionnement_sensor" type: "sensor" @@ -318,6 +327,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" - name: "Heure de maintien de feu" unique_id: "heure_maintien_de_feu_sensor" type: "sensor" @@ -333,6 +344,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" - name: "Heure chauffage" unique_id: "heure_chauffage_sensor" type: "sensor" @@ -348,6 +361,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" - name: "Tampon Haut" unique_id: "tampon_haut_sensor" type: "sensor" @@ -364,6 +379,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" - name: "Tampon Bas" unique_id: "tampon_bas_sensor" type: "sensor" @@ -380,6 +397,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" - name: "T° depart chauffage" unique_id: "temperature_depart_chauffage_sensor" type: "sensor" @@ -396,6 +415,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: "Charge tampon" unique_id: "charge_tampon_sensor" type: "sensor" @@ -411,21 +432,25 @@ entities: precision: 0 value_map: null signed: false -# Digital Outputs -# Function: Read Coil Status (FC=01) - - name: pompe circuit chauffage" - unique_id: "pompe_circuit_chauffage" - type: "binary_sensor" - device_class: "running" - icon: "mdi:pump" - state_topic: "froling/S3Turbo/pump_chauffage/state" - value_template: "{{ value_json.charge_tampon_sensor }}" - input_type: "coil" - address: 30226 + refresh: 1 + refresh_unit: "m" + - name: "pompe accumulateur" + unique_id: "pompe_accu" + type: "sensor" + device_class: "battery" + state_class: "measurement" + icon: "mdi:percent-circle-outline" + unit_of_measurement: "%" + state_topic: "froling/S3Turbo/pompe_accu/state" + value_template: "{{ value_json.pompe_accu }}" + input_type: "input_register" + address: 30141 offset: 30001 precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" # Digital Outputs # Function: Read Coil Status (FC=01) - name: pompe circuit_chauffage @@ -440,6 +465,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: cc_melangeur_ouvert unique_id: "cc_melangeur_ouvert" type: "binary_sensor" @@ -452,6 +479,8 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" - name: cc_melangeur_ferme unique_id: "cc_melangeur_ferme" type: "binary_sensor" @@ -464,4 +493,7 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" + diff --git a/docker-compose.yml b/docker-compose.yml index c89beb9..8084ba2 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.8" +#version: "3.8" services: app: diff --git a/src/config.yaml b/src/config.yaml index 1aa4ff8..cc8971e 100755 --- a/src/config.yaml +++ b/src/config.yaml @@ -15,8 +15,17 @@ modbus: port: 502 unit_id: 2 timeout: 30 + reconnect_min: 1 + reconnect_max: 30 refresh_rate: 10 # Fréquence d'actualisation en secondes +log_level: "INFO" # DEBUG, INFO, WARNING, ERROR +log_file: "" # Exemple: "/usr/src/app/logs/app.log" +error_backoff_max: 300 # secondes, limite max apres erreurs Modbus +publish_on_change_only: true +metrics_interval: 60 # secondes +buffer_volume_l: 1000 +buffer_min_temp_c: 25 device: identifiers: "FrolingS3" # Remplacer par l'identifiant unique du dispositif @@ -78,6 +87,9 @@ entities: precision: 1 value_map: "SystemStatus" signed: false + refresh: 5 + refresh_unit: "m" + - name: "Etat Chaudiere" unique_id: "etats_chaudiere_sensor" type: "sensor" @@ -94,6 +106,9 @@ entities: precision: 0 value_map: "FurnaceStatus" signed: false + refresh: 1 + refresh_unit: "m" + - name: "T° Chaudiere" unique_id: "temperature_chaudiere_sensor" type: "sensor" @@ -110,6 +125,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" + - name: "T° Fumee" unique_id: "temperature_fumee_sensor" type: "sensor" @@ -126,6 +144,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" + - name: "T° Board" unique_id: "temperature_board_sensor" type: "sensor" @@ -142,6 +163,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" + - name: "O2 residuel" unique_id: "o2_residuel_sensor" type: "sensor" @@ -158,6 +182,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "m" + - name: "T° Exterieur" unique_id: "temp_exterieur_sensor" type: "sensor" @@ -174,6 +201,9 @@ entities: precision: 0 value_map: null signed: true + refresh: 1 + refresh_unit: "m" + - name: "Porte chaudiere" unique_id: "porte_chaudiere_sensor" type: "binary_sensor" @@ -190,6 +220,8 @@ entities: offset: 10001 value_map: null signed: false + refresh: 1 + refresh_unit: "s" - name: "Air primaire" @@ -207,6 +239,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" + - name: "Air Secondaire" unique_id: "air_secondaire_sensor" type: "sensor" @@ -222,6 +257,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" + - name: "Vitesse ventilateur" unique_id: "vitesse_ventilateur_sensor" type: "sensor" @@ -237,6 +275,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 5 + refresh_unit: "5" + - name: "Commande tirage" unique_id: "commande_tirage_sensor" type: "sensor" @@ -252,6 +293,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" + - name: "Consigne T° fumée" unique_id: "consigne_temperature_fumee_sensor" type: "sensor" @@ -268,6 +312,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "m" + - name: "Consigne T° chauffage" unique_id: "consigne_temperature_chauffage_sensor" type: "sensor" @@ -284,6 +331,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "m" + - name: "Heure fonctionnement" unique_id: "heure_fonctionnement_sensor" type: "sensor" @@ -299,6 +349,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 50 + refresh_unit: "m" + - name: "Heure de maintien de feu" unique_id: "heure_maintien_de_feu_sensor" type: "sensor" @@ -314,6 +367,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 50 + refresh_unit: "m" + - name: "Heure chauffage" unique_id: "heure_chauffage_sensor" type: "sensor" @@ -329,6 +385,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 50 + refresh_unit: "m" + - name: "Tampon Haut" unique_id: "tampon_haut_sensor" type: "sensor" @@ -345,6 +404,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" + - name: "Tampon Bas" unique_id: "tampon_bas_sensor" type: "sensor" @@ -361,6 +423,26 @@ entities: precision: 0 value_map: null signed: false + refresh: 1 + refresh_unit: "m" + + - name: "Stock energie" + unique_id: "stock_energie_sensor" + type: "sensor" + device_class: "energy" + state_class: "measurement" + icon: "mdi:flash" + unit_of_measurement: "kWh" + state_topic: "froling/S3Turbo/STOCK_ENERGIE/state" + input_type: "computed" + formula: "buffer_energy_kwh" + sources: + - "tampon_haut_sensor" + - "tampon_bas_sensor" + precision: 2 + refresh: 1 + refresh_unit: "m" + - name: "T° depart chauffage" unique_id: "temperature_depart_chauffage_sensor" type: "sensor" @@ -377,6 +459,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "s" + - name: "Charge tampon" unique_id: "charge_tampon_sensor" type: "sensor" @@ -392,6 +477,27 @@ entities: precision: 0 value_map: null signed: false + refresh: 10 + refresh_unit: "m" + + - name: "pompe accumulateur" + unique_id: "pompe_accu" + type: "sensor" + device_class: "battery" + state_class: "measurement" + icon: "mdi:percent-circle-outline" + unit_of_measurement: "%" + state_topic: "froling/S3Turbo/pompe_accu/state" + value_template: "{{ value_json.pompe_accu }}" + input_type: "input_register" + address: 30141 + offset: 30001 + precision: 0 + value_map: null + signed: false + refresh: 2 + refresh_unit: "s" + # Digital Outputs # Function: Read Coil Status (FC=01) - name: pompe circuit_chauffage @@ -406,6 +512,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 2 + refresh_unit: "s" + - name: cc_melangeur_ouvert unique_id: "cc_melangeur_ouvert" type: "binary_sensor" @@ -418,6 +527,9 @@ entities: precision: 0 value_map: null signed: false + refresh: 2 + refresh_unit: "s" + - name: cc_melangeur_ferme unique_id: "cc_melangeur_ferme" type: "binary_sensor" @@ -430,6 +542,6 @@ entities: precision: 0 value_map: null signed: false + refresh: 2 + refresh_unit: "s" - - diff --git a/src/config/config.yaml b/src/config/config.yaml.old similarity index 100% rename from src/config/config.yaml rename to src/config/config.yaml.old diff --git a/src/main.py b/src/main.py index 2114ca5..dc5d4ec 100755 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,8 @@ import os import time import yaml -import json +import json +import logging import paho.mqtt.client as mqtt from pyModbusTCP.client import ModbusClient @@ -46,8 +47,98 @@ def load_config(): config = yaml.safe_load(file) return config +def setup_logging(level_name, log_file=None): + level = getattr(logging, str(level_name).upper(), logging.INFO) + logger = logging.getLogger("modbus2mqtt") + logger.setLevel(level) + if not logger.handlers: + formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + if log_file: + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + return logger + +def get_entity_refresh_seconds(entity, default_seconds): + """ + Return refresh interval in seconds for an entity. + Accepts per-entity "refresh" with "refresh_unit" ("s" or "m"). + Falls back to default_seconds when missing or invalid. + """ + refresh_value = entity.get('refresh') + if refresh_value is None: + return default_seconds + try: + refresh_value = float(refresh_value) + except (TypeError, ValueError): + return default_seconds + if refresh_value <= 0: + return default_seconds + unit = str(entity.get('refresh_unit', 's')).lower() + if unit in ('m', 'min', 'minute', 'minutes'): + return refresh_value * 60 + return refresh_value + +def get_entity_interval_seconds(entity, default_seconds, error_count, error_backoff_max): + base_interval = get_entity_refresh_seconds(entity, default_seconds) + if error_count <= 0: + return base_interval + backoff = base_interval * (2 ** min(error_count, 5)) + if backoff > error_backoff_max: + backoff = error_backoff_max + return backoff + +def get_state_topic(entity, config): + return entity.get('state_topic') or f"{config['mqtt']['topic_prefix']}/{entity['name']}/state" + +def apply_value_map(value, entity, config, logger): + map_name = entity.get('value_map') + if not map_name: + return value + value_maps = config.get('value_maps', {}) + mapping = value_maps.get(map_name) + if mapping is None: + logger.error("Value map not found: %s", map_name) + return value + return mapping.get(value, value) + +def compute_buffer_energy_kwh(avg_temp_c, volume_l, min_temp_c): + if avg_temp_c <= min_temp_c: + return 0.0 + delta = avg_temp_c - min_temp_c + # 1L water ~ 1kg, 4.186 kJ/kgK => kWh = kJ / 3600 + return (volume_l * delta * 4.186) / 3600.0 + +def validate_config(config, logger): + required_top = ['mqtt', 'modbus', 'homeassistant', 'entities'] + for key in required_top: + if key not in config: + raise ValueError(f"Missing config section: {key}") + seen_ids = set() + valid_types = {'input_register', 'holding_register', 'input_status', 'coil', 'computed'} + for entity in config['entities']: + for field in ['name', 'unique_id', 'input_type']: + if field not in entity: + raise ValueError(f"Missing entity field: {field}") + if entity['input_type'] != 'computed': + for field in ['address', 'offset']: + if field not in entity: + raise ValueError(f"Missing entity field: {field}") + else: + if 'formula' not in entity or 'sources' not in entity: + raise ValueError(f"Missing computed entity fields: {entity['unique_id']}") + if entity['unique_id'] in seen_ids: + raise ValueError(f"Duplicate unique_id: {entity['unique_id']}") + seen_ids.add(entity['unique_id']) + if entity['input_type'] not in valid_types: + raise ValueError(f"Invalid input_type: {entity['input_type']}") + logger.info("Configuration validated (%d entities).", len(config['entities'])) + # Ajout de la fonction de publication des messages de découverte -def publish_discovery_messages(client, config): +def publish_discovery_messages(client, config, logger): base_topic = config['homeassistant']['discovery_prefix'] node_id = config['homeassistant']['node_id'] device_info = config['device'] @@ -62,12 +153,10 @@ def publish_discovery_messages(client, config): "unique_id": entity['unique_id'], "device": device_info, "name": entity['name'], - "state_topic": f"{config['mqtt']['topic_prefix']}/{entity['name']}/state", + "state_topic": get_state_topic(entity, config), #"value_template": "{{ value }}", # Ajouter une value_template si nécessaire #"device_class": entity.get('device_class', ''), #"unit_of_measurement": entity.get('unit_of_measurement', '')# La valeur par défaut est '°C - - } if "unit_of_measurement" in entity: message["unit_of_measurement"] = entity["unit_of_measurement"] if "state_class" in entity: message["state_class"] = entity["state_class"] @@ -80,10 +169,12 @@ def publish_discovery_messages(client, config): "value_template": entity.get('value_template', "{{ value_json.value }}"), # Ajouter une value_template si nécessaire }) client.publish(discovery_topic, json.dumps(message), qos=1, retain=True) + logger.info("HA discovery published: %s", discovery_topic) -def publish_availability(client, status, config): +def publish_availability(client, status, config, logger): availability_topic = f"{config['mqtt']['topic_prefix']}/availability" client.publish(availability_topic, status, qos=1, retain=True) + logger.info("Availability set to %s", status) def convert_to_signed(value): NEGATIVE_THRESHOLD = 32000 # Seuil pour déterminer les valeurs négatives @@ -96,23 +187,30 @@ def convert_to_signed(value): def on_connect(client, userdata, flags, rc): if rc == 0: - print("Connected to MQTT Broker!") - publish_availability(client, 'online', userdata) + logger = userdata.get('_logger') + if logger: + logger.info("Connected to MQTT broker") + publish_availability(client, 'online', userdata, logger) # Si l'autodiscovery est activé, publiez les messages de découverte if userdata['homeassistant']['autodiscovery']: - publish_discovery_messages(client, userdata) + publish_discovery_messages(client, userdata, logger) else: - print("Failed to connect, return code %d\n", rc) + logger = userdata.get('_logger') + if logger: + logger.error("Failed to connect to MQTT broker (rc=%d)", rc) def on_publish(client, userdata, result): - print(f"Data published to MQTT. Result: {result}") + logger = userdata.get('_logger') + if logger: + logger.debug("MQTT publish result: %s", result) -def connect_modbus(host, port, unit_id, timeout): +def connect_modbus(host, port, unit_id, timeout, logger, client=None): """ Connect to the Modbus server. """ - client = ModbusClient() + if client is None: + client = ModbusClient() client.host = host # Set the host client.port = port # Set the port client.unit_id = unit_id @@ -120,13 +218,13 @@ def connect_modbus(host, port, unit_id, timeout): connection = client.open() if connection: - print(f"Connected to Modbus server at {host}:{port}") + logger.info("Connected to Modbus server at %s:%s", host, port) else: - print(f"Failed to connect to Modbus server at {host}:{port}") + logger.error("Failed to connect to Modbus server at %s:%s", host, port) - return client + return client if connection else None -def read_modbus_registers(client, input_type, address, offset, scale=1, precision=0, count=1): +def read_modbus_registers(client, input_type, address, offset, scale=1, precision=0, count=1, logger=None): """ Read registers from the Modbus server. @@ -140,57 +238,215 @@ def read_modbus_registers(client, input_type, address, offset, scale=1, precisio :param value_map: a dictionary mapping register values to human-readable strings :return: the scaled and rounded value of the registers or None if an error occurs """ - print(f"Address from call: {address}, Offset from call: {offset}") # Calculate the real address by subtracting the offset real_address = address - offset - print(f"Preparing to read Modbus registers with initial address: {address}, " - f"offset: {offset}, resulting in real address: {real_address}") + if logger: + logger.info( + "Modbus read: type=%s address=%s offset=%s real=%s count=%s", + input_type, + address, + offset, + real_address, + count, + ) try: regs = None if input_type == 'input_register': # Use FC04 to read input registers - print(f"Reading {count} input register(s) starting at initial address {address} " - f"(real address {real_address}, offset {offset}) using FC04") regs = client.read_input_registers(real_address, count) elif input_type == 'holding_register': # Use FC03 to read holding registers - print(f"Reading {count} holding register(s) starting at initial address {address} " - f"(real address {real_address}, offset {offset}) using FC03") regs = client.read_holding_registers(real_address, count) elif input_type == 'input_status': # Use FC02 to read discrete inputs - print(f"Reading {count} input status(es) starting at initial address {address} " - f"(real address {real_address}, offset {offset}) using FC02") regs = client.read_discrete_inputs(real_address, count) elif input_type == 'coil': # Use FC01 to read coils - print(f"Reading {count} coil(s) starting at initial address {address} " - f"(real address {real_address}, offset {offset}) using FC01") regs = client.read_coils(real_address, count) else: - print(f"Invalid input type: {input_type}") + if logger: + logger.error("Invalid input type: %s", input_type) return None if regs is not None: # Apply scaling and rounding to the read values scaled_and_rounded_regs = [round(reg * scale, precision) for reg in regs] - print(f"Read successful: {scaled_and_rounded_regs}") + if logger: + logger.info("Modbus read success: %s", scaled_and_rounded_regs) return scaled_and_rounded_regs else: - print(f"Read error at initial address {address} (real address {real_address}, offset {offset})") + if logger: + logger.error( + "Modbus read error at address=%s (real=%s offset=%s)", + address, + real_address, + offset, + ) return None except Exception as e: - print(f"Modbus read error at initial address {address} (real address {real_address}, offset {offset}): {e}") + if logger: + logger.exception( + "Modbus read exception at address=%s (real=%s offset=%s): %s", + address, + real_address, + offset, + e, + ) return None +def read_modbus_raw(client, input_type, real_address, count, logger=None): + if logger: + logger.info( + "Modbus batch read: type=%s start=%s count=%s", + input_type, + real_address, + count, + ) + try: + if input_type == 'input_register': + return client.read_input_registers(real_address, count) + if input_type == 'holding_register': + return client.read_holding_registers(real_address, count) + if input_type == 'input_status': + return client.read_discrete_inputs(real_address, count) + if input_type == 'coil': + return client.read_coils(real_address, count) + except Exception as e: + if logger: + logger.exception( + "Modbus batch exception at start=%s count=%s: %s", + real_address, + count, + e, + ) + return None + if logger: + logger.error("Invalid input type for batch read: %s", input_type) + return None + +def _process_batch( + modbus_client, + input_type, + batch, + now, + logger, + mqtt_client, + config, + last_read_at, + error_counts, + last_values, + publish_on_change_only, + metrics, +): + start_address = batch[0][0] + end_address = batch[-1][0] + count = end_address - start_address + 1 + regs = read_modbus_raw(modbus_client, input_type, start_address, count, logger=logger) + if regs is None: + for _, entity in batch: + uid = entity['unique_id'] + error_counts[uid] = error_counts.get(uid, 0) + 1 + last_read_at[uid] = now + metrics['errors'] += 1 + return + + for real_address, entity in batch: + uid = entity['unique_id'] + idx = real_address - start_address + if idx < 0 or idx >= len(regs): + logger.error("Batch index out of range for %s", entity['name']) + error_counts[uid] = error_counts.get(uid, 0) + 1 + last_read_at[uid] = now + metrics['errors'] += 1 + continue + + value = regs[idx] + scale = entity.get('scale', 1) + precision = entity.get('precision', 0) + value = round(value * scale, precision) + if entity.get('signed', False): + value = convert_to_signed(value) + logger.info("Signed conversion: %s", value) + value = apply_value_map(value, entity, config, logger) + metrics['reads'] += 1 + + previous_value = last_values.get(uid) + last_values[uid] = value + if publish_on_change_only and previous_value == value: + logger.debug("Unchanged value for %s, skip publish", entity['name']) + last_read_at[uid] = now + error_counts[uid] = 0 + continue + + topic = get_state_topic(entity, config) + mqtt_client.publish(topic, value) + logger.info("MQTT publish: %s => %s", topic, value) + last_read_at[uid] = now + error_counts[uid] = 0 + metrics['published'] += 1 + +def _process_computed( + entity, + now, + logger, + mqtt_client, + config, + last_read_at, + last_values, + publish_on_change_only, + metrics, +): + sources = entity.get('sources', []) + source_values = [] + for source_id in sources: + if source_id not in last_values: + logger.debug("Computed %s missing source %s", entity['name'], source_id) + return + source_values.append(last_values[source_id]) + + if entity.get('formula') == 'buffer_energy_kwh': + volume_l = float(config.get('buffer_volume_l', 1000)) + min_temp_c = float(config.get('buffer_min_temp_c', 25)) + avg_temp_c = sum(source_values) / len(source_values) + value = compute_buffer_energy_kwh(avg_temp_c, volume_l, min_temp_c) + else: + logger.error("Unknown formula for %s", entity['name']) + return + + precision = entity.get('precision', 2) + value = round(value, precision) + metrics['reads'] += 1 + + previous_value = last_values.get(entity['unique_id']) + last_values[entity['unique_id']] = value + if publish_on_change_only and previous_value == value: + logger.debug("Unchanged value for %s, skip publish", entity['name']) + last_read_at[entity['unique_id']] = now + return + + topic = get_state_topic(entity, config) + mqtt_client.publish(topic, value) + logger.info("MQTT publish: %s => %s", topic, value) + last_read_at[entity['unique_id']] = now + metrics['published'] += 1 + def main(): config = load_config() + logger = setup_logging(config.get('log_level', 'INFO'), config.get('log_file')) + config['_logger'] = logger + validate_config(config, logger) refresh_rate = config['refresh_rate'] # Obtenez la fréquence d'actualisation depuis la configuration + default_refresh_seconds = float(refresh_rate) + error_backoff_max = float(config.get('error_backoff_max', 300)) + metrics_interval = float(config.get('metrics_interval', 60)) + metrics_topic = f"{config['mqtt']['topic_prefix']}/metrics" + start_time = time.monotonic() + next_metrics_at = time.monotonic() + metrics_interval # Configuration MQTT mqtt_config = config['mqtt'] @@ -200,11 +456,20 @@ def main(): mqtt_client.on_publish = on_publish if mqtt_config['user'] and mqtt_config['password']: mqtt_client.username_pw_set(mqtt_config['user'], mqtt_config['password']) + availability_topic = f"{config['mqtt']['topic_prefix']}/availability" + mqtt_client.will_set(availability_topic, 'offline', qos=1, retain=True) mqtt_client.connect(mqtt_config['host'], mqtt_config['port'], 60) mqtt_client.loop_start() + last_read_at = {} + error_counts = {} + last_values = {} + metrics = {"reads": 0, "errors": 0, "published": 0} + modbus_client = None + reconnect_delay = float(config.get('modbus', {}).get('reconnect_min', 1)) + reconnect_max = float(config.get('modbus', {}).get('reconnect_max', 30)) + publish_on_change_only = bool(config.get('publish_on_change_only', True)) while True: - modbus_client = None try: # etape de connexion Modbus # Affichage de la configuration pour la vérification @@ -226,53 +491,157 @@ def main(): # Étape de connexion Modbus - modbus_client = connect_modbus(config['modbus']['host'], - config['modbus']['port'], - config['modbus']['unit_id'], - config['modbus']['timeout']) + if modbus_client is None: + modbus_client = connect_modbus( + config['modbus']['host'], + config['modbus']['port'], + config['modbus']['unit_id'], + config['modbus']['timeout'], + logger, + ) + if modbus_client is None: + logger.warning("Modbus reconnect in %.1fs", reconnect_delay) + time.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, reconnect_max) + continue + reconnect_delay = float(config.get('modbus', {}).get('reconnect_min', 1)) + elif not modbus_client.open(): + logger.error("Failed to reconnect to the Modbus server.") + modbus_client = None + logger.warning("Modbus reconnect in %.1fs", reconnect_delay) + time.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, reconnect_max) + continue if modbus_client: # Lecture des registres Modbus pour chaque entité + now = time.monotonic() + due_entities = [] + due_computed = [] for entity in config['entities']: - input_type = entity['input_type'] - address = entity['address'] - offset = entity['offset'] # Utilisation de l'offset de l'entité - scale = entity.get('scale', 1) - precision = entity.get('precision', 0) # Utilisation de get pour une valeur par défaut si non présente - count = 1 # Supposons que count est toujours 1 pour simplifier, à ajuster si nécessaire - - # Affichage de l'entité et de l'offset avant la lecture - print(f"Reading entity: {entity['name']} with offset: {offset}") - # Lecture des registres avec l'offset correct - regs = read_modbus_registers(modbus_client, input_type, address, offset, scale, precision, count) - if regs is not None: - - value = regs[0] # Prend la première valeur si plusieurs registres sont lus - if entity.get('signed', False): - value = convert_to_signed(value) - print(f"Valeur convertie en signée: {value}") - - # Publier la valeur lue sur le topic MQTT approprié - topic = f"{config['mqtt']['topic_prefix']}/{entity['name']}/state" - mqtt_client.publish(topic, value) - print(f"Published value for {entity['name']}: {value}") + uid = entity['unique_id'] + error_count = error_counts.get(uid, 0) + interval_seconds = get_entity_interval_seconds( + entity, + default_refresh_seconds, + error_count, + error_backoff_max, + ) + last_ts = last_read_at.get(uid, 0) + if now - last_ts < interval_seconds: + logger.debug( + "Skip entity %s (next in %.1fs)", + entity['name'], + interval_seconds - (now - last_ts), + ) + continue + if entity['input_type'] == 'computed': + due_computed.append(entity) else: - print(f"Failed to read value for {entity['name']}") - - # Assurez-vous de fermer la connexion Modbus lorsque vous avez terminé - modbus_client.close() - else: - print("Failed to connect to the Modbus server.") + due_entities.append(entity) + + grouped = {} + for entity in due_entities: + real_address = entity['address'] - entity['offset'] + key = entity['input_type'] + grouped.setdefault(key, []).append((real_address, entity)) + + for input_type, items in grouped.items(): + items.sort(key=lambda x: x[0]) + batch = [] + prev_addr = None + for real_address, entity in items: + if prev_addr is None or real_address == prev_addr + 1: + batch.append((real_address, entity)) + else: + _process_batch( + modbus_client, + input_type, + batch, + now, + logger, + mqtt_client, + config, + last_read_at, + error_counts, + last_values, + publish_on_change_only, + metrics, + ) + batch = [(real_address, entity)] + prev_addr = real_address + if batch: + _process_batch( + modbus_client, + input_type, + batch, + now, + logger, + mqtt_client, + config, + last_read_at, + error_counts, + last_values, + publish_on_change_only, + metrics, + ) + for entity in due_computed: + _process_computed( + entity, + now, + logger, + mqtt_client, + config, + last_read_at, + last_values, + publish_on_change_only, + metrics, + ) + + # Calcule le prochain delai selon les entites + now = time.monotonic() + if now >= next_metrics_at: + payload = { + "reads": metrics["reads"], + "errors": metrics["errors"], + "published": metrics["published"], + "entities": len(config['entities']), + "uptime_s": int(now - start_time), + } + mqtt_client.publish(metrics_topic, json.dumps(payload)) + logger.info("MQTT metrics: %s", payload) + next_metrics_at = now + metrics_interval + next_sleep = None + for entity in config['entities']: + uid = entity['unique_id'] + error_count = error_counts.get(uid, 0) + interval_seconds = get_entity_interval_seconds( + entity, + default_refresh_seconds, + error_count, + error_backoff_max, + ) + last_ts = last_read_at.get(entity['unique_id'], 0) + due_in = interval_seconds - (now - last_ts) + if due_in < 0: + due_in = 0 + if next_sleep is None or due_in < next_sleep: + next_sleep = due_in + if next_sleep is None: + next_sleep = default_refresh_seconds + if next_sleep < 0.1: + next_sleep = 0.1 + logger.info("Sleeping for %.1fs until next due entity.", next_sleep) + time.sleep(next_sleep) + continue except Exception as e: - print(f"An error occurred: {e}") - publish_availability(mqtt_client, 'offline', config) - finally: + logger.exception("An error occurred: %s", e) + publish_availability(mqtt_client, 'offline', config, logger) if modbus_client: modbus_client.close() + modbus_client = None - # Attendez la fréquence d'actualisation avant de recommencer - print(f"Waiting for {refresh_rate} seconds before the next update.") - time.sleep(refresh_rate) + time.sleep(default_refresh_seconds) if __name__ == '__main__': main()