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/3] 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/3] 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" From c68fbfae8141bf9b86705376fcd704e3df8eb303 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:39:14 +0100 Subject: [PATCH 3/3] feat: Weekly or monthly PDF moisture report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Reports: daily wood moisture per sensor in a chart and a table with min/max temperature, humidity and wood moisture (yellow above 16 %, orange above 18 %, red above 20 %). E-mailed as a PDF attachment to the notification recipients on Monday or the 1st from 07:00, retried hourly on failure and logged; downloadable for this or last week or month. Report language is selectable. Adds matplotlib. --- CHANGELOG.md | 4 + README.md | 26 +++++ alerts.py | 5 +- app.py | 2 + img/report.png | Bin 0 -> 77029 bytes pyproject.toml | 2 + report.py | 232 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 12 +++ settings.py | 5 +- settings_ui.py | 40 +++++++- test_app.py | 240 +++++++++++++++++++++++++++++++++++++++++++++++ translations.py | 180 +++++++++++++++++++++++++++++++++++ 12 files changed, 742 insertions(+), 6 deletions(-) create mode 100644 img/report.png create mode 100644 report.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d42d007..1797522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to Climate Monitor. Older versions: see the [GitHub releases ### 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. +- **Reports** (Settings → Reports): weekly or monthly PDF with the daily wood moisture per sensor and a table with min/max temperature, humidity and wood moisture (yellow above 16 %, orange above 18 %, red above 20 %). Sent by e-mail to the notification recipients and downloadable; language selectable. + +### Changed +- New dependency: matplotlib (PDF report). `start.sh` installs it automatically; otherwise run `pip install -r requirements.txt`. ## [2.3.0] - 2026-11-02 diff --git a/README.md b/README.md index 76157d2..87e49a1 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - [Multiple buses and sensors](#multiple-buses-and-sensors) - [Notifications](#notifications) - [MQTT](#mqtt) + - [Reports](#reports) - [Adding a New Language](#adding-a-new-language) - [Comfort Score & Humidex](#comfort-score--humidex) - [Mollier Diagram (Psychrometric Chart)](#mollier-diagram-psychrometric-chart) @@ -83,6 +84,7 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - **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), with optional Home Assistant discovery +- **Reports**: weekly or monthly PDF with the wood moisture per sensor and a min/max table, e-mailed and downloadable - **Login protection**: Admin password for the Settings tab, optionally for the whole app ### Screenshots @@ -112,6 +114,10 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity ![Zone editor](img/zone-editor.png) *Zone editor: drag the corners, drag a dot in the middle of a side to add a corner.* +#### Report +![Moisture report](img/report.png) +*Monthly report (Spanish, example data): daily wood moisture per sensor and the min/max table with the wood moisture coloured by level.* + #### Historical replay ![Historical range](img/history-range.png) ![Historical replay](img/mollier-history.png) @@ -287,6 +293,7 @@ ClimateMonitor/ ├── psychrometric.py # Mollier diagram generation ├── wood.py # Wood equilibrium moisture: formula, response time, diagram ├── zones.py # Zones (areas of temperature × humidity) with colour and alarm +├── report.py # Weekly/monthly PDF report, sent by e-mail ├── callbacks.py # Dashboard callbacks (board/meters, chart, Mollier, replay) ├── settings.py # Settings stored in the database, validation, password reset ├── settings_ui.py # Settings tab @@ -357,6 +364,7 @@ Open the **Settings** tab in the web app. The first visit asks you to create an | 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, Home Assistant discovery, test button | +| Reports | Report by e-mail (off / weekly / monthly), report language, download | | 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 | @@ -449,6 +457,23 @@ when it is on). The configs are retained on `homeassistant/sensor/-/ entities go offline with `/status`. Renaming a sensor keeps its entities; removing a sensor or turning discovery off removes them from Home Assistant. +#### Reports + +Settings → Reports makes a PDF report of all sensors in one document: + +- the wood moisture per sensor per day (daily average) in a chart; +- a table with the minimum and maximum temperature, humidity and wood moisture per sensor. Wood moisture + above 16 % is yellow, above 18 % orange and above 20 % red. + +The wood moisture uses the same correction and response time as the board and the alarms, calculated +from hourly averages. + +*Report by e-mail*: **weekly** (Monday from 07:00, about the previous Monday–Sunday) or **monthly** (the +1st from 07:00, about the previous month), as a PDF attachment to the recipients under Notifications +(e-mail must be on). If the app was off at that time, the report is sent as soon as it runs again; a +failed send is retried every hour and shown in the notification log. *Report language* sets the +language of the report. *Download report* gives the PDF of this or last week or month directly. + #### Modbus values in `.env` (first start only) When upgrading, the existing `.env` Modbus configuration becomes *Bus 1* with *Sensor 1*, and all @@ -696,6 +721,7 @@ pip freeze > requirements.txt - **psychrometric.py**: Mollier diagram - **wood.py**: wood equilibrium moisture - **zones.py**: zones, point-in-zone check +- **report.py**: PDF report and its e-mail schedule - **translations.py**: texts in NL, EN, DE, FR, ES - **test_app.py**: automated tests diff --git a/alerts.py b/alerts.py index 438ebac..13b4236 100644 --- a/alerts.py +++ b/alerts.py @@ -377,12 +377,15 @@ def send_telegram(text, config): raise RuntimeError(body.get('description') or f'HTTP {response.status_code}') -def send_email(text, config): +def send_email(text, config, attachment=None): + """attachment: optioneel (bestandsnaam, PDF bytes).""" message = EmailMessage() message['Subject'] = f"XY-MD02: {text.splitlines()[0][:100]}" message['From'] = config['smtp_from'] message['To'] = config['smtp_to'] message.set_content(text) + if attachment: + message.add_attachment(attachment[1], maintype='application', subtype='pdf', filename=attachment[0]) context = ssl.create_default_context() if config['smtp_security'] == 'ssl': server = smtplib.SMTP_SSL(config['smtp_host'], config['smtp_port'], timeout=SEND_TIMEOUT, context=context) diff --git a/app.py b/app.py index e169fb5..ce0b2b1 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ import auth import devices import mqtt_publisher +import report import settings from database import init_database, cleanup_old_data from modbus_reader import start_modbus_thread @@ -30,6 +31,7 @@ # Start meldingen en Modbus threads alerts.start() mqtt_publisher.start() +report.start() modbus_thread = start_modbus_thread() # Dash app initialisatie diff --git a/img/report.png b/img/report.png new file mode 100644 index 0000000000000000000000000000000000000000..56146513a4ef40a74fc8901fc3aee5af4a4b4d58 GIT binary patch literal 77029 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 TPZkTOg!MrDp;-1^ofrQfq@mZ! literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 74b4856..4cb5b1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,8 @@ dependencies = [ "python-dotenv==1.2.4", "pyserial==3.5", "paho-mqtt==2.1.0", + "matplotlib==3.10.9; python_version < '3.11'", + "matplotlib==3.11.2; python_version >= '3.11'", ] [project.optional-dependencies] diff --git a/report.py b/report.py new file mode 100644 index 0000000..349955b --- /dev/null +++ b/report.py @@ -0,0 +1,232 @@ +"""Periodiek rapport (PDF): houtvocht per sensor per dag en een tabel met min/max per sensor.""" +import io +import math +import sqlite3 +import textwrap +import threading +import time +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import matplotlib.dates as mdates +from matplotlib.figure import Figure # zonder pyplot: geen GUI backend, veilig in threads + +import alerts +import devices +import settings +import wood +from database import build_union_query +from translations import TRANSLATIONS + +# Kleurgrenzen houtvocht (%): boven de grens krijgt de waarde deze kleur +LEVELS = ((20, 'red'), (18, 'orange'), (16, 'yellow')) +COLORS = {'normal': ('#1a7f37', '#dcf5e3'), 'yellow': ('#a87800', '#fff3c4'), # (tekst, achtergrond) + 'orange': ('#c2410c', '#ffe1cc'), 'red': ('#c62828', '#fbd5d5')} +MUTED = '#6b6b6b' +RETRY_SECONDS = 3600 # na een mislukte verzending +_next_try = 0 + + +def level(value): + """'normal' tot en met 16 %, daarboven geel, oranje (> 18 %) en rood (> 20 %).""" + return next((name for limit, name in LEVELS if value > limit), 'normal') + + +def period(kind, now, current=False): + """(begin, einde) van de vorige volledige week (maandag-maandag) of maand in de tijdzone van now; + current=True: de lopende week of maand tot nu.""" + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + if kind == 'week': + end = midnight - timedelta(days=midnight.weekday()) + return (end, now) if current else (end - timedelta(days=7), end) + end = midnight.replace(day=1) + return (end, now) if current else ((end - timedelta(days=1)).replace(day=1), end) + + +def _query(sql, columns, start_ts, end_ts): + """sql met {q} = alle metingen van start_ts tot (niet tot en met) end_ts over de dagtabellen.""" + conn = sqlite3.connect(settings.DB_FILE, timeout=30) + try: + query, _ = build_union_query(conn.cursor(), columns, start_timestamp=start_ts, end_timestamp=end_ts - 1, + order_by=None) + return conn.execute(sql.format(q=query)).fetchall() if query else [] + finally: + conn.close() + + +def _wood(hourly, hours): + """[(uur, temp, rv)] -> [(uur, houtvocht)], vertraagd met de reactietijd zoals de alarmen (wood.update).""" + series, smooth, last = [], None, None + for hour, temp, hum in hourly: + live = float(wood.value(temp, hum)) + if smooth is None or not hours: + smooth = live + else: + smooth += (live - smooth) * (1 - math.exp(-(hour - last) / hours)) + series.append((hour, smooth)) + last = hour + return series + + +def collect(start, end): + """Per sensor met metingen in [start, end): naam, min/max van temperatuur, RV en houtvocht, en het + houtvocht per dag [(datum, gemiddelde)]. Houtvocht uit uurgemiddelden; de reactietijd vóór start telt mee.""" + start_ts, end_ts = int(start.timestamp()), int(end.timestamp()) + columns = ['device_id', 'timestamp', 'temperature', 'humidity'] + rows = {r[0]: r[1:] for r in _query( + 'SELECT device_id, MIN(temperature), MAX(temperature), MIN(humidity), MAX(humidity) FROM ({q}) GROUP BY device_id', + columns, start_ts, end_ts)} + hours = settings.get('wood_response_hours') + hourly = {} + for device_id, hour, temp, hum in _query( + 'SELECT device_id, timestamp / 3600 AS h, AVG(temperature), AVG(humidity) FROM ({q}) ' + 'GROUP BY device_id, h ORDER BY h', columns, start_ts - hours * 3600, end_ts): + hourly.setdefault(device_id, []).append((hour, temp, hum)) + result = [] + for d in devices.list_devices(): + if d['id'] not in rows: + continue + values = [(h, v) for h, v in _wood(hourly[d['id']], hours) if h * 3600 >= start_ts] + days = {} + for hour, value in values: + days.setdefault(datetime.fromtimestamp(hour * 3600, start.tzinfo).date(), []).append(value) + temp_min, temp_max, hum_min, hum_max = rows[d['id']] + result.append({'name': d['name'], 'temp_min': temp_min, 'temp_max': temp_max, 'hum_min': hum_min, + 'hum_max': hum_max, 'wood_min': min(v for _, v in values), 'wood_max': max(v for _, v in values), + 'days': [(day, sum(v) / len(v)) for day, v in sorted(days.items())]}) + return result + + +def _dates(start, end): + """Begin- en einddatum voor de tekst (end is exclusief).""" + return start.strftime('%d-%m-%Y'), (end - timedelta(seconds=1)).strftime('%d-%m-%Y') + + +def build_pdf(start, end, lang): + """A4 PDF: titel, uitleg, houtvocht per sensor per dag, tabel met min/max en de kleurgrenzen.""" + from callbacks import SERIES # hier pas: callbacks laadt settings_ui, dat dit module laadt + t = TRANSLATIONS[lang] + sensors = collect(start, end) + fig = Figure(figsize=(8.27, 11.69)) # A4 + fig.text(0.5, 0.95, t['report_title'], ha='center', fontsize=18) + fig.text(0.5, 0.925, t['report_period'].format(start=_dates(start, end)[0], end=_dates(start, end)[1]), + ha='center', fontsize=10, color=MUTED) + fig.text(0.1, 0.9, textwrap.fill(t['report_intro'], 92), va='top', fontsize=10) + + ax = fig.add_axes((0.1, 0.6, 0.85, 0.22)) + palette = SERIES['light'] + for i, sensor in enumerate(sensors): + days, values = zip(*sensor['days']) + ax.plot(days, values, marker='o', markersize=3, linewidth=1.5, label=sensor['name'], + color=palette[i % len(palette)]) + for limit, name in LEVELS: + ax.axhline(limit, color=COLORS[name][0], linewidth=0.8, linestyle='--', alpha=0.6) + ax.set_title(t['report_chart'], fontsize=9, loc='left', color=MUTED) + ax.xaxis.set_major_formatter(mdates.DateFormatter('%d')) + ax.xaxis.set_major_locator(mdates.DayLocator()) + if sensors: + first, last = min(s['days'][0][0] for s in sensors), max(s['days'][-1][0] for s in sensors) + ax.set_xlim(first - timedelta(hours=12), last + timedelta(hours=12)) + ax.tick_params(labelsize=7) + ax.grid(axis='y', color='#e5e5e5') + for side in ('top', 'right'): + ax.spines[side].set_visible(False) + if sensors: + ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=min(len(sensors), 6), fontsize=8, frameon=False) + else: + ax.text(0.5, 0.5, t['report_no_data'], ha='center', transform=ax.transAxes) + + fig.text(0.1, 0.5, t['report_table'], fontsize=11) + columns = ['sensor', 'temp_min', 'temp_max', 'hum_min', 'hum_max', 'wood_min', 'wood_max'] + if sensors: + height = min(0.035 * (len(sensors) + 1), 0.3) + tax = fig.add_axes((0.1, 0.48 - height, 0.85, height)) + tax.axis('off') + cells = [[s['name']] + [f"{s[c]:.1f}" for c in columns[1:]] for s in sensors] + table = tax.table(cellText=cells, colLabels=[t[f'report_col_{c}'] for c in columns], loc='upper center', + cellLoc='right', colLoc='right', bbox=(0, 0, 1, 1), + colWidths=[0.16, 0.115, 0.115, 0.115, 0.115, 0.19, 0.19]) + table.auto_set_font_size(False) + table.set_fontsize(8) + for (row, col), cell in table.get_celld().items(): + cell.set_edgecolor('#e5e5e5') + cell.visible_edges = 'B' + if row == 0: + cell.get_text().set_fontweight('bold') + cell.set_fontsize(7) + elif columns[col] in ('wood_min', 'wood_max') and level(sensors[row - 1][columns[col]]) != 'normal': + cell.get_text().set_color(COLORS[level(sensors[row - 1][columns[col]])][0]) + cell.get_text().set_fontweight('bold') + if col == 0: + cell.get_text().set_horizontalalignment('left') + bottom = 0.48 - height + else: + bottom = 0.48 + + fig.text(0.1, bottom - 0.04, t['report_levels'], fontsize=10, fontweight='bold') + bounds = [None, *[limit for limit, _ in reversed(LEVELS)]] # None, 16, 18, 20 + for i, name in enumerate(('normal', 'yellow', 'orange', 'red')): + low, high = bounds[i], bounds[i + 1] if i + 1 < len(bounds) else None + rule = f'≤ {high} %' if low is None else f'> {low} %' if high is None else \ + f"> {low} % {t['report_and']} ≤ {high} %" + y = bottom - 0.07 - i * 0.022 + fig.text(0.1, y, f" {t[f'report_level_{name}']} ", fontsize=8, color=COLORS[name][0], + bbox=dict(boxstyle='round,pad=0.3', facecolor=COLORS[name][1], edgecolor='none')) + fig.text(0.22, y, rule, fontsize=9) + fig.text(0.1, 0.03, t['report_generated'].format(time=datetime.now(start.tzinfo).strftime('%d-%m-%Y %H:%M')), + fontsize=7, color=MUTED) + + buffer = io.BytesIO() + fig.savefig(buffer, format='pdf', metadata={'Title': t['report_title'], 'Creator': 'Climate Monitor'}) + return buffer.getvalue() + + +def filename(start, lang): + return f"{TRANSLATIONS[lang]['report_filename']}-{start:%Y-%m-%d}.pdf" + + +def check(now=None): + """Verstuur het rapport over de vorige week/maand zodra die om 07:00 voorbij is (ook later, na + uitval), één keer per periode; na een fout opnieuw na RETRY_SECONDS. Geeft True als verstuurd.""" + global _next_try + kind = settings.get('report_period') + if kind == 'off' or not settings.get('smtp_enabled'): + return False + now = now or datetime.now(ZoneInfo(settings.get('timezone'))) + start, end = period(kind, now) + key = f'{kind}:{start:%Y-%m-%d}' + if now < end + timedelta(hours=7) or settings.get('report_last_sent') == key or now.timestamp() < _next_try: + return False + lang = settings.get('report_language') + t = TRANSLATIONS[lang] + first, last = _dates(start, end) + text = t['report_email_subject'].format(start=first, end=last) + '\n\n' + \ + t['report_email_body'].format(start=first, end=last) + config = {k: settings.get(k) for k in settings.SCHEMA} + try: + alerts.send_email(text, config, (filename(start, lang), build_pdf(start, end, lang))) + error = None + except Exception as e: + error = alerts._hide_secrets(str(e) or type(e).__name__, config) + alerts.log.appendleft({'time': time.time(), 'channel': 'email', 'text': text.splitlines()[0], 'ok': error is None, + 'error': error, 'attempt': 1}) # zichtbaar in Settings → Meldingen + if error: + _next_try = now.timestamp() + RETRY_SECONDS + return False + settings.set_internal('report_last_sent', key) + return True + + +def _loop(): + while True: + try: + check() + except Exception as e: # bv. database bezet: volgende minuut opnieuw + print(f"Rapport fout: {e}") + time.sleep(60) + + +def start(): + thread = threading.Thread(target=_loop, daemon=True, name='report') + thread.start() + return thread diff --git a/requirements.txt b/requirements.txt index 187c2e3..0365f4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,16 +3,25 @@ certifi==2026.10.23 charset-normalizer==3.5.2 click==8.5.0 colorama==0.4.6 +contourpy==1.3.2; python_version < "3.11" +contourpy==1.3.3; python_version == "3.11" +contourpy==1.4.0; python_version >= "3.12" +cycler==0.12.1 dash==4.5.0 dash-core-components==2.0.0 dash-html-components==2.0.0 dash-table==5.0.0 Flask==3.1.3 +fonttools==4.65.0; python_version < "3.11" +fonttools==4.68.0; python_version >= "3.11" idna==3.20 importlib_metadata==9.0.1 itsdangerous==2.2.0 Jinja2==3.1.6 +kiwisolver==1.5.1 MarkupSafe==3.0.4 +matplotlib==3.10.9; python_version < "3.11" +matplotlib==3.11.2; python_version >= "3.11" minimalmodbus==2.1.1 nest-asyncio==1.6.0 numpy==2.2.6; python_version < "3.11" @@ -20,7 +29,10 @@ numpy==2.3.5; python_version >= "3.11" packaging==26.3 paho-mqtt==2.1.0 pandas==2.3.3 +pillow==12.3.0; python_version < "3.11" +pillow==13.0.0; python_version >= "3.11" plotly==7.1.0 +pyparsing==3.3.3 pyserial==3.5 python-dateutil==3.0.0 python-dotenv==1.2.4 diff --git a/settings.py b/settings.py index 759fa59..8d5975f 100644 --- a/settings.py +++ b/settings.py @@ -80,6 +80,9 @@ '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'}, + # Rapporten + 'report_period': {'type': str, 'default': 'off', 'section': 'reports', 'choices': ['off', 'week', 'month']}, + 'report_language': {'type': str, 'default': 'en', 'section': 'reports', 'choices': LANGUAGES}, } # Een opgeslagen secret gaat alleen naar dezelfde bestemming. Wijzigt een van deze velden, dan moet @@ -97,7 +100,7 @@ } # Interne waarden: nooit naar de browser -INTERNAL_KEYS = ('admin_password_hash', 'secret_key') +INTERNAL_KEYS = ('admin_password_hash', 'secret_key', 'report_last_sent') _EMAIL_RE = re.compile(r'^[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+$') _TELEGRAM_TOKEN_RE = re.compile(r'^\d{5,}:[A-Za-z0-9_-]{30,}$') diff --git a/settings_ui.py b/settings_ui.py index 38b5be7..a78823f 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -2,7 +2,8 @@ import json import time from urllib.parse import quote -from zoneinfo import available_timezones +from datetime import datetime +from zoneinfo import ZoneInfo, available_timezones from dash import ALL, Input, Output, State, callback_context, dcc, html, no_update @@ -12,11 +13,13 @@ import layout import modbus_reader import mqtt_publisher +import report import settings import zones from translations import LANGUAGE_NAMES, TRANSLATIONS -SECTIONS = ['display', 'modbus', 'notify', 'alerts', 'wood', 'mqtt', 'regional', 'data', 'server', 'security'] +SECTIONS = ['display', 'modbus', 'notify', 'alerts', 'wood', 'mqtt', 'reports', 'regional', 'data', 'server', 'security'] +DOWNLOAD_PERIODS = ['this_week', 'last_week', 'this_month', 'last_month'] # Bussen, sensoren en grenswaarden: één regel gewone formuliervelden per item (geen DataTable: daar verplaatsen # de pijltjestoetsen de cel in plaats van de cursor en zijn de keuzelijsten in cellen onbetrouwbaar) @@ -40,7 +43,7 @@ def _t(lang): def _options(key, t): - if key == 'default_language': + if key in ('default_language', 'report_language'): return [{'label': LANGUAGE_NAMES[code], 'value': code} for code in settings.LANGUAGES] if key == 'timezone': return _TIMEZONES @@ -249,6 +252,19 @@ def _mqtt_section(t): ] +def _reports_section(t): + return [html.Div([ + html.Label(t['lbl_download_report'], className='field-label'), + html.Div(className='draw-actions', children=[ + dcc.Dropdown(id='report-download-period', value='last_month', clearable=False, searchable=False, + options=[{'label': t[f'opt_download_{p}'], 'value': p} for p in DOWNLOAD_PERIODS], + style={'minWidth': '200px'}), + html.Button(t['btn_download_report'], id='report-download-btn', n_clicks=0, className='btn btn-small'), + ]), + dcc.Loading(dcc.Download(id='report-download'), type='dot'), + ], className='field')] + + def mqtt_status_text(t): state = mqtt_publisher.status if not settings.get('mqtt_enabled'): @@ -313,7 +329,7 @@ def device_status_list(t): def _section(name, values, t): keys = [k for k, spec in settings.SCHEMA.items() if spec['section'] == name] extra = {'modbus': _modbus_section, 'notify': _notify_section, 'alerts': _alerts_section, - 'mqtt': _mqtt_section}.get(name, lambda t: [])(t) + 'mqtt': _mqtt_section, 'reports': _reports_section}.get(name, lambda t: [])(t) if name == 'data': extra.append(html.Div([ html.Label(t['lbl_database_file'], className='field-label'), @@ -576,6 +592,22 @@ def refresh_device_status(n, tab, lang): return no_update return device_status_list(_t(lang)) + @app.callback( + Output('report-download', 'data'), + Input('report-download-btn', 'n_clicks'), + [State('report-download-period', 'value'), + State('selected-language', 'data')], + prevent_initial_call=True + ) + def download_report(n_clicks, choice, lang): + """PDF van deze/vorige week of maand, in de taal van het rapport (alleen ingelogd).""" + if not auth.is_logged_in() or choice not in DOWNLOAD_PERIODS: + return no_update + which, kind = choice.split('_') + start, end = report.period(kind, datetime.now(ZoneInfo(settings.get('timezone'))), current=which == 'this') + language = settings.get('report_language') + return dcc.send_bytes(report.build_pdf(start, end, language), report.filename(start, language)) + @app.callback( Output('scan-result', 'children'), Input('scan-bus-btn', 'n_clicks'), diff --git a/test_app.py b/test_app.py index 2c7b51d..5ad2a57 100644 --- a/test_app.py +++ b/test_app.py @@ -50,6 +50,8 @@ def fresh_settings(): alerts._mutes.clear() import zones zones._cache = None + import report + report._next_try = 0 while not alerts._outbox.empty(): alerts._outbox.get_nowait() @@ -1546,6 +1548,244 @@ def test_layout_has_no_untranslated_dutch_text(): assert 'Aplicar' in rendered and 'Selección rápida' in rendered +def test_report_wood_levels(): + import report + assert [report.level(v) for v in (12, 16, 16.1, 18, 18.5, 20, 20.1)] == \ + ['normal', 'normal', 'yellow', 'yellow', 'orange', 'orange', 'red'] + + +def test_report_period_is_previous_month(): + import report + from zoneinfo import ZoneInfo + tz = ZoneInfo('Europe/Amsterdam') + assert report.period('month', datetime(2026, 1, 5, 8, tzinfo=tz)) == \ + (datetime(2025, 12, 1, tzinfo=tz), datetime(2026, 1, 1, tzinfo=tz)) + + +def test_report_period_is_previous_week_from_monday(): + import report + from zoneinfo import ZoneInfo + tz = ZoneInfo('Europe/Amsterdam') + assert report.period('week', datetime(2026, 11, 3, 8, tzinfo=tz)) == \ + (datetime(2026, 10, 26, tzinfo=tz), datetime(2026, 11, 2, tzinfo=tz)) + + +def test_report_current_period_runs_until_now(): + import report + from zoneinfo import ZoneInfo + tz = ZoneInfo('Europe/Amsterdam') + now = datetime(2026, 11, 3, 8, tzinfo=tz) + assert report.period('month', now, current=True) == (datetime(2026, 11, 1, tzinfo=tz), now) + + +def _insert_hourly(device_id, start, hours, temp, hum): + """Eén meting per uur in de juiste dagtabel; temp en hum zijn functies van het uur.""" + conn = sqlite3.connect(settings.DB_FILE) + for h in range(hours): + ts = start + h * 3600 + table = database.get_table_name(datetime.fromtimestamp(ts)) + database.ensure_table_exists(conn.cursor(), table) + conn.execute(f'INSERT INTO {table} (timestamp, temperature, humidity, device_id) VALUES (?, ?, ?, ?)', + (ts, temp(h), hum(h), device_id)) + conn.commit() + conn.close() + + +def test_report_table_has_min_max_within_the_period(): + import report + from zoneinfo import ZoneInfo + fresh_settings() + database.init_database() + devices.save_config([bus()], [device('Kas noord')]) + kas = devices.load_config()[1][0]['id'] + tz = ZoneInfo('Europe/Amsterdam') + start, end = datetime(2026, 10, 1, tzinfo=tz), datetime(2026, 11, 1, tzinfo=tz) + _insert_hourly(kas, int(start.timestamp()), 744, lambda h: 15 + (h % 24) / 4, lambda h: 60 + h / 31) + _insert_hourly(kas, int(start.timestamp()) - 3600, 1, lambda h: 99, lambda h: 99) # buiten de periode + row = report.collect(start, end)[0] + assert row['name'] == 'Kas noord' + assert (row['temp_min'], row['temp_max'], row['hum_min'], row['hum_max']) == (15, 20.75, 60, 60 + 743 / 31) + + +def test_report_wood_moisture_per_day_and_min_max(): + import report + import wood + from zoneinfo import ZoneInfo + fresh_settings() + database.init_database() + settings.save({'wood_response_hours': 0}) # houtvocht = actuele waarde + devices.save_config([bus()], [device('Kas noord')]) + kas = devices.load_config()[1][0]['id'] + tz = ZoneInfo('Europe/Amsterdam') + start, end = datetime(2026, 10, 1, tzinfo=tz), datetime(2026, 11, 1, tzinfo=tz) + _insert_hourly(kas, int(start.timestamp()), 744, lambda h: 15, lambda h: 60 + h / 31) + row = report.collect(start, end)[0] + assert abs(row['wood_min'] - float(wood.value(15, 60))) < 0.01 + assert abs(row['wood_max'] - float(wood.value(15, 60 + 743 / 31))) < 0.01 + days = row['days'] + assert [d.day for d, _ in days] == list(range(1, 32)) + assert days[0][1] < days[-1][1] + + +def test_report_wood_moisture_follows_the_response_time(): + import report + import wood + from zoneinfo import ZoneInfo + fresh_settings() + database.init_database() + settings.save({'wood_response_hours': 24}) + devices.save_config([bus()], [device('Kas noord')]) + kas = devices.load_config()[1][0]['id'] + tz = ZoneInfo('Europe/Amsterdam') + start, end = datetime(2026, 10, 26, tzinfo=tz), datetime(2026, 11, 2, tzinfo=tz) + _insert_hourly(kas, int(start.timestamp()), 168, lambda h: 20, lambda h: 95 if h == 100 else 60) + row = report.collect(start, end)[0] + assert row['hum_max'] == 95 # luchtwaarde wel + assert row['wood_max'] < float(wood.value(20, 70)) # één uur piek nauwelijks in het hout + + +def test_report_pdf_in_spanish(): + import report + from zoneinfo import ZoneInfo + fresh_settings() + database.init_database() + devices.save_config([bus()], [device('Kas noord'), device('Hal', slave_id=2)]) + kas, hal = (d['id'] for d in devices.load_config()[1]) + tz = ZoneInfo('Europe/Amsterdam') + start, end = datetime(2026, 10, 1, tzinfo=tz), datetime(2026, 11, 1, tzinfo=tz) + _insert_hourly(kas, int(start.timestamp()), 744, lambda h: 15, lambda h: 70 + h / 31) + _insert_hourly(hal, int(start.timestamp()), 744, lambda h: 20, lambda h: 50) + pdf = report.build_pdf(start, end, 'es') + assert pdf.startswith(b'%PDF') + assert b'Informe automatizado estado de humedad' in pdf # titel in de metadata + + +def test_report_is_mailed_once_per_period(): + import report + from zoneinfo import ZoneInfo + fresh_settings() + database.init_database() + assert settings.save({'report_period': 'month', 'report_language': 'es', 'smtp_enabled': True, + 'smtp_host': 'mail.example.com', 'smtp_from': 'a@example.com', 'smtp_to': 'b@example.com'}) == {} + tz = ZoneInfo('Europe/Amsterdam') + sent = [] + original = alerts.send_email + alerts.send_email = lambda text, config, attachment=None: sent.append((text, attachment)) + try: + assert not report.check(datetime(2026, 11, 1, 6, 30, tzinfo=tz)) # nog geen 07:00 + assert report.check(datetime(2026, 11, 1, 7, 5, tzinfo=tz)) + assert not report.check(datetime(2026, 11, 2, 9, 0, tzinfo=tz)) # al verstuurd + finally: + alerts.send_email = original + assert len(sent) == 1 + text, (filename, pdf) = sent[0] + assert text.startswith('Informe de humedad 01-10-2026 – 31-10-2026') + assert filename == 'informe-2026-10-01.pdf' and pdf.startswith(b'%PDF') + + +def test_report_failed_mail_is_logged_and_retried_after_an_hour(): + import report + from zoneinfo import ZoneInfo + fresh_settings() + database.init_database() + settings.save({'report_period': 'week', 'smtp_enabled': True, 'smtp_host': 'mail.example.com', + 'smtp_from': 'a@example.com', 'smtp_to': 'b@example.com'}) + tz = ZoneInfo('Europe/Amsterdam') + calls = [] + + def failing(text, config, attachment=None): + calls.append(text) + raise OSError('connection refused') + original = alerts.send_email + alerts.send_email = failing + alerts.log.clear() + try: + assert not report.check(datetime(2026, 11, 2, 7, 0, tzinfo=tz)) + assert not report.check(datetime(2026, 11, 2, 7, 30, tzinfo=tz)) # nog geen uur later + assert not report.check(datetime(2026, 11, 2, 8, 1, tzinfo=tz)) + finally: + alerts.send_email = original + assert len(calls) == 2 + assert alerts.log[0]['ok'] is False and alerts.log[0]['error'] == 'connection refused' + + +def test_email_can_carry_a_pdf_attachment(): + fresh_settings() + config = settings.merged({'smtp_host': 'mail.example.com', 'smtp_from': 'a@example.com', 'smtp_to': 'b@example.com', + 'smtp_security': 'none'}) + sent = [] + + class FakeSMTP: + def __init__(self, host, port, timeout): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def send_message(self, message): + sent.append(message) + + original = alerts.smtplib.SMTP + alerts.smtplib.SMTP = FakeSMTP + try: + alerts.send_email('Informe\n\ntexto', config, ('informe-2026-10-01.pdf', b'%PDF-1.4 demo')) + finally: + alerts.smtplib.SMTP = original + [attachment] = list(sent[0].iter_attachments()) + assert attachment.get_filename() == 'informe-2026-10-01.pdf' + assert attachment.get_content_type() == 'application/pdf' and attachment.get_content() == b'%PDF-1.4 demo' + + +def test_report_download_requires_login(): + import dash + import base64 + import settings_ui + fresh_settings() + database.init_database() + devices.init_devices() + settings.save({'report_language': 'es'}) + app = dash.Dash(__name__, suppress_callback_exceptions=True) + settings_ui.register_settings_callbacks(app) + download = next(e['callback'].__wrapped__ for e in app.callback_map.values() + if getattr(e['callback'], '__wrapped__', None) and e['callback'].__wrapped__.__name__ == 'download_report') + original_login = auth.is_logged_in + try: + with app.server.test_request_context(): + assert download(1, 'last_month', 'nl') is dash.no_update + auth.is_logged_in = lambda: True + result = download(1, 'last_month', 'nl') + finally: + auth.is_logged_in = original_login + assert result['filename'].startswith('informe-') and result['filename'].endswith('-01.pdf') + assert base64.b64decode(result['content']).startswith(b'%PDF') + + +def test_report_thread_keeps_checking_after_errors(): + import report + calls = [] + original_check, original_sleep = report.check, report.time.sleep + + def check(): + calls.append(1) + raise sqlite3.OperationalError('database is locked') + + def sleep(seconds): + assert seconds == 60 + if len(calls) >= 2: + raise SystemExit + report.check, report.time.sleep = check, sleep + try: + report._loop() + except SystemExit: + pass + finally: + report.check, report.time.sleep = original_check, original_sleep + assert len(calls) == 2 + + if __name__ == '__main__': tests = [(name, fn) for name, fn in sorted(globals().items()) if name.startswith('test_') and callable(fn)] failed = 0 diff --git a/translations.py b/translations.py index 4a5e275..15540b9 100644 --- a/translations.py +++ b/translations.py @@ -298,6 +298,42 @@ 'alert_online': '📶 {device}: weer online ({time})', 'err_secret_reenter': 'Server, gebruiker of beveiliging gewijzigd: vul het wachtwoord opnieuw in', 'section_mqtt': 'MQTT', + 'section_reports': 'Rapporten', + 'lbl_report_period': 'Rapport per e-mail', + 'opt_report_period_off': 'Uit', + 'opt_report_period_week': 'Wekelijks (maandag 07:00, vorige week)', + 'opt_report_period_month': 'Maandelijks (de 1e om 07:00, vorige maand)', + 'hint_report_period': 'PDF met het houtvocht per sensor en een tabel met min/max, naar het e-mailadres van Meldingen (e-mail moet aan staan).', + 'lbl_report_language': 'Taal van het rapport', + 'lbl_download_report': 'Rapport downloaden', + 'opt_download_this_week': 'Deze week', + 'opt_download_last_week': 'Vorige week', + 'opt_download_this_month': 'Deze maand', + 'opt_download_last_month': 'Vorige maand', + 'btn_download_report': 'Download PDF', + 'report_title': 'Automatisch rapport vochttoestand', + 'report_filename': 'rapport', + 'report_intro': 'Evenwichtsvochtgehalte van het hout, berekend uit de gemeten temperatuur en luchtvochtigheid. Aanbevolen wordt om 18 % houtvocht niet langdurig te overschrijden.', + 'report_period': 'Periode: {start} – {end}', + 'report_chart': 'Houtvocht, daggemiddelde (%)', + 'report_table': 'Overzicht van de meetwaarden:', + 'report_col_sensor': 'Sensor', + 'report_col_temp_min': 'Temp. min.\n(°C)', + 'report_col_temp_max': 'Temp. max.\n(°C)', + 'report_col_hum_min': 'RV min.\n(%)', + 'report_col_hum_max': 'RV max.\n(%)', + 'report_col_wood_min': 'Houtvocht\nmin. (%)', + 'report_col_wood_max': 'Houtvocht\nmax. (%)', + 'report_levels': 'Alarmgrenzen houtvocht', + 'report_level_normal': 'Normaal', + 'report_level_yellow': 'Geel', + 'report_level_orange': 'Oranje', + 'report_level_red': 'Rood', + 'report_and': 'en', + 'report_no_data': 'Geen metingen in deze periode.', + 'report_generated': 'Gemaakt {time}', + 'report_email_subject': 'Vochtrapport {start} – {end}', + 'report_email_body': 'Bijgevoegd het vochtrapport over {start} – {end}.', 'lbl_mqtt_enabled': 'Waarden doorgeven aan MQTT broker', 'lbl_mqtt_host': 'Broker adres', 'lbl_mqtt_port': 'Poort', @@ -664,6 +700,42 @@ 'alert_online': '📶 {device}: back online ({time})', 'err_secret_reenter': 'Server, user or security changed: enter the password again', 'section_mqtt': 'MQTT', + 'section_reports': 'Reports', + 'lbl_report_period': 'Report by e-mail', + 'opt_report_period_off': 'Off', + 'opt_report_period_week': 'Weekly (Monday 07:00, previous week)', + 'opt_report_period_month': 'Monthly (the 1st at 07:00, previous month)', + 'hint_report_period': 'PDF with the wood moisture per sensor and a min/max table, sent to the e-mail address under Notifications (e-mail must be on).', + 'lbl_report_language': 'Report language', + 'lbl_download_report': 'Download report', + 'opt_download_this_week': 'This week', + 'opt_download_last_week': 'Last week', + 'opt_download_this_month': 'This month', + 'opt_download_last_month': 'Last month', + 'btn_download_report': 'Download PDF', + 'report_title': 'Automated moisture status report', + 'report_filename': 'report', + 'report_intro': 'Equilibrium moisture content of the wood, calculated from the measured temperature and humidity. It is recommended not to exceed 18 % moisture for a prolonged time.', + 'report_period': 'Period: {start} – {end}', + 'report_chart': 'Wood moisture, daily average (%)', + 'report_table': 'Summary of the monitored values:', + 'report_col_sensor': 'Sensor', + 'report_col_temp_min': 'Temp. min.\n(°C)', + 'report_col_temp_max': 'Temp. max.\n(°C)', + 'report_col_hum_min': 'RH min.\n(%)', + 'report_col_hum_max': 'RH max.\n(%)', + 'report_col_wood_min': 'Wood moisture\nmin. (%)', + 'report_col_wood_max': 'Wood moisture\nmax. (%)', + 'report_levels': 'Wood moisture alarm thresholds', + 'report_level_normal': 'Normal', + 'report_level_yellow': 'Yellow', + 'report_level_orange': 'Orange', + 'report_level_red': 'Red', + 'report_and': 'and', + 'report_no_data': 'No measurements in this period.', + 'report_generated': 'Generated {time}', + 'report_email_subject': 'Moisture report {start} – {end}', + 'report_email_body': 'Attached is the moisture report for {start} – {end}.', 'lbl_mqtt_enabled': 'Publish values to an MQTT broker', 'lbl_mqtt_host': 'Broker address', 'lbl_mqtt_port': 'Port', @@ -1030,6 +1102,42 @@ 'alert_online': '📶 {device}: wieder online ({time})', 'err_secret_reenter': 'Server, Benutzer oder Sicherheit geändert: Passwort erneut eingeben', 'section_mqtt': 'MQTT', + 'section_reports': 'Berichte', + 'lbl_report_period': 'Bericht per E-Mail', + 'opt_report_period_off': 'Aus', + 'opt_report_period_week': 'Wöchentlich (Montag 07:00, vorige Woche)', + 'opt_report_period_month': 'Monatlich (am 1. um 07:00, voriger Monat)', + 'hint_report_period': 'PDF mit der Holzfeuchte pro Sensor und einer Min/Max-Tabelle, an die E-Mail-Adresse unter Benachrichtigungen (E-Mail muss an sein).', + 'lbl_report_language': 'Sprache des Berichts', + 'lbl_download_report': 'Bericht herunterladen', + 'opt_download_this_week': 'Diese Woche', + 'opt_download_last_week': 'Vorige Woche', + 'opt_download_this_month': 'Dieser Monat', + 'opt_download_last_month': 'Voriger Monat', + 'btn_download_report': 'PDF herunterladen', + 'report_title': 'Automatischer Feuchtebericht', + 'report_filename': 'feuchtebericht', + 'report_intro': 'Ausgleichsfeuchte des Holzes, berechnet aus der gemessenen Temperatur und Luftfeuchtigkeit. Es wird empfohlen, 18 % Holzfeuchte nicht über längere Zeit zu überschreiten.', + 'report_period': 'Zeitraum: {start} – {end}', + 'report_chart': 'Holzfeuchte, Tagesmittel (%)', + 'report_table': 'Übersicht der Messwerte:', + 'report_col_sensor': 'Sensor', + 'report_col_temp_min': 'Temp. min.\n(°C)', + 'report_col_temp_max': 'Temp. max.\n(°C)', + 'report_col_hum_min': 'RF min.\n(%)', + 'report_col_hum_max': 'RF max.\n(%)', + 'report_col_wood_min': 'Holzfeuchte\nmin. (%)', + 'report_col_wood_max': 'Holzfeuchte\nmax. (%)', + 'report_levels': 'Alarmgrenzen Holzfeuchte', + 'report_level_normal': 'Normal', + 'report_level_yellow': 'Gelb', + 'report_level_orange': 'Orange', + 'report_level_red': 'Rot', + 'report_and': 'und', + 'report_no_data': 'Keine Messungen in diesem Zeitraum.', + 'report_generated': 'Erstellt {time}', + 'report_email_subject': 'Feuchtebericht {start} – {end}', + 'report_email_body': 'Anbei der Feuchtebericht für {start} – {end}.', 'lbl_mqtt_enabled': 'Werte an einen MQTT-Broker senden', 'lbl_mqtt_host': 'Broker-Adresse', 'lbl_mqtt_port': 'Port', @@ -1396,6 +1504,42 @@ 'alert_online': '📶 {device} : de nouveau en ligne ({time})', 'err_secret_reenter': 'Serveur, utilisateur ou sécurité modifié : saisissez à nouveau le mot de passe', 'section_mqtt': 'MQTT', + 'section_reports': 'Rapports', + 'lbl_report_period': 'Rapport par e-mail', + 'opt_report_period_off': 'Désactivé', + 'opt_report_period_week': 'Hebdomadaire (lundi 07:00, semaine précédente)', + 'opt_report_period_month': 'Mensuel (le 1er à 07:00, mois précédent)', + 'hint_report_period': 'PDF avec l’humidité du bois par capteur et un tableau min/max, envoyé à l’adresse e-mail des Notifications (l’e-mail doit être activé).', + 'lbl_report_language': 'Langue du rapport', + 'lbl_download_report': 'Télécharger le rapport', + 'opt_download_this_week': 'Cette semaine', + 'opt_download_last_week': 'Semaine précédente', + 'opt_download_this_month': 'Ce mois-ci', + 'opt_download_last_month': 'Mois précédent', + 'btn_download_report': 'Télécharger le PDF', + 'report_title': 'Rapport automatique de l’état d’humidité', + 'report_filename': 'rapport', + 'report_intro': 'Humidité d’équilibre du bois, calculée à partir de la température et de l’humidité mesurées. Il est recommandé de ne pas dépasser 18 % d’humidité de façon prolongée.', + 'report_period': 'Période : {start} – {end}', + 'report_chart': 'Humidité du bois, moyenne journalière (%)', + 'report_table': 'Tableau récapitulatif des valeurs :', + 'report_col_sensor': 'Capteur', + 'report_col_temp_min': 'Temp. min.\n(°C)', + 'report_col_temp_max': 'Temp. max.\n(°C)', + 'report_col_hum_min': 'HR min.\n(%)', + 'report_col_hum_max': 'HR max.\n(%)', + 'report_col_wood_min': 'Humidité bois\nmin. (%)', + 'report_col_wood_max': 'Humidité bois\nmax. (%)', + 'report_levels': 'Seuils d’alarme de l’humidité du bois', + 'report_level_normal': 'Normal', + 'report_level_yellow': 'Jaune', + 'report_level_orange': 'Orange', + 'report_level_red': 'Rouge', + 'report_and': 'et', + 'report_no_data': 'Aucune mesure pendant cette période.', + 'report_generated': 'Généré le {time}', + 'report_email_subject': 'Rapport d’humidité {start} – {end}', + 'report_email_body': 'Ci-joint le rapport d’humidité pour {start} – {end}.', 'lbl_mqtt_enabled': 'Publier les valeurs sur un broker MQTT', 'lbl_mqtt_host': 'Adresse du broker', 'lbl_mqtt_port': 'Port', @@ -1762,6 +1906,42 @@ 'alert_online': '📶 {device}: de nuevo en línea ({time})', 'err_secret_reenter': 'Servidor, usuario o seguridad cambiados: introduzca de nuevo la contraseña', 'section_mqtt': 'MQTT', + 'section_reports': 'Informes', + 'lbl_report_period': 'Informe por correo', + 'opt_report_period_off': 'Desactivado', + 'opt_report_period_week': 'Semanal (lunes 07:00, semana anterior)', + 'opt_report_period_month': 'Mensual (día 1 a las 07:00, mes anterior)', + 'hint_report_period': 'PDF con la humedad de la madera por sensor y una tabla de mín./máx., enviado a la dirección de correo de Notificaciones (el correo debe estar activado).', + 'lbl_report_language': 'Idioma del informe', + 'lbl_download_report': 'Descargar informe', + 'opt_download_this_week': 'Esta semana', + 'opt_download_last_week': 'Semana anterior', + 'opt_download_this_month': 'Este mes', + 'opt_download_last_month': 'Mes anterior', + 'btn_download_report': 'Descargar PDF', + 'report_title': 'Informe automatizado estado de humedad', + 'report_filename': 'informe', + 'report_intro': 'Humedad de equilibrio higroscópico de la madera calculada en base a las mediciones de temperatura y humedad. Se recomienda no superar el 18 % de humedad durante un tiempo prolongado.', + 'report_period': 'Periodo: {start} – {end}', + 'report_chart': 'Humedad de la madera, media diaria (%)', + 'report_table': 'Tabla resumen de valores de monitorización:', + 'report_col_sensor': 'Sensor', + 'report_col_temp_min': 'Temp. mín.\n(°C)', + 'report_col_temp_max': 'Temp. máx.\n(°C)', + 'report_col_hum_min': 'HR mín.\n(%)', + 'report_col_hum_max': 'HR máx.\n(%)', + 'report_col_wood_min': 'Humedad madera\nmín. (%)', + 'report_col_wood_max': 'Humedad madera\nmáx. (%)', + 'report_levels': 'Umbrales de alarma de humedad de la madera', + 'report_level_normal': 'Normal', + 'report_level_yellow': 'Amarillo', + 'report_level_orange': 'Naranja', + 'report_level_red': 'Rojo', + 'report_and': 'y', + 'report_no_data': 'Sin mediciones en este periodo.', + 'report_generated': 'Generado {time}', + 'report_email_subject': 'Informe de humedad {start} – {end}', + 'report_email_body': 'Adjunto el informe de humedad del periodo {start} – {end}.', 'lbl_mqtt_enabled': 'Publicar valores en un broker MQTT', 'lbl_mqtt_host': 'Dirección del broker', 'lbl_mqtt_port': 'Puerto',