Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to Climate Monitor. Older versions: see the [GitHub releases](https://github.com/CervezaStallone/ClimateMonitor/releases).

## [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.

## [2.3.0] - 2026-11-02

### Added
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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/<prefix>-<id>/<quantity>/config`;
entities go offline with `<prefix>/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
Expand Down
44 changes: 44 additions & 0 deletions mqtt_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<prefix>/status online / offline (retained, offline via last will)
<prefix>/<sensor>/state JSON met alle waarden
<prefix>/<sensor>/<grootheid> losse waarde (temperature, humidity, dewpoint, absolute_humidity, wood_emc)
homeassistant/sensor/<prefix>-<id>/<grootheid>/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.
Expand All @@ -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}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Expand All @@ -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']:
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 10 additions & 5 deletions translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <prefix>/<sensornaam>/state (JSON) en <prefix>/<sensornaam>/temperature, humidity, dewpoint, absolute_humidity.\nStatus: <prefix>/status = online / offline. Let op: een sensor hernoemen verandert zijn topic.',
'hint_mqtt': 'Topics per sensor: <prefix>/<sensornaam>/state (JSON) en <prefix>/<sensornaam>/temperature, humidity, dewpoint, absolute_humidity.\nStatus: <prefix>/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',
Expand Down Expand Up @@ -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: <prefix>/<sensor name>/state (JSON) and <prefix>/<sensor name>/temperature, humidity, dewpoint, absolute_humidity.\nStatus: <prefix>/status = online / offline. Note: renaming a sensor changes its topic.',
'hint_mqtt': 'Topics per sensor: <prefix>/<sensor name>/state (JSON) and <prefix>/<sensor name>/temperature, humidity, dewpoint, absolute_humidity.\nStatus: <prefix>/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',
Expand Down Expand Up @@ -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: <Präfix>/<Sensorname>/state (JSON) und <Präfix>/<Sensorname>/temperature, humidity, dewpoint, absolute_humidity.\nStatus: <Präfix>/status = online / offline. Hinweis: Das Umbenennen eines Sensors ändert sein Topic.',
'hint_mqtt': 'Topics pro Sensor: <Präfix>/<Sensorname>/state (JSON) und <Präfix>/<Sensorname>/temperature, humidity, dewpoint, absolute_humidity.\nStatus: <Präfix>/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',
Expand Down Expand Up @@ -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 : <préfixe>/<nom du capteur>/state (JSON) et <préfixe>/<nom du capteur>/temperature, humidity, dewpoint, absolute_humidity.\nÉtat : <préfixe>/status = online / offline. Attention : renommer un capteur change son topic.',
'hint_mqtt': 'Topics par capteur : <préfixe>/<nom du capteur>/state (JSON) et <préfixe>/<nom du capteur>/temperature, humidity, dewpoint, absolute_humidity.\nÉtat : <préfixe>/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',
Expand Down Expand Up @@ -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: <prefijo>/<nombre del sensor>/state (JSON) y <prefijo>/<nombre del sensor>/temperature, humidity, dewpoint, absolute_humidity.\nEstado: <prefijo>/status = online / offline. Nota: renombrar un sensor cambia su topic.',
'hint_mqtt': 'Topics por sensor: <prefijo>/<nombre del sensor>/state (JSON) y <prefijo>/<nombre del sensor>/temperature, humidity, dewpoint, absolute_humidity.\nEstado: <prefijo>/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',
Expand Down
Loading