Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6830289
feat: Add wood moisture (EMC) with diagram, warning levels and sensor…
CervezaStallone Nov 2, 2026
80d482a
fix: Replace Modbus and limit tables with plain form fields, drop ?ta…
CervezaStallone Nov 2, 2026
3014f12
fix: Wood moisture on phones, meter colours, consistent dew point, le…
CervezaStallone Nov 2, 2026
599853d
feat: Configurable register data type and byte order per sensor
CervezaStallone Nov 2, 2026
9097223
feat: Zones in the Mollier and wood diagram with leave/enter alarms; …
CervezaStallone Nov 2, 2026
dae19b3
feat: Manage zones in Settings
CervezaStallone Nov 2, 2026
bd855e3
feat: Draw zones directly in the Mollier diagram
CervezaStallone Nov 2, 2026
da29363
fix: Zone alarms no longer stick after zones are saved again
CervezaStallone Nov 2, 2026
8c84c4a
feat: Alarm window from the header with muting
CervezaStallone Nov 2, 2026
a0e3e10
feat: Dark/light toggle in the header
CervezaStallone Nov 2, 2026
d49a735
feat: Logo in the header and as favicon
CervezaStallone Nov 2, 2026
7af9c00
fix: Saving settings no longer repeats notifications for active alarms
CervezaStallone Nov 2, 2026
41693c2
feat: Scan a bus for responding sensors; restart wood moisture after …
CervezaStallone Nov 2, 2026
fc76182
docs: New screenshots with wood moisture, zones, form fields, theme t…
CervezaStallone Nov 2, 2026
7662b4c
feat: Choose notification type and colour when drawing a zone
CervezaStallone Nov 2, 2026
7f9f462
fix: Broken French draw-zone hint string
CervezaStallone Nov 2, 2026
4903ee0
chore: Remove unused import in auth.py
CervezaStallone Nov 2, 2026
41ae0da
docs: Bring README and changelog up to date
CervezaStallone Nov 2, 2026
0150236
chore: Release v2.3.0
CervezaStallone Nov 2, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/ci-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
python -m py_compile settings.py
python -m py_compile devices.py
python -m py_compile alerts.py
python -m py_compile wood.py
python -m py_compile mqtt_publisher.py
python -m py_compile settings_ui.py
python -m py_compile auth.py
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

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

## [2.3.0] - 2026-11-02

### Added
- **Wood moisture** (equilibrium moisture content, USDA Wood Handbook) per sensor, with its own diagram, a correction for the wood species and a response time (averaged value, so short peaks do not trigger alarms). On/off in Settings → Display.
- **Warning and alarm level** per limit, e.g. a warning at 18 % and an alarm at 20 % wood moisture.
- **Zones** in the Mollier and wood diagram: coloured areas of temperature × humidity from min/max or drawn freely in the Mollier diagram, with a notification when a sensor leaves a desired zone or enters a danger zone.
- **Alarm window** from the top bar with muting per alarm (15 minutes to 24 hours).
- **Dark/light toggle** in the top bar, remembered per browser.
- **Logo** in the top bar and as favicon.
- Per sensor: **data type** (int16, uint16, int32, uint32, float32), **byte order** (ABCD, DCBA, BADC, CDAB) and a **calibration correction** for temperature and humidity.
- **Scan** in Settings → Modbus finds which slave addresses answer on a bus.

### Changed
- Buses, sensors and limits are edited with normal form fields instead of a table: arrow keys move the cursor and the dropdowns work reliably. The port field suggests detected adapters (stable `/dev/serial/by-id` names first).
- Dew point uses the Magnus formula instead of a rule of thumb (new measurements only); the comfort score uses the same dew point.
- `?tab=settings` disappears from the address bar when you go back to the dashboard.

### Fixed
- Saving settings no longer repeats notifications for alarms that are already active.
- An impossible raw humidity is rejected instead of being shown as 0 %.
- Long status texts on the meter cards no longer overflow.

**Upgrading:** see [Updating to a new version](README.md#updating-to-a-new-version). The database is upgraded automatically: existing sensors become int16 / ABCD without correction (same readings as before), existing limits get the level *Alarm*. Reload the dashboard with `Ctrl+F5` afterwards.

## [2.2.0] - 2026-10-30

### Changed
Expand Down
102 changes: 79 additions & 23 deletions README.md

Large diffs are not rendered by default.

154 changes: 131 additions & 23 deletions alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import requests

import devices
import zones
import settings
from settings import DB_FILE
from translations import TRANSLATIONS
Expand All @@ -30,15 +31,18 @@
'humidity': ('humidity', '%', 'alert_hysteresis_humidity'),
'dewpoint': ('dewpoint', '°C', 'alert_hysteresis_temp'),
'absolute_humidity': ('abs_humidity', 'g/m³', 'alert_hysteresis_humidity'),
'wood_emc': ('wood_emc', '%', 'alert_hysteresis_wood'), # vertraagd houtvocht, zie wood.py
}
LEVELS = ['alarm', 'warning'] # waarschuwing = eigen melding, geel in het dashboard

