From 6830289652c3a8bda9625b274e5da4654a02c891 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:20:31 +0100 Subject: [PATCH 01/19] feat: Add wood moisture (EMC) with diagram, warning levels and sensor calibration - Wood equilibrium moisture per sensor (USDA Wood Handbook, Hailwood-Horrobin), on/off in Settings > Display like the Mollier diagram - Diagram of temperature x humidity with lines of equal wood moisture, sensor points and zones from the alarm rules - Response time of the wood (default 24 h): board and alarms use a slow average, seeded from the database after a restart - Correction setting for wood species instead of a species table (the handbook formula applies to all species) - Alarm rules get a level (warning / alarm), so 18 % can warn and 20 % can alarm - Temperature and humidity correction per sensor for calibration - Dew point now uses the Magnus formula instead of the T - (100 - RH) / 5 rule of thumb - Wood moisture in MQTT when enabled --- .github/workflows/ci-testing.yml | 1 + README.md | 33 ++++++- alerts.py | 52 +++++++---- callbacks.py | 54 +++++++++-- devices.py | 10 ++- layout.py | 19 +++- modbus_reader.py | 15 +++- mqtt_publisher.py | 4 +- settings.py | 5 ++ settings_ui.py | 24 ++--- test_app.py | 149 ++++++++++++++++++++++++++++++- translations.py | 85 ++++++++++++++++++ wood.py | 136 ++++++++++++++++++++++++++++ 13 files changed, 536 insertions(+), 51 deletions(-) create mode 100644 wood.py diff --git a/.github/workflows/ci-testing.yml b/.github/workflows/ci-testing.yml index 853bb55..797ec9c 100644 --- a/.github/workflows/ci-testing.yml +++ b/.github/workflows/ci-testing.yml @@ -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 diff --git a/README.md b/README.md index b5f76dd..a85d26d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - [Adding a New Language](#adding-a-new-language) - [Comfort Score & Humidex](#comfort-score--humidex) - [Mollier Diagram (Psychrometric Chart)](#mollier-diagram-psychrometric-chart) +- [Wood Moisture (Equilibrium Moisture Content)](#wood-moisture-equilibrium-moisture-content) - [Historical Data Replay](#historical-data-replay) - [Database Architecture](#database-architecture) - [Testing](#testing) @@ -64,6 +65,7 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - Humidex (scientific comfort index) - Comfort Score (0-6, based on Humidex) - **Psychrometric chart**: Mollier diagram with live indicator and comfort zone visualization +- **Wood moisture** (optional): equilibrium moisture content per sensor, a diagram with zones from your limits, warning and alarm levels - **Historical data replay**: Time-travel through data with preset buttons or custom date/time selection - **Interactive slider**: Live updates while dragging for smooth historical navigation - **Scientific analysis**: Humidex formula according to Environment Canada standard @@ -272,6 +274,7 @@ ClimateMonitor/ ├── alerts.py # Limits, alarm state, Telegram/e-mail notifications ├── mqtt_publisher.py # MQTT publishing per sensor ├── psychrometric.py # Mollier diagram generation +├── wood.py # Wood equilibrium moisture: formula, response time, diagram ├── callbacks.py # Dashboard callbacks (board/meters, chart, Mollier, replay) ├── settings.py # Settings stored in the database, validation, password reset ├── settings_ui.py # Settings tab @@ -326,11 +329,12 @@ Open the **Settings** tab in the web app. The first visit asks you to create an | Section | Settings | |---------|----------| -| Display | Dashboard view (Board / Meters), theme (Dark / Light), show or hide comfort and the Mollier diagram | -| Modbus buses and sensors | Poll interval, buses (RS-485 interfaces), sensors (Modbus slaves) | +| 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 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 | -| Notifications: limits | Limits per quantity (all sensors or one sensor), alarm delay, hysteresis, recovery and offline alerts | +| Notifications: limits | Limits per quantity (all sensors or one sensor) at warning or alarm level, 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 | | Data | Data retention (days, 0 = forever), debug logging | | Server | Listen address and port (*restart required*) | @@ -404,7 +408,7 @@ saving. With topic prefix `xy-md02` every sensor publishes, at most once per pub |-------|---------| | `xy-md02/status` | `online` / `offline` (retained, `offline` via last will) | | `xy-md02//state` | `{"device_id": 3, "name": "Kas noord", "temperature": 18.7, "humidity": 62.0, "dewpoint": 11.1, "absolute_humidity": 9.92, "timestamp": "2026-10-14T12:15:43+02:00"}` | -| `xy-md02//temperature` (and `humidity`, `dewpoint`, `absolute_humidity`) | plain value, e.g. `18.7` | +| `xy-md02//temperature` (and `humidity`, `dewpoint`, `absolute_humidity`, `wood_emc` when wood moisture is on) | plain value, e.g. `18.7` | `` is the sensor name in lower case with `-` instead of spaces (`Kas noord` → `kas-noord`); renaming a sensor changes its topic, `device_id` stays the same. If the broker is unreachable, @@ -507,6 +511,27 @@ The application displays an interactive **psychrometric chart** with: This diagram helps to see at a glance whether the climate condition is within the comfort zone and how it relates to saturation limits. +### Wood Moisture (Equilibrium Moisture Content) + +Turn on *Show wood moisture* in Settings > Display for storage rooms, workshops or drying rooms with wood. +The app then calculates the **equilibrium moisture content** (EMC): the moisture content wood moves towards +at the measured temperature and humidity, with the Hailwood-Horrobin formula from the USDA Wood Handbook +(20 °C and 65 % RH gives 12 %). + +- **Board, meters and chart**: a *Wood moisture* value per sensor +- **Diagram**: temperature × humidity with lines of equal wood moisture, the sensors as points and the + wood moisture limits from your alarm rules as coloured zones (yellow = warning, red = alarm) +- **Alarms**: add rules for *Wood moisture*, e.g. a warning at a maximum of 18 % and an alarm at 20 % +- **Response time** (default 24 h): wood follows the air slowly, so the board and the alarms use an average + over this time. An open door or a short peak does not trigger an alarm. After a restart the average is + taken from the database. Set it to 0 to use the live value. +- **Correction**: the formula applies to average wood; according to the handbook the difference between + species is small. Tropical or thermally modified wood takes up less moisture: enter a negative correction, + or calibrate against a wood moisture meter. + +18 % wood moisture is about 85 % RH and 20 % about 89 % RH, so a few % RH sensor error matters here. +Use the humidity correction per sensor (Settings > Modbus) to calibrate the sensors. + ### Historical Data Replay The application offers advanced historical data analysis with two input methods: diff --git a/alerts.py b/alerts.py index 2fc76ce..0a8123c 100644 --- a/alerts.py +++ b/alerts.py @@ -30,14 +30,16 @@ '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 _first_seen = {} # device id -> eerste keer gezien door de offline controle (nieuwe sensoren) _outbox = queue.Queue() @@ -53,7 +55,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 @@ -79,6 +83,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: @@ -91,12 +98,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 @@ -112,8 +119,10 @@ def save_rules(rows): 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() @@ -140,17 +149,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 @@ -170,15 +180,15 @@ 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) 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']: @@ -203,7 +213,8 @@ def _evaluate_metric(device, metric, value, rule, delay, now): _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]) + prefix = 'alert_warn_' if level == 'warning' else 'alert_' + _notify(prefix + breach[0], device, metric, value, breach[1]) def _notify(template, device, metric, value, limit): @@ -223,10 +234,13 @@ def _check_online(device, now): 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()): + 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(): diff --git a/callbacks.py b/callbacks.py index 73b7154..8c20b1b 100644 --- a/callbacks.py +++ b/callbacks.py @@ -9,6 +9,7 @@ import devices import layout import settings +import wood from translations import TRANSLATIONS from database import get_all_measurement_tables, build_union_query, DB_FILE from psychrometric import add_device_points, create_psychrometric_chart, create_psychrometric_chart_historical @@ -27,9 +28,10 @@ hover='#eceee8', ok='#0b8a0b', warn='#c98500', alarm='#d03b3b', offline='#7d838a'), } FONT = 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, sans-serif' -METRIC_UNITS = {'temperature': '°C', 'humidity': '%', 'dewpoint': '°C', 'absolute_humidity': 'g/m³', 'comfort': ''} +METRIC_UNITS = {'temperature': '°C', 'humidity': '%', 'dewpoint': '°C', 'absolute_humidity': 'g/m³', 'comfort': '', + 'wood_emc': '%'} METRIC_KEYS = {'temperature': 'temperature', 'humidity': 'humidity', 'dewpoint': 'dewpoint', - 'absolute_humidity': 'abs_humidity', 'comfort': 'comfort'} + 'absolute_humidity': 'abs_humidity', 'comfort': 'comfort', 'wood_emc': 'wood_emc'} def theme_mode(): @@ -122,6 +124,8 @@ def metric_figure(df, names, colors, metric, t, mode): continue if metric == 'comfort': values = [humidex_score(tmp, hum) for tmp, hum in zip(group['temperature'], group['humidity'])] + elif metric == 'wood_emc': # evenwichtswaarde van de lucht per interval (niet vertraagd) + values = wood.value(group['temperature'], group['humidity']) else: values = group[metric] fig.add_trace(go.Scatter( @@ -139,6 +143,15 @@ def metric_figure(df, names, colors, metric, t, mode): return fig +def wood_zones(device_id): + """Houtvochtgrenzen uit de alarmregels: [(min, max, niveau)] voor één sensor of de globale regels.""" + if device_id: + rules = alerts.effective_rules(device_id).values() + else: + rules = [r for r in alerts.load_rules() if r['enabled'] and r['device_id'] is None] + return [(r['min_value'], r['max_value'], r['level']) for r in rules if r['metric'] == 'wood_emc'] + + def theme_mollier(fig, mode, point_color=None): """Mollier diagram in de stijl van het thema: gedempte RV-lijnen, comfortzone, punten in sensorkleur.""" k = THEMES[mode] @@ -225,7 +238,8 @@ def register_callbacks(app): Output('metric-tabs', 'options'), Output('time-range-dropdown', 'options'), Output('label-psychrometric-chart', 'children'), - Output('label-time-position', 'children')], + Output('label-time-position', 'children'), + Output('label-wood-chart', 'children')], [Input('language-selector', 'value')] ) def update_language(lang): @@ -234,7 +248,7 @@ def update_language(lang): lang = 'en' t = TRANSLATIONS[lang] return (lang, t['app_name'], layout.nav_options(t), layout.view_options(t), layout.metric_options(t), - layout.range_options(t), t['psychrometric_chart'], t['time_position']) + layout.range_options(t), t['psychrometric_chart'], t['time_position'], t['wood_chart']) @app.callback( [Output('page-dashboard', 'style'), @@ -321,6 +335,27 @@ def update_psychrometric_chart(n, lang, device_sel): fig.update_xaxes(range=[coldest - 3, 40]) # punten onder 0 °C zichtbaar houden return fig + @app.callback( + Output('wood-chart', 'figure'), + [Input('graph-update', 'n_intervals'), + Input('selected-language', 'data'), + Input('device-selector', 'value')], + State('main-tabs', 'value') + ) + def update_wood_chart(n, lang, device_sel, tab): + """Houtvochtdiagram: actuele punten per sensor en de zones uit de houtvochtregels""" + if not settings.get('show_wood') or (callback_context.triggered_id == 'graph-update' and (n % 5 or tab == 'settings')): + return no_update + t = TRANSLATIONS.get(lang) or TRANSLATIONS['en'] + mode = theme_mode() + colors = device_colors(mode) + latest = latest_per_device() + points = [(d['name'], latest[d['id']][1], latest[d['id']][2], colors.get(d['id'])) for d in devices.list_devices() + if d['id'] in latest and (not device_sel or d['id'] == device_sel)] + fig = style_figure(wood.figure(points, wood_zones(device_sel), t, THEMES[mode]), mode, height=520) + fig.update_layout(hovermode='closest', margin=dict(l=60, r=50, t=30, b=50)) + return fig + # Callback voor modal open/close @app.callback( Output('range-modal', 'style'), @@ -654,9 +689,12 @@ def sensor_states(t): else: label, sub = t['st_never'], devices.status(device['id']).get('error') or '' score = humidex_score(temp, hum) if temp is not None else None + wood_now = wood.current(device['id']) + if wood_now is None and temp is not None: + wood_now = float(wood.value(temp, hum)) states.append({ 'id': device['id'], 'name': device['name'], 'status': status, 'phases': phases, 'label': label, - 'sub': sub[:80], 'temp': temp, 'hum': hum, 'dew': dew, 'ah': ah, + 'sub': sub[:80], 'temp': temp, 'hum': hum, 'dew': dew, 'ah': ah, 'wood': wood_now, 'comfort': t[f'comfort_{score}'] if score is not None else '—', 'where': f"{config.get('bus', '')} · #{config.get('slave_id', '')}", }) @@ -672,10 +710,11 @@ def spark(device_id, color, width=120): return html.Img(src=src, className='spark', alt='') if src else None def render_board(states, colors, t): - show_comfort = settings.get('show_comfort') + show_comfort, show_wood = settings.get('show_comfort'), settings.get('show_wood') head = html.Tr([html.Th('', className='bar'), html.Th(t['hdr_sensor']), html.Th(t['temperature']), html.Th(t['humidity']), html.Th(t['dewpoint'], className='col-wide'), html.Th(t['abs_humidity'], className='col-wide'), html.Th(t['comfort'], className='col-wide') if show_comfort else None, + html.Th(t['wood_emc'], className='col-wide') if show_wood else None, html.Th(t['hdr_trend'], className='col-wide'), html.Th(t['hdr_status'], className='col-status')]) rows = [] @@ -693,6 +732,7 @@ def render_board(states, colors, t): html.Td(value_cell(s_['dew'], '°C', ph.get('dewpoint')), className='col-wide'), html.Td(value_cell(s_['ah'], 'g/m³', ph.get('absolute_humidity')), className='col-wide'), html.Td(s_['comfort'], className='comfort col-wide') if show_comfort else None, + html.Td(value_cell(s_['wood'], '%', ph.get('wood_emc')), className='col-wide') if show_wood else None, html.Td(spark(s_['id'], color), className='col-wide'), html.Td([html.Span(s_['label'], className=f"st st-{s_['status']}"), html.Span(s_['sub'], className='st-sub')], className='col-status'), @@ -719,6 +759,8 @@ def render_meters(states, colors, t): html.Div(className='meter-stats', children=[ html.Span(f"{t['dewpoint']} {fmt(s_['dew'])} °C"), html.Span(f"{fmt(s_['ah'])} g/m³"), + html.Span(f"{t['wood_emc']} {fmt(s_['wood'])} %", + className=cls('wood_emc')) if settings.get('show_wood') else None, html.Span(s_['sub']), ]), spark(s_['id'], colors.get(s_['id']), width=300), diff --git a/devices.py b/devices.py index beaf563..71da234 100644 --- a/devices.py +++ b/devices.py @@ -38,6 +38,9 @@ 'register_humidity': {'type': int, 'default': 2, 'env': 'MODBUS_REGISTER_HUMIDITY', 'min': 0, 'max': 65535}, 'scale_temp': {'type': float, 'default': 0.1, 'min': -1000, 'max': 1000, 'nonzero': True}, 'scale_humidity': {'type': float, 'default': 0.1, 'min': -1000, 'max': 1000, 'nonzero': True}, + # Kalibratie: opgeteld bij de gemeten waarde (leeg = 0) + 'offset_temp': {'type': float, 'default': 0.0, 'min': -20, 'max': 20, 'blank_default': True}, + 'offset_humidity': {'type': float, 'default': 0.0, 'min': -30, 'max': 30, 'blank_default': True}, 'enabled': {'type': bool, 'default': True}, } @@ -62,7 +65,12 @@ def _connect(): id INTEGER PRIMARY KEY AUTOINCREMENT, bus_id INTEGER NOT NULL REFERENCES buses(id), name TEXT NOT NULL UNIQUE, slave_id INTEGER NOT NULL, function_code INTEGER NOT NULL, register_temp INTEGER NOT NULL, register_humidity INTEGER NOT NULL, scale_temp REAL NOT NULL, scale_humidity REAL NOT NULL, + offset_temp REAL NOT NULL DEFAULT 0, offset_humidity REAL NOT NULL DEFAULT 0, enabled INTEGER NOT NULL, UNIQUE (bus_id, slave_id))''') + columns = [row[1] for row in conn.execute('PRAGMA table_info(devices)')] + for column in ('offset_temp', 'offset_humidity'): # tabel van vóór de kalibratie + if column not in columns: + conn.execute(f'ALTER TABLE devices ADD COLUMN {column} REAL NOT NULL DEFAULT 0') return conn @@ -110,7 +118,7 @@ def _clean_rows(rows, fields): item, row_errors = {'id': row.get('id')}, {} for field, spec in fields.items(): raw = row.get(field) - if raw is None and spec['type'] is bool: + if (raw is None and spec['type'] is bool) or (spec.get('blank_default') and str(raw or '').strip() == ''): raw = spec['default'] try: value = _coerce(spec, raw) diff --git a/layout.py b/layout.py index 8330af6..d9688ca 100644 --- a/layout.py +++ b/layout.py @@ -246,9 +246,10 @@ ''' -METRICS = ['temperature', 'humidity', 'dewpoint', 'absolute_humidity', 'comfort'] +METRICS = ['temperature', 'humidity', 'dewpoint', 'absolute_humidity', 'comfort', 'wood_emc'] METRIC_LABEL_KEYS = {'temperature': 'temperature', 'humidity': 'humidity', 'dewpoint': 'dewpoint', - 'absolute_humidity': 'abs_humidity', 'comfort': 'comfort'} + 'absolute_humidity': 'abs_humidity', 'comfort': 'comfort', 'wood_emc': 'wood_emc'} +OPTIONAL_METRICS = {'comfort': 'show_comfort', 'wood_emc': 'show_wood'} # grootheid -> aan/uit instelling TIME_RANGES = [('last_1min', 1), ('last_5min', 5), ('last_15min', 15), ('last_30min', 30), ('last_1hour', 60), ('last_6hours', 360), ('last_12hours', 720), ('last_24hours', 1440), ('last_7days', 10080), ('last_14days', 20160), ('last_1month', 43200), ('last_3months', 129600), @@ -266,7 +267,7 @@ def view_options(t): def metric_options(t): return [{'label': t[METRIC_LABEL_KEYS[m]], 'value': m} for m in METRICS - if m != 'comfort' or settings.get('show_comfort')] + if m not in OPTIONAL_METRICS or settings.get(OPTIONAL_METRICS[m])] def device_options(t): @@ -279,6 +280,10 @@ def mollier_style(): return {} if settings.get('show_mollier') else {'display': 'none'} +def wood_style(): + return {} if settings.get('show_wood') else {'display': 'none'} + + def range_options(t): return [{'label': t[key], 'value': value} for key, value in TIME_RANGES] @@ -348,6 +353,14 @@ def create_layout(): ]), ]), + html.Section(id='wood-panel', className='panel', style=wood_style(), children=[ + html.Div(className='panel-head', children=[ + html.Span(id='label-wood-chart', className='panel-title', children=t['wood_chart']), + ]), + html.Div(className='panel-body', children=dcc.Graph( + id='wood-chart', config={'displaylogo': False, 'displayModeBar': False})), + ]), + # Keuzevenster voor de historische periode html.Div(id='range-modal', className='modal-backdrop', style={'display': 'none'}, children=[ html.Div(className='modal', role='dialog', children=[ diff --git a/modbus_reader.py b/modbus_reader.py index 73467d0..dcd61a1 100644 --- a/modbus_reader.py +++ b/modbus_reader.py @@ -21,6 +21,7 @@ import devices import mqtt_publisher import settings +import wood from database import DB_FILE, cleanup_old_data, ensure_table_exists, get_table_name PARITY_MAP = { @@ -42,7 +43,9 @@ def derived_values(temperature, humidity): """Bereken dauwpunt (°C) en absolute vochtigheid (g/m³).""" - dewpoint = temperature - ((100 - humidity) / 5.0) + # Magnus (Sonntag 1990); de oude vuistregel T - (100-RV)/5 week bij lage RV tot enkele graden af + gamma = math.log(max(humidity, 0.1) / 100) + 17.62 * temperature / (243.12 + temperature) + dewpoint = 243.12 * gamma / (17.62 - gamma) # Formule: AH = (6.112 × e^((17.67 × T)/(T+243.5)) × RH × 2.1674) / (273.15+T) absolute_humidity = (6.112 * math.exp((17.67 * temperature) / (temperature + 243.5)) * humidity * 2.1674) / (273.15 + temperature) return dewpoint, absolute_humidity @@ -52,7 +55,10 @@ def read_device(instrument, device): """Lees temperatuur en luchtvochtigheid (signed registers × schaal).""" raw_temp = instrument.read_register(device['register_temp'], 0, device['function_code'], signed=True) raw_humidity = instrument.read_register(device['register_humidity'], 0, device['function_code'], signed=True) - return round(raw_temp * device['scale_temp'], 3), round(raw_humidity * device['scale_humidity'], 3) + # Kalibratie per sensor; RV na correctie binnen 0-100 % (een sensor op 99 % + 3 is geen meetfout) + temperature = raw_temp * device['scale_temp'] + device.get('offset_temp', 0) + humidity = min(max(raw_humidity * device['scale_humidity'] + device.get('offset_humidity', 0), 0), 100) + return round(temperature, 3), round(humidity, 3) def open_instrument(bus, device): @@ -182,8 +188,9 @@ def poll(self, instrument, device): devices.report_ok(device['id'], temperature, humidity) values = {'temperature': temperature, 'humidity': humidity, 'dewpoint': dewpoint, 'absolute_humidity': absolute_humidity} - alerts.evaluate(device, values) - mqtt_publisher.store(device, values) + wood_emc = wood.update(device['id'], temperature, humidity) # altijd: houtvochtregels werken ook als het verborgen is + alerts.evaluate(device, {**values, 'wood_emc': wood_emc}) + mqtt_publisher.store(device, {**values, 'wood_emc': wood_emc} if settings.get('show_wood') else values) self.last_errors.pop(device['id'], None) if settings.get('debug_logging'): print(f"{datetime.now().strftime('%H:%M:%S')} - {device['name']}: {temperature:.1f}°C, {humidity:.1f}%") diff --git a/mqtt_publisher.py b/mqtt_publisher.py index 692b9f9..a275be6 100644 --- a/mqtt_publisher.py +++ b/mqtt_publisher.py @@ -3,7 +3,7 @@ Topics (prefix instelbaar, standaard 'xy-md02'): /status online / offline (retained, offline via last will) //state JSON met alle waarden - // losse waarde (temperature, humidity, dewpoint, absolute_humidity) + // losse waarde (temperature, humidity, dewpoint, absolute_humidity, wood_emc) 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. @@ -21,7 +21,7 @@ import devices import settings -METRICS = ('temperature', 'humidity', 'dewpoint', 'absolute_humidity') +METRICS = ('temperature', 'humidity', 'dewpoint', 'absolute_humidity', 'wood_emc') # wood_emc alleen als houtvocht aan staat 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 diff --git a/settings.py b/settings.py index 3292124..1064ea2 100644 --- a/settings.py +++ b/settings.py @@ -31,6 +31,11 @@ 'dashboard_theme': {'type': str, 'default': 'dark', 'section': 'display', 'choices': ['dark', 'light']}, 'show_comfort': {'type': bool, 'default': True, 'section': 'display'}, 'show_mollier': {'type': bool, 'default': True, 'section': 'display'}, + 'show_wood': {'type': bool, 'default': False, 'section': 'display'}, + # Houtvocht (wood.py): correctie voor houtsoort of eigen ijking + 'wood_emc_offset': {'type': float, 'default': 0.0, 'section': 'wood', 'min': -5, 'max': 5}, + 'wood_response_hours': {'type': int, 'default': 24, 'section': 'wood', 'min': 0, 'max': 720}, + 'alert_hysteresis_wood': {'type': float, 'default': 0.5, 'section': 'wood', 'min': 0, 'max': 10}, 'poll_interval_seconds': {'type': int, 'default': 1, 'section': 'modbus', 'min': 1, 'max': 3600}, 'data_retention_days': {'type': int, 'default': 0, 'env': 'DATA_RETENTION_DAYS', 'section': 'data', 'min': 0, 'max': 3650}, diff --git a/settings_ui.py b/settings_ui.py index 624d3c1..39aa0d7 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -13,11 +13,11 @@ import settings from translations import LANGUAGE_NAMES, TRANSLATIONS -SECTIONS = ['display', 'modbus', 'notify', 'alerts', 'mqtt', 'regional', 'data', 'server', 'security'] +SECTIONS = ['display', 'modbus', 'notify', 'alerts', 'wood', 'mqtt', 'regional', 'data', 'server', 'security'] BUS_COLUMNS = ['name', 'port', 'baudrate', 'bytesize', 'parity', 'stopbits', 'timeout', 'enabled'] DEVICE_COLUMNS = ['id', 'name', 'bus', 'slave_id', 'function_code', 'register_temp', 'register_humidity', - 'scale_temp', 'scale_humidity', 'enabled'] + 'scale_temp', 'scale_humidity', 'offset_temp', 'offset_humidity', 'enabled'] _TIMEZONES = sorted(available_timezones()) @@ -95,7 +95,7 @@ def _device_table(device_rows, bus_names, t): id='device-table', columns=[{'id': c, 'name': t[f'col_{c}'], 'editable': c != 'id', 'presentation': 'dropdown' if c in ('bus', 'function_code', 'enabled') else 'input', - 'type': 'numeric' if c.startswith(('slave', 'register', 'scale')) else 'any'} for c in DEVICE_COLUMNS], + 'type': 'numeric' if c.startswith(('slave', 'register', 'scale', 'offset')) else 'any'} for c in DEVICE_COLUMNS], data=_table_rows(device_rows, DEVICE_COLUMNS), editable=True, row_deletable=True, dropdown={'bus': {'options': [{'label': n, 'value': n} for n in bus_names], 'clearable': False}, @@ -173,7 +173,7 @@ def mqtt_status_text(t): def _rule_rows(): - return [{'scope': r['device_id'] or 0, 'metric': r['metric'], 'min_value': r['min_value'], + return [{'scope': r['device_id'] or 0, 'metric': r['metric'], 'level': r['level'], 'min_value': r['min_value'], 'max_value': r['max_value'], 'enabled': 'true' if r['enabled'] else 'false'} for r in alerts.load_rules()] @@ -188,12 +188,15 @@ def _alerts_section(t): id='alert-rule-table', columns=[{'id': 'scope', 'name': t['col_scope'], 'presentation': 'dropdown'}, {'id': 'metric', 'name': t['col_metric'], 'presentation': 'dropdown'}, + {'id': 'level', 'name': t['col_level'], 'presentation': 'dropdown'}, {'id': 'min_value', 'name': t['col_min_value'], 'type': 'numeric'}, {'id': 'max_value', 'name': t['col_max_value'], 'type': 'numeric'}, {'id': 'enabled', 'name': t['col_enabled'], 'presentation': 'dropdown'}], data=_rule_rows(), editable=True, row_deletable=True, dropdown={'scope': {'options': scopes, 'clearable': False}, 'metric': {'options': metrics, 'clearable': False}, + 'level': {'options': [{'label': t[f'opt_level_{v}'], 'value': v} for v in alerts.LEVELS], + 'clearable': False}, 'enabled': {'options': _yes_no(t), 'clearable': False}}, **TABLE_STYLE), html.Button(t['btn_add_rule'], id='add-rule', n_clicks=0, className='btn btn-small'), @@ -423,7 +426,7 @@ def add_device(n_clicks, rows, bus_rows): slave_id = next(i for i in range(1, 248) if i not in used) return rows + [{'id': None, 'name': f'Sensor {len(rows) + 1}', 'bus': bus_name, 'slave_id': slave_id, 'function_code': 4, 'register_temp': 1, 'register_humidity': 2, 'scale_temp': 0.1, - 'scale_humidity': 0.1, 'enabled': 'true'}] + 'scale_humidity': 0.1, 'offset_temp': 0, 'offset_humidity': 0, 'enabled': 'true'}] @app.callback( Output('device-table', 'dropdown'), @@ -459,8 +462,8 @@ def refresh_device_status(n, tab, lang): def add_rule(n_clicks, rows): if not auth.is_logged_in(): return no_update - return (rows or []) + [{'scope': 0, 'metric': 'temperature', 'min_value': None, 'max_value': None, - 'enabled': 'true'}] + return (rows or []) + [{'scope': 0, 'metric': 'temperature', 'level': 'alarm', 'min_value': None, + 'max_value': None, 'enabled': 'true'}] @app.callback( Output('test-status', 'children'), @@ -537,19 +540,20 @@ def refresh_alert_log(n, tab, lang): Output('view-toggle', 'value'), Output('metric-tabs', 'options', allow_duplicate=True), Output('metric-tabs', 'value'), - Output('mollier-panel', 'style')], + Output('mollier-panel', 'style'), + Output('wood-panel', 'style')], Input('settings-status', 'children'), [State('selected-language', 'data'), State('metric-tabs', 'value')], prevent_initial_call=True ) def apply_saved_display(status, lang, metric): - """Na opslaan thema, weergave, comfort en Mollier diagram direct toepassen""" + """Na opslaan thema, weergave, comfort, Mollier en houtvocht direct toepassen""" options = layout.metric_options(_t(lang)) if metric not in [o['value'] for o in options]: metric = 'temperature' return (f"theme-{settings.get('dashboard_theme')}", settings.get('dashboard_view'), options, metric, - layout.mollier_style()) + layout.mollier_style(), layout.wood_style()) @app.callback( [Output('pw-status', 'children'), diff --git a/test_app.py b/test_app.py index 6e9de8a..b729fa3 100644 --- a/test_app.py +++ b/test_app.py @@ -552,8 +552,8 @@ def test_rule_validation_and_override(): assert alerts.validate_rules([{'scope': 0, 'metric': 'humidity', 'max_value': 5}] * 2)[1] == {1: {'metric': 'err_duplicate'}} assert alerts.save_rules([{'scope': 0, 'metric': 'humidity', 'max_value': 70, 'enabled': 'true'}, {'scope': kas, 'metric': 'humidity', 'max_value': 90, 'enabled': 'true'}]) == {} - assert alerts.effective_rules(kas)['humidity']['max_value'] == 90 - assert alerts.effective_rules(names['Koelcel']['id'])['humidity']['max_value'] == 70 + assert alerts.effective_rules(kas)[('humidity', 'alarm')]['max_value'] == 90 + assert alerts.effective_rules(names['Koelcel']['id'])[('humidity', 'alarm')]['max_value'] == 70 def test_alarm_delay_single_alert_and_recovery(): @@ -743,6 +743,151 @@ def test_mqtt_test_connection_reports_errors(): assert error and 'refused' in error.lower() +# --- houtvocht en kalibratie --------------------------------------------------- + +def test_wood_emc_matches_wood_handbook(): + import wood + fresh_settings() + for temp, rh, expected in ((20, 65, 12.0), (20, 85, 18.0), (20, 90, 20.5)): + assert abs(float(wood.emc(temp, rh)) - expected) < 0.2, (temp, rh) + assert settings.save({'wood_emc_offset': -1.5}) == {} + assert abs(float(wood.value(20, 65)) - 10.5) < 0.2 + line = wood.iso_line(18, [20]) # RV waarbij het gecorrigeerde houtvocht 18 % is + assert abs(float(wood.value(20, line[0])) - 18) < 0.05 + + +def test_wood_value_follows_air_slowly_and_starts_from_history(): + import wood + fresh_settings() + database.init_database() + wood._smooth.clear() + assert settings.save({'wood_response_hours': 24}) == {} + now = int(time.time()) + conn = sqlite3.connect(settings.DB_FILE) + conn.executemany(f'INSERT INTO {database.get_table_name()} (timestamp, temperature, humidity, device_id) ' + 'VALUES (?, 20, 65, 1)', [(now - s,) for s in range(1, 600, 60)]) + conn.commit() + conn.close() + first = wood.update(1, 20, 90, now=now) # natte lucht, maar het hout zat op 12 % (geen vals alarm) + assert abs(first - 12.0) < 0.2 + after_day = wood.update(1, 20, 90, now=now + 86400) # na één reactietijd ~63 % van de weg naar 20.5 % + assert 17 < after_day < 18.5 and wood.current(1) == after_day + settings.save({'wood_response_hours': 0}) + assert abs(wood.update(1, 20, 65, now=now + 86401) - 12.0) < 0.2 + + +def test_calibration_offsets_and_dewpoint(): + fresh_settings() + q = queue.Queue() + dev = {**device('Koelcel', offset_temp=-0.5, offset_humidity=3), 'id': 1} + poller = modbus_reader.BusPoller({**bus(), 'id': 1}, [dev], q) + poller.poll(FakeInstrument({1: 200, 2: 980}), dev) # 20.0 °C / 98.0 % -> 19.5 °C / 100 % (begrensd) + assert q.get_nowait()[1:3] == (19.5, 100.0) + dew, _ = modbus_reader.derived_values(20.0, 50.0) + assert abs(dew - 9.3) < 0.1 # Magnus; de oude vuistregel gaf 10.0 + assert devices.validate_config([bus()], [device(offset_humidity=50)])[2] == \ + {'devices': {0: {'offset_humidity': 'err_range'}}} + assert devices.save_config([bus()], [device(offset_temp=0.3)]) == {} + assert devices.load_config()[1][0]['offset_temp'] == 0.3 + + +def test_poller_feeds_wood_moisture_to_alarms_and_optionally_mqtt(): + import wood + names = setup_alerts([{'scope': 0, 'metric': 'wood_emc', 'max_value': 20, 'enabled': 'true'}]) + assert settings.save({'alert_delay_seconds': 0, 'wood_response_hours': 0, 'show_wood': []}) == {} + wood._smooth.clear() + dev = {**devices.load_config()[1][0], 'id': names['Koelcel']['id']} + poller = modbus_reader.BusPoller({**bus(), 'id': 1}, [dev], queue.Queue()) + poller.poll(FakeInstrument({1: 200, 2: 920}), dev) # 20 °C / 92 % -> ~21.7 % houtvocht + assert 'maximum van 20.0' in outbox_texts()[0] + assert 'wood_emc' not in mqtt_publisher._latest[dev['id']]['values'] + assert settings.save({'show_wood': ['on']}) == {} + poller.poll(FakeInstrument({1: 200, 2: 920}), dev) + assert abs(mqtt_publisher._latest[dev['id']]['values']['wood_emc'] - 21.7) < 0.3 + + +def test_wood_ui_follows_setting(): + import callbacks + import layout + import settings_ui + import wood + fresh_settings() + t = TRANSLATIONS['nl'] + assert 'wood_emc' not in [o['value'] for o in layout.metric_options(t)] + assert layout.wood_style() == {'display': 'none'} + assert settings.save({'show_wood': ['on']}) == {} + assert 'wood_emc' in [o['value'] for o in layout.metric_options(t)] and layout.wood_style() == {} + fig = wood.figure([('Kas', 20.0, 85.0, '#2a78d6')], [(None, 18, 'warning'), (None, 20, 'alarm')], t, + callbacks.THEMES['dark']) + names = [trace.name for trace in fig.data] + assert 'Kas' in names and 'Waarschuwing ≥ 18 %' in names and 'Alarm ≥ 20 %' in names + assert {'offset_temp', 'offset_humidity'} <= set(settings_ui.DEVICE_COLUMNS) and 'wood' in settings_ui.SECTIONS + + +def test_wood_graph_and_zones_from_rules(): + import callbacks + import pandas as pd + names = setup_alerts([{'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning', 'enabled': 'true'}, + {'scope': 0, 'metric': 'humidity', 'max_value': 80, 'enabled': 'true'}]) + kas = names['Kas']['id'] + alerts.save_rules([{'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning', 'enabled': 'true'}, + {'scope': kas, 'metric': 'wood_emc', 'max_value': 16, 'level': 'warning', 'enabled': 'true'}]) + assert callbacks.wood_zones(None) == [(None, 18.0, 'warning')] + assert callbacks.wood_zones(kas) == [(None, 16.0, 'warning')] + df = pd.DataFrame({'timestamp': [1], 'temperature': [20.0], 'humidity': [65.0], 'device_id': [1]}) + fig = callbacks.metric_figure(df, {1: 'Kas'}, {}, 'wood_emc', TRANSLATIONS['nl'], 'dark') + assert abs(fig.data[0].y[0] - 12.0) < 0.2 + + +def run_callback(app, output, *args, trigger): + """Roep een geregistreerde Dash callback aan alsof 'trigger' net veranderde.""" + from dash._callback_context import context_value + from dash._utils import AttributeDict + context_value.set(AttributeDict(triggered_inputs=[{'prop_id': trigger, 'value': None}])) + key = next(k for k in app.callback_map if output in k) + return app.callback_map[key]['callback'].__wrapped__(*args) + + +def test_wood_chart_and_board_follow_setting(): + import json + import callbacks + import plotly + from dash import Dash + names = setup_alerts([{'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning', 'enabled': 'true'}]) + database.init_database() + conn = sqlite3.connect(settings.DB_FILE) + conn.execute(f'INSERT INTO {database.get_table_name()} (timestamp, temperature, humidity, device_id) VALUES (?, 20, 85, ?)', + (int(time.time()), names['Koelcel']['id'])) + conn.commit() + conn.close() + app = Dash(__name__) + callbacks.register_callbacks(app) + assert run_callback(app, 'wood-chart.figure', 0, 'nl', 0, 'dashboard', trigger='selected-language.data') is \ + callbacks.no_update # houtvocht staat uit + assert settings.save({'show_wood': ['on']}) == {} + fig = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, 'dashboard', trigger='selected-language.data') + assert {'Koelcel', 'Waarschuwing ≥ 18 %'} <= {trace.name for trace in fig.data} + board, _ = run_callback(app, 'device-overview.children', 0, 'nl', 'board', 'dashboard', trigger='view-toggle.value') + assert 'Houtvocht' in json.dumps(board, cls=plotly.utils.PlotlyJSONEncoder, ensure_ascii=False) + + +def test_warning_and_alarm_levels(): + names = setup_alerts([{'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning', 'enabled': 'true'}, + {'scope': 0, 'metric': 'wood_emc', 'max_value': 20, 'level': 'alarm', 'enabled': 'true'}]) + assert settings.save({'alert_delay_seconds': 0, 'alert_hysteresis_wood': 0.5}) == {} + cel = names['Koelcel'] + alerts.evaluate(cel, {'wood_emc': 18.5}, now=1) + texts = outbox_texts() + assert len(texts) == 1 and 'waarschuwingsgrens van 18.0' in texts[0] + assert alerts.device_status(cel['id']) == ('warn', {'wood_emc': 'pending'}) + alerts.evaluate(cel, {'wood_emc': 20.4}, now=2) + assert 'maximum van 20.0' in outbox_texts()[0] + assert alerts.device_status(cel['id']) == ('alarm', {'wood_emc': 'alarm'}) + rule = {'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning'} + assert alerts.validate_rules([rule, rule])[1] == {1: {'metric': 'err_duplicate'}} + assert alerts.validate_rules([{**rule, 'level': 'x'}])[1] == {0: {'level': 'err_choice'}} + + # --- vertalingen -------------------------------------------------------------- def test_translations_have_same_placeholders(): diff --git a/translations.py b/translations.py index 74d645d..ba76c7c 100644 --- a/translations.py +++ b/translations.py @@ -211,6 +211,23 @@ 'err_min_max': 'Maximum moet groter zijn dan minimum', 'alert_high': '⚠️ {device}: {metric} {value} {unit} is boven het maximum van {limit} {unit} ({time})', 'alert_low': '⚠️ {device}: {metric} {value} {unit} is onder het minimum van {limit} {unit} ({time})', + 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is boven de waarschuwingsgrens van {limit} {unit} ({time})', + 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is onder de waarschuwingsgrens van {limit} {unit} ({time})', + 'wood_emc': 'Houtvocht', + 'wood_chart': 'Houtvochtevenwicht', + 'lbl_show_wood': 'Houtvocht tonen', + 'hint_show_wood': 'Het vochtgehalte waar hout naartoe gaat bij de gemeten temperatuur en luchtvochtigheid (USDA Wood Handbook), met diagram.', + 'section_wood': 'Houtvocht', + 'lbl_wood_emc_offset': 'Correctie houtvocht (%-punt)', + 'hint_wood_emc_offset': 'De formule geldt voor gemiddeld hout. Tropisch of thermisch gemodificeerd hout neemt minder vocht op: vul dan een negatieve correctie in, of ijk met een houtvochtmeter.', + 'lbl_wood_response_hours': 'Reactietijd hout (uur)', + 'hint_wood_response_hours': 'Hout volgt de lucht traag. Dashboard en alarmen gebruiken een gemiddelde over deze tijd, zodat een open deur of korte piek geen alarm geeft. 0 = actuele waarde.', + 'lbl_alert_hysteresis_wood': 'Hysterese houtvocht (%-punt)', + 'col_level': 'Niveau', + 'opt_level_alarm': 'Alarm', + 'opt_level_warning': 'Waarschuwing', + 'col_offset_temp': 'Correctie °C', + 'col_offset_humidity': 'Correctie % RV', 'alert_recovered': '✅ {device}: {metric} is weer normaal: {value} {unit} (grens {limit} {unit}, {time})', 'alert_offline': '📴 {device}: geen meetwaarden meer sinds {time}', 'alert_online': '📶 {device}: weer online ({time})', @@ -494,6 +511,23 @@ 'err_min_max': 'Maximum must be greater than minimum', 'alert_high': '⚠️ {device}: {metric} {value} {unit} is above the maximum of {limit} {unit} ({time})', 'alert_low': '⚠️ {device}: {metric} {value} {unit} is below the minimum of {limit} {unit} ({time})', + 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is above the warning limit of {limit} {unit} ({time})', + 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is below the warning limit of {limit} {unit} ({time})', + 'wood_emc': 'Wood moisture', + 'wood_chart': 'Wood equilibrium moisture', + 'lbl_show_wood': 'Show wood moisture', + 'hint_show_wood': 'The moisture content wood moves towards at the measured temperature and humidity (USDA Wood Handbook), with a diagram.', + 'section_wood': 'Wood moisture', + 'lbl_wood_emc_offset': 'Wood moisture correction (% points)', + 'hint_wood_emc_offset': 'The formula applies to average wood. Tropical or thermally modified wood absorbs less moisture: enter a negative correction, or calibrate with a moisture meter.', + 'lbl_wood_response_hours': 'Wood response time (hours)', + 'hint_wood_response_hours': 'Wood follows the air slowly. Dashboard and alarms use an average over this time, so an open door or short peak does not trigger an alarm. 0 = live value.', + 'lbl_alert_hysteresis_wood': 'Wood moisture hysteresis (% points)', + 'col_level': 'Level', + 'opt_level_alarm': 'Alarm', + 'opt_level_warning': 'Warning', + 'col_offset_temp': 'Correction °C', + 'col_offset_humidity': 'Correction % RH', 'alert_recovered': '✅ {device}: {metric} is back to normal: {value} {unit} (limit {limit} {unit}, {time})', 'alert_offline': '📴 {device}: no readings since {time}', 'alert_online': '📶 {device}: back online ({time})', @@ -777,6 +811,23 @@ 'err_min_max': 'Maximum muss größer als Minimum sein', 'alert_high': '⚠️ {device}: {metric} {value} {unit} liegt über dem Maximum von {limit} {unit} ({time})', 'alert_low': '⚠️ {device}: {metric} {value} {unit} liegt unter dem Minimum von {limit} {unit} ({time})', + 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} liegt über der Warngrenze von {limit} {unit} ({time})', + 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} liegt unter der Warngrenze von {limit} {unit} ({time})', + 'wood_emc': 'Holzfeuchte', + 'wood_chart': 'Holzfeuchte-Gleichgewicht', + 'lbl_show_wood': 'Holzfeuchte anzeigen', + 'hint_show_wood': 'Der Feuchtegehalt, den Holz bei der gemessenen Temperatur und Luftfeuchte annimmt (USDA Wood Handbook), mit Diagramm.', + 'section_wood': 'Holzfeuchte', + 'lbl_wood_emc_offset': 'Korrektur Holzfeuchte (%-Punkte)', + 'hint_wood_emc_offset': 'Die Formel gilt für durchschnittliches Holz. Tropen- oder Thermoholz nimmt weniger Feuchte auf: negative Korrektur eingeben oder mit einem Holzfeuchtemessgerät kalibrieren.', + 'lbl_wood_response_hours': 'Reaktionszeit Holz (Stunden)', + 'hint_wood_response_hours': 'Holz folgt der Luft langsam. Dashboard und Alarme nutzen einen Mittelwert über diese Zeit, damit eine offene Tür oder kurze Spitze keinen Alarm auslöst. 0 = aktueller Wert.', + 'lbl_alert_hysteresis_wood': 'Hysterese Holzfeuchte (%-Punkte)', + 'col_level': 'Stufe', + 'opt_level_alarm': 'Alarm', + 'opt_level_warning': 'Warnung', + 'col_offset_temp': 'Korrektur °C', + 'col_offset_humidity': 'Korrektur % rF', 'alert_recovered': '✅ {device}: {metric} ist wieder normal: {value} {unit} (Grenze {limit} {unit}, {time})', 'alert_offline': '📴 {device}: keine Messwerte seit {time}', 'alert_online': '📶 {device}: wieder online ({time})', @@ -1060,6 +1111,23 @@ 'err_min_max': 'Le maximum doit être supérieur au minimum', 'alert_high': '⚠️ {device} : {metric} {value} {unit} dépasse le maximum de {limit} {unit} ({time})', 'alert_low': '⚠️ {device} : {metric} {value} {unit} est sous le minimum de {limit} {unit} ({time})', + 'alert_warn_high': "🟡 {device} : {metric} {value} {unit} dépasse le seuil d'avertissement de {limit} {unit} ({time})", + 'alert_warn_low': "🟡 {device} : {metric} {value} {unit} est sous le seuil d'avertissement de {limit} {unit} ({time})", + 'wood_emc': 'Humidité du bois', + 'wood_chart': "Humidité d'équilibre du bois", + 'lbl_show_wood': "Afficher l'humidité du bois", + 'hint_show_wood': "La teneur en eau vers laquelle tend le bois à la température et l'humidité mesurées (USDA Wood Handbook), avec diagramme.", + 'section_wood': 'Humidité du bois', + 'lbl_wood_emc_offset': "Correction de l'humidité du bois (points %)", + 'hint_wood_emc_offset': "La formule vaut pour un bois moyen. Le bois tropical ou thermo-modifié absorbe moins d'eau : saisissez une correction négative, ou étalonnez avec un humidimètre.", + 'lbl_wood_response_hours': 'Temps de réponse du bois (heures)', + 'hint_wood_response_hours': "Le bois suit l'air lentement. Le tableau de bord et les alarmes utilisent une moyenne sur cette durée, pour qu'une porte ouverte ou un pic bref ne déclenche pas d'alarme. 0 = valeur actuelle.", + 'lbl_alert_hysteresis_wood': "Hystérésis de l'humidité du bois (points %)", + 'col_level': 'Niveau', + 'opt_level_alarm': 'Alarme', + 'opt_level_warning': 'Avertissement', + 'col_offset_temp': 'Correction °C', + 'col_offset_humidity': 'Correction % HR', 'alert_recovered': '✅ {device} : {metric} est redevenu normal : {value} {unit} (seuil {limit} {unit}, {time})', 'alert_offline': '📴 {device} : aucune mesure depuis {time}', 'alert_online': '📶 {device} : de nouveau en ligne ({time})', @@ -1343,6 +1411,23 @@ 'err_min_max': 'El máximo debe ser mayor que el mínimo', 'alert_high': '⚠️ {device}: {metric} {value} {unit} supera el máximo de {limit} {unit} ({time})', 'alert_low': '⚠️ {device}: {metric} {value} {unit} está por debajo del mínimo de {limit} {unit} ({time})', + 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} supera el límite de aviso de {limit} {unit} ({time})', + 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} está por debajo del límite de aviso de {limit} {unit} ({time})', + 'wood_emc': 'Humedad de la madera', + 'wood_chart': 'Humedad de equilibrio de la madera', + 'lbl_show_wood': 'Mostrar humedad de la madera', + 'hint_show_wood': 'El contenido de humedad hacia el que tiende la madera con la temperatura y humedad medidas (USDA Wood Handbook), con diagrama.', + 'section_wood': 'Humedad de la madera', + 'lbl_wood_emc_offset': 'Corrección humedad de la madera (puntos %)', + 'hint_wood_emc_offset': 'La fórmula vale para madera media. La madera tropical o termomodificada absorbe menos humedad: introduzca una corrección negativa o calibre con un medidor de humedad.', + 'lbl_wood_response_hours': 'Tiempo de respuesta de la madera (horas)', + 'hint_wood_response_hours': 'La madera sigue al aire lentamente. El panel y las alarmas usan una media sobre este tiempo, para que una puerta abierta o un pico breve no active una alarma. 0 = valor actual.', + 'lbl_alert_hysteresis_wood': 'Histéresis humedad de la madera (puntos %)', + 'col_level': 'Nivel', + 'opt_level_alarm': 'Alarma', + 'opt_level_warning': 'Aviso', + 'col_offset_temp': 'Corrección °C', + 'col_offset_humidity': 'Corrección % HR', 'alert_recovered': '✅ {device}: {metric} ha vuelto a la normalidad: {value} {unit} (límite {limit} {unit}, {time})', 'alert_offline': '📴 {device}: sin mediciones desde {time}', 'alert_online': '📶 {device}: de nuevo en línea ({time})', diff --git a/wood.py b/wood.py new file mode 100644 index 0000000..4c4488c --- /dev/null +++ b/wood.py @@ -0,0 +1,136 @@ +"""Houtvochtevenwicht (EMC): het vochtgehalte waar hout naartoe gaat bij een gegeven temperatuur en RV. + +Formule: Hailwood-Horrobin (Simpson 1973), USDA Wood Handbook hoofdstuk 4, versie in °C. +Volgens het handboek bruikbaar voor alle houtsoorten (afwijking per soort ~1-2 %-punt). Daarom +geen tabel met houtsoorten maar één correctie in Settings. +""" +import math +import sqlite3 +import threading +import time + +import numpy as np +import plotly.graph_objects as go + +import settings +from database import build_union_query + +_lock = threading.Lock() +_smooth = {} # device_id -> (tijdstip, vertraagd houtvocht) + + +def emc(temperature, humidity): + """EMC in % (vocht t.o.v. droog hout) zonder correctie. Werkt ook op numpy arrays / pandas series.""" + t = temperature + h = np.clip(humidity, 0, 99.5) / 100 # bij 100 % RV deelt de formule door nul + w = 349 + 1.29 * t + 0.0135 * t * t + k = 0.805 + 0.000736 * t - 0.00000273 * t * t + k1 = 6.27 - 0.00938 * t - 0.000303 * t * t + k2 = 1.91 + 0.0407 * t - 0.000293 * t * t + kh = k * h + return 1800 / w * (kh / (1 - kh) + (k1 * kh + 2 * k1 * k2 * kh * kh) / (1 + k1 * kh + k1 * k2 * kh * kh)) + + +def value(temperature, humidity): + """EMC met de correctie uit Settings (houtsoort / eigen ijking).""" + return emc(temperature, humidity) + settings.get('wood_emc_offset') + + +def _history_value(device_id, hours, now): + """Houtvocht uit het gemiddelde van de afgelopen reactietijd in de database, None zonder data.""" + conn = sqlite3.connect(settings.DB_FILE, timeout=30) + try: + query, _ = build_union_query(conn.cursor(), ['temperature', 'humidity'], start_timestamp=int(now - hours * 3600), + device_id=device_id, order_by=None) + if not query: + return None + temp, hum = conn.execute(f'SELECT AVG(temperature), AVG(humidity) FROM ({query})').fetchone() + return None if temp is None else float(value(temp, hum)) + except sqlite3.Error: + return None + finally: + conn.close() + + +def update(device_id, temperature, humidity, now=None): + """Nieuwe meting: geeft het vertraagde houtvocht. + + Hout volgt de lucht traag (dagen), daarom een exponentieel gemiddelde met de reactietijd uit + Settings (0 = actuele waarde). Bij de eerste meting na een start beginnen we bij het gemiddelde + uit de database, zodat een herstart geen vals alarm geeft. + """ + now = now or time.time() + live = float(value(temperature, humidity)) + hours = settings.get('wood_response_hours') + with _lock: + if not hours: + _smooth[device_id] = (now, live) + return live + if device_id not in _smooth: + seed = _history_value(device_id, hours, now) + _smooth[device_id] = (now, live if seed is None else seed) + last, smooth = _smooth[device_id] + smooth += (live - smooth) * (1 - math.exp(-max(now - last, 0) / (hours * 3600))) + _smooth[device_id] = (now, smooth) + return smooth + + +def current(device_id): + """Laatste vertraagde houtvocht van een sensor, None als die nog niet gemeten is.""" + entry = _smooth.get(device_id) + return entry[1] if entry else None + + +def iso_line(target, temps): + """RV (%) waarbij de gecorrigeerde EMC gelijk is aan target, per temperatuur (EMC stijgt met RV).""" + rh = np.linspace(0, 99.5, 400) + raw = target - settings.get('wood_emc_offset') + return np.array([np.interp(raw, emc(t, rh), rh, left=0, right=100) for t in temps]) + + +# --- diagram ------------------------------------------------------------------- + +def _rgba(hex_color, alpha): + r, g, b = (int(hex_color[i:i + 2], 16) for i in (1, 3, 5)) + return f'rgba({r}, {g}, {b}, {alpha})' + + +def figure(points, zones, t, k): + """Diagram temperatuur × RV met lijnen van gelijk houtvocht, zones uit de alarmregels en de sensoren. + + points: [(naam, temperatuur, rv, kleur)]; zones: [(min, max, niveau)] in % houtvocht; + k: themakleuren (callbacks.THEMES). + """ + seen = [p[1] for p in points if p[1] is not None] + temps = np.linspace(min([-5] + [x - 3 for x in seen]), max([45] + [x + 3 for x in seen]), 60) + fig = go.Figure() + for level in ('warning', 'alarm'): # alarm als laatste: ligt over de waarschuwing + color = k['warn'] if level == 'warning' else k['alarm'] + for low, high, zone_level in zones: + if zone_level != level: + continue + for limit, edge, sign in ((high, 100, '≥'), (low, 0, '≤')): + if limit is None: + continue + fig.add_trace(go.Scatter( + x=list(temps) + list(temps[::-1]), y=list(iso_line(limit, temps)) + [edge] * len(temps), + fill='toself', mode='lines', fillcolor=_rgba(color, 0.16), line=dict(color=color, width=1.5), + name=f"{t['opt_level_' + level]} {sign} {limit:g} %", hoverinfo='skip')) + for target in range(4, 28, 2): + line = iso_line(target, temps) + fig.add_trace(go.Scatter(x=temps, y=line, mode='lines', line=dict(color=k['axis'], width=1), + showlegend=False, hoverinfo='skip')) + if line[-1] < 96: # boven 96 % liggen de lijnen te dicht op elkaar voor labels + fig.add_annotation(x=temps[-1], y=line[-1], text=f'{target} %', showarrow=False, xanchor='left', + font=dict(color=k['muted'], size=11)) + for name, temp, rh, color in points: + if temp is None or rh is None: + continue + fig.add_trace(go.Scatter( + x=[temp], y=[rh], mode='markers+text', name=name, text=[name], textposition='top center', + marker=dict(size=13, color=color, line=dict(width=2, color=k['plot'])), textfont=dict(color=k['text']), + hovertemplate=f"{name}
{temp:.1f} °C · {rh:.0f} %
" + f"{t['wood_emc']}: {float(value(temp, rh)):.1f} %")) + fig.update_xaxes(title_text=f"{t['temperature']} (°C)", range=[temps[0], temps[-1]]) + fig.update_yaxes(title_text=f"{t['humidity']} (%)", range=[0, 100]) + return fig From 80d482a21411390ee11c2229335c4d610496cf00 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:07:50 +0100 Subject: [PATCH 02/19] fix: Replace Modbus and limit tables with plain form fields, drop ?tab=settings when leaving Settings - Buses, sensors and limits are now rows of normal inputs, dropdowns and checkboxes instead of a DataTable: arrow keys move the cursor, dropdowns and the enabled switch work reliably - Port field suggests detected adapters, stable /dev/serial/by-id names first; empty legacy ports (ttyS*) are no longer listed - Invalid fields are marked in red after saving; forms reload only after the save has finished - The address bar follows the tab: ?tab=settings disappears on the dashboard, F5 keeps the current tab - Save bar stays above the form fields --- devices.py | 8 +- layout.py | 33 +++-- settings_ui.py | 363 +++++++++++++++++++++++++++--------------------- test_app.py | 59 ++++++-- translations.py | 10 ++ 5 files changed, 294 insertions(+), 179 deletions(-) diff --git a/devices.py b/devices.py index 71da234..2d24206 100644 --- a/devices.py +++ b/devices.py @@ -3,6 +3,7 @@ Een bus = één seriële interface (bijv. USB RS-485 dongle) met één of meer slaves. Bij de eerste start wordt de oude .env configuratie (MODBUS_*) bus 1 met device 1. """ +import glob import os import sqlite3 import threading @@ -311,6 +312,9 @@ def available_ports(): """Seriële poorten op dit apparaat, als suggestie in de Settings tab.""" try: from serial.tools import list_ports - return sorted(p.device for p in list_ports.comports()) + # hwid 'n/a' = lege seriële poort zonder hardware (/dev/ttyS0-31 op een pc): geen suggestie waard + ports = sorted(p.device for p in list_ports.comports() if p.hwid != 'n/a') except Exception: - return [] + ports = [] + # Linux: vaste namen per adapter (serienummer) eerst; ttyUSB0/1 kunnen na een herstart wisselen + return sorted(glob.glob('/dev/serial/by-id/*')) + ports diff --git a/layout.py b/layout.py index d9688ca..bee37cd 100644 --- a/layout.py +++ b/layout.py @@ -14,7 +14,7 @@ {%favicon%} {%css%} diff --git a/settings_ui.py b/settings_ui.py index 39aa0d7..a0f215d 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -3,7 +3,7 @@ from urllib.parse import quote from zoneinfo import available_timezones -from dash import ALL, Input, Output, State, callback_context, dash_table, dcc, html, no_update +from dash import ALL, Input, Output, State, callback_context, dcc, html, no_update import alerts import auth @@ -15,9 +15,14 @@ SECTIONS = ['display', 'modbus', 'notify', 'alerts', 'wood', 'mqtt', 'regional', 'data', 'server', 'security'] +# 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) BUS_COLUMNS = ['name', 'port', 'baudrate', 'bytesize', 'parity', 'stopbits', 'timeout', 'enabled'] -DEVICE_COLUMNS = ['id', 'name', 'bus', 'slave_id', 'function_code', 'register_temp', 'register_humidity', +DEVICE_COLUMNS = ['name', 'bus', 'slave_id', 'function_code', 'register_temp', 'register_humidity', 'scale_temp', 'scale_humidity', 'offset_temp', 'offset_humidity', 'enabled'] +RULE_COLUMNS = ['scope', 'metric', 'level', 'min_value', 'max_value', 'enabled'] +TABLE_COLUMNS = {'buses': BUS_COLUMNS, 'devices': DEVICE_COLUMNS, 'rules': RULE_COLUMNS} +WIDE_FIELDS = {'name', 'port', 'bus', 'scope', 'metric'} # twee kolommen breed _TIMEZONES = sorted(available_timezones()) @@ -66,73 +71,125 @@ def _field(key, value, t): ], className='field') -def _yes_no(t): - return [{'label': t['opt_yes'], 'value': 'true'}, {'label': t['opt_no'], 'value': 'false'}] - - -def _table_rows(rows, columns): - """Database rijen naar DataTable rijen (bool als 'true'/'false', id blijft mee voor bijwerken).""" - return [{**{c: ('true' if r[c] else 'false') if isinstance(r.get(c), bool) else r.get(c) for c in columns}, - 'id': r.get('id')} for r in rows] - - -def _bus_table(buses, t): - choices = {'baudrate': devices.BAUDRATES, 'bytesize': [7, 8], 'parity': ['N', 'E', 'O'], 'stopbits': [1, 2]} - return dash_table.DataTable( - id='bus-table', - columns=[{'id': c, 'name': t[f'col_{c}'], 'presentation': 'dropdown' if c in choices or c == 'enabled' else 'input', - 'type': 'numeric' if c == 'timeout' else 'any'} for c in BUS_COLUMNS], - data=_table_rows(buses, BUS_COLUMNS), - editable=True, row_deletable=True, - dropdown={**{c: {'options': [{'label': str(v), 'value': v} for v in opts], 'clearable': False} - for c, opts in choices.items()}, - 'enabled': {'options': _yes_no(t), 'clearable': False}}, - **TABLE_STYLE) - - -def _device_table(device_rows, bus_names, t): - return dash_table.DataTable( - id='device-table', - columns=[{'id': c, 'name': t[f'col_{c}'], 'editable': c != 'id', - 'presentation': 'dropdown' if c in ('bus', 'function_code', 'enabled') else 'input', - 'type': 'numeric' if c.startswith(('slave', 'register', 'scale', 'offset')) else 'any'} for c in DEVICE_COLUMNS], - data=_table_rows(device_rows, DEVICE_COLUMNS), - editable=True, row_deletable=True, - dropdown={'bus': {'options': [{'label': n, 'value': n} for n in bus_names], 'clearable': False}, - 'function_code': {'options': [{'label': t['opt_function_code_3'], 'value': 3}, - {'label': t['opt_function_code_4'], 'value': 4}], 'clearable': False}, - 'enabled': {'options': _yes_no(t), 'clearable': False}}, - **TABLE_STYLE) - - -# Actieve/geselecteerde cel in thema-kleuren (anders zet de DataTable er inline hotpink op) -CELL_STATES = [{'if': {'state': state}, 'backgroundColor': 'var(--hover)', 'border': '1px solid var(--accent)'} - for state in ('active', 'selected')] - -TABLE_STYLE = { - 'style_data_conditional': CELL_STATES, - # Geen overflow op brede schermen: die knipt de keuzelijsten in de cellen af (alleen mobiel scrollt, zie layout.py) - 'style_table': {'marginBottom': '10px'}, - 'style_cell': {'fontFamily': 'var(--sans)', 'fontSize': '14px', 'padding': '7px 8px', 'minWidth': '70px', - 'backgroundColor': 'var(--panel-2)', 'color': 'var(--text)', 'border': '1px solid var(--line)', - 'textAlign': 'left'}, - 'style_header': {'backgroundColor': 'var(--panel)', 'fontWeight': '600', 'color': 'var(--muted)', - 'border': '1px solid var(--line)', 'fontSize': '12px'}, - 'css': [{'selector': '.Select-menu-outer', 'rule': 'display: block !important'}], -} +def table_rows(table): + """Opgeslagen bussen, sensoren of grenswaarden als formulierregels (aan/uit als 'true'/'false').""" + if table == 'rules': + return [{'id': None, 'scope': r['device_id'] or 0, 'metric': r['metric'], 'level': r['level'], + 'min_value': r['min_value'], 'max_value': r['max_value'], 'enabled': 'true' if r['enabled'] else 'false'} + for r in alerts.load_rules()] + buses, device_rows = devices.load_config() + return [{**{c: ('true' if r[c] else 'false') if isinstance(r.get(c), bool) else r.get(c) for c in TABLE_COLUMNS[table]}, + 'id': r['id']} for r in (buses if table == 'buses' else device_rows)] + + +def collect(states): + """Formulierwaarden (callback_context.states_list) terug naar regels, in schermvolgorde.""" + rows = {} + for item in states: + rows.setdefault(item['id']['row'], {})[item['id']['field']] = item.get('value') + result = [] + for index in sorted(rows): + row = rows[index] + if 'enabled' in row: + row['enabled'] = 'true' if row['enabled'] else 'false' # vinkje: ['on'] of [] + row['id'] = row.get('id') or None + result.append(row) + return result + + +def _choices(table, field, t, bus_names): + """Keuzes voor een veld, None = vrij invoerveld.""" + plain = {'baudrate': devices.BAUDRATES, 'bytesize': [7, 8], 'parity': ['N', 'E', 'O'], 'stopbits': [1, 2]} + if table == 'buses' and field in plain: + return [{'label': str(v), 'value': v} for v in plain[field]] + if field == 'bus': + return [{'label': n, 'value': n} for n in bus_names] + if field == 'function_code': + return [{'label': t[f'opt_function_code_{v}'], 'value': v} for v in (4, 3)] + if field == 'scope': + return [{'label': t['opt_all_devices'], 'value': 0}] + [ + {'label': f"#{d['id']} {d['name']}", 'value': d['id']} for d in devices.list_devices()] + if field == 'metric': + return [{'label': t[alerts.METRICS[m][0]], 'value': m} for m in alerts.METRICS] + if field == 'level': + return [{'label': t[f'opt_level_{v}'], 'value': v} for v in alerts.LEVELS] + return None + + +def _control(table, index, field, value, t, bus_names): + field_id = {'type': 'cfg', 'table': table, 'row': index, 'field': field} + options = _choices(table, field, t, bus_names) + if field == 'enabled': + return dcc.Checklist(id=field_id, options=[{'label': ' ' + t['lbl_enabled'], 'value': 'on'}], + value=['on'] if str(value).lower() in ('true', '1') else [], className='cfg-switch') + if options is not None: + return dcc.Dropdown(id=field_id, options=options, value=value, clearable=False, searchable=False) + if field in ('name', 'port'): + # Poort: vrij tekstveld met de gevonden adapters als suggesties (datalist) + return dcc.Input(id=field_id, type='text', value=value, className='input', autoComplete='off', spellCheck=False, + list='port-options' if field == 'port' else None) + return dcc.Input(id=field_id, type='number', value=value, step='any', className='input') + + +def row_form(table, rows, t, bus_names=None): + """Formulierregels voor bussen, sensoren of grenswaarden.""" + if bus_names is None: + bus_names = [b['name'] for b in devices.load_config()[0]] + items = [] + for index, row in enumerate(rows): + cells = [dcc.Input(id={'type': 'cfg', 'table': table, 'row': index, 'field': 'id'}, type='hidden', + value=row.get('id'))] + for field in TABLE_COLUMNS[table]: + cell_id = {'type': 'cfg-cell', 'table': table, 'row': index, 'field': field} + control = _control(table, index, field, row.get(field), t, bus_names) + # Label om het veld heen (koppeling zonder id); het vinkje heeft al een eigen label + wrapper = html.Div if field == 'enabled' else html.Label + cells.append(wrapper(id=cell_id, className=_cell_class(field), children=[ + html.Span(t[f'col_{field}'], className='cfg-label'), control])) + cells.append(html.Button(t['btn_remove'], id={'type': 'cfg-remove', 'table': table, 'row': index}, n_clicks=0, + className='btn btn-link cfg-remove')) + items.append(html.Div(cells, className='cfg-row')) + return items or [html.Div(t['cfg_empty'], className='cfg-empty')] + + +def _fields(table): + """Pattern-matching id van alle velden (incl. verborgen id) van één formulier.""" + return {'type': 'cfg', 'table': table, 'row': ALL, 'field': ALL} + + +def _cell_class(field, invalid=False): + return 'cfg-cell' + (' wide' if field in WIDE_FIELDS else '') + (' is-invalid' if invalid else '') + + +def _new_row(table, rows, bus_names): + """Nieuwe regel met handige standaardwaarden (vrije poort, vrij slave adres).""" + if table == 'buses': + used = {r.get('port') for r in rows} + port = next((p for p in devices.available_ports() if p not in used), '') + return {'id': None, 'name': f'Bus {len(rows) + 1}', 'port': port, 'baudrate': 9600, 'bytesize': 8, + 'parity': 'N', 'stopbits': 1, 'timeout': 1.0, 'enabled': 'true'} + if table == 'devices': + bus_name = next((n for n in bus_names if n), '') + used = {r.get('slave_id') for r in rows if r.get('bus') == bus_name} + return {'id': None, 'name': f'Sensor {len(rows) + 1}', 'bus': bus_name, + 'slave_id': next(i for i in range(1, 248) if i not in used), 'function_code': 4, 'register_temp': 1, + 'register_humidity': 2, 'scale_temp': 0.1, 'scale_humidity': 0.1, 'offset_temp': 0, + 'offset_humidity': 0, 'enabled': 'true'} + return {'id': None, 'scope': 0, 'metric': 'temperature', 'level': 'alarm', 'min_value': None, 'max_value': None, + 'enabled': 'true'} def _modbus_section(t): - buses, device_rows = devices.load_config() ports = devices.available_ports() return [ + html.Datalist(id='port-options', children=[html.Option(value=p) for p in ports]), html.H4(t['table_buses']), - _bus_table(buses, t), + html.Div(id='buses-form', className='cfg-list', children=row_form('buses', table_rows('buses'), t)), html.Div(t['hint_ports'].format(ports=', '.join(ports)) if ports else t['hint_no_ports'], className='field-hint'), html.Button(t['btn_add_bus'], id='add-bus', n_clicks=0, className='btn btn-small'), html.H4(t['table_devices']), html.Div(t['hint_registers'], className='field-hint'), - _device_table(device_rows, [b['name'] for b in buses], t), + html.Div(id='devices-form', className='cfg-list', children=row_form('devices', table_rows('devices'), t)), html.Button(t['btn_add_device'], id='add-device', n_clicks=0, className='btn btn-small'), html.Div(id='modbus-errors', className='field-error'), html.H4(t['lbl_device_status']), @@ -172,34 +229,13 @@ def mqtt_status_text(t): style={'color': 'var(--alarm)', 'fontWeight': 'bold'}) -def _rule_rows(): - return [{'scope': r['device_id'] or 0, 'metric': r['metric'], 'level': r['level'], 'min_value': r['min_value'], - 'max_value': r['max_value'], 'enabled': 'true' if r['enabled'] else 'false'} for r in alerts.load_rules()] - - def _alerts_section(t): - scopes = [{'label': t['opt_all_devices'], 'value': 0}] + [ - {'label': f"#{d['id']} {d['name']}", 'value': d['id']} for d in devices.list_devices()] - metrics = [{'label': t[alerts.METRICS[m][0]], 'value': m} for m in alerts.METRICS] return [ html.H4(t['table_rules']), html.Div(t['hint_rules'], className='field-hint'), - dash_table.DataTable( - id='alert-rule-table', - columns=[{'id': 'scope', 'name': t['col_scope'], 'presentation': 'dropdown'}, - {'id': 'metric', 'name': t['col_metric'], 'presentation': 'dropdown'}, - {'id': 'level', 'name': t['col_level'], 'presentation': 'dropdown'}, - {'id': 'min_value', 'name': t['col_min_value'], 'type': 'numeric'}, - {'id': 'max_value', 'name': t['col_max_value'], 'type': 'numeric'}, - {'id': 'enabled', 'name': t['col_enabled'], 'presentation': 'dropdown'}], - data=_rule_rows(), editable=True, row_deletable=True, - dropdown={'scope': {'options': scopes, 'clearable': False}, - 'metric': {'options': metrics, 'clearable': False}, - 'level': {'options': [{'label': t[f'opt_level_{v}'], 'value': v} for v in alerts.LEVELS], - 'clearable': False}, - 'enabled': {'options': _yes_no(t), 'clearable': False}}, - **TABLE_STYLE), + html.Div(id='rules-form', className='cfg-list', children=row_form('rules', table_rows('rules'), t)), html.Button(t['btn_add_rule'], id='add-rule', n_clicks=0, className='btn btn-small'), + html.Div(id='rule-errors', className='field-error'), html.H4(t['lbl_alert_log']), html.Div(id='alert-log', children=alert_log_list(t)), ] @@ -302,11 +338,17 @@ def render_settings(lang): def register_settings_callbacks(app): @app.callback( - Output('main-tabs', 'value'), - Input('url', 'search') + [Output('main-tabs', 'value'), + Output('url', 'search')], + [Input('url', 'search'), + Input('main-tabs', 'value')] ) - def select_tab_from_url(search): - return 'settings' if search and 'tab=settings' in search else no_update + def sync_tab_url(search, tab): + """Tab en adresbalk gelijk houden: ?tab=settings alleen zolang Settings open is (F5 blijft op de tab).""" + if callback_context.triggered_id == 'main-tabs': + wanted = '?tab=settings' if tab == 'settings' else '' + return no_update, wanted if (search or '') != wanted else no_update + return ('settings' if search and 'tab=settings' in search else no_update), no_update @app.callback( Output('settings-content', 'children'), @@ -325,18 +367,19 @@ def show_settings(tab, lang): Input('settings-save', 'n_clicks'), [State({'type': 'setting', 'key': ALL}, 'value'), State('selected-language', 'data'), - State('bus-table', 'data'), - State('device-table', 'data'), - State('alert-rule-table', 'data')], + State(_fields('buses'), 'value'), + State(_fields('devices'), 'value'), + State(_fields('rules'), 'value')], prevent_initial_call=True ) - def save_settings(n_clicks, values, lang, bus_rows, device_rows, rule_rows): + def save_settings(n_clicks, values, lang, bus_values, device_values, rule_values): t = _t(lang) ok_style = {'marginTop': '10px', 'fontWeight': 'bold', 'color': 'var(--ok)'} bad_style = {'marginTop': '10px', 'fontWeight': 'bold', 'color': 'var(--alarm)'} if not auth.is_logged_in(): return no_update, t['settings_locked'], bad_style keys = [item['id']['key'] for item in callback_context.states_list[0]] + bus_rows, device_rows, rule_rows = (collect(callback_context.states_list[i]) for i in (2, 3, 4)) # Eerst de Modbus configuratie valideren, zodat bij een fout niets wordt opgeslagen _, _, modbus_errors = devices.validate_config(bus_rows or [], device_rows or []) @@ -359,87 +402,107 @@ def save_settings(n_clicks, values, lang, bus_rows, device_rows, rule_rows): @app.callback( [Output('modbus-errors', 'children'), - Output('bus-table', 'style_data_conditional'), - Output('device-table', 'style_data_conditional'), - Output('bus-table', 'data', allow_duplicate=True), - Output('device-table', 'data', allow_duplicate=True), - Output('alert-rule-table', 'style_data_conditional'), - Output('alert-rule-table', 'data', allow_duplicate=True)], - Input('settings-save', 'n_clicks'), - [State('bus-table', 'data'), - State('device-table', 'data'), - State('selected-language', 'data'), - State('alert-rule-table', 'data')], + Output('rule-errors', 'children'), + Output('buses-form', 'children', allow_duplicate=True), + Output('devices-form', 'children', allow_duplicate=True), + Output('rules-form', 'children', allow_duplicate=True), + Output({'type': 'cfg-cell', 'table': ALL, 'row': ALL, 'field': ALL}, 'className')], + Input('settings-status', 'children'), # na save_settings: anders kan het herladen vóór het opslaan lopen + [State(_fields('buses'), 'value'), + State(_fields('devices'), 'value'), + State(_fields('rules'), 'value'), + State('selected-language', 'data')], prevent_initial_call=True ) - def show_modbus_errors(n_clicks, bus_rows, device_rows, lang, rule_rows): - """Markeer foute cellen; na succesvol opslaan de tabellen herladen (nieuwe rijen krijgen een id).""" + def show_modbus_errors(status, bus_values, device_values, rule_values, lang): + """Markeer foute velden; na succesvol opslaan de formulieren herladen (nieuwe regels krijgen een id).""" t = _t(lang) if not auth.is_logged_in(): - return (no_update,) * 7 - _, _, errors = devices.validate_config(bus_rows or [], device_rows or []) - _, rule_errors = alerts.validate_rules(rule_rows or []) + return (no_update,) * 6 + bus_rows, device_rows, rule_rows = (collect(callback_context.states_list[i]) for i in (0, 1, 2)) + _, _, errors = devices.validate_config(bus_rows, device_rows) + _, rule_errors = alerts.validate_rules(rule_rows) if rule_errors: errors = {**errors, 'rules': rule_errors} - messages, styles = [], {'buses': list(CELL_STATES), 'devices': list(CELL_STATES), 'rules': list(CELL_STATES)} + messages = {'modbus': [], 'rules': []} for table in ('buses', 'devices', 'rules'): for row, fields in errors.get(table, {}).items(): for field, code in fields.items(): - messages.append(html.Li(t['row_error'].format(table=t[f'table_{table}'], row=row + 1, - field=t[f'col_{field}'], error=t.get(code, code)))) - styles[table].append({'if': {'row_index': row, 'column_id': field}, - 'backgroundColor': 'var(--alarm-cell)', 'color': 'var(--alarm)'}) - if errors: - return html.Ul(messages), styles['buses'], styles['devices'], no_update, no_update, styles['rules'], no_update - buses, device_rows = devices.load_config() - return ('', CELL_STATES, CELL_STATES, _table_rows(buses, BUS_COLUMNS), _table_rows(device_rows, DEVICE_COLUMNS), - CELL_STATES, _rule_rows()) + messages['rules' if table == 'rules' else 'modbus'].append(html.Li(t['row_error'].format( + table=t[f'table_{table}'], row=row + 1, field=t[f'col_{field}'], error=t.get(code, code)))) + cells = callback_context.outputs_list[-1] + classes = [_cell_class(c['id']['field'], c['id']['field'] in errors.get(c['id']['table'], {}).get(c['id']['row'], {})) + for c in cells] + modbus_text = html.Ul(messages['modbus']) if messages['modbus'] else '' + rules_text = html.Ul(messages['rules']) if messages['rules'] else '' + if errors or status not in (t['msg_saved'], t['msg_saved_restart']): + return modbus_text, rules_text, no_update, no_update, no_update, classes + return ('', '', row_form('buses', table_rows('buses'), t), row_form('devices', table_rows('devices'), t), + row_form('rules', table_rows('rules'), t), classes) + + def edit_rows(table, t, bus_names): + """Regel toevoegen of verwijderen; de rest van het formulier houdt de (nog niet opgeslagen) invoer.""" + rows = collect(callback_context.states_list[0]) + trigger = callback_context.triggered_id + if isinstance(trigger, dict): + if not callback_context.triggered[0].get('value'): + return no_update # nieuw getekende verwijderknop, geen klik + rows.pop(trigger['row']) + else: + rows.append(_new_row(table, rows, bus_names)) + return row_form(table, rows, t, bus_names) @app.callback( - Output('bus-table', 'data'), - Input('add-bus', 'n_clicks'), - State('bus-table', 'data'), + Output('buses-form', 'children'), + [Input('add-bus', 'n_clicks'), + Input({'type': 'cfg-remove', 'table': 'buses', 'row': ALL}, 'n_clicks')], + [State(_fields('buses'), 'value'), + State('selected-language', 'data')], prevent_initial_call=True ) - def add_bus(n_clicks, rows): + def edit_buses(n_clicks, remove_clicks, values, lang): if not auth.is_logged_in(): return no_update - rows = rows or [] - used = {r.get('port') for r in rows} - port = next((p for p in devices.available_ports() if p not in used), '') - return rows + [{'id': None, 'name': f'Bus {len(rows) + 1}', 'port': port, 'baudrate': 9600, 'bytesize': 8, - 'parity': 'N', 'stopbits': 1, 'timeout': 1.0, 'enabled': 'true'}] + return edit_rows('buses', _t(lang), bus_names=[]) @app.callback( - Output('device-table', 'data'), - Input('add-device', 'n_clicks'), - [State('device-table', 'data'), - State('bus-table', 'data')], + Output('devices-form', 'children'), + [Input('add-device', 'n_clicks'), + Input({'type': 'cfg-remove', 'table': 'devices', 'row': ALL}, 'n_clicks')], + [State(_fields('devices'), 'value'), + State({'type': 'cfg', 'table': 'buses', 'row': ALL, 'field': 'name'}, 'value'), + State('selected-language', 'data')], prevent_initial_call=True ) - def add_device(n_clicks, rows, bus_rows): + def edit_devices(n_clicks, remove_clicks, values, bus_names, lang): if not auth.is_logged_in(): return no_update - rows = rows or [] - bus_name = (bus_rows or [{}])[0].get('name', '') - used = {r.get('slave_id') for r in rows if r.get('bus') == bus_name} - slave_id = next(i for i in range(1, 248) if i not in used) - return rows + [{'id': None, 'name': f'Sensor {len(rows) + 1}', 'bus': bus_name, 'slave_id': slave_id, - 'function_code': 4, 'register_temp': 1, 'register_humidity': 2, 'scale_temp': 0.1, - 'scale_humidity': 0.1, 'offset_temp': 0, 'offset_humidity': 0, 'enabled': 'true'}] + return edit_rows('devices', _t(lang), bus_names=[n for n in bus_names if n]) + + @app.callback( + Output('rules-form', 'children'), + [Input('add-rule', 'n_clicks'), + Input({'type': 'cfg-remove', 'table': 'rules', 'row': ALL}, 'n_clicks')], + [State(_fields('rules'), 'value'), + State('selected-language', 'data')], + prevent_initial_call=True + ) + def edit_rules(n_clicks, remove_clicks, values, lang): + if not auth.is_logged_in(): + return no_update + return edit_rows('rules', _t(lang), bus_names=[]) @app.callback( - Output('device-table', 'dropdown'), - Input('bus-table', 'data'), - State('device-table', 'dropdown'), + Output({'type': 'cfg', 'table': 'devices', 'row': ALL, 'field': 'bus'}, 'options'), + Input({'type': 'cfg', 'table': 'buses', 'row': ALL, 'field': 'name'}, 'value'), prevent_initial_call=True ) - def update_bus_choices(bus_rows, dropdown): - """Bus keuze in de device tabel volgt de (nog niet opgeslagen) bustabel.""" + def update_bus_choices(names): + """Buskeuze bij de sensoren volgt de (nog niet opgeslagen) bussen.""" if not auth.is_logged_in(): return no_update - names = [r['name'] for r in bus_rows or [] if r.get('name')] - return {**dropdown, 'bus': {'options': [{'label': n, 'value': n} for n in names], 'clearable': False}} + options = [{'label': n, 'value': n} for n in names if n] + return [options] * len(callback_context.outputs_list) @app.callback( Output('device-status', 'children'), @@ -453,18 +516,6 @@ def refresh_device_status(n, tab, lang): return no_update return device_status_list(_t(lang)) - @app.callback( - Output('alert-rule-table', 'data'), - Input('add-rule', 'n_clicks'), - State('alert-rule-table', 'data'), - prevent_initial_call=True - ) - def add_rule(n_clicks, rows): - if not auth.is_logged_in(): - return no_update - return (rows or []) + [{'scope': 0, 'metric': 'temperature', 'level': 'alarm', 'min_value': None, - 'max_value': None, 'enabled': 'true'}] - @app.callback( Output('test-status', 'children'), [Input('test-telegram', 'n_clicks'), diff --git a/test_app.py b/test_app.py index b729fa3..837aa0b 100644 --- a/test_app.py +++ b/test_app.py @@ -472,25 +472,66 @@ def test_settings_callbacks_require_login(): fn = getattr(entry['callback'], '__wrapped__', None) if fn: callbacks[fn.__name__] = fn - rows = [bus()] with app.server.test_request_context(): - assert callbacks['add_bus'](1, rows) is dash.no_update - assert callbacks['add_device'](1, [], rows) is dash.no_update - assert callbacks['update_bus_choices'](rows, {}) is dash.no_update + assert callbacks['edit_buses'](1, [], [], 'nl') is dash.no_update + assert callbacks['edit_devices'](1, [], [], [], 'nl') is dash.no_update + assert callbacks['update_bus_choices'](['Bus 1']) is dash.no_update assert callbacks['refresh_device_status'](3, 'settings', 'nl') is dash.no_update - assert all(v is dash.no_update for v in callbacks['show_modbus_errors'](1, rows, [], 'nl', [])) - errors, status, _ = callbacks['save_settings'](1, [], 'nl', rows, [], []) + assert all(v is dash.no_update for v in callbacks['show_modbus_errors'](1, [], [], [], 'nl')) + errors, status, _ = callbacks['save_settings'](1, [], 'nl', [], [], []) assert errors is dash.no_update and status == TRANSLATIONS_NL['settings_locked'] assert callbacks['change_password'](1, 'x', 'y', 'y', 'nl')[0] == TRANSLATIONS_NL['settings_locked'] assert 'admin' not in str(callbacks['show_settings']('settings', 'nl')) # alleen het inlogscherm - assert callbacks['add_rule'](1, []) is dash.no_update + assert callbacks['edit_rules'](1, [], [], 'nl') is dash.no_update assert callbacks['send_test'](1, 0, [], 'nl') == TRANSLATIONS_NL['settings_locked'] assert callbacks['refresh_alert_log'](5, 'settings', 'nl') is dash.no_update assert callbacks['test_mqtt'](1, [], 'nl') == TRANSLATIONS_NL['settings_locked'] assert callbacks['refresh_mqtt_status'](3, 'settings', 'nl') is dash.no_update - assert set(callbacks) >= {'add_bus', 'add_device', 'update_bus_choices', 'refresh_device_status', + assert set(callbacks) >= {'edit_buses', 'edit_devices', 'update_bus_choices', 'refresh_device_status', 'show_modbus_errors', 'save_settings', 'change_password', 'show_settings', - 'add_rule', 'send_test', 'refresh_alert_log', 'test_mqtt', 'refresh_mqtt_status'} + 'edit_rules', 'send_test', 'refresh_alert_log', 'test_mqtt', 'refresh_mqtt_status'} + + +def components(node): + """Alle Dash componenten in een boom.""" + yield node + children = getattr(node, 'children', None) + for child in children if isinstance(children, (list, tuple)) else [children]: + if hasattr(child, 'to_plotly_json'): + yield from components(child) + + +def test_config_forms_round_trip(): + """Bussen, sensoren en regels zijn gewone formuliervelden; terug uitgelezen leveren ze dezelfde rijen.""" + import settings_ui + from dash import html + names = setup_alerts([{'scope': 0, 'metric': 'humidity', 'max_value': 70, 'level': 'warning', 'enabled': 'true'}]) + t = TRANSLATIONS['nl'] + rows = {} + for table in ('buses', 'devices', 'rules'): + form = html.Div(settings_ui.row_form(table, settings_ui.table_rows(table), t)) + assert not any(type(c).__name__ == 'DataTable' for c in components(form)) + fields = [c for c in components(form) if isinstance(getattr(c, 'id', None), dict) and c.id.get('type') == 'cfg'] + rows[table] = settings_ui.collect([{'id': c.id, 'value': c.value} for c in fields]) + assert [d['name'] for d in rows['devices']] == ['Koelcel', 'Kas'] and rows['devices'][0]['enabled'] == 'true' + assert rows['devices'][0]['id'] == names['Koelcel']['id'] + assert devices.validate_config(rows['buses'], rows['devices'])[2] == {} + assert alerts.validate_rules(rows['rules'])[1] == {} and rows['rules'][0]['level'] == 'warning' + + +def test_tab_and_url_stay_in_sync(): + """Terug naar het dashboard haalt ?tab=settings uit de adresbalk; F5 op settings blijft op settings.""" + import dash + import settings_ui + fresh_settings() + app = dash.Dash(__name__, suppress_callback_exceptions=True) + settings_ui.register_settings_callbacks(app) + assert run_callback(app, 'main-tabs.value', '?tab=settings', 'dashboard', trigger='url.search') == \ + ('settings', dash.no_update) + assert run_callback(app, 'main-tabs.value', '?tab=settings', 'dashboard', trigger='main-tabs.value') == \ + (dash.no_update, '') + assert run_callback(app, 'main-tabs.value', '', 'settings', trigger='main-tabs.value') == \ + (dash.no_update, '?tab=settings') # --- meldingen ----------------------------------------------------------------- diff --git a/translations.py b/translations.py index ba76c7c..a7060be 100644 --- a/translations.py +++ b/translations.py @@ -202,6 +202,8 @@ 'col_min_value': 'Minimum', 'col_max_value': 'Maximum', 'btn_add_rule': '+ Grenswaarde toevoegen', + 'btn_remove': 'Verwijderen', + 'cfg_empty': 'Nog niets toegevoegd.', 'lbl_alert_log': 'Laatste meldingen', 'alert_log_empty': 'Nog geen meldingen verstuurd.', 'err_email': 'Ongeldig e-mailadres', @@ -502,6 +504,8 @@ 'col_min_value': 'Minimum', 'col_max_value': 'Maximum', 'btn_add_rule': '+ Add limit', + 'btn_remove': 'Remove', + 'cfg_empty': 'Nothing added yet.', 'lbl_alert_log': 'Recent notifications', 'alert_log_empty': 'No notifications sent yet.', 'err_email': 'Invalid e-mail address', @@ -802,6 +806,8 @@ 'col_min_value': 'Minimum', 'col_max_value': 'Maximum', 'btn_add_rule': '+ Grenzwert hinzufügen', + 'btn_remove': 'Entfernen', + 'cfg_empty': 'Noch nichts hinzugefügt.', 'lbl_alert_log': 'Letzte Benachrichtigungen', 'alert_log_empty': 'Noch keine Benachrichtigungen gesendet.', 'err_email': 'Ungültige E-Mail-Adresse', @@ -1102,6 +1108,8 @@ 'col_min_value': 'Minimum', 'col_max_value': 'Maximum', 'btn_add_rule': '+ Ajouter un seuil', + 'btn_remove': 'Supprimer', + 'cfg_empty': "Rien n'a encore été ajouté.", 'lbl_alert_log': 'Dernières notifications', 'alert_log_empty': 'Aucune notification envoyée pour le moment.', 'err_email': 'Adresse e-mail invalide', @@ -1402,6 +1410,8 @@ 'col_min_value': 'Mínimo', 'col_max_value': 'Máximo', 'btn_add_rule': '+ Añadir límite', + 'btn_remove': 'Eliminar', + 'cfg_empty': 'Todavía no se ha añadido nada.', 'lbl_alert_log': 'Últimas notificaciones', 'alert_log_empty': 'Aún no se han enviado notificaciones.', 'err_email': 'Dirección de correo no válida', From 3014f12c161b4826f15cf771acda02d08410b15e Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:31:55 +0100 Subject: [PATCH 03/19] fix: Wood moisture on phones, meter colours, consistent dew point, level hint, reject invalid humidity - Wood moisture column stays visible on phones; board uses short headers there (Temp. / RH / Wood) - Wood moisture warning/alarm is coloured in the meters view - Comfort score uses the same Magnus dew point as the measurements - Limits hint explains the warning and alarm levels - An impossible raw humidity (e.g. -5 %) is rejected again instead of being clamped to 0 %; only the calibration correction is clamped --- callbacks.py | 15 +++++++++++---- layout.py | 5 +++++ modbus_reader.py | 7 +++++-- test_app.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ translations.py | 25 +++++++++++++++++++----- 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/callbacks.py b/callbacks.py index 8c20b1b..ab7cdb3 100644 --- a/callbacks.py +++ b/callbacks.py @@ -12,6 +12,7 @@ import wood from translations import TRANSLATIONS from database import get_all_measurement_tables, build_union_query, DB_FILE +from modbus_reader import derived_values from psychrometric import add_device_points, create_psychrometric_chart, create_psychrometric_chart_historical # Categorisch palet per sensor (vaste volgorde, gevalideerd op kleurenblindheid en contrast). @@ -105,7 +106,7 @@ def humidex_score(temp, hum): """Comfort score 0-6 op basis van Humidex (Environment Canada schaal)""" if pd.isna(temp) or pd.isna(hum): return None - dewpoint_kelvin = temp - ((100 - hum) / 5.0) + 273.15 + dewpoint_kelvin = derived_values(temp, hum)[0] + 273.15 # zelfde dauwpunt (Magnus) als de metingen e = 6.11 * math.exp(5417.7530 * ((1/273.16) - (1/dewpoint_kelvin))) humidex = temp + 0.5555 * (e - 10) for limit, score in ((20, 0), (27, 4), (30, 5), (35, 6), (40, 3), (46, 2), (54, 1)): @@ -711,10 +712,16 @@ def spark(device_id, color, width=120): def render_board(states, colors, t): show_comfort, show_wood = settings.get('show_comfort'), settings.get('show_wood') - head = html.Tr([html.Th('', className='bar'), html.Th(t['hdr_sensor']), html.Th(t['temperature']), html.Th(t['humidity']), + + def th(key, short_key, className=None): + """Kop met een korte variant voor smalle schermen (CSS toont één van beide).""" + return html.Th([html.Span(t[key], className='th-full'), html.Span(t[short_key], className='th-short')], + className=className) + head = html.Tr([html.Th('', className='bar'), html.Th(t['hdr_sensor']), th('temperature', 'short_temperature'), + th('humidity', 'short_humidity'), html.Th(t['dewpoint'], className='col-wide'), html.Th(t['abs_humidity'], className='col-wide'), html.Th(t['comfort'], className='col-wide') if show_comfort else None, - html.Th(t['wood_emc'], className='col-wide') if show_wood else None, + th('wood_emc', 'short_wood', 'col-wood') if show_wood else None, html.Th(t['hdr_trend'], className='col-wide'), html.Th(t['hdr_status'], className='col-status')]) rows = [] @@ -732,7 +739,7 @@ def render_board(states, colors, t): html.Td(value_cell(s_['dew'], '°C', ph.get('dewpoint')), className='col-wide'), html.Td(value_cell(s_['ah'], 'g/m³', ph.get('absolute_humidity')), className='col-wide'), html.Td(s_['comfort'], className='comfort col-wide') if show_comfort else None, - html.Td(value_cell(s_['wood'], '%', ph.get('wood_emc')), className='col-wide') if show_wood else None, + html.Td(value_cell(s_['wood'], '%', ph.get('wood_emc')), className='col-wood') if show_wood else None, html.Td(spark(s_['id'], color), className='col-wide'), html.Td([html.Span(s_['label'], className=f"st st-{s_['status']}"), html.Span(s_['sub'], className='st-sub')], className='col-status'), diff --git a/layout.py b/layout.py index bee37cd..88f3aed 100644 --- a/layout.py +++ b/layout.py @@ -107,6 +107,7 @@ /* Bord */ .board-wrap { overflow-x: auto; } .board { width: 100%; border-collapse: collapse; } + .board .th-short { display: none; } .board th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; } .board td { padding: 8px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; } @@ -131,6 +132,7 @@ .meter-display .v1 { font-size: 46px; } .meter-display .v2 { font-size: 28px; } .meter-display .u { font-size: 13px; color: var(--muted); margin-left: 6px; } .meter-display .is-alarm { color: var(--alarm); } .meter-display .is-warn { color: var(--warn); } + .meter-stats .is-alarm { color: var(--alarm); font-weight: 600; } .meter-stats .is-warn { color: var(--warn); font-weight: 600; } .meter-stats { display: flex; justify-content: space-between; gap: 8px; margin-top: 10px; font-family: var(--mono); font-size: 12px; color: var(--muted); } .meter .spark { margin-top: 8px; width: 100%; } .empty { padding: 28px; color: var(--muted); } @@ -238,6 +240,9 @@ .panel-head { padding: 0 6px; } .controls { width: 100%; } .controls > div:first-child, .controls > div:last-child { width: auto; flex: 1; min-width: 0; } .board .col-status { display: none; } .st.st-inline { display: inline-flex; } + .board th { padding: 8px 5px; } .board .th-full { display: none; } .board .th-short { display: inline; } + .board td { padding: 8px 5px; } .board .lcd { font-size: 16px; padding: 4px 6px; } .board .lcd .u { font-size: 10px; margin-left: 2px; } + .board .name { font-size: 14px; } .tabs label { padding: 10px 9px 8px; } .settings { padding: 14px; } .cfg-row { grid-template-columns: 1fr 1fr; } diff --git a/modbus_reader.py b/modbus_reader.py index dcd61a1..b3a2beb 100644 --- a/modbus_reader.py +++ b/modbus_reader.py @@ -55,9 +55,12 @@ def read_device(instrument, device): """Lees temperatuur en luchtvochtigheid (signed registers × schaal).""" raw_temp = instrument.read_register(device['register_temp'], 0, device['function_code'], signed=True) raw_humidity = instrument.read_register(device['register_humidity'], 0, device['function_code'], signed=True) - # Kalibratie per sensor; RV na correctie binnen 0-100 % (een sensor op 99 % + 3 is geen meetfout) + # Kalibratie per sensor. Alleen een geldige meting (0-100 %) wordt na de correctie begrensd: een sensor op + # 99 % + 3 is geen meetfout, een ruwe -5 % wel (die moet als 'out of range' geweigerd worden, niet 0 % worden) temperature = raw_temp * device['scale_temp'] + device.get('offset_temp', 0) - humidity = min(max(raw_humidity * device['scale_humidity'] + device.get('offset_humidity', 0), 0), 100) + humidity = raw_humidity * device['scale_humidity'] + if HUMIDITY_RANGE[0] <= humidity <= HUMIDITY_RANGE[1]: + humidity = min(max(humidity + device.get('offset_humidity', 0), HUMIDITY_RANGE[0]), HUMIDITY_RANGE[1]) return round(temperature, 3), round(humidity, 3) diff --git a/test_app.py b/test_app.py index 837aa0b..0a85f5d 100644 --- a/test_app.py +++ b/test_app.py @@ -912,6 +912,55 @@ def test_wood_chart_and_board_follow_setting(): assert 'Houtvocht' in json.dumps(board, cls=plotly.utils.PlotlyJSONEncoder, ensure_ascii=False) +def test_wood_column_stays_visible_on_phones(): + """Houtvocht is voor wie het aanzet de hoofdwaarde: geen col-wide (die is verborgen op smalle schermen).""" + import callbacks + from dash import Dash + setup_alerts([]) + assert settings.save({'show_wood': ['on']}) == {} + app = Dash(__name__) + callbacks.register_callbacks(app) + board, _ = run_callback(app, 'device-overview.children', 0, 'nl', 'board', 'dashboard', trigger='view-toggle.value') + head = board.children.children[0].children.children + wood_head = next(cell for cell in head if cell is not None and TRANSLATIONS_NL['wood_emc'] in str(cell.children)) + assert 'col-wide' not in (wood_head.className or '') + + +def test_board_has_short_headers_for_phones(): + """Lange woorden als 'Luchtvochtigheid' passen niet op een telefoon: daar korte koppen.""" + import callbacks + from dash import Dash + setup_alerts([]) + assert settings.save({'show_wood': ['on']}) == {} + app = Dash(__name__) + callbacks.register_callbacks(app) + board, _ = run_callback(app, 'device-overview.children', 0, 'nl', 'board', 'dashboard', trigger='view-toggle.value') + head = board.children.children[0].children.children + shorts = [span.children for cell in head if cell is not None and isinstance(cell.children, list) + for span in cell.children if getattr(span, 'className', '') == 'th-short'] + assert shorts == [TRANSLATIONS_NL['short_temperature'], TRANSLATIONS_NL['short_humidity'], TRANSLATIONS_NL['short_wood']] + + +def test_invalid_humidity_is_rejected_not_clamped_to_zero(): + """Alleen de correctie mag worden begrensd; een kapotte ruwe meting blijft een fout (geen stille 0 %).""" + fresh_settings() + q = queue.Queue() + dev = {**device('Koelcel'), 'id': 1} + poller = modbus_reader.BusPoller({**bus(), 'id': 1}, [dev], q) + poller.poll(FakeInstrument({1: 188, 2: -50}), dev) # -5.0 % RV: onmogelijk + assert q.empty() and 'out of range' in devices.status(1)['error'] + + +def test_rule_hint_explains_levels(): + for lang, texts in TRANSLATIONS.items(): + assert texts['opt_level_warning'].lower() in texts['hint_rules'].lower(), lang + + +def test_comfort_uses_same_dewpoint_as_measurements(): + import callbacks + assert callbacks.humidex_score(26, 30) == 4 # Magnus: humidex 26.0; de oude vuistregel gaf 28.3 (score 5) + + def test_warning_and_alarm_levels(): names = setup_alerts([{'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning', 'enabled': 'true'}, {'scope': 0, 'metric': 'wood_emc', 'max_value': 20, 'level': 'alarm', 'enabled': 'true'}]) diff --git a/translations.py b/translations.py index a7060be..6cc9d67 100644 --- a/translations.py +++ b/translations.py @@ -196,7 +196,7 @@ 'test_ok': 'Testbericht verstuurd', 'test_message': '✅ Testbericht van Climate Monitor ({time})', 'table_rules': 'Grenswaarden', - 'hint_rules': 'Een regel voor één sensor vervangt de regel voor alle sensoren voor dezelfde grootheid. Minimum of maximum mag leeg blijven.', + 'hint_rules': 'Een regel voor één sensor vervangt de regel voor alle sensoren voor dezelfde grootheid en hetzelfde niveau. Minimum of maximum mag leeg blijven. Niveau: Waarschuwing geeft een eigen melding en een gele markering, Alarm een rode; zo kunt u bijvoorbeeld bij 18 % waarschuwen en bij 20 % alarmeren.', 'col_scope': 'Geldt voor', 'col_metric': 'Grootheid', 'col_min_value': 'Minimum', @@ -217,6 +217,9 @@ 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is onder de waarschuwingsgrens van {limit} {unit} ({time})', 'wood_emc': 'Houtvocht', 'wood_chart': 'Houtvochtevenwicht', + 'short_temperature': 'Temp.', + 'short_humidity': 'RV', + 'short_wood': 'Hout', 'lbl_show_wood': 'Houtvocht tonen', 'hint_show_wood': 'Het vochtgehalte waar hout naartoe gaat bij de gemeten temperatuur en luchtvochtigheid (USDA Wood Handbook), met diagram.', 'section_wood': 'Houtvocht', @@ -498,7 +501,7 @@ 'test_ok': 'Test message sent', 'test_message': '✅ Test message from Climate Monitor ({time})', 'table_rules': 'Limits', - 'hint_rules': 'A rule for one sensor replaces the rule for all sensors for the same quantity. Minimum or maximum may stay empty.', + 'hint_rules': 'A rule for one sensor replaces the rule for all sensors for the same quantity and level. Minimum or maximum may stay empty. Level: Warning sends its own notification and shows yellow, Alarm shows red; for example warn at 18 % and alarm at 20 %.', 'col_scope': 'Applies to', 'col_metric': 'Quantity', 'col_min_value': 'Minimum', @@ -519,6 +522,9 @@ 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is below the warning limit of {limit} {unit} ({time})', 'wood_emc': 'Wood moisture', 'wood_chart': 'Wood equilibrium moisture', + 'short_temperature': 'Temp.', + 'short_humidity': 'RH', + 'short_wood': 'Wood', 'lbl_show_wood': 'Show wood moisture', 'hint_show_wood': 'The moisture content wood moves towards at the measured temperature and humidity (USDA Wood Handbook), with a diagram.', 'section_wood': 'Wood moisture', @@ -800,7 +806,7 @@ 'test_ok': 'Testnachricht gesendet', 'test_message': '✅ Testnachricht von Climate Monitor ({time})', 'table_rules': 'Grenzwerte', - 'hint_rules': 'Eine Regel für einen Sensor ersetzt die Regel für alle Sensoren für dieselbe Größe. Minimum oder Maximum darf leer bleiben.', + 'hint_rules': 'Eine Regel für einen Sensor ersetzt die Regel für alle Sensoren für dieselbe Größe und Stufe. Minimum oder Maximum darf leer bleiben. Stufe: Warnung sendet eine eigene Meldung und erscheint gelb, Alarm rot; z. B. bei 18 % warnen und bei 20 % alarmieren.', 'col_scope': 'Gilt für', 'col_metric': 'Größe', 'col_min_value': 'Minimum', @@ -821,6 +827,9 @@ 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} liegt unter der Warngrenze von {limit} {unit} ({time})', 'wood_emc': 'Holzfeuchte', 'wood_chart': 'Holzfeuchte-Gleichgewicht', + 'short_temperature': 'Temp.', + 'short_humidity': 'rF', + 'short_wood': 'Holz', 'lbl_show_wood': 'Holzfeuchte anzeigen', 'hint_show_wood': 'Der Feuchtegehalt, den Holz bei der gemessenen Temperatur und Luftfeuchte annimmt (USDA Wood Handbook), mit Diagramm.', 'section_wood': 'Holzfeuchte', @@ -1102,7 +1111,7 @@ 'test_ok': 'Message de test envoyé', 'test_message': '✅ Message de test de Climate Monitor ({time})', 'table_rules': 'Seuils', - 'hint_rules': 'Une règle pour un capteur remplace la règle pour tous les capteurs pour la même grandeur. Le minimum ou le maximum peut rester vide.', + 'hint_rules': 'Une règle pour un capteur remplace la règle pour tous les capteurs pour la même grandeur et le même niveau. Le minimum ou le maximum peut rester vide. Niveau : Avertissement envoie sa propre notification et s’affiche en jaune, Alarme en rouge ; par exemple avertir à 18 % et alarmer à 20 %.', 'col_scope': "S'applique à", 'col_metric': 'Grandeur', 'col_min_value': 'Minimum', @@ -1123,6 +1132,9 @@ 'alert_warn_low': "🟡 {device} : {metric} {value} {unit} est sous le seuil d'avertissement de {limit} {unit} ({time})", 'wood_emc': 'Humidité du bois', 'wood_chart': "Humidité d'équilibre du bois", + 'short_temperature': 'Temp.', + 'short_humidity': 'HR', + 'short_wood': 'Bois', 'lbl_show_wood': "Afficher l'humidité du bois", 'hint_show_wood': "La teneur en eau vers laquelle tend le bois à la température et l'humidité mesurées (USDA Wood Handbook), avec diagramme.", 'section_wood': 'Humidité du bois', @@ -1404,7 +1416,7 @@ 'test_ok': 'Mensaje de prueba enviado', 'test_message': '✅ Mensaje de prueba de Climate Monitor ({time})', 'table_rules': 'Límites', - 'hint_rules': 'Una regla para un sensor sustituye la regla para todos los sensores para la misma magnitud. El mínimo o el máximo pueden quedar vacíos.', + 'hint_rules': 'Una regla para un sensor sustituye la regla para todos los sensores para la misma magnitud y nivel. El mínimo o el máximo pueden quedar vacíos. Nivel: Aviso envía su propia notificación y se muestra en amarillo, Alarma en rojo; por ejemplo avisar al 18 % y alarmar al 20 %.', 'col_scope': 'Se aplica a', 'col_metric': 'Magnitud', 'col_min_value': 'Mínimo', @@ -1425,6 +1437,9 @@ 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} está por debajo del límite de aviso de {limit} {unit} ({time})', 'wood_emc': 'Humedad de la madera', 'wood_chart': 'Humedad de equilibrio de la madera', + 'short_temperature': 'Temp.', + 'short_humidity': 'HR', + 'short_wood': 'Madera', 'lbl_show_wood': 'Mostrar humedad de la madera', 'hint_show_wood': 'El contenido de humedad hacia el que tiende la madera con la temperatura y humedad medidas (USDA Wood Handbook), con diagrama.', 'section_wood': 'Humedad de la madera', From 599853d23cb670495c519f4503bc36fabfd6f4ed Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:35:27 +0100 Subject: [PATCH 04/19] feat: Configurable register data type and byte order per sensor - Data type per sensor: int16 (default, XY-MD02), uint16, int32, uint32, float32; 32-bit types read two registers - Byte order per sensor: ABCD (big endian), DCBA (little endian), BADC (byte swap), CDAB (word swap) - Existing sensors are migrated to int16 / ABCD, so current setups keep reading the same values --- README.md | 2 +- devices.py | 12 ++++++++++-- modbus_reader.py | 28 +++++++++++++++++++++++++--- settings_ui.py | 16 +++++++++++----- test_app.py | 32 ++++++++++++++++++++++++++++++++ translations.py | 20 +++++++++++++++----- 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a85d26d..a0733f0 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ Open the **Settings** tab in the web app. The first visit asks you to create an | Section | Settings | |---------|----------| | 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 a temperature and humidity correction for calibration | +| 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 | | Notifications: limits | Limits per quantity (all sensors or one sensor) at warning or alarm level, alarm delay, hysteresis, recovery and offline alerts | diff --git a/devices.py b/devices.py index 2d24206..75c26c7 100644 --- a/devices.py +++ b/devices.py @@ -42,6 +42,10 @@ # Kalibratie: opgeteld bij de gemeten waarde (leeg = 0) 'offset_temp': {'type': float, 'default': 0.0, 'min': -20, 'max': 20, 'blank_default': True}, 'offset_humidity': {'type': float, 'default': 0.0, 'min': -30, 'max': 30, 'blank_default': True}, + # Registerformaat (decodering in modbus_reader.decode); XY-MD02 = int16, ABCD + 'data_type': {'type': str, 'default': 'int16', 'choices': ['int16', 'uint16', 'int32', 'uint32', 'float32'], + 'blank_default': True}, + 'byte_order': {'type': str, 'default': 'ABCD', 'choices': ['ABCD', 'DCBA', 'BADC', 'CDAB'], 'blank_default': True}, 'enabled': {'type': bool, 'default': True}, } @@ -67,11 +71,15 @@ def _connect(): slave_id INTEGER NOT NULL, function_code INTEGER NOT NULL, register_temp INTEGER NOT NULL, register_humidity INTEGER NOT NULL, scale_temp REAL NOT NULL, scale_humidity REAL NOT NULL, offset_temp REAL NOT NULL DEFAULT 0, offset_humidity REAL NOT NULL DEFAULT 0, + data_type TEXT NOT NULL DEFAULT 'int16', byte_order TEXT NOT NULL DEFAULT 'ABCD', enabled INTEGER NOT NULL, UNIQUE (bus_id, slave_id))''') columns = [row[1] for row in conn.execute('PRAGMA table_info(devices)')] - for column in ('offset_temp', 'offset_humidity'): # tabel van vóór de kalibratie + # Tabellen van vóór de kalibratie en het registerformaat: kolommen met de oude vaste waarden erbij + for column, definition in (('offset_temp', 'REAL NOT NULL DEFAULT 0'), ('offset_humidity', 'REAL NOT NULL DEFAULT 0'), + ('data_type', "TEXT NOT NULL DEFAULT 'int16'"), + ('byte_order', "TEXT NOT NULL DEFAULT 'ABCD'")): if column not in columns: - conn.execute(f'ALTER TABLE devices ADD COLUMN {column} REAL NOT NULL DEFAULT 0') + conn.execute(f'ALTER TABLE devices ADD COLUMN {column} {definition}') return conn diff --git a/modbus_reader.py b/modbus_reader.py index b3a2beb..035ec9a 100644 --- a/modbus_reader.py +++ b/modbus_reader.py @@ -9,6 +9,7 @@ """ import math import queue +import struct import sqlite3 import threading import time @@ -51,10 +52,31 @@ def derived_values(temperature, humidity): return dewpoint, absolute_humidity +# Datatype -> (struct formaat, aantal registers). Byte-volgorde: letters = bytes van hoog naar laag zoals +# ze op de bus staan (ABCD = big endian, DCBA = little endian, BADC = bytes per woord gewisseld, CDAB = woorden gewisseld) +DATA_TYPES = {'int16': ('h', 1), 'uint16': ('H', 1), 'int32': ('i', 2), 'uint32': ('I', 2), 'float32': ('f', 2)} +BYTE_ORDERS = {'ABCD': (0, 1, 2, 3), 'DCBA': (3, 2, 1, 0), 'BADC': (1, 0, 3, 2), 'CDAB': (2, 3, 0, 1)} + + +def decode(registers, data_type, byte_order): + """Registers (16-bit, zoals ontvangen) naar een getal volgens datatype en byte-volgorde.""" + raw = b''.join((r & 0xFFFF).to_bytes(2, 'big') for r in registers) + order = BYTE_ORDERS[byte_order] + if len(raw) == 2: # één register: alleen de bytevolgorde binnen het woord telt + order = (1, 0) if byte_order in ('DCBA', 'BADC') else (0, 1) + return struct.unpack('>' + DATA_TYPES[data_type][0], bytes(raw[i] for i in order))[0] + + +def read_value(instrument, device, register): + data_type = device.get('data_type', 'int16') + registers = instrument.read_registers(register, DATA_TYPES[data_type][1], device['function_code']) + return decode(registers, data_type, device.get('byte_order', 'ABCD')) + + def read_device(instrument, device): - """Lees temperatuur en luchtvochtigheid (signed registers × schaal).""" - raw_temp = instrument.read_register(device['register_temp'], 0, device['function_code'], signed=True) - raw_humidity = instrument.read_register(device['register_humidity'], 0, device['function_code'], signed=True) + """Lees temperatuur en luchtvochtigheid (registers volgens datatype en byte-volgorde × schaal).""" + raw_temp = read_value(instrument, device, device['register_temp']) + raw_humidity = read_value(instrument, device, device['register_humidity']) # Kalibratie per sensor. Alleen een geldige meting (0-100 %) wordt na de correctie begrensd: een sensor op # 99 % + 3 is geen meetfout, een ruwe -5 % wel (die moet als 'out of range' geweigerd worden, niet 0 % worden) temperature = raw_temp * device['scale_temp'] + device.get('offset_temp', 0) diff --git a/settings_ui.py b/settings_ui.py index a0f215d..c4cadce 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -18,11 +18,13 @@ # 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) BUS_COLUMNS = ['name', 'port', 'baudrate', 'bytesize', 'parity', 'stopbits', 'timeout', 'enabled'] -DEVICE_COLUMNS = ['name', 'bus', 'slave_id', 'function_code', 'register_temp', 'register_humidity', - 'scale_temp', 'scale_humidity', 'offset_temp', 'offset_humidity', 'enabled'] +DEVICE_COLUMNS = ['name', 'bus', 'slave_id', 'function_code', 'register_temp', 'register_humidity', 'data_type', + 'byte_order', 'scale_temp', 'scale_humidity', 'offset_temp', 'offset_humidity', 'enabled'] +BYTE_ORDER_LABELS = {'ABCD': 'ABCD · big endian', 'DCBA': 'DCBA · little endian', 'BADC': 'BADC · byte swap', + 'CDAB': 'CDAB · word swap'} RULE_COLUMNS = ['scope', 'metric', 'level', 'min_value', 'max_value', 'enabled'] TABLE_COLUMNS = {'buses': BUS_COLUMNS, 'devices': DEVICE_COLUMNS, 'rules': RULE_COLUMNS} -WIDE_FIELDS = {'name', 'port', 'bus', 'scope', 'metric'} # twee kolommen breed +WIDE_FIELDS = {'name', 'port', 'bus', 'scope', 'metric', 'byte_order'} # twee kolommen breed _TIMEZONES = sorted(available_timezones()) @@ -104,6 +106,10 @@ def _choices(table, field, t, bus_names): return [{'label': str(v), 'value': v} for v in plain[field]] if field == 'bus': return [{'label': n, 'value': n} for n in bus_names] + if field == 'data_type': + return [{'label': v, 'value': v} for v in devices.DEVICE_FIELDS['data_type']['choices']] + if field == 'byte_order': + return [{'label': BYTE_ORDER_LABELS[v], 'value': v} for v in devices.DEVICE_FIELDS['byte_order']['choices']] if field == 'function_code': return [{'label': t[f'opt_function_code_{v}'], 'value': v} for v in (4, 3)] if field == 'scope': @@ -173,8 +179,8 @@ def _new_row(table, rows, bus_names): used = {r.get('slave_id') for r in rows if r.get('bus') == bus_name} return {'id': None, 'name': f'Sensor {len(rows) + 1}', 'bus': bus_name, 'slave_id': next(i for i in range(1, 248) if i not in used), 'function_code': 4, 'register_temp': 1, - 'register_humidity': 2, 'scale_temp': 0.1, 'scale_humidity': 0.1, 'offset_temp': 0, - 'offset_humidity': 0, 'enabled': 'true'} + 'register_humidity': 2, 'data_type': 'int16', 'byte_order': 'ABCD', 'scale_temp': 0.1, + 'scale_humidity': 0.1, 'offset_temp': 0, 'offset_humidity': 0, 'enabled': 'true'} return {'id': None, 'scope': 0, 'metric': 'temperature', 'level': 'alarm', 'min_value': None, 'max_value': None, 'enabled': 'true'} diff --git a/test_app.py b/test_app.py index 0a85f5d..0a00004 100644 --- a/test_app.py +++ b/test_app.py @@ -389,6 +389,26 @@ def read_register(self, register, decimals, functioncode, signed=False): raise minimalmodbus.NoResponseError('No communication with the instrument (no answer)') return self.registers[register] + def read_registers(self, register, count, functioncode): + if self.fail: + raise minimalmodbus.NoResponseError('No communication with the instrument (no answer)') + return [self.registers[register + i] & 0xFFFF for i in range(count)] + + +def test_poller_decodes_data_type_and_byte_order(): + """32-bit float met verwisselde woorden (CDAB) en 16-bit unsigned met verwisselde bytes (DCBA).""" + fresh_settings() + q = queue.Queue() + dev = {**device('Float', register_temp=1, register_humidity=3, scale_temp=1, scale_humidity=1, + data_type='float32', byte_order='CDAB'), 'id': 1} + poller = modbus_reader.BusPoller({**bus(), 'id': 1}, [dev], q) + poller.poll(FakeInstrument({1: 0x0000, 2: 0x41AC, 3: 0x0000, 4: 0x425D}), dev) # 21.5 °C, 55.25 % + assert q.get_nowait()[1:3] == (21.5, 55.25) + dev = {**device('Swap', data_type='uint16', byte_order='DCBA'), 'id': 1} + poller.poll(FakeInstrument({1: 0x2C01, 2: 0x2602}), dev) # 0x012C = 30.0, 0x0226 = 55.0 + assert q.get_nowait()[1:3] == (30.0, 55.0) + assert devices.validate_config([bus()], [device(data_type='int64')])[2] == {'devices': {0: {'data_type': 'err_choice'}}} + def test_poller_reads_signed_scaled_values_and_isolates_failures(): fresh_settings() @@ -519,6 +539,18 @@ def test_config_forms_round_trip(): assert alerts.validate_rules(rows['rules'])[1] == {} and rows['rules'][0]['level'] == 'warning' +def test_sensor_form_has_data_type_and_byte_order(): + import settings_ui + from dash import html + setup_alerts([]) + form = html.Div(settings_ui.row_form('devices', settings_ui.table_rows('devices'), TRANSLATIONS['nl'])) + fields = {c.id['field']: c for c in components(form) + if isinstance(getattr(c, 'id', None), dict) and c.id.get('type') == 'cfg' and c.id['row'] == 0} + assert fields['data_type'].value == 'int16' and fields['byte_order'].value == 'ABCD' + assert [o['value'] for o in fields['data_type'].options] == devices.DEVICE_FIELDS['data_type']['choices'] + assert [o['value'] for o in fields['byte_order'].options] == devices.DEVICE_FIELDS['byte_order']['choices'] + + def test_tab_and_url_stay_in_sync(): """Terug naar het dashboard haalt ?tab=settings uit de adresbalk; F5 op settings blijft op settings.""" import dash diff --git a/translations.py b/translations.py index 6cc9d67..c5fe3f8 100644 --- a/translations.py +++ b/translations.py @@ -147,7 +147,9 @@ 'hint_no_ports': 'Geen seriële poorten gevonden op dit apparaat.', 'btn_add_bus': '+ Bus toevoegen', 'btn_add_device': '+ Sensor toevoegen', - 'hint_registers': 'XY-MD02: functiecode 4, register 1 = temperatuur, 2 = luchtvochtigheid, schaal 0.1. Elke sensor op een bus heeft een eigen slave ID.', + 'hint_registers': 'XY-MD02: functiecode 4, register 1 = temperatuur, 2 = luchtvochtigheid, datatype int16, byte-volgorde ABCD, schaal 0.1. Elke sensor op een bus heeft een eigen slave ID. 32-bit typen lezen twee registers vanaf het opgegeven register.', + 'col_data_type': 'Datatype', + 'col_byte_order': 'Byte-volgorde', 'lbl_device_status': 'Live status', 'status_disabled': 'uitgeschakeld', 'status_ok': '{temp} °C, {hum} % ({ago} s geleden)', @@ -452,7 +454,9 @@ 'hint_no_ports': 'No serial ports found on this device.', 'btn_add_bus': '+ Add bus', 'btn_add_device': '+ Add sensor', - 'hint_registers': 'XY-MD02: function code 4, register 1 = temperature, 2 = humidity, scale 0.1. Every sensor on a bus has its own slave ID.', + 'hint_registers': 'XY-MD02: function code 4, register 1 = temperature, 2 = humidity, data type int16, byte order ABCD, scale 0.1. Every sensor on a bus has its own slave ID. 32-bit types read two registers starting at the given register.', + 'col_data_type': 'Data type', + 'col_byte_order': 'Byte order', 'lbl_device_status': 'Live status', 'status_disabled': 'disabled', 'status_ok': '{temp} °C, {hum} % ({ago} s ago)', @@ -757,7 +761,9 @@ 'hint_no_ports': 'Keine seriellen Ports auf diesem Gerät gefunden.', 'btn_add_bus': '+ Bus hinzufügen', 'btn_add_device': '+ Sensor hinzufügen', - 'hint_registers': 'XY-MD02: Funktionscode 4, Register 1 = Temperatur, 2 = Luftfeuchtigkeit, Skala 0.1. Jeder Sensor an einem Bus hat eine eigene Slave-ID.', + 'hint_registers': 'XY-MD02: Funktionscode 4, Register 1 = Temperatur, 2 = Luftfeuchtigkeit, Datentyp int16, Byte-Reihenfolge ABCD, Skala 0.1. Jeder Sensor an einem Bus hat eine eigene Slave-ID. 32-Bit-Typen lesen zwei Register ab dem angegebenen Register.', + 'col_data_type': 'Datentyp', + 'col_byte_order': 'Byte-Reihenfolge', 'lbl_device_status': 'Live-Status', 'status_disabled': 'deaktiviert', 'status_ok': '{temp} °C, {hum} % (vor {ago} s)', @@ -1062,7 +1068,9 @@ 'hint_no_ports': 'Aucun port série trouvé sur cet appareil.', 'btn_add_bus': '+ Ajouter un bus', 'btn_add_device': '+ Ajouter un capteur', - 'hint_registers': "XY-MD02 : code fonction 4, registre 1 = température, 2 = humidité, échelle 0.1. Chaque capteur d'un bus a son propre ID esclave.", + 'hint_registers': "XY-MD02 : code fonction 4, registre 1 = température, 2 = humidité, type de données int16, ordre des octets ABCD, échelle 0.1. Chaque capteur d'un bus a son propre ID esclave. Les types 32 bits lisent deux registres à partir du registre indiqué.", + 'col_data_type': 'Type de données', + 'col_byte_order': 'Ordre des octets', 'lbl_device_status': 'État en direct', 'status_disabled': 'désactivé', 'status_ok': '{temp} °C, {hum} % (il y a {ago} s)', @@ -1367,7 +1375,9 @@ 'hint_no_ports': 'No se encontraron puertos serie en este dispositivo.', 'btn_add_bus': '+ Añadir bus', 'btn_add_device': '+ Añadir sensor', - 'hint_registers': 'XY-MD02: código de función 4, registro 1 = temperatura, 2 = humedad, escala 0.1. Cada sensor de un bus tiene su propio ID de esclavo.', + 'hint_registers': 'XY-MD02: código de función 4, registro 1 = temperatura, 2 = humedad, tipo de dato int16, orden de bytes ABCD, escala 0.1. Cada sensor de un bus tiene su propio ID de esclavo. Los tipos de 32 bits leen dos registros a partir del registro indicado.', + 'col_data_type': 'Tipo de dato', + 'col_byte_order': 'Orden de bytes', 'lbl_device_status': 'Estado en vivo', 'status_disabled': 'desactivado', 'status_ok': '{temp} °C, {hum} % (hace {ago} s)', From 9097223f1cac2f2212c546c9e3b41ff4a5017d74 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:46:31 +0100 Subject: [PATCH 05/19] feat: Zones in the Mollier and wood diagram with leave/enter alarms; fix meter card text overflow - Zones are areas of temperature x humidity (rectangle from min/max or a free shape) with a colour, for all sensors or one sensor - Per zone a notification when a sensor leaves it (desired zone) or enters it (danger zone), at warning or alarm level, using the alarm delay - Recovery only once the sensor is the temperature/humidity hysteresis away from the zone edge, so a sensor on the edge does not flap - Zones are drawn in both diagrams; zone alarms show as 'Zone' in the sensor status - Meter cards: long status text wraps, values below the display no longer break inside a unit --- alerts.py | 37 ++++++++++ callbacks.py | 11 +-- layout.py | 8 ++- psychrometric.py | 19 +++++ test_app.py | 108 ++++++++++++++++++++++++++++ translations.py | 165 ++++++++++++++++++++++++++++++++++++++++++ wood.py | 16 +++-- zones.py | 181 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 534 insertions(+), 11 deletions(-) create mode 100644 zones.py diff --git a/alerts.py b/alerts.py index 0a8123c..eb6b1dc 100644 --- a/alerts.py +++ b/alerts.py @@ -21,6 +21,7 @@ import requests import devices +import zones import settings from settings import DB_FILE from translations import TRANSLATIONS @@ -183,6 +184,10 @@ def evaluate(device, values, now=None): for (metric, level), rule in effective_rules(device['id']).items(): if metric in values: _evaluate_metric(device, metric, level, values[metric], rule, delay, now) + if 'temperature' in values and 'humidity' in values: + for zone in zones.effective(device['id']): + if zone['mode'] != 'none': + _evaluate_zone(device, zone, values['temperature'], values['humidity'], delay, now) except Exception as e: print(f"Alarm evaluatie fout ({device.get('name')}): {e}") @@ -217,6 +222,37 @@ def _evaluate_metric(device, metric, level, value, rule, delay, now): _notify(prefix + breach[0], device, metric, value, breach[1]) +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) + 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'} + _notify_zone('alert_zone_entered' if inside else 'alert_zone_left', device, zone, temperature, humidity) + + +def _notify_zone(template, device, zone, temperature, humidity): + 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): t = _t() name_key, unit, _ = METRICS[metric] @@ -239,6 +275,7 @@ def device_status(device_id): """ 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(): diff --git a/callbacks.py b/callbacks.py index ab7cdb3..f73a1cf 100644 --- a/callbacks.py +++ b/callbacks.py @@ -10,10 +10,11 @@ import layout import settings import wood +import zones from translations import TRANSLATIONS from database import get_all_measurement_tables, build_union_query, DB_FILE from modbus_reader import derived_values -from psychrometric import add_device_points, create_psychrometric_chart, create_psychrometric_chart_historical +from psychrometric import add_device_points, add_zones, create_psychrometric_chart, create_psychrometric_chart_historical # Categorisch palet per sensor (vaste volgorde, gevalideerd op kleurenblindheid en contrast). # Sensor 9 en verder krijgen een neutrale kleur; status-kleuren worden nooit voor sensoren gebruikt. @@ -32,7 +33,8 @@ METRIC_UNITS = {'temperature': '°C', 'humidity': '%', 'dewpoint': '°C', 'absolute_humidity': 'g/m³', 'comfort': '', 'wood_emc': '%'} METRIC_KEYS = {'temperature': 'temperature', 'humidity': 'humidity', 'dewpoint': 'dewpoint', - 'absolute_humidity': 'abs_humidity', 'comfort': 'comfort', 'wood_emc': 'wood_emc'} + 'absolute_humidity': 'abs_humidity', 'comfort': 'comfort', 'wood_emc': 'wood_emc', + 'zone': 'zone'} # zone: alleen als statuslabel (alle zone-alarmen samen) def theme_mode(): @@ -334,7 +336,7 @@ def update_psychrometric_chart(n, lang, device_sel): coldest = min((p[1] for p in points if p[1] is not None), default=0) if coldest < 0: fig.update_xaxes(range=[coldest - 3, 40]) # punten onder 0 °C zichtbaar houden - return fig + return add_zones(fig, zones.visible(device_sel)) # na theme_mollier: dat kleurt vlakken als comfortzone @app.callback( Output('wood-chart', 'figure'), @@ -353,7 +355,8 @@ def update_wood_chart(n, lang, device_sel, tab): latest = latest_per_device() points = [(d['name'], latest[d['id']][1], latest[d['id']][2], colors.get(d['id'])) for d in devices.list_devices() if d['id'] in latest and (not device_sel or d['id'] == device_sel)] - fig = style_figure(wood.figure(points, wood_zones(device_sel), t, THEMES[mode]), mode, height=520) + fig = wood.figure(points, wood_zones(device_sel), t, THEMES[mode], zones.visible(device_sel)) + fig = style_figure(fig, mode, height=520) fig.update_layout(hovermode='closest', margin=dict(l=60, r=50, t=30, b=50)) return fig diff --git a/layout.py b/layout.py index 88f3aed..f6dac19 100644 --- a/layout.py +++ b/layout.py @@ -125,7 +125,10 @@ .meters { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; } .meter { background: var(--panel); border: 1px solid var(--line); border-top: 3px solid var(--st); padding: 14px 16px; } .meter-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; } - .meter .name { font-weight: 600; display: flex; align-items: center; gap: 8px; } + .meter-head > div:first-child { min-width: 0; flex: 1 1 auto; } + .meter-head .st { white-space: normal; flex: 0 1 auto; max-width: 58%; align-items: flex-start; line-height: 1.35; } + .meter-head .st::before { margin-top: 4px; } + .meter .name { font-weight: 600; display: flex; align-items: center; gap: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .meter .where { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 2px; } .meter-display { background: var(--cell); margin-top: 12px; padding: 12px 14px; display: grid; gap: 6px; } .meter-display .v1, .meter-display .v2 { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--digit); line-height: 1.1; } @@ -133,7 +136,8 @@ .meter-display .u { font-size: 13px; color: var(--muted); margin-left: 6px; } .meter-display .is-alarm { color: var(--alarm); } .meter-display .is-warn { color: var(--warn); } .meter-stats .is-alarm { color: var(--alarm); font-weight: 600; } .meter-stats .is-warn { color: var(--warn); font-weight: 600; } - .meter-stats { display: flex; justify-content: space-between; gap: 8px; margin-top: 10px; font-family: var(--mono); font-size: 12px; color: var(--muted); } + .meter-stats { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 14px; margin-top: 10px; font-family: var(--mono); font-size: 12px; color: var(--muted); } + .meter-stats > span { white-space: nowrap; } .meter .spark { margin-top: 8px; width: 100%; } .empty { padding: 28px; color: var(--muted); } diff --git a/psychrometric.py b/psychrometric.py index 9c395ea..4bb76d3 100644 --- a/psychrometric.py +++ b/psychrometric.py @@ -314,3 +314,22 @@ def add_device_points(fig, points): hovertemplate=f'{name}
T: {temp:.1f}°C
RH: {rh:.0f}%
ω: {w_g_kg:.1f} g/kg' )) return fig + + +def add_zones(fig, zone_list): + """Zones (veelhoek in temperatuur × RV) in het Mollier diagram; randen volgen de RV-lijnen.""" + import zones + for zone in zone_list: + temps, ratios = [], [] + points = zone['points'] + for (t1, h1), (t2, h2) in zip(points, points[1:] + points[:1]): + for f in np.linspace(0, 1, 16, endpoint=False): + temp, rh = t1 + (t2 - t1) * f, h1 + (h2 - h1) * f + pw = 611.2 * np.exp(17.62 * temp / (243.12 + temp)) * rh / 100 + temps.append(temp) + ratios.append(0.622 * pw / (101325 - pw) * 1000) + color = zones.COLORS[zone['color']] + fig.add_trace(go.Scatter(x=temps + temps[:1], y=ratios + ratios[:1], mode='lines', fill='toself', + name=zone['name'], fillcolor=zones.fill_color(zone['color']), + line=dict(color=color, width=1.5), hoverinfo='skip')) + return fig diff --git a/test_app.py b/test_app.py index 0a00004..7fae6ca 100644 --- a/test_app.py +++ b/test_app.py @@ -47,6 +47,8 @@ def fresh_settings(): alerts._state.clear() alerts._offline.clear() alerts._first_seen.clear() + import zones + zones._cache = None while not alerts._outbox.empty(): alerts._outbox.get_nowait() @@ -551,6 +553,112 @@ def test_sensor_form_has_data_type_and_byte_order(): assert [o['value'] for o in fields['byte_order'].options] == devices.DEVICE_FIELDS['byte_order']['choices'] +def test_zones_rectangle_and_free_shape(): + """Zone = veelhoek in temperatuur × RV; rechthoek uit min/max of vrij getekend.""" + import zones + names = setup_alerts([]) + kas = names['Kas']['id'] + rect = {'scope': 0, 'name': 'Comfort', 'color': 'green', 'mode': 'keep_inside', 'level': 'warning', + 'temp_min': 18, 'temp_max': 24, 'rh_min': 40, 'rh_max': 60, 'enabled': 'true'} + free = {'scope': kas, 'name': 'Schimmel', 'color': 'red', 'mode': 'keep_outside', 'level': 'alarm', + 'shape': 'polygon', 'points': '[[10, 80], [30, 80], [30, 100], [10, 100]]', 'enabled': 'true'} + assert zones.validate([{**rect, 'temp_min': 30}])[1] == {0: {'temp_max': 'err_min_max'}} + assert zones.validate([{**rect, 'color': 'pink'}])[1] == {0: {'color': 'err_choice'}} + assert zones.save([rect, free]) == {} + comfort, mold = zones.load() + assert comfort['points'] == [[18.0, 40.0], [24.0, 40.0], [24.0, 60.0], [18.0, 60.0]] + assert zones.contains(comfort, 20, 50) and not zones.contains(comfort, 20, 70) + assert zones.contains(mold, 20, 90) and not zones.contains(mold, 20, 60) + assert [z['name'] for z in zones.effective(kas)] == ['Comfort', 'Schimmel'] + assert [z['name'] for z in zones.effective(names['Koelcel']['id'])] == ['Comfort'] + + +def test_zone_alarms_on_leaving_and_entering(): + import zones + names = setup_alerts([]) + assert settings.save({'alert_delay_seconds': 0}) == {} + assert zones.save([ + {'scope': 0, 'name': 'Comfort', 'color': 'green', 'mode': 'keep_inside', 'level': 'warning', + 'temp_min': 18, 'temp_max': 24, 'rh_min': 40, 'rh_max': 60, 'enabled': 'true'}, + {'scope': 0, 'name': 'Schimmel', 'color': 'red', 'mode': 'keep_outside', 'level': 'alarm', + 'temp_min': 10, 'temp_max': 30, 'rh_min': 80, 'rh_max': 100, 'enabled': 'true'}]) == {} + cel = names['Koelcel'] + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 50.0}, now=1) + assert outbox_texts() == [] + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 85.0}, now=2) + texts = outbox_texts() + assert len(texts) == 2 and any('buiten zone Comfort' in x for x in texts) and any('in zone Schimmel' in x for x in texts) + assert alerts.device_status(cel['id']) == ('alarm', {'zone': 'alarm'}) + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 50.0}, now=3) + assert len(outbox_texts()) == 2 and alerts.device_status(cel['id'])[0] == 'ok' + + +def test_zones_drawn_in_mollier_and_wood_diagram(): + import callbacks + import zones + from dash import Dash + setup_alerts([]) + assert settings.save({'show_wood': ['on']}) == {} + assert zones.save([{'scope': 0, 'name': 'Opslag', 'color': 'blue', 'mode': 'none', 'level': 'alarm', + 'temp_min': 10, 'temp_max': 20, 'rh_min': 50, 'rh_max': 70, 'enabled': 'true'}]) == {} + app = Dash(__name__) + callbacks.register_callbacks(app) + for output in ('psychrometric-chart.figure', 'wood-chart.figure'): + fig = run_callback(app, output, 0, 'nl', 0, *(['dashboard'] if output == 'wood-chart.figure' else []), + trigger='selected-language.data') + zone = next(trace for trace in fig.data if trace.name == 'Opslag') + assert zone.fill == 'toself' and zone.line.color == zones.COLORS['blue'], output + + +def test_zone_alarm_has_margin_at_the_edge(): + """Herstel pas als de sensor de hysterese (0.5 °C / 2 % RV) van de rand af is: geen klapperen op de grens.""" + import zones + names = setup_alerts([]) + assert settings.save({'alert_delay_seconds': 0, 'alert_hysteresis_temp': 0.5, 'alert_hysteresis_humidity': 2}) == {} + assert zones.save([ + {'scope': 0, 'name': 'Comfort', 'color': 'green', 'mode': 'keep_inside', 'level': 'alarm', + 'temp_min': 18, 'temp_max': 24, 'rh_min': 40, 'rh_max': 60, 'enabled': 'true'}, + {'scope': 0, 'name': 'Schimmel', 'color': 'red', 'mode': 'keep_outside', 'level': 'alarm', + 'temp_min': 0, 'temp_max': 40, 'rh_min': 80, 'rh_max': 100, 'enabled': 'true'}]) == {} + cel = names['Koelcel'] + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 61.0}, now=1) # Comfort verlaten + assert len(outbox_texts()) == 1 + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 59.5}, now=2) # net binnen: nog geen herstel + assert outbox_texts() == [] + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 57.0}, now=3) # 3 % binnen de rand: hersteld + assert len(outbox_texts()) == 1 + kas = names['Kas'] + alerts.evaluate(kas, {'temperature': 20.0, 'humidity': 81.0}, now=5) # Schimmel binnen (en Comfort uit) + outbox_texts() + alerts.evaluate(kas, {'temperature': 20.0, 'humidity': 79.0}, now=6) # net buiten Schimmel: nog alarm + assert not any('Schimmel' in x for x in outbox_texts()) + alerts.evaluate(kas, {'temperature': 20.0, 'humidity': 77.0}, now=7) + assert any('Schimmel' in x for x in outbox_texts()) + + +def test_board_shows_zone_alarm(): + import json + import callbacks + import plotly + import zones + from dash import Dash + names = setup_alerts([]) + database.init_database() + conn = sqlite3.connect(settings.DB_FILE) + conn.execute(f'INSERT INTO {database.get_table_name()} (timestamp, temperature, humidity, device_id) VALUES (?, 20, 85, ?)', + (int(time.time()), names['Koelcel']['id'])) + conn.commit() + conn.close() + assert settings.save({'alert_delay_seconds': 0}) == {} + assert zones.save([{'scope': 0, 'name': 'Schimmel', 'color': 'red', 'mode': 'keep_outside', 'level': 'alarm', + 'temp_min': 10, 'temp_max': 30, 'rh_min': 80, 'rh_max': 100, 'enabled': 'true'}]) == {} + alerts.evaluate(names['Koelcel'], {'temperature': 20.0, 'humidity': 85.0}) + app = Dash(__name__) + callbacks.register_callbacks(app) + board, _ = run_callback(app, 'device-overview.children', 0, 'nl', 'board', 'dashboard', trigger='view-toggle.value') + assert 'Alarm · Zone' in json.dumps(board, cls=plotly.utils.PlotlyJSONEncoder, ensure_ascii=False) + + def test_tab_and_url_stay_in_sync(): """Terug naar het dashboard haalt ?tab=settings uit de adresbalk; F5 op settings blijft op settings.""" import dash diff --git a/translations.py b/translations.py index c5fe3f8..dc58338 100644 --- a/translations.py +++ b/translations.py @@ -217,6 +217,39 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} is onder het minimum van {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is boven de waarschuwingsgrens van {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is onder de waarschuwingsgrens van {limit} {unit} ({time})', + 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % is buiten zone {zone} ({time})', + 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % is in zone {zone} ({time})', + 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % is weer goed voor zone {zone} ({time})', + 'zone': 'Zone', + 'err_zone_points': 'Een getekende zone heeft minstens 3 punten nodig', + 'table_zones': 'Zones (Mollier- en houtdiagram)', + 'hint_zones': 'Een zone is een gebied van temperatuur × luchtvochtigheid met een eigen kleur. Gewenste zone: melding als een sensor de zone verlaat. Gevarenzone: melding als een sensor de zone binnengaat. Vrije vormen teken je in het Mollier-diagram.', + 'btn_add_zone': '+ Zone toevoegen', + 'col_color': 'Kleur', + 'col_mode': 'Melding', + 'col_temp_min': 'Temp. min', + 'col_temp_max': 'Temp. max', + 'col_rh_min': 'RV min', + 'col_rh_max': 'RV max', + 'col_shape': 'Vorm', + 'opt_mode_keep_inside': 'Gewenste zone (bij verlaten)', + 'opt_mode_keep_outside': 'Gevarenzone (bij binnengaan)', + 'opt_mode_none': 'Alleen tonen', + 'opt_color_green': 'Groen', + 'opt_color_blue': 'Blauw', + 'opt_color_orange': 'Oranje', + 'opt_color_red': 'Rood', + 'opt_color_purple': 'Paars', + 'opt_color_yellow': 'Geel', + 'zone_free_shape': 'Vrije vorm (getekend)', + 'btn_draw_zone': 'Zone tekenen', + 'hint_draw_zone': 'Teken met de muis of je vinger een gesloten vorm in het diagram, geef hem een naam en sla op. Kleur, melding en sensor stel je daarna in bij Settings > Zones.', + 'lbl_zone_name': 'Naam van de zone', + 'btn_save_zone': 'Zone opslaan', + 'btn_cancel': 'Annuleren', + 'draw_login': 'Log in via Settings om zones op te slaan.', + 'draw_saved': 'Zone opgeslagen.', + 'draw_empty': 'Teken eerst een vorm in het diagram.', 'wood_emc': 'Houtvocht', 'wood_chart': 'Houtvochtevenwicht', 'short_temperature': 'Temp.', @@ -524,6 +557,39 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} is below the minimum of {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is above the warning limit of {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is below the warning limit of {limit} {unit} ({time})', + 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % is outside zone {zone} ({time})', + 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % is inside zone {zone} ({time})', + 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % is fine again for zone {zone} ({time})', + 'zone': 'Zone', + 'err_zone_points': 'A drawn zone needs at least 3 points', + 'table_zones': 'Zones (Mollier and wood diagram)', + 'hint_zones': 'A zone is an area of temperature × humidity with its own colour. Desired zone: notification when a sensor leaves it. Danger zone: notification when a sensor enters it. Draw free shapes in the Mollier diagram.', + 'btn_add_zone': '+ Add zone', + 'col_color': 'Colour', + 'col_mode': 'Notification', + 'col_temp_min': 'Temp. min', + 'col_temp_max': 'Temp. max', + 'col_rh_min': 'RH min', + 'col_rh_max': 'RH max', + 'col_shape': 'Shape', + 'opt_mode_keep_inside': 'Desired zone (on leaving)', + 'opt_mode_keep_outside': 'Danger zone (on entering)', + 'opt_mode_none': 'Display only', + 'opt_color_green': 'Green', + 'opt_color_blue': 'Blue', + 'opt_color_orange': 'Orange', + 'opt_color_red': 'Red', + 'opt_color_purple': 'Purple', + 'opt_color_yellow': 'Yellow', + 'zone_free_shape': 'Free shape (drawn)', + 'btn_draw_zone': 'Draw zone', + 'hint_draw_zone': 'Draw a closed shape in the diagram with your mouse or finger, give it a name and save. Set colour, notification and sensor afterwards in Settings > Zones.', + 'lbl_zone_name': 'Zone name', + 'btn_save_zone': 'Save zone', + 'btn_cancel': 'Cancel', + 'draw_login': 'Log in via Settings to save zones.', + 'draw_saved': 'Zone saved.', + 'draw_empty': 'Draw a shape in the diagram first.', 'wood_emc': 'Wood moisture', 'wood_chart': 'Wood equilibrium moisture', 'short_temperature': 'Temp.', @@ -831,6 +897,39 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} liegt unter dem Minimum von {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} liegt über der Warngrenze von {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} liegt unter der Warngrenze von {limit} {unit} ({time})', + 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % ist außerhalb der Zone {zone} ({time})', + 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % ist in der Zone {zone} ({time})', + 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % ist wieder in Ordnung für Zone {zone} ({time})', + 'zone': 'Zone', + 'err_zone_points': 'Eine gezeichnete Zone braucht mindestens 3 Punkte', + 'table_zones': 'Zonen (Mollier- und Holzdiagramm)', + 'hint_zones': 'Eine Zone ist ein Bereich aus Temperatur × Luftfeuchte mit eigener Farbe. Wunschzone: Meldung, wenn ein Sensor sie verlässt. Gefahrenzone: Meldung, wenn ein Sensor sie betritt. Freie Formen zeichnen Sie im Mollier-Diagramm.', + 'btn_add_zone': '+ Zone hinzufügen', + 'col_color': 'Farbe', + 'col_mode': 'Meldung', + 'col_temp_min': 'Temp. min', + 'col_temp_max': 'Temp. max', + 'col_rh_min': 'rF min', + 'col_rh_max': 'rF max', + 'col_shape': 'Form', + 'opt_mode_keep_inside': 'Wunschzone (beim Verlassen)', + 'opt_mode_keep_outside': 'Gefahrenzone (beim Betreten)', + 'opt_mode_none': 'Nur anzeigen', + 'opt_color_green': 'Grün', + 'opt_color_blue': 'Blau', + 'opt_color_orange': 'Orange', + 'opt_color_red': 'Rot', + 'opt_color_purple': 'Lila', + 'opt_color_yellow': 'Gelb', + 'zone_free_shape': 'Freie Form (gezeichnet)', + 'btn_draw_zone': 'Zone zeichnen', + 'hint_draw_zone': 'Zeichnen Sie mit Maus oder Finger eine geschlossene Form ins Diagramm, geben Sie ihr einen Namen und speichern Sie. Farbe, Meldung und Sensor stellen Sie danach unter Settings > Zonen ein.', + 'lbl_zone_name': 'Name der Zone', + 'btn_save_zone': 'Zone speichern', + 'btn_cancel': 'Abbrechen', + 'draw_login': 'Melden Sie sich über Settings an, um Zonen zu speichern.', + 'draw_saved': 'Zone gespeichert.', + 'draw_empty': 'Zeichnen Sie zuerst eine Form ins Diagramm.', 'wood_emc': 'Holzfeuchte', 'wood_chart': 'Holzfeuchte-Gleichgewicht', 'short_temperature': 'Temp.', @@ -1138,6 +1237,39 @@ 'alert_low': '⚠️ {device} : {metric} {value} {unit} est sous le minimum de {limit} {unit} ({time})', 'alert_warn_high': "🟡 {device} : {metric} {value} {unit} dépasse le seuil d'avertissement de {limit} {unit} ({time})", 'alert_warn_low': "🟡 {device} : {metric} {value} {unit} est sous le seuil d'avertissement de {limit} {unit} ({time})", + 'alert_zone_left': '{icon} {device} : {temperature} °C / {humidity} % est hors de la zone {zone} ({time})', + 'alert_zone_entered': '{icon} {device} : {temperature} °C / {humidity} % est dans la zone {zone} ({time})', + 'alert_zone_recovered': '✅ {device} : {temperature} °C / {humidity} % est de nouveau correct pour la zone {zone} ({time})', + 'zone': 'Zone', + 'err_zone_points': 'Une zone dessinée doit avoir au moins 3 points', + 'table_zones': 'Zones (diagramme de Mollier et du bois)', + 'hint_zones': 'Une zone est une plage de température × humidité avec sa propre couleur. Zone souhaitée : notification quand un capteur la quitte. Zone de danger : notification quand un capteur y entre. Dessinez des formes libres dans le diagramme de Mollier.', + 'btn_add_zone': '+ Ajouter une zone', + 'col_color': 'Couleur', + 'col_mode': 'Notification', + 'col_temp_min': 'Temp. min', + 'col_temp_max': 'Temp. max', + 'col_rh_min': 'HR min', + 'col_rh_max': 'HR max', + 'col_shape': 'Forme', + 'opt_mode_keep_inside': 'Zone souhaitée (en sortant)', + 'opt_mode_keep_outside': 'Zone de danger (en entrant)', + 'opt_mode_none': 'Affichage seulement', + 'opt_color_green': 'Vert', + 'opt_color_blue': 'Bleu', + 'opt_color_orange': 'Orange', + 'opt_color_red': 'Rouge', + 'opt_color_purple': 'Violet', + 'opt_color_yellow': 'Jaune', + 'zone_free_shape': 'Forme libre (dessinée)', + 'btn_draw_zone': 'Dessiner une zone', + 'hint_draw_zone': 'Dessinez une forme fermée dans le diagramme avec la souris ou le doigt, donnez-lui un nom et enregistrez. Réglez ensuite la couleur, la notification et le capteur dans Settings > Zones.', + 'lbl_zone_name': 'Nom de la zone', + 'btn_save_zone': 'Enregistrer la zone', + 'btn_cancel': 'Annuler', + 'draw_login': 'Connectez-vous via Settings pour enregistrer des zones.', + 'draw_saved': 'Zone enregistrée.', + 'draw_empty': "Dessinez d'abord une forme dans le diagramme.", 'wood_emc': 'Humidité du bois', 'wood_chart': "Humidité d'équilibre du bois", 'short_temperature': 'Temp.', @@ -1445,6 +1577,39 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} está por debajo del mínimo de {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} supera el límite de aviso de {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} está por debajo del límite de aviso de {limit} {unit} ({time})', + 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % está fuera de la zona {zone} ({time})', + 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % está dentro de la zona {zone} ({time})', + 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % vuelve a estar bien para la zona {zone} ({time})', + 'zone': 'Zona', + 'err_zone_points': 'Una zona dibujada necesita al menos 3 puntos', + 'table_zones': 'Zonas (diagrama de Mollier y de la madera)', + 'hint_zones': 'Una zona es un área de temperatura × humedad con su propio color. Zona deseada: aviso cuando un sensor sale de ella. Zona de peligro: aviso cuando un sensor entra en ella. Las formas libres se dibujan en el diagrama de Mollier.', + 'btn_add_zone': '+ Añadir zona', + 'col_color': 'Color', + 'col_mode': 'Aviso', + 'col_temp_min': 'Temp. mín', + 'col_temp_max': 'Temp. máx', + 'col_rh_min': 'HR mín', + 'col_rh_max': 'HR máx', + 'col_shape': 'Forma', + 'opt_mode_keep_inside': 'Zona deseada (al salir)', + 'opt_mode_keep_outside': 'Zona de peligro (al entrar)', + 'opt_mode_none': 'Solo mostrar', + 'opt_color_green': 'Verde', + 'opt_color_blue': 'Azul', + 'opt_color_orange': 'Naranja', + 'opt_color_red': 'Rojo', + 'opt_color_purple': 'Morado', + 'opt_color_yellow': 'Amarillo', + 'zone_free_shape': 'Forma libre (dibujada)', + 'btn_draw_zone': 'Dibujar zona', + 'hint_draw_zone': 'Dibuje una forma cerrada en el diagrama con el ratón o el dedo, póngale un nombre y guárdela. Después ajuste el color, el aviso y el sensor en Settings > Zonas.', + 'lbl_zone_name': 'Nombre de la zona', + 'btn_save_zone': 'Guardar zona', + 'btn_cancel': 'Cancelar', + 'draw_login': 'Inicie sesión en Settings para guardar zonas.', + 'draw_saved': 'Zona guardada.', + 'draw_empty': 'Dibuje primero una forma en el diagrama.', 'wood_emc': 'Humedad de la madera', 'wood_chart': 'Humedad de equilibrio de la madera', 'short_temperature': 'Temp.', diff --git a/wood.py b/wood.py index 4c4488c..0e6f3cb 100644 --- a/wood.py +++ b/wood.py @@ -95,18 +95,24 @@ def _rgba(hex_color, alpha): return f'rgba({r}, {g}, {b}, {alpha})' -def figure(points, zones, t, k): - """Diagram temperatuur × RV met lijnen van gelijk houtvocht, zones uit de alarmregels en de sensoren. +def figure(points, limits, t, k, areas=()): + """Diagram temperatuur × RV met lijnen van gelijk houtvocht, houtvochtgrenzen, zones en de sensoren. - points: [(naam, temperatuur, rv, kleur)]; zones: [(min, max, niveau)] in % houtvocht; - k: themakleuren (callbacks.THEMES). + points: [(naam, temperatuur, rv, kleur)]; limits: [(min, max, niveau)] in % houtvocht uit de alarmregels; + areas: zones (zones.py, veelhoek in temperatuur × RV); k: themakleuren (callbacks.THEMES). """ + import zones seen = [p[1] for p in points if p[1] is not None] temps = np.linspace(min([-5] + [x - 3 for x in seen]), max([45] + [x + 3 for x in seen]), 60) fig = go.Figure() + for zone in areas: + corners = zone['points'] + zone['points'][:1] + fig.add_trace(go.Scatter(x=[p[0] for p in corners], y=[p[1] for p in corners], mode='lines', fill='toself', + name=zone['name'], fillcolor=zones.fill_color(zone['color']), + line=dict(color=zones.COLORS[zone['color']], width=1.5), hoverinfo='skip')) for level in ('warning', 'alarm'): # alarm als laatste: ligt over de waarschuwing color = k['warn'] if level == 'warning' else k['alarm'] - for low, high, zone_level in zones: + for low, high, zone_level in limits: if zone_level != level: continue for limit, edge, sign in ((high, 100, '≥'), (low, 0, '≤')): diff --git a/zones.py b/zones.py new file mode 100644 index 0000000..1f8d186 --- /dev/null +++ b/zones.py @@ -0,0 +1,181 @@ +"""Zones in het Mollier- en houtdiagram: gebieden in temperatuur × RV met een kleur en optioneel een alarm. + +Een zone is een veelhoek van (temperatuur, RV) punten: een rechthoek uit min/max in Settings, of vrij +getekend in het Mollier-diagram. Per zone een alarmsoort: 'keep_inside' (gewenst klimaat, melding bij +verlaten), 'keep_outside' (gevarengebied, melding bij binnengaan) of 'none' (alleen tonen). +""" +import json +import os +import sqlite3 +import threading + +import devices +from settings import DB_FILE + +COLORS = {'green': '#1baf7a', 'blue': '#2a78d6', 'orange': '#eb6834', 'red': '#e34948', 'purple': '#6250d6', + 'yellow': '#eda100'} +MODES = ['keep_inside', 'keep_outside', 'none'] +LEVELS = ['alarm', 'warning'] +SHAPES = ['rect', 'polygon'] + +_lock = threading.Lock() +_cache = None # [zone dicts], None = opnieuw laden + + +def _connect(): + db_dir = os.path.dirname(DB_FILE) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir) + conn = sqlite3.connect(DB_FILE, timeout=30) + conn.execute('''CREATE TABLE IF NOT EXISTS zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER, name TEXT NOT NULL, color TEXT NOT NULL, + mode TEXT NOT NULL, level TEXT NOT NULL, shape TEXT NOT NULL, points TEXT NOT NULL, enabled INTEGER NOT NULL)''') + return conn + + +def _number(raw): + """float, of ValueError bij lege/ongeldige invoer.""" + if raw is None or str(raw).strip() == '': + raise ValueError(raw) + return float(str(raw).replace(',', '.')) + + +def _points(row, errors): + """Hoekpunten [[temperatuur, rv], ...] uit een formulierregel; fouten in errors.""" + if (row.get('shape') or 'rect') == 'polygon': + try: + points = [[float(t), float(h)] for t, h in json.loads(row.get('points') or '[]')] + except (TypeError, ValueError): + points = [] + if len(points) < 3: + errors['shape'] = 'err_zone_points' + return points + values = {} + for field in ('temp_min', 'temp_max', 'rh_min', 'rh_max'): + try: + values[field] = _number(row.get(field)) + except (TypeError, ValueError): + errors[field] = 'err_required' if row.get(field) in (None, '') else 'err_type' + if 'temp_min' in values and 'temp_max' in values and values['temp_min'] >= values['temp_max']: + errors['temp_max'] = 'err_min_max' + for field in ('rh_min', 'rh_max'): + if field in values and not 0 <= values[field] <= 100: + errors[field] = 'err_range' + if 'rh_min' in values and 'rh_max' in values and values['rh_min'] >= values['rh_max'] and 'rh_max' not in errors: + errors['rh_max'] = 'err_min_max' + if errors: + return [] + t0, t1, h0, h1 = values['temp_min'], values['temp_max'], values['rh_min'], values['rh_max'] + return [[t0, h0], [t1, h0], [t1, h1], [t0, h1]] + + +def validate(rows): + """Valideer zones uit de UI. Geeft (schone zones, {rij: {veld: foutcode}}).""" + device_ids = {d['id'] for d in devices.list_devices()} + clean, errors = [], {} + for index, row in enumerate(rows): + row_errors = {} + try: + scope = int(row.get('scope') or 0) + except (TypeError, ValueError): + scope = -1 + if scope != 0 and scope not in device_ids: + row_errors['scope'] = 'err_choice' + name = str(row.get('name') or '').strip() + if not name or len(name) > 50: + row_errors['name'] = 'err_required' if not name else 'err_range' + for field, choices in (('color', COLORS), ('mode', MODES), ('level', LEVELS)): + if row.get(field) not in choices: + row_errors[field] = 'err_choice' + shape = row.get('shape') or 'rect' + points = _points(row, row_errors) if shape in SHAPES else [] + if row_errors: + errors[index] = row_errors + clean.append({'device_id': scope or None, 'name': name, 'color': row.get('color'), 'mode': row.get('mode'), + 'level': row.get('level'), 'shape': shape, 'points': points, + 'enabled': str(row.get('enabled', 'true')).lower() in ('true', '1')}) + return clean, errors + + +def save(rows): + """Sla alle zones op (alles of niets). Geeft fouten terug; leeg dict = opgeslagen.""" + global _cache + clean, errors = validate(rows) + if errors: + return errors + with _lock: + conn = _connect() + try: + conn.execute('BEGIN') + conn.execute('DELETE FROM zones') + conn.executemany('INSERT INTO zones (device_id, name, color, mode, level, shape, points, enabled) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [(z['device_id'], z['name'], z['color'], z['mode'], z['level'], z['shape'], + json.dumps(z['points']), int(z['enabled'])) for z in clean]) + conn.commit() + finally: + conn.close() + _cache = None + return {} + + +def load(): + """Alle zones (punten als lijst, min/max van de omhullende rechthoek erbij voor het formulier).""" + global _cache + with _lock: + if _cache is None: + conn = _connect() + conn.row_factory = sqlite3.Row + try: + rows = [dict(r) for r in conn.execute('SELECT * FROM zones ORDER BY id')] + finally: + conn.close() + device_ids = {d['id'] for d in devices.list_devices()} + _cache = [] + for row in rows: + if row['device_id'] is not None and row['device_id'] not in device_ids: + continue # zone van een verwijderde sensor + points = json.loads(row['points']) + temps, hums = [p[0] for p in points], [p[1] for p in points] + _cache.append({**row, 'points': points, 'enabled': bool(row['enabled']), + 'temp_min': min(temps), 'temp_max': max(temps), 'rh_min': min(hums), 'rh_max': max(hums)}) + return [dict(z) for z in _cache] + + +def effective(device_id): + """Ingeschakelde zones die voor deze sensor gelden (voor alle sensoren + alleen deze sensor).""" + return [z for z in load() if z['enabled'] and z['device_id'] in (None, device_id)] + + +def contains(zone, temperature, humidity): + """Ligt (temperatuur, RV) in de zone? Ray casting over de veelhoek.""" + inside = False + points = zone['points'] + for (t1, h1), (t2, h2) in zip(points, points[1:] + points[:1]): + if (h1 > humidity) != (h2 > humidity) and temperature < t1 + (humidity - h1) * (t2 - t1) / (h2 - h1): + inside = not inside + return inside + + +def fill_color(name, alpha=0.15): + """Doorzichtige vulkleur van een zonekleur.""" + hex_color = COLORS[name] + return f'rgba({int(hex_color[1:3], 16)}, {int(hex_color[3:5], 16)}, {int(hex_color[5:7], 16)}, {alpha})' + + +def visible(device_id): + """Zones voor een diagram: van één sensor (incl. alle-sensoren-zones) of, zonder keuze, de algemene zones.""" + if device_id: + return effective(device_id) + return [z for z in load() if z['enabled'] and z['device_id'] is None] + + +def clearly(zone, temperature, humidity, margin_temp, margin_humidity, inside): + """Ligt het punt binnen (inside=True) of buiten de zone, met minstens de marge tot de rand? + + Voor herstel na een zone-alarm: zonder marge meldt een sensor die op de rand hangt steeds opnieuw alarm + en herstel. Getoetst op het punt zelf en vier punten op marge-afstand. + """ + probes = [(temperature, humidity), (temperature - margin_temp, humidity), (temperature + margin_temp, humidity), + (temperature, humidity - margin_humidity), (temperature, humidity + margin_humidity)] + return all(contains(zone, t, h) == inside for t, h in probes) From dae19b31add1aa3b1aa28957036133721e783a8e Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:50:01 +0100 Subject: [PATCH 06/19] feat: Manage zones in Settings - Zones section under notifications: name, sensor, desired/danger zone, level, colour and temperature/humidity range - Drawn zones keep their shape and show as 'Free shape' instead of min/max fields - Zones are validated and saved together with the other settings; invalid fields are marked - Zones are drawn beneath the sensor points in the Mollier diagram --- README.md | 3 +- layout.py | 3 ++ psychrometric.py | 2 ++ settings_ui.py | 92 +++++++++++++++++++++++++++++++++++++----------- test_app.py | 28 +++++++++++++-- 5 files changed, 104 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a0733f0..94d97da 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - Comfort Score (0-6, based on Humidex) - **Psychrometric chart**: Mollier diagram with live indicator and comfort zone visualization - **Wood moisture** (optional): equilibrium moisture content per sensor, a diagram with zones from your limits, warning and alarm levels +- **Zones**: your own coloured areas of temperature × humidity in the Mollier and wood diagram (from min/max or drawn freely), with a notification when a sensor leaves a desired zone or enters a danger zone - **Historical data replay**: Time-travel through data with preset buttons or custom date/time selection - **Interactive slider**: Live updates while dragging for smooth historical navigation - **Scientific analysis**: Humidex formula according to Environment Canada standard @@ -333,7 +334,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, test button | -| Notifications: limits | Limits per quantity (all sensors or one sensor) at warning or alarm level, alarm delay, hysteresis, recovery and offline alerts | +| 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 | | Data | Data retention (days, 0 = forever), debug logging | diff --git a/layout.py b/layout.py index f6dac19..7e45c37 100644 --- a/layout.py +++ b/layout.py @@ -228,6 +228,9 @@ .cfg-remove { justify-self: start; padding: 8px 0; } .cfg-remove:hover { color: var(--alarm); } .cfg-empty { padding: 14px 0; color: var(--muted); font-size: 13px; border-bottom: 1px solid var(--line); } + .cfg-static { padding: 8px 0; color: var(--muted); font-size: 13px; } + .color-option { display: inline-flex; align-items: center; gap: 8px; } + .color-option .swatch { width: 12px; height: 12px; border-radius: 2px; } .site-footer { text-align: center; padding: 8px 16px 28px; font-size: 12px; color: var(--muted); } .site-footer a { color: var(--muted); text-underline-offset: 3px; } diff --git a/psychrometric.py b/psychrometric.py index 4bb76d3..c285b68 100644 --- a/psychrometric.py +++ b/psychrometric.py @@ -332,4 +332,6 @@ def add_zones(fig, zone_list): fig.add_trace(go.Scatter(x=temps + temps[:1], y=ratios + ratios[:1], mode='lines', fill='toself', name=zone['name'], fillcolor=zones.fill_color(zone['color']), line=dict(color=color, width=1.5), hoverinfo='skip')) + if zone_list: # zones onder de sensorpunten en lijnen leggen + fig.data = fig.data[-len(zone_list):] + fig.data[:-len(zone_list)] return fig diff --git a/settings_ui.py b/settings_ui.py index c4cadce..9364209 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1,4 +1,5 @@ """Settings tab: formulier opgebouwd uit settings.SCHEMA, Modbus bussen/devices, opslaan, wachtwoord wijzigen.""" +import json import time from urllib.parse import quote from zoneinfo import available_timezones @@ -11,6 +12,7 @@ import layout import mqtt_publisher import settings +import zones from translations import LANGUAGE_NAMES, TRANSLATIONS SECTIONS = ['display', 'modbus', 'notify', 'alerts', 'wood', 'mqtt', 'regional', 'data', 'server', 'security'] @@ -23,8 +25,11 @@ BYTE_ORDER_LABELS = {'ABCD': 'ABCD · big endian', 'DCBA': 'DCBA · little endian', 'BADC': 'BADC · byte swap', 'CDAB': 'CDAB · word swap'} RULE_COLUMNS = ['scope', 'metric', 'level', 'min_value', 'max_value', 'enabled'] -TABLE_COLUMNS = {'buses': BUS_COLUMNS, 'devices': DEVICE_COLUMNS, 'rules': RULE_COLUMNS} -WIDE_FIELDS = {'name', 'port', 'bus', 'scope', 'metric', 'byte_order'} # twee kolommen breed +ZONE_COLUMNS = ['name', 'scope', 'mode', 'level', 'color', 'temp_min', 'temp_max', 'rh_min', 'rh_max', 'enabled'] +RECT_FIELDS = ['temp_min', 'temp_max', 'rh_min', 'rh_max'] +TABLE_COLUMNS = {'buses': BUS_COLUMNS, 'devices': DEVICE_COLUMNS, 'rules': RULE_COLUMNS, 'zones': ZONE_COLUMNS} +HIDDEN_FIELDS = {'zones': ['shape', 'points']} # meegestuurd zonder zichtbaar veld (getekende vorm) +WIDE_FIELDS = {'name', 'port', 'bus', 'scope', 'metric', 'byte_order', 'mode'} # twee kolommen breed _TIMEZONES = sorted(available_timezones()) @@ -74,7 +79,11 @@ def _field(key, value, t): def table_rows(table): - """Opgeslagen bussen, sensoren of grenswaarden als formulierregels (aan/uit als 'true'/'false').""" + """Opgeslagen bussen, sensoren, grenswaarden of zones als formulierregels (aan/uit als 'true'/'false').""" + if table == 'zones': + return [{'id': None, 'scope': z['device_id'] or 0, 'name': z['name'], 'mode': z['mode'], 'level': z['level'], + 'color': z['color'], 'shape': z['shape'], 'points': json.dumps(z['points']), + **{f: z[f] for f in RECT_FIELDS}, 'enabled': 'true' if z['enabled'] else 'false'} for z in zones.load()] if table == 'rules': return [{'id': None, 'scope': r['device_id'] or 0, 'metric': r['metric'], 'level': r['level'], 'min_value': r['min_value'], 'max_value': r['max_value'], 'enabled': 'true' if r['enabled'] else 'false'} @@ -119,6 +128,11 @@ def _choices(table, field, t, bus_names): return [{'label': t[alerts.METRICS[m][0]], 'value': m} for m in alerts.METRICS] if field == 'level': return [{'label': t[f'opt_level_{v}'], 'value': v} for v in alerts.LEVELS] + if field == 'mode': + return [{'label': t[f'opt_mode_{v}'], 'value': v} for v in zones.MODES] + if field == 'color': + return [{'label': html.Span([html.Span(className='swatch', style={'--series': hex_color}), t[f'opt_color_{v}']], + className='color-option'), 'value': v} for v, hex_color in zones.COLORS.items()] return None @@ -143,10 +157,16 @@ def row_form(table, rows, t, bus_names=None): bus_names = [b['name'] for b in devices.load_config()[0]] items = [] for index, row in enumerate(rows): - cells = [dcc.Input(id={'type': 'cfg', 'table': table, 'row': index, 'field': 'id'}, type='hidden', - value=row.get('id'))] + cells = [dcc.Input(id={'type': 'cfg', 'table': table, 'row': index, 'field': field}, type='hidden', + value=row.get(field)) for field in ['id'] + HIDDEN_FIELDS.get(table, [])] for field in TABLE_COLUMNS[table]: cell_id = {'type': 'cfg-cell', 'table': table, 'row': index, 'field': field} + if row.get('shape') == 'polygon' and field in RECT_FIELDS: + if field == RECT_FIELDS[0]: # getekende vorm: één vak in plaats van vier min/max-velden + cells.append(html.Div(id=cell_id, className='cfg-cell wide', children=[ + html.Span(t['col_shape'], className='cfg-label'), + html.Span(t['zone_free_shape'], className='cfg-static')])) + continue control = _control(table, index, field, row.get(field), t, bus_names) # Label om het veld heen (koppeling zonder id); het vinkje heeft al een eigen label wrapper = html.Div if field == 'enabled' else html.Label @@ -181,6 +201,10 @@ def _new_row(table, rows, bus_names): 'slave_id': next(i for i in range(1, 248) if i not in used), 'function_code': 4, 'register_temp': 1, 'register_humidity': 2, 'data_type': 'int16', 'byte_order': 'ABCD', 'scale_temp': 0.1, 'scale_humidity': 0.1, 'offset_temp': 0, 'offset_humidity': 0, 'enabled': 'true'} + if table == 'zones': + return {'id': None, 'scope': 0, 'name': f'Zone {len(rows) + 1}', 'mode': 'keep_inside', 'level': 'warning', + 'color': 'green', 'shape': 'rect', 'points': None, 'temp_min': 18, 'temp_max': 24, 'rh_min': 40, + 'rh_max': 60, 'enabled': 'true'} return {'id': None, 'scope': 0, 'metric': 'temperature', 'level': 'alarm', 'min_value': None, 'max_value': None, 'enabled': 'true'} @@ -242,6 +266,11 @@ def _alerts_section(t): html.Div(id='rules-form', className='cfg-list', children=row_form('rules', table_rows('rules'), t)), html.Button(t['btn_add_rule'], id='add-rule', n_clicks=0, className='btn btn-small'), html.Div(id='rule-errors', className='field-error'), + html.H4(t['table_zones']), + html.Div(t['hint_zones'], className='field-hint'), + html.Div(id='zones-form', className='cfg-list', children=row_form('zones', table_rows('zones'), t)), + html.Button(t['btn_add_zone'], id='add-zone', n_clicks=0, className='btn btn-small'), + html.Div(id='zone-errors', className='field-error'), html.H4(t['lbl_alert_log']), html.Div(id='alert-log', children=alert_log_list(t)), ] @@ -375,22 +404,24 @@ def show_settings(tab, lang): State('selected-language', 'data'), State(_fields('buses'), 'value'), State(_fields('devices'), 'value'), - State(_fields('rules'), 'value')], + State(_fields('rules'), 'value'), + State(_fields('zones'), 'value')], prevent_initial_call=True ) - def save_settings(n_clicks, values, lang, bus_values, device_values, rule_values): + def save_settings(n_clicks, values, lang, bus_values, device_values, rule_values, zone_values): t = _t(lang) ok_style = {'marginTop': '10px', 'fontWeight': 'bold', 'color': 'var(--ok)'} bad_style = {'marginTop': '10px', 'fontWeight': 'bold', 'color': 'var(--alarm)'} if not auth.is_logged_in(): return no_update, t['settings_locked'], bad_style keys = [item['id']['key'] for item in callback_context.states_list[0]] - bus_rows, device_rows, rule_rows = (collect(callback_context.states_list[i]) for i in (2, 3, 4)) + bus_rows, device_rows, rule_rows, zone_rows = (collect(callback_context.states_list[i]) for i in (2, 3, 4, 5)) - # Eerst de Modbus configuratie valideren, zodat bij een fout niets wordt opgeslagen + # Eerst de Modbus configuratie, grenswaarden en zones valideren, zodat bij een fout niets wordt opgeslagen _, _, modbus_errors = devices.validate_config(bus_rows or [], device_rows or []) _, rule_errors = alerts.validate_rules(rule_rows or []) - if modbus_errors or rule_errors: + _, zone_errors = zones.validate(zone_rows or []) + if modbus_errors or rule_errors or zone_errors: return [''] * len(keys), t['msg_save_failed'], bad_style submitted = dict(zip(keys, values)) @@ -400,7 +431,8 @@ def save_settings(n_clicks, values, lang, bus_values, device_values, rule_values if errors: return field_errors, t['msg_save_failed'], bad_style devices.save_config(bus_rows or [], device_rows or []) - alerts.save_rules(rule_rows or []) + zones.save(zone_rows or []) + alerts.save_rules(rule_rows or []) # na de zones: wist ook de alarmtoestand (zone-id's zijn nieuw) restart_changed = any(settings.SCHEMA[k].get('restart') and settings.get(k) != before[k] for k in keys) message = t['msg_saved_restart'] if restart_changed else t['msg_saved'] @@ -409,42 +441,49 @@ def save_settings(n_clicks, values, lang, bus_values, device_values, rule_values @app.callback( [Output('modbus-errors', 'children'), Output('rule-errors', 'children'), + Output('zone-errors', 'children'), Output('buses-form', 'children', allow_duplicate=True), Output('devices-form', 'children', allow_duplicate=True), Output('rules-form', 'children', allow_duplicate=True), + Output('zones-form', 'children', allow_duplicate=True), Output({'type': 'cfg-cell', 'table': ALL, 'row': ALL, 'field': ALL}, 'className')], Input('settings-status', 'children'), # na save_settings: anders kan het herladen vóór het opslaan lopen [State(_fields('buses'), 'value'), State(_fields('devices'), 'value'), State(_fields('rules'), 'value'), + State(_fields('zones'), 'value'), State('selected-language', 'data')], prevent_initial_call=True ) - def show_modbus_errors(status, bus_values, device_values, rule_values, lang): + def show_modbus_errors(status, bus_values, device_values, rule_values, zone_values, lang): """Markeer foute velden; na succesvol opslaan de formulieren herladen (nieuwe regels krijgen een id).""" t = _t(lang) if not auth.is_logged_in(): - return (no_update,) * 6 - bus_rows, device_rows, rule_rows = (collect(callback_context.states_list[i]) for i in (0, 1, 2)) + return (no_update,) * 8 + bus_rows, device_rows, rule_rows, zone_rows = (collect(callback_context.states_list[i]) for i in (0, 1, 2, 3)) _, _, errors = devices.validate_config(bus_rows, device_rows) _, rule_errors = alerts.validate_rules(rule_rows) + _, zone_errors = zones.validate(zone_rows) if rule_errors: errors = {**errors, 'rules': rule_errors} - messages = {'modbus': [], 'rules': []} - for table in ('buses', 'devices', 'rules'): + if zone_errors: + errors = {**errors, 'zones': zone_errors} + messages = {'modbus': [], 'rules': [], 'zones': []} + for table in ('buses', 'devices', 'rules', 'zones'): for row, fields in errors.get(table, {}).items(): for field, code in fields.items(): - messages['rules' if table == 'rules' else 'modbus'].append(html.Li(t['row_error'].format( + messages[table if table in ('rules', 'zones') else 'modbus'].append(html.Li(t['row_error'].format( table=t[f'table_{table}'], row=row + 1, field=t[f'col_{field}'], error=t.get(code, code)))) cells = callback_context.outputs_list[-1] classes = [_cell_class(c['id']['field'], c['id']['field'] in errors.get(c['id']['table'], {}).get(c['id']['row'], {})) for c in cells] modbus_text = html.Ul(messages['modbus']) if messages['modbus'] else '' rules_text = html.Ul(messages['rules']) if messages['rules'] else '' + zones_text = html.Ul(messages['zones']) if messages['zones'] else '' if errors or status not in (t['msg_saved'], t['msg_saved_restart']): - return modbus_text, rules_text, no_update, no_update, no_update, classes - return ('', '', row_form('buses', table_rows('buses'), t), row_form('devices', table_rows('devices'), t), - row_form('rules', table_rows('rules'), t), classes) + return modbus_text, rules_text, zones_text, no_update, no_update, no_update, no_update, classes + return ('', '', '', row_form('buses', table_rows('buses'), t), row_form('devices', table_rows('devices'), t), + row_form('rules', table_rows('rules'), t), row_form('zones', table_rows('zones'), t), classes) def edit_rows(table, t, bus_names): """Regel toevoegen of verwijderen; de rest van het formulier houdt de (nog niet opgeslagen) invoer.""" @@ -498,6 +537,19 @@ def edit_rules(n_clicks, remove_clicks, values, lang): return no_update return edit_rows('rules', _t(lang), bus_names=[]) + @app.callback( + Output('zones-form', 'children'), + [Input('add-zone', 'n_clicks'), + Input({'type': 'cfg-remove', 'table': 'zones', 'row': ALL}, 'n_clicks')], + [State(_fields('zones'), 'value'), + State('selected-language', 'data')], + prevent_initial_call=True + ) + def edit_zones(n_clicks, remove_clicks, values, lang): + if not auth.is_logged_in(): + return no_update + return edit_rows('zones', _t(lang), bus_names=[]) + @app.callback( Output({'type': 'cfg', 'table': 'devices', 'row': ALL, 'field': 'bus'}, 'options'), Input({'type': 'cfg', 'table': 'buses', 'row': ALL, 'field': 'name'}, 'value'), diff --git a/test_app.py b/test_app.py index 7fae6ca..e9e7cbd 100644 --- a/test_app.py +++ b/test_app.py @@ -499,19 +499,20 @@ def test_settings_callbacks_require_login(): assert callbacks['edit_devices'](1, [], [], [], 'nl') is dash.no_update assert callbacks['update_bus_choices'](['Bus 1']) is dash.no_update assert callbacks['refresh_device_status'](3, 'settings', 'nl') is dash.no_update - assert all(v is dash.no_update for v in callbacks['show_modbus_errors'](1, [], [], [], 'nl')) - errors, status, _ = callbacks['save_settings'](1, [], 'nl', [], [], []) + assert all(v is dash.no_update for v in callbacks['show_modbus_errors'](1, [], [], [], [], 'nl')) + errors, status, _ = callbacks['save_settings'](1, [], 'nl', [], [], [], []) assert errors is dash.no_update and status == TRANSLATIONS_NL['settings_locked'] assert callbacks['change_password'](1, 'x', 'y', 'y', 'nl')[0] == TRANSLATIONS_NL['settings_locked'] assert 'admin' not in str(callbacks['show_settings']('settings', 'nl')) # alleen het inlogscherm assert callbacks['edit_rules'](1, [], [], 'nl') is dash.no_update + assert callbacks['edit_zones'](1, [], [], 'nl') is dash.no_update assert callbacks['send_test'](1, 0, [], 'nl') == TRANSLATIONS_NL['settings_locked'] assert callbacks['refresh_alert_log'](5, 'settings', 'nl') is dash.no_update assert callbacks['test_mqtt'](1, [], 'nl') == TRANSLATIONS_NL['settings_locked'] assert callbacks['refresh_mqtt_status'](3, 'settings', 'nl') is dash.no_update assert set(callbacks) >= {'edit_buses', 'edit_devices', 'update_bus_choices', 'refresh_device_status', 'show_modbus_errors', 'save_settings', 'change_password', 'show_settings', - 'edit_rules', 'send_test', 'refresh_alert_log', 'test_mqtt', 'refresh_mqtt_status'} + 'edit_rules', 'edit_zones', 'send_test', 'refresh_alert_log', 'test_mqtt', 'refresh_mqtt_status'} def components(node): @@ -541,6 +542,27 @@ def test_config_forms_round_trip(): assert alerts.validate_rules(rows['rules'])[1] == {} and rows['rules'][0]['level'] == 'warning' +def test_zone_form_round_trip(): + """Zones in Settings: rechthoek via min/max-velden, getekende vorm blijft behouden (geen min/max-velden).""" + import settings_ui + import zones + from dash import html + setup_alerts([]) + assert zones.save([ + {'scope': 0, 'name': 'Comfort', 'color': 'green', 'mode': 'keep_inside', 'level': 'warning', + 'temp_min': 18, 'temp_max': 24, 'rh_min': 40, 'rh_max': 60, 'enabled': 'true'}, + {'scope': 0, 'name': 'Getekend', 'color': 'red', 'mode': 'keep_outside', 'level': 'alarm', 'shape': 'polygon', + 'points': '[[10, 80], [30, 85], [20, 95]]', 'enabled': 'true'}]) == {} + form = html.Div(settings_ui.row_form('zones', settings_ui.table_rows('zones'), TRANSLATIONS['nl'])) + fields = [c for c in components(form) if isinstance(getattr(c, 'id', None), dict) and c.id.get('type') == 'cfg'] + assert not any(c.id['row'] == 1 and c.id['field'] == 'temp_min' for c in fields) + assert TRANSLATIONS_NL['zone_free_shape'] in str(form) + rows = settings_ui.collect([{'id': c.id, 'value': c.value} for c in fields]) + assert zones.save(rows) == {} + assert [z['points'] for z in zones.load()] == [[[18.0, 40.0], [24.0, 40.0], [24.0, 60.0], [18.0, 60.0]], + [[10.0, 80.0], [30.0, 85.0], [20.0, 95.0]]] + + def test_sensor_form_has_data_type_and_byte_order(): import settings_ui from dash import html From bd855e369cb53013ff4b0ce6e5e47b867fe8a5b6 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:54:47 +0100 Subject: [PATCH 07/19] feat: Draw zones directly in the Mollier diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'Draw zone' button switches the Mollier diagram to drawing a closed shape; live updates pause meanwhile - The drawn shape (°C, g/kg) is converted to temperature and humidity points and saved as a free-shape zone - Saving requires being logged in, because the dashboard can be public - Colour, notification and sensor of a drawn zone are set afterwards in Settings > Zones --- callbacks.py | 84 ++++++++++++++++++++++++++++++++++++++++++++---- layout.py | 17 ++++++++++ psychrometric.py | 12 +++++++ test_app.py | 56 ++++++++++++++++++++++++++++++-- 4 files changed, 160 insertions(+), 9 deletions(-) diff --git a/callbacks.py b/callbacks.py index f73a1cf..656c22f 100644 --- a/callbacks.py +++ b/callbacks.py @@ -1,20 +1,23 @@ +import json import sqlite3 import math import pandas as pd from datetime import datetime, timedelta -from dash import callback_context, html, no_update, Input, Output, State +from dash import Patch, callback_context, html, no_update, Input, Output, State import plotly.graph_objects as go import alerts +import auth import devices import layout import settings +import settings_ui import wood import zones from translations import TRANSLATIONS from database import get_all_measurement_tables, build_union_query, DB_FILE from modbus_reader import derived_values -from psychrometric import add_device_points, add_zones, create_psychrometric_chart, create_psychrometric_chart_historical +from psychrometric import add_device_points, add_zones, path_to_points, create_psychrometric_chart, create_psychrometric_chart_historical # Categorisch palet per sensor (vaste volgorde, gevalideerd op kleurenblindheid en contrast). # Sensor 9 en verder krijgen een neutrale kleur; status-kleuren worden nooit voor sensoren gebruikt. @@ -313,16 +316,77 @@ def update_graph(n, time_range_minutes, relayout_data, lang, device_sel, metric, fig = metric_figure(df, names, device_colors(mode), metric, t, mode) return fig, count_text, stored_relayout + @app.callback( + [Output('draw-zone-msg', 'children'), + Output('zone-drawing', 'data', allow_duplicate=True), + Output('draw-bar', 'style', allow_duplicate=True)], + Input('save-zone-btn', 'n_clicks'), + [State('zone-draw-path', 'data'), + State('draw-zone-name', 'value'), + State('device-selector', 'value'), + State('selected-language', 'data')], + prevent_initial_call=True + ) + def save_drawn_zone(n_clicks, path, name, device_sel, lang): + """Getekende vorm opslaan als zone (alleen ingelogd: het dashboard kan publiek zijn).""" + t = TRANSLATIONS.get(lang) or TRANSLATIONS['en'] + if not auth.is_logged_in(): + return t['draw_login'], no_update, no_update + points = path_to_points(path) + if len(points) < 3: + return t['draw_empty'], no_update, no_update + rows = settings_ui.table_rows('zones') + [{ + 'scope': device_sel or 0, 'name': (name or '').strip() or f"{t['zone']} {len(zones.load()) + 1}", + 'color': 'blue', 'mode': 'none', 'level': 'warning', 'shape': 'polygon', 'points': json.dumps(points), + 'enabled': 'true'}] + errors = zones.save(rows) + if errors: + return t['msg_save_failed'], no_update, no_update + return t['draw_saved'], False, {'display': 'none'} + + @app.callback( + [Output('zone-drawing', 'data'), + Output('draw-bar', 'style'), + Output('psychrometric-chart', 'figure', allow_duplicate=True)], + [Input('draw-zone-btn', 'n_clicks'), + Input('cancel-zone-btn', 'n_clicks')], + prevent_initial_call=True + ) + def toggle_zone_drawing(draw_clicks, cancel_clicks): + """Tekenmodus: Plotly tekent een gesloten pad; annuleren wist de tekening.""" + drawing = callback_context.triggered_id == 'draw-zone-btn' + figure = Patch() + figure['layout']['dragmode'] = 'drawclosedpath' if drawing else 'zoom' + figure['layout']['newshape'] = {'line': {'color': zones.COLORS['blue'], 'width': 2}, + 'fillcolor': zones.fill_color('blue', 0.2)} + if not drawing: + figure['layout']['shapes'] = [] + return drawing, {} if drawing else {'display': 'none'}, figure + + @app.callback( + Output('zone-draw-path', 'data'), + Input('psychrometric-chart', 'relayoutData'), + State('zone-drawing', 'data'), + prevent_initial_call=True + ) + def keep_drawn_path(relayout, drawing): + """Laatst getekende vorm onthouden (Plotly geeft alle vormen in 'shapes').""" + shapes = (relayout or {}).get('shapes') + if not drawing or not shapes: + return no_update + return shapes[-1].get('path') + # Callback voor psychrometric chart @app.callback( Output('psychrometric-chart', 'figure'), [Input('graph-update', 'n_intervals'), Input('selected-language', 'data'), - Input('device-selector', 'value')] + Input('device-selector', 'value')], + State('zone-drawing', 'data') ) - def update_psychrometric_chart(n, lang, device_sel): + def update_psychrometric_chart(n, lang, device_sel, drawing): """Update psychrometrisch diagram met actuele meetwaarden (alle devices of één device)""" - if not settings.get('show_mollier'): + if not settings.get('show_mollier') or drawing: # tijdens tekenen niet verversen: wist de tekening return no_update if lang is None: lang = 'nl' @@ -818,7 +882,12 @@ def update_device_overview(n, lang, view, tab): Output('custom-start-date', 'placeholder'), Output('custom-end-date', 'placeholder'), Output('custom-start-hour', 'placeholder'), - Output('custom-end-hour', 'placeholder')] + + Output('custom-end-hour', 'placeholder'), + Output('draw-zone-btn', 'children'), + Output('draw-zone-hint', 'children'), + Output('draw-zone-name', 'placeholder'), + Output('save-zone-btn', 'children'), + Output('cancel-zone-btn', 'children')] + [Output(f'preset-{key}', 'children') for key in PRESET_BUTTONS], Input('selected-language', 'data') ) @@ -827,4 +896,5 @@ def update_history_language(lang): t = TRANSLATIONS.get(lang) or TRANSLATIONS['en'] return [t['btn_select_range'], t['modal_title'], t['quick_select'], t['specific_period'], t['start_datetime'], t['end_datetime'], t['btn_apply'], t['select_date'], t['select_date'], t['hour_placeholder'], - t['hour_placeholder']] + [t[f'btn_{key}'] for key in PRESET_BUTTONS] + t['hour_placeholder'], t['btn_draw_zone'], t['hint_draw_zone'], t['lbl_zone_name'], t['btn_save_zone'], + t['btn_cancel']] + [t[f'btn_{key}'] for key in PRESET_BUTTONS] diff --git a/layout.py b/layout.py index 7e45c37..fae5e4e 100644 --- a/layout.py +++ b/layout.py @@ -144,6 +144,9 @@ /* Mollier + historie */ .history-bar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 0 16px 14px; } .hint { color: var(--muted); font-size: 13px; } + .draw-bar { display: grid; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2); } + .draw-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + .draw-actions .input { width: 220px; max-width: 100%; } .slider-box { padding: 4px 16px 18px; } .slider-time { font-family: var(--mono); font-size: 15px; margin-bottom: 8px; } @@ -358,6 +361,20 @@ def create_layout(): html.Section(id='mollier-panel', className='panel', style=mollier_style(), children=[ html.Div(className='panel-head', children=[ html.Span(id='label-psychrometric-chart', className='panel-title', children=t['psychrometric_chart']), + html.Button(t['btn_draw_zone'], id='draw-zone-btn', n_clicks=0, className='btn btn-small'), + ]), + # Zone tekenen: zolang dit open is pauzeert de live-verversing (die zou de tekening wissen) + dcc.Store(id='zone-drawing', data=False), + dcc.Store(id='zone-draw-path'), + html.Div(id='draw-bar', className='draw-bar', style={'display': 'none'}, children=[ + html.Div(t['hint_draw_zone'], id='draw-zone-hint', className='hint'), + html.Div(className='draw-actions', children=[ + dcc.Input(id='draw-zone-name', type='text', placeholder=t['lbl_zone_name'], className='input', + maxLength=50), + html.Button(t['btn_save_zone'], id='save-zone-btn', n_clicks=0, className='btn btn-primary btn-small'), + html.Button(t['btn_cancel'], id='cancel-zone-btn', n_clicks=0, className='btn btn-small'), + html.Span(id='draw-zone-msg', className='hint'), + ]), ]), html.Div(className='panel-body', children=dcc.Graph( id='psychrometric-chart', config={'displaylogo': False, 'displayModeBar': False})), diff --git a/psychrometric.py b/psychrometric.py index c285b68..82b9965 100644 --- a/psychrometric.py +++ b/psychrometric.py @@ -335,3 +335,15 @@ def add_zones(fig, zone_list): if zone_list: # zones onder de sensorpunten en lijnen leggen fig.data = fig.data[-len(zone_list):] + fig.data[:-len(zone_list)] return fig + + +def path_to_points(path): + """Plotly tekenpad ('M20,7.3L30,13.3Z', x = °C, y = g/kg) naar zonepunten [[°C, RV %], ...].""" + import re + points = [] + for temp, ratio in re.findall(r'(-?[\d.]+),(-?[\d.]+)', path or ''): + temp, w = float(temp), max(float(ratio), 0) / 1000 + pw = w * 101325 / (0.622 + w) + pws = 611.2 * np.exp(17.62 * temp / (243.12 + temp)) + points.append([round(temp, 2), round(float(min(max(pw / pws * 100, 0), 100)), 1)]) + return points diff --git a/test_app.py b/test_app.py index e9e7cbd..8191f2a 100644 --- a/test_app.py +++ b/test_app.py @@ -626,7 +626,7 @@ def test_zones_drawn_in_mollier_and_wood_diagram(): app = Dash(__name__) callbacks.register_callbacks(app) for output in ('psychrometric-chart.figure', 'wood-chart.figure'): - fig = run_callback(app, output, 0, 'nl', 0, *(['dashboard'] if output == 'wood-chart.figure' else []), + fig = run_callback(app, output, 0, 'nl', 0, *(['dashboard'] if output == 'wood-chart.figure' else [False]), trigger='selected-language.data') zone = next(trace for trace in fig.data if trace.name == 'Opslag') assert zone.fill == 'toself' and zone.line.color == zones.COLORS['blue'], output @@ -658,6 +658,58 @@ def test_zone_alarm_has_margin_at_the_edge(): assert any('Schimmel' in x for x in outbox_texts()) +def test_drawn_mollier_path_becomes_zone_points(): + """Plotly tekenpad (x = °C, y = g/kg) naar zonepunten in °C × RV.""" + import psychrometric + points = psychrometric.path_to_points('M20,7.29L30,13.31L25,40Z') + assert [round(t) for t, _ in points] == [20, 30, 25] + assert abs(points[0][1] - 50) < 1 and abs(points[1][1] - 50) < 1 and points[2][1] == 100 # boven verzadiging: 100 % + + +def test_save_drawn_zone_only_when_logged_in(): + import callbacks + import zones + from dash import Dash + setup_alerts([]) + app = Dash(__name__) + callbacks.register_callbacks(app) + path = 'M20,7.29L30,13.31L25,4Z' + with app.server.test_request_context(): + msg, drawing, _ = run_callback(app, 'draw-zone-msg.children', 1, path, 'Droog', 0, 'nl', + trigger='save-zone-btn.n_clicks') + assert msg == TRANSLATIONS_NL['draw_login'] and zones.load() == [] # dashboard kan publiek zijn + original, auth.is_logged_in = auth.is_logged_in, lambda: True + try: + assert run_callback(app, 'draw-zone-msg.children', 1, None, 'Droog', 0, 'nl', + trigger='save-zone-btn.n_clicks')[0] == TRANSLATIONS_NL['draw_empty'] + msg, drawing, _ = run_callback(app, 'draw-zone-msg.children', 1, path, 'Droog', 0, 'nl', + trigger='save-zone-btn.n_clicks') + finally: + auth.is_logged_in = original + assert msg == TRANSLATIONS_NL['draw_saved'] and drawing is False + zone = zones.load()[0] + assert zone['name'] == 'Droog' and zone['shape'] == 'polygon' and len(zone['points']) == 3 and zone['mode'] == 'none' + + +def test_draw_mode_pauses_live_mollier_and_keeps_path(): + import callbacks + import layout + from dash import Dash + setup_alerts([]) + rendered = str(layout.create_layout()) + assert 'draw-zone-btn' in rendered and 'draw-bar' in rendered + app = Dash(__name__) + callbacks.register_callbacks(app) + drawing, bar, _ = run_callback(app, '..zone-drawing.data...draw-bar.style', 1, 0, trigger='draw-zone-btn.n_clicks') + assert drawing is True and bar == {} + assert run_callback(app, 'psychrometric-chart.figure', 1, 'nl', 0, True, trigger='graph-update.n_intervals') \ + is callbacks.no_update # live-verversing zou de tekening wissen + assert run_callback(app, 'zone-draw-path.data', {'shapes': [{'path': 'M1,2L3,4L5,6Z'}]}, True, + trigger='psychrometric-chart.relayoutData') == 'M1,2L3,4L5,6Z' + drawing, bar, _ = run_callback(app, '..zone-drawing.data...draw-bar.style', 1, 1, trigger='cancel-zone-btn.n_clicks') + assert drawing is False and bar == {'display': 'none'} + + def test_board_shows_zone_alarm(): import json import callbacks @@ -1047,7 +1099,7 @@ def run_callback(app, output, *args, trigger): from dash._callback_context import context_value from dash._utils import AttributeDict context_value.set(AttributeDict(triggered_inputs=[{'prop_id': trigger, 'value': None}])) - key = next(k for k in app.callback_map if output in k) + key = output if output in app.callback_map else next(k for k in app.callback_map if output in k) return app.callback_map[key]['callback'].__wrapped__(*args) From da29363ab43f9d84862da8dc913fca9a2b21b1b8 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:55:38 +0100 Subject: [PATCH 08/19] fix: Zone alarms no longer stick after zones are saved again Saving zones (also after drawing one) gives them new ids; the alarm state of zones that no longer exist, are disabled or are display-only is now cleaned up on the next measurement. --- alerts.py | 11 ++++++++--- test_app.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/alerts.py b/alerts.py index eb6b1dc..4a6e8b7 100644 --- a/alerts.py +++ b/alerts.py @@ -185,9 +185,14 @@ def evaluate(device, values, now=None): if metric in values: _evaluate_metric(device, metric, level, values[metric], rule, delay, now) if 'temperature' in values and 'humidity' in values: - for zone in zones.effective(device['id']): - if zone['mode'] != 'none': - _evaluate_zone(device, zone, values['temperature'], values['humidity'], delay, now) + 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}") diff --git a/test_app.py b/test_app.py index 8191f2a..d8d0e09 100644 --- a/test_app.py +++ b/test_app.py @@ -710,6 +710,22 @@ def test_draw_mode_pauses_live_mollier_and_keeps_path(): assert drawing is False and bar == {'display': 'none'} +def test_zone_alarm_does_not_stick_after_zones_are_saved_again(): + """Opslaan geeft zones nieuwe id's (bv. na tekenen): een alarm op een oude zone mag niet blijven hangen.""" + import zones + names = setup_alerts([]) + assert settings.save({'alert_delay_seconds': 0}) == {} + danger = {'scope': 0, 'name': 'Schimmel', 'color': 'red', 'mode': 'keep_outside', 'level': 'alarm', + 'temp_min': 10, 'temp_max': 30, 'rh_min': 80, 'rh_max': 100, 'enabled': 'true'} + assert zones.save([danger]) == {} + cel = names['Koelcel'] + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 85.0}, now=1) + assert alerts.device_status(cel['id'])[0] == 'alarm' + assert zones.save([{**danger, 'mode': 'none'}]) == {} # nieuwe id, geen alarm meer + alerts.evaluate(cel, {'temperature': 20.0, 'humidity': 85.0}, now=2) + assert alerts.device_status(cel['id']) == ('ok', {}) + + def test_board_shows_zone_alarm(): import json import callbacks From 8c84c4a3c408187a6909ef034c6b26d6887ca9c2 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:00:08 +0100 Subject: [PATCH 09/19] feat: Alarm window from the header with muting - Clicking the sensor/alarm summary in the header opens a window with all active alarms and warnings per sensor, with the time they started - Each alarm can be muted for 15 minutes, 1, 8 or 24 hours, or unmuted; muted alarms stay visible but send no alarm or recovery notifications - Muting requires being logged in, because the dashboard can be public; mutes last until a restart --- alerts.py | 55 +++++++++++++++++++++++++++++++++++++------- callbacks.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++++- layout.py | 23 ++++++++++++++++++- test_app.py | 60 +++++++++++++++++++++++++++++++++++++++++++++--- translations.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 13 deletions(-) diff --git a/alerts.py b/alerts.py index 4a6e8b7..a9bf978 100644 --- a/alerts.py +++ b/alerts.py @@ -42,6 +42,7 @@ _rules_cache = None # [regel dicts], None = opnieuw laden _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:') -> 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'} @@ -214,7 +215,7 @@ def _evaluate_metric(device, metric, level, 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'} @@ -222,9 +223,9 @@ def _evaluate_metric(device, metric, level, value, rule, delay, now): 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]} + _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]) + _notify(prefix + breach[0], device, metric, value, breach[1], now) def _evaluate_zone(device, zone, temperature, humidity, delay, now): @@ -240,7 +241,7 @@ def _evaluate_zone(device, zone, temperature, humidity, delay, now): if safe: _state[key] = {'phase': 'normal'} if settings.get('alert_notify_recovery'): - _notify_zone('alert_zone_recovered', device, zone, temperature, humidity) + _notify_zone('alert_zone_recovered', device, zone, temperature, humidity, now) return if not breach: _state[key] = {'phase': 'normal'} @@ -248,17 +249,21 @@ def _evaluate_zone(device, zone, temperature, humidity, delay, now): if state['phase'] != 'pending': _state[key] = state = {'phase': 'pending', 'since': now} if now - state['since'] >= delay: - _state[key] = {'phase': 'alarm'} - _notify_zone('alert_zone_entered' if inside else 'alert_zone_left', device, zone, temperature, humidity) + _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_zone(template, device, zone, temperature, humidity): +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): +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}', @@ -272,6 +277,40 @@ 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}). diff --git a/callbacks.py b/callbacks.py index 656c22f..c4d2eb3 100644 --- a/callbacks.py +++ b/callbacks.py @@ -3,7 +3,7 @@ import math import pandas as pd from datetime import datetime, timedelta -from dash import Patch, callback_context, html, no_update, Input, Output, State +from dash import ALL, Patch, callback_context, html, no_update, Input, Output, State import plotly.graph_objects as go import alerts @@ -841,6 +841,65 @@ def render_meters(states, colors, t): ])) return html.Div(cards, className='meters') + @app.callback( + Output('alarm-modal', 'style'), + [Input('status-summary', 'n_clicks'), + Input('close-alarm-modal', 'n_clicks')], + prevent_initial_call=True + ) + def toggle_alarm_modal(open_clicks, close_clicks): + return {} if callback_context.triggered_id == 'status-summary' else {'display': 'none'} + + def alarm_list(t, logged_in): + """Actieve alarmen en waarschuwingen met dempknoppen (dempen alleen ingelogd).""" + when = lambda ts: datetime.fromtimestamp(ts).strftime('%d-%m %H:%M') # noqa: E731 + items = [] + for a in alerts.active_alarms(): + what = f"{t['zone']} {a['zone']}" if a['zone'] else t[METRIC_KEYS.get(a['metric'], a['metric'])] + status = 'alarm' if a['phase'] == 'alarm' and a['level'] == 'alarm' else 'warn' + details = [t['alarm_since'].format(time=when(a['since']))] if a['since'] else [] + if a['muted_until']: + details.append(t['muted_until'].format(time=when(a['muted_until']))) + buttons = [html.Button(t[f'btn_mute_{minutes}'], n_clicks=0, className='btn btn-small', + id={'type': 'mute', 'device': a['device_id'], 'metric': a['metric'], 'minutes': minutes}) + for minutes in (15, 60, 480, 1440)] + if a['muted_until']: + buttons.append(html.Button(t['btn_unmute'], n_clicks=0, className='btn btn-small btn-link', + id={'type': 'mute', 'device': a['device_id'], 'metric': a['metric'], 'minutes': 0})) + items.append(html.Li(className='alarm-item' + (' is-muted' if a['muted_until'] else ''), children=[ + html.Div([html.Span(t['st_' + status], className=f'st st-{status}'), html.Strong(a['device']), + html.Span(what)], className='alarm-what'), + html.Div(' · '.join(details), className='alarm-when'), + html.Div([html.Span(t['lbl_mute'], className='cfg-label'), *buttons], className='alarm-actions') + if logged_in else None, + ])) + if not items: + return html.Div(t['alarms_none'], className='hint') + return [None if logged_in else html.Div(t['mute_login'], className='hint'), + html.Ul(items, className='alarm-items')] + + @app.callback( + Output('alarm-list', 'children'), + [Input('graph-update', 'n_intervals'), + Input({'type': 'mute', 'device': ALL, 'metric': ALL, 'minutes': ALL}, 'n_clicks')], + [State('selected-language', 'data'), + State('alarm-modal', 'style')], + prevent_initial_call=True + ) + def update_alarm_list(n, mute_clicks, lang, modal_style): + """Lijst verversen zolang het venster open is; dempknop: dempen of opheffen (alleen ingelogd).""" + if (modal_style or {}).get('display') == 'none': + return no_update + t = TRANSLATIONS.get(lang) or TRANSLATIONS['en'] + trigger = callback_context.triggered_id + logged_in = auth.is_logged_in() + if isinstance(trigger, dict) and callback_context.triggered[0].get('value') and logged_in: + if trigger['minutes']: + alerts.mute(trigger['device'], trigger['metric'], trigger['minutes'] * 60) + else: + alerts.unmute(trigger['device'], trigger['metric']) + return alarm_list(t, logged_in) + @app.callback( [Output('device-overview', 'children'), Output('status-summary', 'children')], diff --git a/layout.py b/layout.py index fae5e4e..78efca0 100644 --- a/layout.py +++ b/layout.py @@ -190,6 +190,16 @@ .modal h3 { margin: 0 0 8px; font-size: 18px; } .modal h4 { margin: 20px 0 10px; font-size: 12px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); } .modal-close { position: absolute; top: 14px; right: 16px; font-size: 18px; } + .modal-wide { max-width: 760px; } + .summary-btn { background: none; border: 1px solid transparent; padding: 5px 8px; cursor: pointer; text-align: left; } + .summary-btn:hover { border-color: var(--line); background: var(--hover); } + .alarm-items { list-style: none; margin: 8px 0 0; padding: 0; border-top: 1px solid var(--line); } + .alarm-item { padding: 12px 0; border-bottom: 1px solid var(--line); display: grid; gap: 6px; } + .alarm-item.is-muted { opacity: .65; } + .alarm-what { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; } + .alarm-when { font-family: var(--mono); font-size: 12px; color: var(--muted); } + .alarm-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + .alarm-actions .cfg-label { margin-right: 4px; } .presets { display: flex; flex-wrap: wrap; gap: 8px; } .time-row { display: flex; align-items: center; gap: 6px; margin: 8px 0 14px; } .time-row input { width: 72px; } @@ -331,7 +341,7 @@ def create_layout(): dcc.RadioItems(id='main-tabs', className='nav', options=nav_options(t), value='dashboard'), ]), html.Div(className='topbar-right', children=[ - html.Span(id='status-summary', className='summary'), + html.Button(id='status-summary', className='summary summary-btn', n_clicks=0, title=t['alarms_open']), dcc.RadioItems(id='view-toggle', className='seg', options=view_options(t), value=settings.get('dashboard_view')), dcc.Dropdown(id='language-selector', className='lang', clearable=False, searchable=False, @@ -399,6 +409,17 @@ def create_layout(): id='wood-chart', config={'displaylogo': False, 'displayModeBar': False})), ]), + # Alarmvenster: actieve alarmen en waarschuwingen, dempen per sensor + grootheid + html.Div(id='alarm-modal', className='modal-backdrop', style={'display': 'none'}, children=[ + html.Div(className='modal modal-wide', role='dialog', **{'aria-modal': 'true', 'aria-labelledby': 'alarm-title'}, + children=[ + html.H3(t['alarms_title'], id='alarm-title'), + html.Button('✕', id='close-alarm-modal', n_clicks=0, className='btn btn-link modal-close', + title=t['btn_close']), + html.Div(id='alarm-list'), + ]), + ]), + # Keuzevenster voor de historische periode html.Div(id='range-modal', className='modal-backdrop', style={'display': 'none'}, children=[ html.Div(className='modal', role='dialog', children=[ diff --git a/test_app.py b/test_app.py index d8d0e09..ec12592 100644 --- a/test_app.py +++ b/test_app.py @@ -47,6 +47,7 @@ def fresh_settings(): alerts._state.clear() alerts._offline.clear() alerts._first_seen.clear() + alerts._mutes.clear() import zones zones._cache = None while not alerts._outbox.empty(): @@ -726,6 +727,58 @@ def test_zone_alarm_does_not_stick_after_zones_are_saved_again(): assert alerts.device_status(cel['id']) == ('ok', {}) +def test_muted_alarm_is_listed_but_sends_nothing(): + names = setup_alerts([{'scope': 0, 'metric': 'temperature', 'max_value': 8, 'enabled': 'true'}]) + assert settings.save({'alert_delay_seconds': 0}) == {} + cel = names['Koelcel'] + alerts.evaluate(cel, {'temperature': 9.0}, now=1000) + assert len(outbox_texts()) == 1 + active = alerts.active_alarms(now=1000) + assert [(a['device'], a['metric'], a['level'], a['phase'], a['since'], a['muted_until']) for a in active] == \ + [('Koelcel', 'temperature', 'alarm', 'alarm', 1000, None)] + alerts.mute(cel['id'], 'temperature', 3600, now=1000) + alerts.evaluate(cel, {'temperature': 5.0}, now=1100) # herstel tijdens dempen: geen melding + alerts.evaluate(cel, {'temperature': 9.0}, now=1200) # nieuw alarm tijdens dempen: geen melding + assert outbox_texts() == [] + assert alerts.active_alarms(now=1200)[0]['muted_until'] == 4600 + alerts.evaluate(cel, {'temperature': 5.0}, now=4700) # demping voorbij: herstel wordt weer gemeld + assert len(outbox_texts()) == 1 + alerts.evaluate(cel, {'temperature': 9.0}, now=4800) + alerts.mute(cel['id'], 'temperature', 3600, now=4800) + alerts.unmute(cel['id'], 'temperature') + alerts.evaluate(cel, {'temperature': 5.0}, now=4900) + assert len(outbox_texts()) == 2 + + +def test_alarm_window_lists_alarms_and_mutes_when_logged_in(): + import json + import callbacks + import layout + from dash import Dash + names = setup_alerts([{'scope': 0, 'metric': 'temperature', 'max_value': 8, 'enabled': 'true'}]) + alerts._mutes.clear() + assert settings.save({'alert_delay_seconds': 0}) == {} + cel = names['Koelcel'] + alerts.evaluate(cel, {'temperature': 9.0}) + assert 'alarm-modal' in str(layout.create_layout()) + app = Dash(__name__) + callbacks.register_callbacks(app) + assert run_callback(app, 'alarm-modal.style', 1, 0, trigger='status-summary.n_clicks') == {} + assert run_callback(app, 'alarm-modal.style', 1, 1, trigger='close-alarm-modal.n_clicks') == {'display': 'none'} + mute = json.dumps({'device': cel['id'], 'metric': 'temperature', 'minutes': 60, 'type': 'mute'}, + separators=(',', ':')) + '.n_clicks' + with app.server.test_request_context(): + listing = str(run_callback(app, 'alarm-list.children', 0, [1], 'nl', {}, trigger=mute, value=1)) + assert 'Koelcel' in listing and not alerts.is_muted(cel['id'], 'temperature') # dashboard kan publiek zijn + assert TRANSLATIONS_NL['mute_login'] in listing + original, auth.is_logged_in = auth.is_logged_in, lambda: True + try: + listing = str(run_callback(app, 'alarm-list.children', 0, [1], 'nl', {}, trigger=mute, value=1)) + finally: + auth.is_logged_in = original + assert alerts.is_muted(cel['id'], 'temperature') and TRANSLATIONS_NL['muted_until'].split('{')[0] in listing + + def test_board_shows_zone_alarm(): import json import callbacks @@ -874,6 +927,7 @@ def test_new_sensor_without_readings_gets_full_offline_period(): names = setup_alerts([]) settings.save({'alert_offline_minutes': 5}) alerts._first_seen.clear() + alerts._mutes.clear() alerts.check_offline(now=10_000) # net toegevoegd, nog geen meting alerts.check_offline(now=10_000 + 4 * 60) assert outbox_texts() == [] @@ -1110,11 +1164,11 @@ def test_wood_graph_and_zones_from_rules(): assert abs(fig.data[0].y[0] - 12.0) < 0.2 -def run_callback(app, output, *args, trigger): - """Roep een geregistreerde Dash callback aan alsof 'trigger' net veranderde.""" +def run_callback(app, output, *args, trigger, value=None): + """Roep een geregistreerde Dash callback aan alsof 'trigger' net veranderde (naar value).""" from dash._callback_context import context_value from dash._utils import AttributeDict - context_value.set(AttributeDict(triggered_inputs=[{'prop_id': trigger, 'value': None}])) + context_value.set(AttributeDict(triggered_inputs=[{'prop_id': trigger, 'value': value}])) key = output if output in app.callback_map else next(k for k in app.callback_map if output in k) return app.callback_map[key]['callback'].__wrapped__(*args) diff --git a/translations.py b/translations.py index dc58338..7dd0ed5 100644 --- a/translations.py +++ b/translations.py @@ -217,6 +217,18 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} is onder het minimum van {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is boven de waarschuwingsgrens van {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is onder de waarschuwingsgrens van {limit} {unit} ({time})', + 'alarms_title': 'Actieve alarmen', + 'alarms_none': 'Geen actieve alarmen of waarschuwingen.', + 'alarm_since': 'sinds {time}', + 'muted_until': 'gedempt tot {time}', + 'lbl_mute': 'Dempen', + 'btn_mute_15': '15 min', + 'btn_mute_60': '1 uur', + 'btn_mute_480': '8 uur', + 'btn_mute_1440': '24 uur', + 'btn_unmute': 'Demping opheffen', + 'mute_login': 'Log in via Settings om alarmen te dempen.', + 'alarms_open': 'Alarmen bekijken', 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % is buiten zone {zone} ({time})', 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % is in zone {zone} ({time})', 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % is weer goed voor zone {zone} ({time})', @@ -557,6 +569,18 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} is below the minimum of {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} is above the warning limit of {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} is below the warning limit of {limit} {unit} ({time})', + 'alarms_title': 'Active alarms', + 'alarms_none': 'No active alarms or warnings.', + 'alarm_since': 'since {time}', + 'muted_until': 'muted until {time}', + 'lbl_mute': 'Mute', + 'btn_mute_15': '15 min', + 'btn_mute_60': '1 hour', + 'btn_mute_480': '8 hours', + 'btn_mute_1440': '24 hours', + 'btn_unmute': 'Unmute', + 'mute_login': 'Log in via Settings to mute alarms.', + 'alarms_open': 'View alarms', 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % is outside zone {zone} ({time})', 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % is inside zone {zone} ({time})', 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % is fine again for zone {zone} ({time})', @@ -897,6 +921,18 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} liegt unter dem Minimum von {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} liegt über der Warngrenze von {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} liegt unter der Warngrenze von {limit} {unit} ({time})', + 'alarms_title': 'Aktive Alarme', + 'alarms_none': 'Keine aktiven Alarme oder Warnungen.', + 'alarm_since': 'seit {time}', + 'muted_until': 'stummgeschaltet bis {time}', + 'lbl_mute': 'Stummschalten', + 'btn_mute_15': '15 Min.', + 'btn_mute_60': '1 Std.', + 'btn_mute_480': '8 Std.', + 'btn_mute_1440': '24 Std.', + 'btn_unmute': 'Stummschaltung aufheben', + 'mute_login': 'Melden Sie sich über Settings an, um Alarme stummzuschalten.', + 'alarms_open': 'Alarme anzeigen', 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % ist außerhalb der Zone {zone} ({time})', 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % ist in der Zone {zone} ({time})', 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % ist wieder in Ordnung für Zone {zone} ({time})', @@ -1237,6 +1273,18 @@ 'alert_low': '⚠️ {device} : {metric} {value} {unit} est sous le minimum de {limit} {unit} ({time})', 'alert_warn_high': "🟡 {device} : {metric} {value} {unit} dépasse le seuil d'avertissement de {limit} {unit} ({time})", 'alert_warn_low': "🟡 {device} : {metric} {value} {unit} est sous le seuil d'avertissement de {limit} {unit} ({time})", + 'alarms_title': 'Alarmes actives', + 'alarms_none': 'Aucune alarme ni avertissement actif.', + 'alarm_since': 'depuis {time}', + 'muted_until': "en sourdine jusqu'à {time}", + 'lbl_mute': 'Mettre en sourdine', + 'btn_mute_15': '15 min', + 'btn_mute_60': '1 h', + 'btn_mute_480': '8 h', + 'btn_mute_1440': '24 h', + 'btn_unmute': 'Réactiver', + 'mute_login': 'Connectez-vous via Settings pour mettre des alarmes en sourdine.', + 'alarms_open': 'Voir les alarmes', 'alert_zone_left': '{icon} {device} : {temperature} °C / {humidity} % est hors de la zone {zone} ({time})', 'alert_zone_entered': '{icon} {device} : {temperature} °C / {humidity} % est dans la zone {zone} ({time})', 'alert_zone_recovered': '✅ {device} : {temperature} °C / {humidity} % est de nouveau correct pour la zone {zone} ({time})', @@ -1577,6 +1625,18 @@ 'alert_low': '⚠️ {device}: {metric} {value} {unit} está por debajo del mínimo de {limit} {unit} ({time})', 'alert_warn_high': '🟡 {device}: {metric} {value} {unit} supera el límite de aviso de {limit} {unit} ({time})', 'alert_warn_low': '🟡 {device}: {metric} {value} {unit} está por debajo del límite de aviso de {limit} {unit} ({time})', + 'alarms_title': 'Alarmas activas', + 'alarms_none': 'No hay alarmas ni avisos activos.', + 'alarm_since': 'desde {time}', + 'muted_until': 'silenciada hasta {time}', + 'lbl_mute': 'Silenciar', + 'btn_mute_15': '15 min', + 'btn_mute_60': '1 hora', + 'btn_mute_480': '8 horas', + 'btn_mute_1440': '24 horas', + 'btn_unmute': 'Quitar silencio', + 'mute_login': 'Inicie sesión en Settings para silenciar alarmas.', + 'alarms_open': 'Ver alarmas', 'alert_zone_left': '{icon} {device}: {temperature} °C / {humidity} % está fuera de la zona {zone} ({time})', 'alert_zone_entered': '{icon} {device}: {temperature} °C / {humidity} % está dentro de la zona {zone} ({time})', 'alert_zone_recovered': '✅ {device}: {temperature} °C / {humidity} % vuelve a estar bien para la zona {zone} ({time})', From a0e3e10338a895d7661ff3c372f0963d1ebb3841 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:04:37 +0100 Subject: [PATCH 10/19] feat: Dark/light toggle in the header - Toggle next to Board/Meters switches the theme instantly, charts included - The choice is remembered per browser (cookie); browsers without a choice use the default from Settings --- callbacks.py | 31 +++++++++++++++++++++++++------ layout.py | 26 ++++++++++++++++++++++++-- settings_ui.py | 2 +- test_app.py | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/callbacks.py b/callbacks.py index c4d2eb3..8df7041 100644 --- a/callbacks.py +++ b/callbacks.py @@ -41,8 +41,8 @@ def theme_mode(): - mode = settings.get('dashboard_theme') - return mode if mode in THEMES else 'dark' + """Thema van deze browser (zie layout.current_theme).""" + return layout.current_theme() def device_colors(mode): @@ -245,7 +245,8 @@ def register_callbacks(app): Output('time-range-dropdown', 'options'), Output('label-psychrometric-chart', 'children'), Output('label-time-position', 'children'), - Output('label-wood-chart', 'children')], + Output('label-wood-chart', 'children'), + Output('theme-toggle', 'options')], [Input('language-selector', 'value')] ) def update_language(lang): @@ -254,7 +255,8 @@ def update_language(lang): lang = 'en' t = TRANSLATIONS[lang] return (lang, t['app_name'], layout.nav_options(t), layout.view_options(t), layout.metric_options(t), - layout.range_options(t), t['psychrometric_chart'], t['time_position'], t['wood_chart']) + layout.range_options(t), t['psychrometric_chart'], t['time_position'], t['wood_chart'], + layout.theme_options(t)) @app.callback( [Output('page-dashboard', 'style'), @@ -275,6 +277,21 @@ def apply_theme(tab): """Thema uit Settings direct toepassen (ook na opslaan)""" return f'theme-{theme_mode()}' + @app.callback( + [Output('app-root', 'className', allow_duplicate=True), + Output('theme-applied', 'data')], + Input('theme-toggle', 'value'), + prevent_initial_call=True + ) + def set_theme(mode): + """Schakelaar in de kopbalk: keuze per browser onthouden (cookie, 1 jaar). + + 'theme-applied' komt pas na dit antwoord (cookie staat dan in de browser): de grafiek hangt daaraan. + """ + mode = mode if mode in THEMES else 'dark' + callback_context.response.set_cookie('theme', mode, max_age=365 * 86400, samesite='Lax') + return f'theme-{mode}', mode + @app.callback( [Output('live-graph', 'figure'), Output('data-count', 'children'), @@ -284,11 +301,13 @@ def apply_theme(tab): Input('live-graph', 'relayoutData'), Input('selected-language', 'data'), Input('device-selector', 'value'), - Input('metric-tabs', 'value')], + Input('metric-tabs', 'value'), + Input('theme-applied', 'data')], # na de themaschakelaar meteen hertekenen [State('graph-relayout-data', 'data'), State('main-tabs', 'value')] ) - def update_graph(n, time_range_minutes, relayout_data, lang, device_sel, metric, stored_relayout, tab): + def update_graph(n, time_range_minutes, relayout_data, lang, device_sel, metric, theme_applied, stored_relayout, + tab): """Grafiek van één grootheid: één lijn per sensor of alleen de gekozen sensor""" metric = metric or 'temperature' time_range_minutes = time_range_minutes or 1440 diff --git a/layout.py b/layout.py index 78efca0..bd130af 100644 --- a/layout.py +++ b/layout.py @@ -1,4 +1,5 @@ from dash import dcc, html +from flask import has_request_context, request import devices import settings @@ -322,12 +323,32 @@ def range_options(t): return [{'label': t[key], 'value': value} for key, value in TIME_RANGES] +THEMES = ('dark', 'light') + + +def current_theme(): + """Thema: keuze van deze browser (cookie van de schakelaar in de kopbalk), anders de standaard uit Settings. + + Ook de grafieken (op de server gekleurd) gebruiken dit: elke callback is een request met de cookie. + """ + mode = request.cookies.get('theme') if has_request_context() else None + if mode not in THEMES: + mode = settings.get('dashboard_theme') + return mode if mode in THEMES else 'dark' + + +def theme_options(t): + return [{'label': t[f'opt_dashboard_theme_{mode}'], 'value': mode} for mode in THEMES] + + def create_layout(): - """Creëert de hoofdlayout (per page load: taal, thema en weergave uit Settings)""" + """Creëert de hoofdlayout (per page load: taal en weergave uit Settings, thema van deze browser)""" lang = settings.get('default_language') t = TRANSLATIONS[lang] - return html.Div(id='app-root', className=f"theme-{settings.get('dashboard_theme')}", children=[ + theme = current_theme() + return html.Div(id='app-root', className=f'theme-{theme}', children=[ dcc.Location(id='url', refresh=False), + dcc.Store(id='theme-applied'), dcc.Store(id='selected-language', data=lang), dcc.Store(id='graph-relayout-data', data={}), dcc.Store(id='historical-data-store'), @@ -344,6 +365,7 @@ def create_layout(): html.Button(id='status-summary', className='summary summary-btn', n_clicks=0, title=t['alarms_open']), dcc.RadioItems(id='view-toggle', className='seg', options=view_options(t), value=settings.get('dashboard_view')), + dcc.RadioItems(id='theme-toggle', className='seg', options=theme_options(t), value=theme), dcc.Dropdown(id='language-selector', className='lang', clearable=False, searchable=False, options=[{'label': LANGUAGE_NAMES[code], 'value': code} for code in LANGUAGE_NAMES], value=lang), diff --git a/settings_ui.py b/settings_ui.py index 9364209..3355818 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -661,7 +661,7 @@ def apply_saved_display(status, lang, metric): options = layout.metric_options(_t(lang)) if metric not in [o['value'] for o in options]: metric = 'temperature' - return (f"theme-{settings.get('dashboard_theme')}", settings.get('dashboard_view'), options, metric, + return (f'theme-{layout.current_theme()}', settings.get('dashboard_view'), options, metric, layout.mollier_style(), layout.wood_style()) @app.callback( diff --git a/test_app.py b/test_app.py index ec12592..a006e44 100644 --- a/test_app.py +++ b/test_app.py @@ -779,6 +779,39 @@ def test_alarm_window_lists_alarms_and_mutes_when_logged_in(): assert alerts.is_muted(cel['id'], 'temperature') and TRANSLATIONS_NL['muted_until'].split('{')[0] in listing +def test_theme_follows_browser_choice_before_setting(): + """Schakelaar in de kopbalk: keuze per browser (cookie); zonder keuze de standaard uit Settings.""" + import callbacks + from flask import Flask + fresh_settings() + assert settings.save({'dashboard_theme': 'dark'}) == {} + server = Flask(__name__) + with server.test_request_context(headers={'Cookie': 'theme=light'}): + assert callbacks.theme_mode() == 'light' + with server.test_request_context(headers={'Cookie': 'theme=paars'}): + assert callbacks.theme_mode() == 'dark' + with server.test_request_context(): + assert callbacks.theme_mode() == 'dark' + assert callbacks.theme_mode() == 'dark' # buiten een request (threads) + + +def test_theme_toggle_in_header_remembers_choice(): + import callbacks + import layout + from dash import Dash + from dash._callback_context import context_value + fresh_settings() + app = Dash(__name__) + callbacks.register_callbacks(app) + with app.server.test_request_context(headers={'Cookie': 'theme=light'}): + root = layout.create_layout() + toggle = next(c for c in components(root) if getattr(c, 'id', None) == 'theme-toggle') + assert root.className == 'theme-light' and toggle.value == 'light' + css_class, applied = run_callback(app, 'theme-applied.data', 'dark', trigger='theme-toggle.value') + assert css_class == 'theme-dark' and applied == 'dark' + assert 'theme=dark' in context_value.get().dash_response.headers.get('Set-Cookie') + + def test_board_shows_zone_alarm(): import json import callbacks @@ -1168,7 +1201,8 @@ def run_callback(app, output, *args, trigger, value=None): """Roep een geregistreerde Dash callback aan alsof 'trigger' net veranderde (naar value).""" from dash._callback_context import context_value from dash._utils import AttributeDict - context_value.set(AttributeDict(triggered_inputs=[{'prop_id': trigger, 'value': value}])) + from flask import Response + context_value.set(AttributeDict(triggered_inputs=[{'prop_id': trigger, 'value': value}], dash_response=Response())) key = output if output in app.callback_map else next(k for k in app.callback_map if output in k) return app.callback_map[key]['callback'].__wrapped__(*args) From d49a7351f82e81ef356b3178de08afe06d9adfb9 Mon Sep 17 00:00:00 2001 From: CervezaStallone <48717031+CervezaStallone@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:06:02 +0100 Subject: [PATCH 11/19] feat: Logo in the header and as favicon --- assets/logo.svg | 18 ++++++++++++++++++ layout.py | 9 +++++++-- test_app.py | 9 +++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 assets/logo.svg diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..a4b8e09 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/layout.py b/layout.py index bd130af..818898e 100644 --- a/layout.py +++ b/layout.py @@ -12,7 +12,7 @@ {%metas%} {%title%} - {%favicon%} + {%css%}