From 8fdb28623997d17dd401cc751aa55162f666f805 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:05:19 +0100 Subject: [PATCH 1/2] feat: Home Assistant MQTT discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional in Settings → MQTT. Every sensor becomes a Home Assistant device with the sensor's name and manufacturer BRDC, with one entity per quantity. Configs are retained and only resent when they change; removed sensors, quantities and disabling discovery clear them. --- CHANGELOG.md | 5 +++++ README.md | 11 +++++++++-- mqtt_publisher.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ settings.py | 1 + test_app.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ translations.py | 15 ++++++++++----- 6 files changed, 114 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 376f2a7..6a5a685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Climate Monitor. Older versions: see the [GitHub releases](https://github.com/CervezaStallone/ClimateMonitor/releases). +## [Unreleased] + +### Added +- **Home Assistant discovery** (Settings → MQTT): every sensor shows up in Home Assistant as a device with the sensor's name and manufacturer BRDC, with an entity per quantity. + ## [2.3.0] - 2026-11-02 ### Added diff --git a/README.md b/README.md index 4ca91c3..76157d2 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - **Flexible sensors**: register data type (int16, uint16, int32, uint32, float32), byte order (ABCD, DCBA, BADC, CDAB) and a calibration correction per sensor; *Scan* finds which slave addresses answer on a bus - **Configurable in the browser**: Settings tab for buses, sensors, timezone, language, data retention, server and security - **Notifications**: Telegram and/or e-mail when limits are exceeded (global or per sensor, at warning or alarm level) or a zone is left or entered, when values are back to normal and when a sensor goes offline -- **MQTT**: publish the values of every sensor to an MQTT broker (Home Assistant, Node-RED, SCADA) +- **MQTT**: publish the values of every sensor to an MQTT broker (Home Assistant, Node-RED, SCADA), with optional Home Assistant discovery - **Login protection**: Admin password for the Settings tab, optionally for the whole app ### Screenshots @@ -356,7 +356,7 @@ Open the **Settings** tab in the web app. The first visit asks you to create an | Display | Dashboard view (Board / Meters), theme (Dark / Light), show or hide comfort, the Mollier diagram and wood moisture | | Modbus buses and sensors | Poll interval, buses (RS-485 interfaces), sensors (Modbus slaves) with data type (int16, uint16, int32, uint32, float32), byte order (ABCD, DCBA, BADC, CDAB) and a temperature and humidity correction for calibration | | Notifications: channels | Telegram bot token and chat ID, SMTP server, sender and recipients, test buttons | -| MQTT | Broker, port, TLS, login, client ID, topic prefix, publish interval, QoS, retain, test button | +| MQTT | Broker, port, TLS, login, client ID, topic prefix, publish interval, QoS, retain, Home Assistant discovery, test button | | Notifications: limits | Limits per quantity (all sensors or one sensor) at warning or alarm level, zones (desired or danger zone, colour, per sensor), alarm delay, hysteresis, recovery and offline alerts | | Wood moisture | Correction for the wood species (% points), response time of the wood (hours), hysteresis | | Region and language | Timezone, default language | @@ -442,6 +442,13 @@ saving. With topic prefix `xy-md02` every sensor publishes, at most once per pub renaming a sensor changes its topic, `device_id` stays the same. If the broker is unreachable, measuring continues and the app reconnects automatically; the connection status is shown in Settings. +**Home Assistant discovery**: enable *Home Assistant discovery* in Settings → MQTT and every sensor +appears in Home Assistant (MQTT integration) as a device with the sensor's name, manufacturer BRDC, +with one entity per quantity (temperature, humidity, dew point, absolute humidity and wood moisture +when it is on). The configs are retained on `homeassistant/sensor/-//config`; +entities go offline with `/status`. Renaming a sensor keeps its entities; removing a sensor +or turning discovery off removes them from Home Assistant. + #### Modbus values in `.env` (first start only) When upgrading, the existing `.env` Modbus configuration becomes *Bus 1* with *Sensor 1*, and all diff --git a/mqtt_publisher.py b/mqtt_publisher.py index a275be6..d59568a 100644 --- a/mqtt_publisher.py +++ b/mqtt_publisher.py @@ -4,6 +4,7 @@ /status online / offline (retained, offline via last will) //state JSON met alle waarden // losse waarde (temperature, humidity, dewpoint, absolute_humidity, wood_emc) + homeassistant/sensor/-//config Home Assistant discovery (optioneel, retained) De poller schrijft alleen de laatste waarden in het geheugen; een eigen thread verbindt, verbindt opnieuw na wijzigingen in Settings of uitval, en publiceert elk interval. @@ -25,6 +26,16 @@ CONFIG_KEYS = ('mqtt_enabled', 'mqtt_host', 'mqtt_port', 'mqtt_tls', 'mqtt_username', 'mqtt_password', 'mqtt_client_id', 'mqtt_topic_prefix', 'mqtt_qos', 'mqtt_retain') CONNECT_TIMEOUT = 10 +DISCOVERY_PREFIX = 'homeassistant' # ponytail: vast, instelbaar maken als iemand een andere discovery prefix gebruikt +MANUFACTURER = 'BRDC' +# grootheid -> (naam, device_class, eenheid, icoon) +HA_SENSORS = { + 'temperature': ('Temperature', 'temperature', '°C', None), + 'humidity': ('Humidity', 'humidity', '%', None), + 'dewpoint': ('Dew point', 'temperature', '°C', 'mdi:water-thermometer'), + 'absolute_humidity': ('Absolute humidity', None, 'g/m³', 'mdi:water'), + 'wood_emc': ('Wood moisture', None, '%', 'mdi:tree'), +} _lock = threading.Lock() _latest = {} # device_id -> {'name', 'values', 'ts', 'published': bool} @@ -106,6 +117,7 @@ def __init__(self): self.client = None self.active = None self.last_publish = 0 + self.announced = {} # discovery topic -> payload dat al op de broker staat def run(self): while True: @@ -152,6 +164,7 @@ def on_connect(self, client, userdata, flags, reason_code, properties): status.update(connected=False, error=str(reason_code)) return status.update(connected=True, error=None) + self.announced = {} # broker kan herstart zijn: discovery opnieuw sturen client.publish(f"{self.active['mqtt_topic_prefix']}/status", 'online', qos=1, retain=True) print("✓ MQTT verbonden") @@ -173,6 +186,7 @@ def publish(self, config): entries = {k: dict(v) for k, v in _latest.items()} for entry in _latest.values(): entry['published'] = True + self.discover(prefix, entries) for device_id, level in topic_names(entries).items(): entry = entries[device_id] if entry['published']: @@ -186,6 +200,36 @@ def publish(self, config): self.last_publish = time.time() status['last_publish'] = self.last_publish + def discover(self, prefix, entries): + """Home Assistant discovery: per sensor een device (naam = sensornaam), per grootheid een entity. + Alleen wijzigingen sturen; verdwenen entities krijgen een lege retained config.""" + wanted = {} + if settings.get('mqtt_ha_discovery'): + node = slug(prefix) + for device_id, level in topic_names(entries).items(): + entry = entries[device_id] + device = {'identifiers': [f'{node}-{device_id}'], 'name': entry['name'], + 'manufacturer': MANUFACTURER} + for metric, (name, device_class, unit, icon) in HA_SENSORS.items(): + if metric not in entry['values']: + continue + config = {'name': name, 'unique_id': f'{node}-{device_id}-{metric}', + 'state_topic': f'{prefix}/{level}/state', + 'value_template': f'{{{{ value_json.{metric} }}}}', + 'availability_topic': f'{prefix}/status', + 'unit_of_measurement': unit, 'state_class': 'measurement', 'device': device} + if device_class: + config['device_class'] = device_class + if icon: + config['icon'] = icon + wanted[f'{DISCOVERY_PREFIX}/sensor/{node}-{device_id}/{metric}/config'] = json.dumps(config) + for topic in set(self.announced) - set(wanted): + self.client.publish(topic, '', qos=1, retain=True) + for topic, payload in wanted.items(): + if self.announced.get(topic) != payload: + self.client.publish(topic, payload, qos=1, retain=True) + self.announced = wanted + def start(): publisher = Publisher() diff --git a/settings.py b/settings.py index 1064ea2..759fa59 100644 --- a/settings.py +++ b/settings.py @@ -79,6 +79,7 @@ 'mqtt_publish_interval_seconds': {'type': int, 'default': 10, 'section': 'mqtt', 'min': 1, 'max': 3600}, 'mqtt_qos': {'type': int, 'default': 0, 'section': 'mqtt', 'choices': [0, 1]}, 'mqtt_retain': {'type': bool, 'default': True, 'section': 'mqtt'}, + 'mqtt_ha_discovery': {'type': bool, 'default': False, 'section': 'mqtt'}, } # Een opgeslagen secret gaat alleen naar dezelfde bestemming. Wijzigt een van deze velden, dan moet diff --git a/test_app.py b/test_app.py index b8bb1ee..2c7b51d 100644 --- a/test_app.py +++ b/test_app.py @@ -1226,6 +1226,51 @@ def test_mqtt_publishes_only_new_measurements(): assert publisher.client.messages == [] +def test_mqtt_home_assistant_discovery(): + import json + fresh_settings() + assert settings.save({'mqtt_ha_discovery': True}) == {} + mqtt_publisher._latest.clear() + devices.save_config([bus()], [device('Kas noord')]) + kas_id = devices.load_config()[1][0]['id'] + values = {'temperature': 18.0, 'humidity': 62.0, 'dewpoint': 11.1, 'absolute_humidity': 9.9, 'wood_emc': 12.0} + mqtt_publisher.store({'id': kas_id, 'name': 'Kas noord'}, values) + publisher = mqtt_publisher.Publisher() + publisher.client = FakeMqttClient() + config = {'mqtt_topic_prefix': 'fabriek/hal1', 'mqtt_qos': 0, 'mqtt_retain': False} + publisher.publish(config) + topics = {m[0]: m for m in publisher.client.messages} + base = f'homeassistant/sensor/fabriek-hal1-{kas_id}' + assert sorted(t for t in topics if t.startswith('homeassistant/')) == sorted(f'{base}/{m}/config' for m in values) + temp = json.loads(topics[f'{base}/temperature/config'][1]) + assert topics[f'{base}/temperature/config'][3] is True # config altijd retained + assert temp['device']['name'] == 'Kas noord' and temp['device']['manufacturer'] == 'BRDC' + assert temp['device']['identifiers'] == [f'fabriek-hal1-{kas_id}'] + assert temp['unique_id'] == f'fabriek-hal1-{kas_id}-temperature' + assert temp['state_topic'] == 'fabriek/hal1/kas-noord/state' + assert temp['value_template'] == '{{ value_json.temperature }}' + assert temp['availability_topic'] == 'fabriek/hal1/status' + assert (temp['device_class'], temp['unit_of_measurement'], temp['state_class']) == ('temperature', '°C', 'measurement') + assert json.loads(topics[f'{base}/humidity/config'][1])['device_class'] == 'humidity' + + publisher.client.messages.clear() # ongewijzigd: geen nieuwe config + mqtt_publisher.store({'id': kas_id, 'name': 'Kas noord'}, values) + publisher.publish(config) + assert not [m for m in publisher.client.messages if m[0].startswith('homeassistant/')] + + publisher.client.messages.clear() # houtvocht uit: die entity verdwijnt + mqtt_publisher.store({'id': kas_id, 'name': 'Kas noord'}, {'temperature': 18.0, 'humidity': 62.0}) + publisher.publish(config) + removed = {m[0] for m in publisher.client.messages if m[1] == ''} + assert removed == {f'{base}/{m}/config' for m in ('dewpoint', 'absolute_humidity', 'wood_emc')} + + publisher.client.messages.clear() # discovery uit: alles weg + settings.save({'mqtt_ha_discovery': False}) + publisher.publish(config) + assert {m[0] for m in publisher.client.messages if m[1] == ''} == \ + {f'{base}/temperature/config', f'{base}/humidity/config'} + + def test_mqtt_forgets_deleted_sensors(): fresh_settings() mqtt_publisher._latest.clear() diff --git a/translations.py b/translations.py index ecfe568..4a5e275 100644 --- a/translations.py +++ b/translations.py @@ -309,11 +309,12 @@ 'lbl_mqtt_publish_interval_seconds': 'Publicatie-interval (seconden)', 'lbl_mqtt_qos': 'QoS', 'lbl_mqtt_retain': 'Retain (laatste waarde bewaren op de broker)', + 'lbl_mqtt_ha_discovery': 'Home Assistant discovery (sensoren verschijnen automatisch in Home Assistant)', 'opt_mqtt_qos_0': '0 - hooguit één keer', 'opt_mqtt_qos_1': '1 - minstens één keer', 'hint_mqtt_client_id': 'Leeg = automatisch', 'hint_mqtt_topic_prefix': 'Bijv. xy-md02 of fabriek/hal1 (geen # of +)', - 'hint_mqtt': 'Topics per sensor: //state (JSON) en //temperature, humidity, dewpoint, absolute_humidity.\nStatus: /status = online / offline. Let op: een sensor hernoemen verandert zijn topic.', + 'hint_mqtt': 'Topics per sensor: //state (JSON) en //temperature, humidity, dewpoint, absolute_humidity.\nStatus: /status = online / offline. Let op: een sensor hernoemen verandert zijn topic.\nHome Assistant: elke sensor wordt een apparaat (fabrikant BRDC) met een entity per grootheid, via homeassistant/sensor/…/config.', 'btn_test_mqtt': 'Test verbinding', 'test_mqtt_ok': 'Verbinding met de broker gelukt', 'mqtt_status_connected': 'Verbonden met de broker', @@ -674,11 +675,12 @@ 'lbl_mqtt_publish_interval_seconds': 'Publish interval (seconds)', 'lbl_mqtt_qos': 'QoS', 'lbl_mqtt_retain': 'Retain (keep the last value on the broker)', + 'lbl_mqtt_ha_discovery': 'Home Assistant discovery (sensors show up in Home Assistant automatically)', 'opt_mqtt_qos_0': '0 - at most once', 'opt_mqtt_qos_1': '1 - at least once', 'hint_mqtt_client_id': 'Empty = automatic', 'hint_mqtt_topic_prefix': 'E.g. xy-md02 or factory/hall1 (no # or +)', - 'hint_mqtt': 'Topics per sensor: //state (JSON) and //temperature, humidity, dewpoint, absolute_humidity.\nStatus: /status = online / offline. Note: renaming a sensor changes its topic.', + 'hint_mqtt': 'Topics per sensor: //state (JSON) and //temperature, humidity, dewpoint, absolute_humidity.\nStatus: /status = online / offline. Note: renaming a sensor changes its topic.\nHome Assistant: every sensor becomes a device (manufacturer BRDC) with an entity per quantity, via homeassistant/sensor/…/config.', 'btn_test_mqtt': 'Test connection', 'test_mqtt_ok': 'Connected to the broker', 'mqtt_status_connected': 'Connected to the broker', @@ -1039,11 +1041,12 @@ 'lbl_mqtt_publish_interval_seconds': 'Veröffentlichungsintervall (Sekunden)', 'lbl_mqtt_qos': 'QoS', 'lbl_mqtt_retain': 'Retain (letzten Wert auf dem Broker behalten)', + 'lbl_mqtt_ha_discovery': 'Home Assistant Discovery (Sensoren erscheinen automatisch in Home Assistant)', 'opt_mqtt_qos_0': '0 - höchstens einmal', 'opt_mqtt_qos_1': '1 - mindestens einmal', 'hint_mqtt_client_id': 'Leer = automatisch', 'hint_mqtt_topic_prefix': 'Z. B. xy-md02 oder fabrik/halle1 (kein # oder +)', - 'hint_mqtt': 'Topics pro Sensor: //state (JSON) und //temperature, humidity, dewpoint, absolute_humidity.\nStatus: /status = online / offline. Hinweis: Das Umbenennen eines Sensors ändert sein Topic.', + 'hint_mqtt': 'Topics pro Sensor: //state (JSON) und //temperature, humidity, dewpoint, absolute_humidity.\nStatus: /status = online / offline. Hinweis: Das Umbenennen eines Sensors ändert sein Topic.\nHome Assistant: Jeder Sensor wird ein Gerät (Hersteller BRDC) mit einer Entität pro Größe, über homeassistant/sensor/…/config.', 'btn_test_mqtt': 'Verbindung testen', 'test_mqtt_ok': 'Verbindung zum Broker hergestellt', 'mqtt_status_connected': 'Mit dem Broker verbunden', @@ -1404,11 +1407,12 @@ 'lbl_mqtt_publish_interval_seconds': 'Intervalle de publication (secondes)', 'lbl_mqtt_qos': 'QoS', 'lbl_mqtt_retain': 'Retain (conserver la dernière valeur sur le broker)', + 'lbl_mqtt_ha_discovery': 'Home Assistant discovery (les capteurs apparaissent automatiquement dans Home Assistant)', 'opt_mqtt_qos_0': '0 - au plus une fois', 'opt_mqtt_qos_1': '1 - au moins une fois', 'hint_mqtt_client_id': 'Vide = automatique', 'hint_mqtt_topic_prefix': 'Par ex. xy-md02 ou usine/hall1 (sans # ni +)', - 'hint_mqtt': 'Topics par capteur : //state (JSON) et //temperature, humidity, dewpoint, absolute_humidity.\nÉtat : /status = online / offline. Attention : renommer un capteur change son topic.', + 'hint_mqtt': 'Topics par capteur : //state (JSON) et //temperature, humidity, dewpoint, absolute_humidity.\nÉtat : /status = online / offline. Attention : renommer un capteur change son topic.\nHome Assistant : chaque capteur devient un appareil (fabricant BRDC) avec une entité par grandeur, via homeassistant/sensor/…/config.', 'btn_test_mqtt': 'Tester la connexion', 'test_mqtt_ok': 'Connexion au broker réussie', 'mqtt_status_connected': 'Connecté au broker', @@ -1769,11 +1773,12 @@ 'lbl_mqtt_publish_interval_seconds': 'Intervalo de publicación (segundos)', 'lbl_mqtt_qos': 'QoS', 'lbl_mqtt_retain': 'Retain (conservar el último valor en el broker)', + 'lbl_mqtt_ha_discovery': 'Home Assistant discovery (los sensores aparecen automáticamente en Home Assistant)', 'opt_mqtt_qos_0': '0 - como máximo una vez', 'opt_mqtt_qos_1': '1 - al menos una vez', 'hint_mqtt_client_id': 'Vacío = automático', 'hint_mqtt_topic_prefix': 'P. ej. xy-md02 o fabrica/nave1 (sin # ni +)', - 'hint_mqtt': 'Topics por sensor: //state (JSON) y //temperature, humidity, dewpoint, absolute_humidity.\nEstado: /status = online / offline. Nota: renombrar un sensor cambia su topic.', + 'hint_mqtt': 'Topics por sensor: //state (JSON) y //temperature, humidity, dewpoint, absolute_humidity.\nEstado: /status = online / offline. Nota: renombrar un sensor cambia su topic.\nHome Assistant: cada sensor se convierte en un dispositivo (fabricante BRDC) con una entidad por magnitud, vía homeassistant/sensor/…/config.', 'btn_test_mqtt': 'Probar conexión', 'test_mqtt_ok': 'Conexión con el broker correcta', 'mqtt_status_connected': 'Conectado al broker', From b817bd60f970cedfec29016820a3b0904a4cf21a Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:26:50 +0100 Subject: [PATCH 2/2] chore: Version 2.3.1-beta --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a5a685..d42d007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Climate Monitor. Older versions: see the [GitHub releases](https://github.com/CervezaStallone/ClimateMonitor/releases). -## [Unreleased] +## [2.3.1-beta] - 2026-11-03 (pre-release) ### Added - **Home Assistant discovery** (Settings → MQTT): every sensor shows up in Home Assistant as a device with the sensor's name and manufacturer BRDC, with an entity per quantity. diff --git a/pyproject.toml b/pyproject.toml index 538a203..74b4856 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "xy-md02-webapp" -version = "2.3.0" +version = "2.3.1b1" description = "Web-based monitoring dashboard for XY-MD02 Modbus temperature and humidity sensor" readme = "README.md" requires-python = ">=3.10"