MAX_RETRY_DELAY = 3600
SEND_TIMEOUT = 15

_lock = threading.Lock()
_rules_cache = None # [regel dicts], None = opnieuw laden
_state = {} # (device_id, metric) -> {'phase', 'since', 'kind', 'limit'}
_state = {} # (device_id, metric, level) -> {'phase', 'since', 'kind', 'limit'}
_offline = set() # device ids waarvoor een offline melding is verstuurd
_mutes = {} # (device_id, grootheid of 'zone:<id>') -> gedempt tot (epoch)
_first_seen = {} # device id -> eerste keer gezien door de offline controle (nieuwe sensoren)
_outbox = queue.Queue()
log = collections.deque(maxlen=20) # laatste meldingen: {'time', 'channel', 'text', 'ok', 'error'}
Expand All @@ -53,7 +57,9 @@ def _connect():
conn = sqlite3.connect(DB_FILE, timeout=30)
conn.execute('''CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER, metric TEXT NOT NULL,
min_value REAL, max_value REAL, enabled INTEGER NOT NULL)''')
min_value REAL, max_value REAL, enabled INTEGER NOT NULL, level TEXT NOT NULL DEFAULT 'alarm')''')
if 'level' not in [row[1] for row in conn.execute('PRAGMA table_info(alert_rules)')]: # tabel van vóór niveaus
conn.execute("ALTER TABLE alert_rules ADD COLUMN level TEXT NOT NULL DEFAULT 'alarm'")
return conn


Expand All @@ -79,6 +85,9 @@ def validate_rules(rows):
metric = row.get('metric')
if metric not in METRICS:
row_errors['metric'] = 'err_choice'
level = row.get('level') or 'alarm'
if level not in LEVELS:
row_errors['level'] = 'err_choice'
values = {}
for field in ('min_value', 'max_value'):
try:
Expand All @@ -91,12 +100,12 @@ def validate_rules(rows):
elif values['min_value'] is not None and values['max_value'] is not None \
and values['min_value'] >= values['max_value']:
row_errors['max_value'] = 'err_min_max'
elif (scope, metric) in seen:
elif (scope, metric, level) in seen:
row_errors['metric'] = 'err_duplicate'
seen.add((scope, metric))
seen.add((scope, metric, level))
if row_errors:
errors[index] = row_errors
clean.append({'device_id': scope or None, 'metric': metric, **values,
clean.append({'device_id': scope or None, 'metric': metric, 'level': level, **values,
'enabled': str(row.get('enabled', 'true')).lower() in ('true', '1')})
return clean, errors

Expand All @@ -107,21 +116,36 @@ def save_rules(rows):
clean, errors = validate_rules(rows)
if errors:
return errors
before = {key: _rule_limits(*key) for key in list(_state)}
with _lock:
conn = _connect()
try:
conn.execute('BEGIN')
conn.execute('DELETE FROM alert_rules')
conn.executemany('INSERT INTO alert_rules (device_id, metric, min_value, max_value, enabled) VALUES (?, ?, ?, ?, ?)',
[(r['device_id'], r['metric'], r['min_value'], r['max_value'], int(r['enabled'])) for r in clean])
conn.executemany('INSERT INTO alert_rules (device_id, metric, level, min_value, max_value, enabled) '
'VALUES (?, ?, ?, ?, ?, ?)',
[(r['device_id'], r['metric'], r['level'], r['min_value'], r['max_value'], int(r['enabled']))
for r in clean])
conn.commit()
finally:
conn.close()
_rules_cache = None
_state.clear() # nieuwe grenzen: opnieuw beoordelen
# Alleen regels met andere grenzen opnieuw beoordelen: anders meldt elk Opslaan in Settings actieve alarmen opnieuw.
# Zones hebben hun eigen opruiming (evaluate) en houden hun id bij opslaan.
for key, limits in before.items():
if not key[1].startswith('zone:') and _rule_limits(*key) != limits:
_state.pop(key, None)
return {}


def _rule_limits(device_id, metric, level):
"""(min, max) van de regel die nu voor deze sensor + grootheid + niveau geldt, None zonder regel."""
if metric.startswith('zone:'):
return None
rule = effective_rules(device_id).get((metric, level))
return (rule['min_value'], rule['max_value']) if rule else None


def load_rules():
"""Alle regels; regels van verwijderde sensoren worden genegeerd."""
global _rules_cache
Expand All @@ -140,17 +164,18 @@ def load_rules():


def effective_rules(device_id):
"""{grootheid: regel} voor één sensor: sensorregel vervangt globale regel."""
"""{(grootheid, niveau): regel} voor één sensor: sensorregel vervangt globale regel van hetzelfde niveau."""
rules = {}
for rule in load_rules():
if rule['enabled'] and rule['device_id'] is None:
rules.setdefault(rule['metric'], rule)
rules.setdefault((rule['metric'], rule['level']), rule)
for rule in load_rules():
if rule['device_id'] == device_id:
key = (rule['metric'], rule['level'])
if rule['enabled']:
rules[rule['metric']] = rule
rules[key] = rule
else:
rules.pop(rule['metric'], None) # uitgeschakelde sensorregel = globale regel uit voor deze sensor
rules.pop(key, None) # uitgeschakelde sensorregel = globale regel uit voor deze sensor
return rules


Expand All @@ -170,15 +195,24 @@ def evaluate(device, values, now=None):
now = now or time.time()
_check_online(device, now)
delay = settings.get('alert_delay_seconds')
for metric, rule in effective_rules(device['id']).items():
for (metric, level), rule in effective_rules(device['id']).items():
if metric in values:
_evaluate_metric(device, metric, values[metric], rule, delay, now)
_evaluate_metric(device, metric, level, values[metric], rule, delay, now)
if 'temperature' in values and 'humidity' in values:
active = [z for z in zones.effective(device['id']) if z['mode'] != 'none']
for zone in active:
_evaluate_zone(device, zone, values['temperature'], values['humidity'], delay, now)
# Toestand van zones die weg zijn, uit staan of nieuwe id's kregen (opnieuw opgeslagen) opruimen:
# anders blijft een oud zone-alarm voor altijd op het dashboard staan
current = {f"zone:{z['id']}" for z in active}
for key in [k for k in list(_state) if k[0] == device['id'] and k[1].startswith('zone:') and k[1] not in current]:
_state.pop(key, None)
except Exception as e:
print(f"Alarm evaluatie fout ({device.get('name')}): {e}")


def _evaluate_metric(device, metric, value, rule, delay, now):
key = (device['id'], metric)
def _evaluate_metric(device, metric, level, value, rule, delay, now):
key = (device['id'], metric, level)
state = _state.setdefault(key, {'phase': 'normal'})
hysteresis = settings.get(METRICS[metric][2])
if rule['max_value'] is not None and value > rule['max_value']:
Expand All @@ -194,19 +228,55 @@ def _evaluate_metric(device, metric, value, rule, delay, now):
if recovered and breach is None:
_state[key] = {'phase': 'normal'}
if settings.get('alert_notify_recovery'):
_notify('alert_recovered', device, metric, value, limit)
_notify('alert_recovered', device, metric, value, limit, now)
return
if breach is None:
_state[key] = {'phase': 'normal'}
return
if state['phase'] != 'pending' or state['kind'] != breach[0]:
_state[key] = state = {'phase': 'pending', 'since': now, 'kind': breach[0], 'limit': breach[1]}
if now - state['since'] >= delay:
_state[key] = {'phase': 'alarm', 'kind': breach[0], 'limit': breach[1]}
_notify('alert_high' if breach[0] == 'high' else 'alert_low', device, metric, value, breach[1])
_state[key] = {'phase': 'alarm', 'kind': breach[0], 'limit': breach[1], 'since': state['since']}
prefix = 'alert_warn_' if level == 'warning' else 'alert_'
_notify(prefix + breach[0], device, metric, value, breach[1], now)


def _evaluate_zone(device, zone, temperature, humidity, delay, now):
"""Zone-alarm: gewenste zone verlaten ('keep_inside') of gevarenzone binnengaan ('keep_outside')."""
key = (device['id'], f"zone:{zone['id']}", zone['level'])
inside = zones.contains(zone, temperature, humidity)
breach = inside if zone['mode'] == 'keep_outside' else not inside
state = _state.setdefault(key, {'phase': 'normal'})
if state['phase'] == 'alarm':
# Herstel pas met de hysterese (temperatuur en RV) als marge tot de rand: geen klapperen op de grens
safe = zones.clearly(zone, temperature, humidity, settings.get('alert_hysteresis_temp'),
settings.get('alert_hysteresis_humidity'), inside=zone['mode'] == 'keep_inside')
if safe:
_state[key] = {'phase': 'normal'}
if settings.get('alert_notify_recovery'):
_notify_zone('alert_zone_recovered', device, zone, temperature, humidity, now)
return
if not breach:
_state[key] = {'phase': 'normal'}
return
if state['phase'] != 'pending':
_state[key] = state = {'phase': 'pending', 'since': now}
if now - state['since'] >= delay:
_state[key] = {'phase': 'alarm', 'since': state['since']}
_notify_zone('alert_zone_entered' if inside else 'alert_zone_left', device, zone, temperature, humidity, now)


def _notify(template, device, metric, value, limit):
def _notify_zone(template, device, zone, temperature, humidity, now):
if is_muted(device['id'], f"zone:{zone['id']}", now):
return
send(_t()[template].format(icon='🟡' if zone['level'] == 'warning' else '⚠️', device=device['name'],
zone=zone['name'], temperature=f'{temperature:.1f}', humidity=f'{humidity:.0f}',
time=_now_text()))


def _notify(template, device, metric, value, limit, now):
if is_muted(device['id'], metric, now):
return
t = _t()
name_key, unit, _ = METRICS[metric]
send(t[template].format(device=device['name'], metric=t[name_key], value=f'{value:.1f}', limit=f'{limit:.1f}',
Expand All @@ -220,13 +290,51 @@ def _check_online(device, now):
send(_t()['alert_online'].format(device=device['name'], time=_now_text(now)))


# --- dempen -----------------------------------------------------------------------
# ponytail: dempingen staan in het geheugen en vervallen bij een herstart (dan weer meldingen); in de database
# zetten als langere dempingen over een herstart heen moeten blijven

def mute(device_id, metric, seconds, now=None):
"""Geen meldingen (alarm of herstel) voor deze sensor + grootheid/zone tot nu + seconds."""
_mutes[(device_id, metric)] = (now or time.time()) + seconds


def unmute(device_id, metric):
_mutes.pop((device_id, metric), None)


def is_muted(device_id, metric, now=None):
return _mutes.get((device_id, metric), 0) > (now or time.time())


def active_alarms(now=None):
"""Actieve alarmen en waarschuwingen voor het alarmvenster, zwaarste eerst."""
now = now or time.time()
names = {d['id']: d['name'] for d in devices.list_devices()}
zone_names = {f"zone:{z['id']}": z['name'] for z in zones.load()}
result = []
for (device_id, metric, level), state in list(_state.items()):
if state['phase'] == 'normal' or device_id not in names:
continue
until = _mutes.get((device_id, metric))
result.append({'device_id': device_id, 'device': names[device_id], 'metric': metric,
'zone': zone_names.get(metric), 'level': level,
'phase': 'alarm' if state['phase'] == 'alarm' else 'pending', 'since': state.get('since'),
'muted_until': until if until and until > now else None})
return sorted(result, key=lambda a: (a['phase'] != 'alarm', a['level'] != 'alarm', a['device'], a['metric']))


def device_status(device_id):
"""Alarmstatus van een sensor: ('alarm' | 'warn' | 'ok', {grootheid: fase}).

'warn' = grens overschreden maar de alarmvertraging loopt nog; 'alarm' = alarm actief.
Fase per grootheid: 'alarm' = alarm actief; 'pending' = alarmvertraging loopt of een
waarschuwing is actief. Het zwaarste niveau per grootheid telt.
"""
phases = {metric: state['phase'] for (dev, metric), state in list(_state.items())
if dev == device_id and state['phase'] != 'normal'}
phases = {}
for (dev, metric, level), state in list(_state.items()):
metric = 'zone' if metric.startswith('zone:') else metric # alle zones samen als één grootheid
if dev == device_id and state['phase'] != 'normal' and phases.get(metric) != 'alarm':
phases[metric] = 'alarm' if state['phase'] == 'alarm' and level == 'alarm' else 'pending'
if 'alarm' in phases.values():
return 'alarm', phases
if 'pending' in phases.values():
Expand Down
18 changes: 18 additions & 0 deletions assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import threading
import time
from html import escape
from urllib.parse import urlsplit

from flask import Response, has_request_context, redirect, request, session
from itsdangerous import BadSignature, URLSafeTimedSerializer
Expand Down
Loading
Loading