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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -449,6 +457,23 @@ when it is on). The configs are retained on `homeassistant/sensor/<prefix>-<id>/
entities go offline with `<prefix>/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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,7 @@
# Start meldingen en Modbus threads
alerts.start()
mqtt_publisher.start()
report.start()
modbus_thread = start_modbus_thread()

# Dash app initialisatie
Expand Down
Binary file added img/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
232 changes: 232 additions & 0 deletions report.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,36 @@ 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"
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
Expand Down
Loading
Loading