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 0000000..5614651 Binary files /dev/null and b/img/report.png differ 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',