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/CHANGELOG.md b/CHANGELOG.md index 367c2e4..c46129a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to Climate Monitor. Older versions: see the [GitHub releases](https://github.com/CervezaStallone/ClimateMonitor/releases). +## [2.3.0] - 2026-11-02 + +### Added +- **Wood moisture** (equilibrium moisture content, USDA Wood Handbook) per sensor, with its own diagram, a correction for the wood species and a response time (averaged value, so short peaks do not trigger alarms). On/off in Settings → Display. +- **Warning and alarm level** per limit, e.g. a warning at 18 % and an alarm at 20 % wood moisture. +- **Zones** in the Mollier and wood diagram: coloured areas of temperature × humidity from min/max or drawn freely in the Mollier diagram, with a notification when a sensor leaves a desired zone or enters a danger zone. +- **Alarm window** from the top bar with muting per alarm (15 minutes to 24 hours). +- **Dark/light toggle** in the top bar, remembered per browser. +- **Logo** in the top bar and as favicon. +- Per sensor: **data type** (int16, uint16, int32, uint32, float32), **byte order** (ABCD, DCBA, BADC, CDAB) and a **calibration correction** for temperature and humidity. +- **Scan** in Settings → Modbus finds which slave addresses answer on a bus. + +### Changed +- Buses, sensors and limits are edited with normal form fields instead of a table: arrow keys move the cursor and the dropdowns work reliably. The port field suggests detected adapters (stable `/dev/serial/by-id` names first). +- Dew point uses the Magnus formula instead of a rule of thumb (new measurements only); the comfort score uses the same dew point. +- `?tab=settings` disappears from the address bar when you go back to the dashboard. + +### Fixed +- Saving settings no longer repeats notifications for alarms that are already active. +- An impossible raw humidity is rejected instead of being shown as 0 %. +- Long status texts on the meter cards no longer overflow. + +**Upgrading:** see [Updating to a new version](README.md#updating-to-a-new-version). The database is upgraded automatically: existing sensors become int16 / ABCD without correction (same readings as before), existing limits get the level *Alarm*. Reload the dashboard with `Ctrl+F5` afterwards. + ## [2.2.0] - 2026-10-30 ### Changed diff --git a/README.md b/README.md index b5f76dd..0684756 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) @@ -59,23 +60,27 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - **Multiple measurements**: - Temperature (°C) - Humidity (%) - - Dew Point (calculated) + - Dew Point (Magnus formula) - Absolute Humidity (g/m³) - 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 +- **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 - **Multilingual**: 5 languages fully supported (Dutch, English, German, French, Spanish) - **Two dashboard views**: *Board* (every sensor as one row, all sensors at a glance) or *Meters* (one panel per sensor) -- **Dark and light theme**: chosen in Settings; layout adapts to phones and tablets +- **Dark and light theme**: toggle in the top bar, remembered per browser (default in Settings); layout adapts to phones and tablets +- **Alarm window**: click the sensor summary in the top bar to see all active alarms and warnings and mute them for 15 minutes to 24 hours - **Clear status per sensor**: OK, warning, alarm or offline, with the value that is out of limits highlighted - **Zoom persistence**: Graph zoom stays preserved during live updates - **Robust**: Extensive input validation and error handling - **Multiple sensors and RS-485 buses**: several USB/RS-485 interfaces, each with several Modbus slaves; combined and per-sensor graphs +- **Flexible sensors**: register data type (int16, uint16, int32, uint32, float32), byte order (ABCD, DCBA, BADC, CDAB) and a calibration correction per sensor; *Scan* finds which slave addresses answer on a bus - **Configurable in the browser**: Settings tab for buses, sensors, timezone, language, data retention, server and security -- **Notifications**: Telegram and/or e-mail when limits are exceeded (global or per sensor), when values are back to normal and when a sensor goes offline +- **Notifications**: Telegram and/or e-mail when limits are exceeded (global or per sensor, at warning or alarm level) or a zone is left or entered, when values are back to normal and when a sensor goes offline - **MQTT**: publish the values of every sensor to an MQTT broker (Home Assistant, Node-RED, SCADA) - **Login protection**: Admin password for the Settings tab, optionally for the whole app @@ -97,7 +102,11 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity #### Mollier diagram ![Mollier diagram](img/mollier.png) -*Current state of every sensor in its own colour, with saturation line, RH curves and comfort zone.* +*Current state of every sensor in its own colour, with saturation line, RH curves, comfort zone and your own zones (here a desired comfort zone and a mould-risk danger zone).* + +#### Wood moisture +![Wood moisture diagram](img/wood-diagram.png) +*Equilibrium wood moisture: lines of equal wood moisture, the 18 % warning and 20 % alarm limits and the zones.* #### Historical replay ![Historical range](img/history-range.png) @@ -272,6 +281,8 @@ 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 +├── zones.py # Zones (areas of temperature × humidity) with colour and alarm ├── callbacks.py # Dashboard callbacks (board/meters, chart, Mollier, replay) ├── settings.py # Settings stored in the database, validation, password reset ├── settings_ui.py # Settings tab @@ -280,6 +291,7 @@ ClimateMonitor/ ├── translations.py # Texts in NL, EN, DE, FR, ES ├── test_app.py # Automated tests ├── deploy/ # Service install scripts (Linux, Windows) +├── assets/ # Logo (header and favicon), served by Dash ├── img/ # README screenshots ├── .env # Configuration (not in git) ├── .env.example # Example configuration @@ -297,16 +309,21 @@ ClimateMonitor/ | Status | Meaning | |--------|---------| | OK | Value received recently and all limits are met | -| Warning | A limit is exceeded, but the alarm delay is still running (no notification sent yet) | -| Alarm | A limit has been exceeded for longer than the alarm delay; notifications are sent | +| Warning | A warning-level limit or zone is exceeded, or an alarm limit is exceeded while the alarm delay is still running | +| Alarm | An alarm limit or zone has been exceeded for longer than the alarm delay; notifications are sent | | Offline | No valid reading for 3 poll intervals (at least 30 seconds); the last reading time is shown | The value that is out of limits is highlighted in its cell. The top bar shows how many sensors are -in alarm, warning or offline. +in alarm, warning or offline; click it to open the **alarm window**. There you see every active alarm +and warning with the time it started, and (when logged in) mute it for 15 minutes, 1, 8 or 24 hours. +A muted alarm stays visible but sends no notifications; mutes end when the app restarts. + +**Theme**: *Dark* / *Light* in the top bar switches instantly and is remembered in this browser. **Views** - *Board*: one row per sensor with temperature, humidity, dew point, absolute humidity, comfort, - a 24-hour temperature trend and the status. On a phone the less important columns are hidden. + wood moisture (when enabled), a 24-hour temperature trend and the status. On a phone the less + important columns are hidden and the headers are shortened. - *Meters*: one panel per sensor with large values. Every sensor keeps the same colour everywhere (its marker, trend line, chart line and Mollier point). @@ -314,8 +331,13 @@ Every sensor keeps the same colour everywhere (its marker, trend line, chart lin **Chart**: choose the quantity with the tabs (temperature, humidity, dew point, absolute humidity, comfort), and choose *All sensors (combined)* or a single sensor and the time range on the right. -**Mollier diagram and replay**: shows the current state of all sensors. Select one sensor in the -chart to enable *Choose range for historical analysis*. +**Mollier diagram and replay**: shows the current state of all sensors and your zones. Select one +sensor in the chart to enable *Choose range for historical analysis*. + +**Drawing a zone**: click *Draw zone* above the Mollier diagram, draw a closed shape with the mouse +or a finger, give it a name, choose desired zone / danger zone / display only and a colour, and save +(requires being logged in). The zone applies to the sensor selected above the chart, or to all +sensors. Change it later in Settings → Notifications: limits → Zones. ### Configuration @@ -326,11 +348,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 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), 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 | | Server | Listen address and port (*restart required*) | @@ -384,12 +407,16 @@ its detailed view and historical replay. - Use **Test Telegram** / **Test e-mail** to check the settings before saving. - Token and password are never shown again; leave the field empty to keep the stored value. 2. **Limits** (Settings → Notifications: limits) - - Add a limit per quantity (temperature, humidity, dew point, absolute humidity) with a - minimum and/or maximum, for *All sensors* or for one sensor. A rule for one sensor replaces - the rule for all sensors for that quantity. + - Add a limit per quantity (temperature, humidity, dew point, absolute humidity, wood moisture) + with a minimum and/or maximum and a level (*Warning* or *Alarm*), for *All sensors* or for one + sensor. A rule for one sensor replaces the rule for all sensors for that quantity and level. + - *Zones*: an area of temperature × humidity (min/max, or drawn in the Mollier diagram) with a + colour. *Desired zone* notifies when a sensor leaves it, *danger zone* when a sensor enters it, + *display only* just draws it. - *Alarm delay*: how long a limit must be exceeded before an alert is sent (no alerts for spikes). - - *Hysteresis*: how far back inside the limit a value must be before it counts as normal again, - so a value hovering around the limit does not cause a stream of messages. + - *Hysteresis*: how far back inside the limit (or away from a zone edge) a value must be before it + counts as normal again, so a value hovering around the limit does not cause a stream of messages. + - Saving the settings keeps alarms that are already active, as long as their limit did not change. - *Notify when back to normal* and *Offline alert after N minutes* (0 = off). - The last 20 notifications and their delivery status are listed below the limits. Failed messages are retried automatically (up to every hour). @@ -404,7 +431,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 +534,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: @@ -604,11 +652,13 @@ The application includes an automated test suite: python test_app.py ``` -**Test coverage** (45 tests, also run by the *CI Testing* workflow on every pull request): +**Test coverage** (84 tests, also run by the *CI Testing* workflow on every pull request): - Settings: migration from `.env`, validation, all-or-nothing saving, write-only secrets - Login: password rules, sessions, throttling, protection levels, redirects -- Buses and sensors: validation, upgrade of existing data, signed register values, failure isolation -- Notifications: limits, alarm delay, hysteresis, recovery and offline alerts, retries +- Buses and sensors: validation, upgrade of existing data, data types and byte order, calibration, scanning, failure isolation +- Notifications: limits and levels, zones, alarm delay, hysteresis, recovery, muting, offline alerts, retries +- Wood moisture: formula against the Wood Handbook, response time, restart after settings change +- Dashboard: form fields, zones in the diagrams, drawing zones, theme toggle, alarm window, phone layout - MQTT: topics, payloads, reconnect behaviour - Translations: same placeholders in every language, no untranslated text in the layout @@ -623,7 +673,7 @@ pip freeze > requirements.txt - **app.py**: entry point, starts the Modbus reader, notifications, MQTT and the web server - **layout.py**: page structure, theme tokens (dark/light) and styling -- **callbacks.py**: dashboard (board/meters, chart, status, Mollier diagram, historical replay) +- **callbacks.py**: dashboard (board/meters, chart, status, Mollier and wood diagram, drawing zones, alarm window, theme) - **settings_ui.py**: Settings tab - **settings.py** / **devices.py**: settings, buses and sensors stored in the database - **modbus_reader.py**: one thread per RS-485 bus, batched database writes @@ -632,6 +682,8 @@ pip freeze > requirements.txt - **auth.py**: admin password, login sessions, protection - **database.py**: table-per-day storage, queries, cleanup - **psychrometric.py**: Mollier diagram +- **wood.py**: wood equilibrium moisture +- **zones.py**: zones, point-in-zone check - **translations.py**: texts in NL, EN, DE, FR, ES - **test_app.py**: automated tests @@ -647,6 +699,10 @@ pip freeze > requirements.txt - Ensure the `src/` folder exists - Check write permissions in the project folder +#### `TypeError: ... unexpected keyword argument` after updating +The code was updated but the Python packages were not. Activate the virtual environment and run +`pip install -r requirements.txt` (or run the service install script again), then start the app. + #### Port already in use - Change the port in the Settings tab (Server section) and restart the app diff --git a/alerts.py b/alerts.py index 2fc76ce..438ebac 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 @@ -30,15 +31,18 @@ 'humidity': ('humidity', '%', 'alert_hysteresis_humidity'), 'dewpoint': ('dewpoint', '°C', 'alert_hysteresis_temp'), 'absolute_humidity': ('abs_humidity', 'g/m³', 'alert_hysteresis_humidity'), + 'wood_emc': ('wood_emc', '%', 'alert_hysteresis_wood'), # vertraagd houtvocht, zie wood.py } +LEVELS = ['alarm', 'warning'] # waarschuwing = eigen melding, geel in het dashboard MAX_RETRY_DELAY = 3600 SEND_TIMEOUT = 15 _lock = threading.Lock() _rules_cache = None # [regel dicts], None = opnieuw laden -_state = {} # (device_id, metric) -> {'phase', 'since', 'kind', 'limit'} +_state = {} # (device_id, metric, level) -> {'phase', 'since', 'kind', 'limit'} _offline = set() # device ids waarvoor een offline melding is verstuurd +_mutes = {} # (device_id, grootheid of 'zone:') -> 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'} @@ -53,7 +57,9 @@ def _connect(): conn = sqlite3.connect(DB_FILE, timeout=30) conn.execute('''CREATE TABLE IF NOT EXISTS alert_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER, metric TEXT NOT NULL, - min_value REAL, max_value REAL, enabled INTEGER NOT NULL)''') + min_value REAL, max_value REAL, enabled INTEGER NOT NULL, level TEXT NOT NULL DEFAULT 'alarm')''') + if 'level' not in [row[1] for row in conn.execute('PRAGMA table_info(alert_rules)')]: # tabel van vóór niveaus + conn.execute("ALTER TABLE alert_rules ADD COLUMN level TEXT NOT NULL DEFAULT 'alarm'") return conn @@ -79,6 +85,9 @@ def validate_rules(rows): metric = row.get('metric') if metric not in METRICS: row_errors['metric'] = 'err_choice' + level = row.get('level') or 'alarm' + if level not in LEVELS: + row_errors['level'] = 'err_choice' values = {} for field in ('min_value', 'max_value'): try: @@ -91,12 +100,12 @@ def validate_rules(rows): elif values['min_value'] is not None and values['max_value'] is not None \ and values['min_value'] >= values['max_value']: row_errors['max_value'] = 'err_min_max' - elif (scope, metric) in seen: + elif (scope, metric, level) in seen: row_errors['metric'] = 'err_duplicate' - seen.add((scope, metric)) + seen.add((scope, metric, level)) if row_errors: errors[index] = row_errors - clean.append({'device_id': scope or None, 'metric': metric, **values, + clean.append({'device_id': scope or None, 'metric': metric, 'level': level, **values, 'enabled': str(row.get('enabled', 'true')).lower() in ('true', '1')}) return clean, errors @@ -107,21 +116,36 @@ def save_rules(rows): clean, errors = validate_rules(rows) if errors: return errors + before = {key: _rule_limits(*key) for key in list(_state)} with _lock: conn = _connect() try: conn.execute('BEGIN') conn.execute('DELETE FROM alert_rules') - conn.executemany('INSERT INTO alert_rules (device_id, metric, min_value, max_value, enabled) VALUES (?, ?, ?, ?, ?)', - [(r['device_id'], r['metric'], r['min_value'], r['max_value'], int(r['enabled'])) for r in clean]) + conn.executemany('INSERT INTO alert_rules (device_id, metric, level, min_value, max_value, enabled) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + [(r['device_id'], r['metric'], r['level'], r['min_value'], r['max_value'], int(r['enabled'])) + for r in clean]) conn.commit() finally: conn.close() _rules_cache = None - _state.clear() # nieuwe grenzen: opnieuw beoordelen + # Alleen regels met andere grenzen opnieuw beoordelen: anders meldt elk Opslaan in Settings actieve alarmen opnieuw. + # Zones hebben hun eigen opruiming (evaluate) en houden hun id bij opslaan. + for key, limits in before.items(): + if not key[1].startswith('zone:') and _rule_limits(*key) != limits: + _state.pop(key, None) return {} +def _rule_limits(device_id, metric, level): + """(min, max) van de regel die nu voor deze sensor + grootheid + niveau geldt, None zonder regel.""" + if metric.startswith('zone:'): + return None + rule = effective_rules(device_id).get((metric, level)) + return (rule['min_value'], rule['max_value']) if rule else None + + def load_rules(): """Alle regels; regels van verwijderde sensoren worden genegeerd.""" global _rules_cache @@ -140,17 +164,18 @@ def load_rules(): def effective_rules(device_id): - """{grootheid: regel} voor één sensor: sensorregel vervangt globale regel.""" + """{(grootheid, niveau): regel} voor één sensor: sensorregel vervangt globale regel van hetzelfde niveau.""" rules = {} for rule in load_rules(): if rule['enabled'] and rule['device_id'] is None: - rules.setdefault(rule['metric'], rule) + rules.setdefault((rule['metric'], rule['level']), rule) for rule in load_rules(): if rule['device_id'] == device_id: + key = (rule['metric'], rule['level']) if rule['enabled']: - rules[rule['metric']] = rule + rules[key] = rule else: - rules.pop(rule['metric'], None) # uitgeschakelde sensorregel = globale regel uit voor deze sensor + rules.pop(key, None) # uitgeschakelde sensorregel = globale regel uit voor deze sensor return rules @@ -170,15 +195,24 @@ def evaluate(device, values, now=None): now = now or time.time() _check_online(device, now) delay = settings.get('alert_delay_seconds') - for metric, rule in effective_rules(device['id']).items(): + for (metric, level), rule in effective_rules(device['id']).items(): if metric in values: - _evaluate_metric(device, metric, values[metric], rule, delay, now) + _evaluate_metric(device, metric, level, values[metric], rule, delay, now) + if 'temperature' in values and 'humidity' in values: + active = [z for z in zones.effective(device['id']) if z['mode'] != 'none'] + for zone in active: + _evaluate_zone(device, zone, values['temperature'], values['humidity'], delay, now) + # Toestand van zones die weg zijn, uit staan of nieuwe id's kregen (opnieuw opgeslagen) opruimen: + # anders blijft een oud zone-alarm voor altijd op het dashboard staan + current = {f"zone:{z['id']}" for z in active} + for key in [k for k in list(_state) if k[0] == device['id'] and k[1].startswith('zone:') and k[1] not in current]: + _state.pop(key, None) except Exception as e: print(f"Alarm evaluatie fout ({device.get('name')}): {e}") -def _evaluate_metric(device, metric, value, rule, delay, now): - key = (device['id'], metric) +def _evaluate_metric(device, metric, level, value, rule, delay, now): + key = (device['id'], metric, level) state = _state.setdefault(key, {'phase': 'normal'}) hysteresis = settings.get(METRICS[metric][2]) if rule['max_value'] is not None and value > rule['max_value']: @@ -194,7 +228,7 @@ def _evaluate_metric(device, metric, value, rule, delay, now): if recovered and breach is None: _state[key] = {'phase': 'normal'} if settings.get('alert_notify_recovery'): - _notify('alert_recovered', device, metric, value, limit) + _notify('alert_recovered', device, metric, value, limit, now) return if breach is None: _state[key] = {'phase': 'normal'} @@ -202,11 +236,47 @@ def _evaluate_metric(device, metric, 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]} - _notify('alert_high' if breach[0] == 'high' else 'alert_low', device, metric, value, breach[1]) + _state[key] = {'phase': 'alarm', 'kind': breach[0], 'limit': breach[1], 'since': state['since']} + prefix = 'alert_warn_' if level == 'warning' else 'alert_' + _notify(prefix + breach[0], device, metric, value, breach[1], now) + + +def _evaluate_zone(device, zone, temperature, humidity, delay, now): + """Zone-alarm: gewenste zone verlaten ('keep_inside') of gevarenzone binnengaan ('keep_outside').""" + key = (device['id'], f"zone:{zone['id']}", zone['level']) + inside = zones.contains(zone, temperature, humidity) + breach = inside if zone['mode'] == 'keep_outside' else not inside + state = _state.setdefault(key, {'phase': 'normal'}) + if state['phase'] == 'alarm': + # Herstel pas met de hysterese (temperatuur en RV) als marge tot de rand: geen klapperen op de grens + safe = zones.clearly(zone, temperature, humidity, settings.get('alert_hysteresis_temp'), + settings.get('alert_hysteresis_humidity'), inside=zone['mode'] == 'keep_inside') + if safe: + _state[key] = {'phase': 'normal'} + if settings.get('alert_notify_recovery'): + _notify_zone('alert_zone_recovered', device, zone, temperature, humidity, now) + return + if not breach: + _state[key] = {'phase': 'normal'} + return + if state['phase'] != 'pending': + _state[key] = state = {'phase': 'pending', 'since': now} + if now - state['since'] >= delay: + _state[key] = {'phase': 'alarm', 'since': state['since']} + _notify_zone('alert_zone_entered' if inside else 'alert_zone_left', device, zone, temperature, humidity, now) -def _notify(template, device, metric, value, limit): +def _notify_zone(template, device, zone, temperature, humidity, now): + if is_muted(device['id'], f"zone:{zone['id']}", now): + return + send(_t()[template].format(icon='🟡' if zone['level'] == 'warning' else '⚠️', device=device['name'], + zone=zone['name'], temperature=f'{temperature:.1f}', humidity=f'{humidity:.0f}', + time=_now_text())) + + +def _notify(template, device, metric, value, limit, now): + if is_muted(device['id'], metric, now): + return t = _t() name_key, unit, _ = METRICS[metric] send(t[template].format(device=device['name'], metric=t[name_key], value=f'{value:.1f}', limit=f'{limit:.1f}', @@ -220,13 +290,51 @@ def _check_online(device, now): send(_t()['alert_online'].format(device=device['name'], time=_now_text(now))) +# --- dempen ----------------------------------------------------------------------- +# ponytail: dempingen staan in het geheugen en vervallen bij een herstart (dan weer meldingen); in de database +# zetten als langere dempingen over een herstart heen moeten blijven + +def mute(device_id, metric, seconds, now=None): + """Geen meldingen (alarm of herstel) voor deze sensor + grootheid/zone tot nu + seconds.""" + _mutes[(device_id, metric)] = (now or time.time()) + seconds + + +def unmute(device_id, metric): + _mutes.pop((device_id, metric), None) + + +def is_muted(device_id, metric, now=None): + return _mutes.get((device_id, metric), 0) > (now or time.time()) + + +def active_alarms(now=None): + """Actieve alarmen en waarschuwingen voor het alarmvenster, zwaarste eerst.""" + now = now or time.time() + names = {d['id']: d['name'] for d in devices.list_devices()} + zone_names = {f"zone:{z['id']}": z['name'] for z in zones.load()} + result = [] + for (device_id, metric, level), state in list(_state.items()): + if state['phase'] == 'normal' or device_id not in names: + continue + until = _mutes.get((device_id, metric)) + result.append({'device_id': device_id, 'device': names[device_id], 'metric': metric, + 'zone': zone_names.get(metric), 'level': level, + 'phase': 'alarm' if state['phase'] == 'alarm' else 'pending', 'since': state.get('since'), + 'muted_until': until if until and until > now else None}) + return sorted(result, key=lambda a: (a['phase'] != 'alarm', a['level'] != 'alarm', a['device'], a['metric'])) + + def device_status(device_id): """Alarmstatus van een sensor: ('alarm' | 'warn' | 'ok', {grootheid: fase}). - 'warn' = grens overschreden maar de alarmvertraging loopt nog; 'alarm' = alarm actief. + Fase per grootheid: 'alarm' = alarm actief; 'pending' = alarmvertraging loopt of een + waarschuwing is actief. Het zwaarste niveau per grootheid telt. """ - phases = {metric: state['phase'] for (dev, metric), state in list(_state.items()) - if dev == device_id and state['phase'] != 'normal'} + phases = {} + for (dev, metric, level), state in list(_state.items()): + metric = 'zone' if metric.startswith('zone:') else metric # alle zones samen als één grootheid + if dev == device_id and state['phase'] != 'normal' and phases.get(metric) != 'alarm': + phases[metric] = 'alarm' if state['phase'] == 'alarm' and level == 'alarm' else 'pending' if 'alarm' in phases.values(): return 'alarm', phases if 'pending' in phases.values(): 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/auth.py b/auth.py index 2c4bf93..459991d 100644 --- a/auth.py +++ b/auth.py @@ -8,7 +8,6 @@ import threading import time from html import escape -from urllib.parse import urlsplit from flask import Response, has_request_context, redirect, request, session from itsdangerous import BadSignature, URLSafeTimedSerializer diff --git a/callbacks.py b/callbacks.py index 73b7154..21fa106 100644 --- a/callbacks.py +++ b/callbacks.py @@ -1,17 +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 ALL, 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 psychrometric import add_device_points, create_psychrometric_chart, create_psychrometric_chart_historical +from modbus_reader import derived_values +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. @@ -27,14 +33,16 @@ 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', + 'zone': 'zone'} # zone: alleen als statuslabel (alle zone-alarmen samen) 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): @@ -103,7 +111,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)): @@ -122,6 +130,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 +149,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 +244,9 @@ 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'), + Output('theme-toggle', 'options')], [Input('language-selector', 'value')] ) def update_language(lang): @@ -234,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']) + layout.range_options(t), t['psychrometric_chart'], t['time_position'], t['wood_chart'], + layout.theme_options(t)) @app.callback( [Output('page-dashboard', 'style'), @@ -255,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'), @@ -264,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 @@ -296,16 +335,80 @@ 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('draw-zone-mode', 'value'), + State('draw-zone-color', 'value'), + State('device-selector', 'value'), + State('selected-language', 'data')], + prevent_initial_call=True + ) + def save_drawn_zone(n_clicks, path, name, mode, color, 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': color or 'blue', 'mode': mode or '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' @@ -319,8 +422,30 @@ 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'), + [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 = 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 + # Callback voor modal open/close @app.callback( Output('range-modal', 'style'), @@ -654,9 +779,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 +800,17 @@ 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') - head = html.Tr([html.Th('', className='bar'), html.Th(t['hdr_sensor']), html.Th(t['temperature']), html.Th(t['humidity']), + show_comfort, show_wood = settings.get('show_comfort'), settings.get('show_wood') + + 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, + 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 = [] @@ -693,6 +828,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-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'), @@ -719,12 +855,73 @@ 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), ])) 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')], @@ -766,7 +963,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') ) @@ -775,4 +977,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/devices.py b/devices.py index beaf563..75c26c7 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 @@ -38,6 +39,13 @@ '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}, + # 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}, } @@ -62,7 +70,16 @@ 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, + 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)')] + # 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} {definition}') return conn @@ -110,7 +127,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) @@ -303,6 +320,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/img/dashboard-board-dark.png b/img/dashboard-board-dark.png index ee1a944..fe3c5b8 100644 Binary files a/img/dashboard-board-dark.png and b/img/dashboard-board-dark.png differ diff --git a/img/dashboard-meters-light.png b/img/dashboard-meters-light.png index 3ef4947..702486b 100644 Binary files a/img/dashboard-meters-light.png and b/img/dashboard-meters-light.png differ diff --git a/img/dashboard-mobile-dark.png b/img/dashboard-mobile-dark.png index bfe6626..24b67b2 100644 Binary files a/img/dashboard-mobile-dark.png and b/img/dashboard-mobile-dark.png differ diff --git a/img/dashboard-mobile-light.png b/img/dashboard-mobile-light.png index 32419ad..c6682c6 100644 Binary files a/img/dashboard-mobile-light.png and b/img/dashboard-mobile-light.png differ diff --git a/img/mollier.png b/img/mollier.png index 14fac39..0bdf5b3 100644 Binary files a/img/mollier.png and b/img/mollier.png differ diff --git a/img/settings-limits.png b/img/settings-limits.png index ad3de53..a0c3a90 100644 Binary files a/img/settings-limits.png and b/img/settings-limits.png differ diff --git a/img/settings-modbus.png b/img/settings-modbus.png index 285490c..cd7c8e4 100644 Binary files a/img/settings-modbus.png and b/img/settings-modbus.png differ diff --git a/img/wood-diagram.png b/img/wood-diagram.png new file mode 100644 index 0000000..8860b91 Binary files /dev/null and b/img/wood-diagram.png differ diff --git a/layout.py b/layout.py index 8330af6..34fef7f 100644 --- a/layout.py +++ b/layout.py @@ -1,7 +1,9 @@ from dash import dcc, html +from flask import has_request_context, request import devices import settings +import zones from translations import LANGUAGE_NAMES, TRANSLATIONS # Design tokens per thema (CSS variabelen) en componentstijlen. Alles lokaal: geen externe fonts of CDN's. @@ -11,10 +13,10 @@ {%metas%} {%title%} - {%favicon%} + {%css%} @@ -246,9 +285,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 +306,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,16 +319,49 @@ 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] +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 zone_mode_options(t): + return [{'label': t[f'opt_mode_{mode}'], 'value': mode} for mode in zones.MODES] + + +def zone_color_options(t): + return [{'label': html.Span([html.Span(className='swatch', style={'--series': hex_color}), t[f'opt_color_{name}']], + className='color-option'), 'value': name} for name, hex_color in zones.COLORS.items()] + + +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'), @@ -298,13 +371,17 @@ def create_layout(): html.Header(className='topbar', children=[ html.Div(className='topbar-left', children=[ - html.Span(id='brand', className='brand', children=t['app_name']), + html.Div(className='brand-wrap', children=[ + html.Img(src='/assets/logo.svg', className='brand-logo', alt=''), + html.Span(id='brand', className='brand', children=t['app_name']), + ]), 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.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), @@ -332,6 +409,24 @@ 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), + dcc.Dropdown(id='draw-zone-mode', options=zone_mode_options(t), value='keep_outside', + clearable=False, searchable=False, className='draw-select'), + dcc.Dropdown(id='draw-zone-color', options=zone_color_options(t), value='red', + clearable=False, searchable=False, className='draw-select'), + 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})), @@ -348,6 +443,25 @@ 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})), + ]), + + # 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/modbus_reader.py b/modbus_reader.py index 73467d0..f726d8d 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 @@ -21,6 +22,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,17 +44,46 @@ 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 +# 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) - return round(raw_temp * device['scale_temp'], 3), round(raw_humidity * device['scale_humidity'], 3) + """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) + 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) def open_instrument(bus, device): @@ -68,6 +99,50 @@ def open_instrument(bus, device): return instrument +_port_locks = {} # poort -> Lock: poller en scan gebruiken dezelfde seriële verbinding om beurten +_port_locks_guard = threading.Lock() +_active_ports = set() # poorten met een draaiende poller (scan sluit die niet) +SCAN_TIMEOUT = 0.3 # seconden per adres tijdens een scan + + +def port_lock(port): + with _port_locks_guard: + return _port_locks.setdefault(port, threading.Lock()) + + +def scan_bus(bus, slave_ids, instrument_factory=open_instrument): + """Welke slave-adressen antwoorden op de XY-MD02-registers (functiecode 4, register 1-2)? + + Geeft [{'slave_id', 'temperature', 'humidity'}] of {'slave_id', 'error'} voor een slave die antwoordt maar + de registers niet kent. Wacht op de poller van dezelfde poort (zelfde verbinding), zodat antwoorden niet + door elkaar lopen. SerialException (interface weg) gaat door naar de aanroeper. + """ + probe = {'function_code': 4, 'register_temp': 1, 'register_humidity': 2, 'scale_temp': 0.1, 'scale_humidity': 0.1, + 'data_type': 'int16', 'byte_order': 'ABCD'} + found, instrument = [], None + with port_lock(bus['port']): + try: + for slave_id in slave_ids: + instrument = instrument_factory(bus, {**probe, 'slave_id': slave_id}) + instrument.serial.timeout = SCAN_TIMEOUT + try: + temperature, humidity = read_device(instrument, {**probe, 'slave_id': slave_id}) + found.append({'slave_id': slave_id, 'temperature': temperature, 'humidity': humidity}) + except minimalmodbus.NoResponseError: + continue + except minimalmodbus.ModbusException as e: # antwoordt wel, maar andere registers/functiecode + found.append({'slave_id': slave_id, 'error': str(e)}) + finally: + if instrument is not None: + instrument.serial.timeout = bus['timeout'] + if bus['port'] not in _active_ports: + try: + instrument.serial.close() + except Exception: + pass + return found + + class Writer(threading.Thread): """Schrijft metingen (timestamp, temp, rh, dauwpunt, abs. vochtigheid, device_id) in batches.""" @@ -133,6 +208,13 @@ def stop(self): self.stop_event.set() def run(self): + _active_ports.add(self.bus['port']) + try: + self.loop() + finally: + _active_ports.discard(self.bus['port']) + + def loop(self): backoff = 1 while not self.stop_event.is_set(): instruments = {} @@ -142,10 +224,11 @@ def run(self): backoff = 1 while not self.stop_event.is_set(): started = time.time() - for device in self.devices: - if self.stop_event.is_set(): - break - self.poll(instruments[device['id']], device) + with port_lock(self.bus['port']): # een scan van deze poort gaat om beurten met het uitlezen + for device in self.devices: + if self.stop_event.is_set(): + break + self.poll(instruments[device['id']], device) interval = settings.get('poll_interval_seconds') self.stop_event.wait(max(0, interval - (time.time() - started))) except (serial.SerialException, OSError) as e: @@ -182,8 +265,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/psychrometric.py b/psychrometric.py index 9c395ea..82b9965 100644 --- a/psychrometric.py +++ b/psychrometric.py @@ -314,3 +314,36 @@ 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')) + 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/pyproject.toml b/pyproject.toml index 200ae7f..0d4678d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "xy-md02-webapp" -version = "2.2.0" +version = "2.3.0" description = "Web-based monitoring dashboard for XY-MD02 Modbus temperature and humidity sensor" readme = "README.md" requires-python = ">=3.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..1e11235 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1,23 +1,36 @@ """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 -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 import devices import layout +import modbus_reader import mqtt_publisher import settings +import zones 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'] +# 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', - 'scale_temp', 'scale_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'] +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()) @@ -66,77 +79,161 @@ 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')) 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, grenswaarden of zones als formulierregels (aan/uit als 'true'/'false').""" + if table == 'zones': + return [{'id': z['id'], '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'} + 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 == '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': + 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] + 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 + + +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': 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 + 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, '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'} 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']), html.Div(id='device-status', children=device_status_list(t)), + html.H4(t['lbl_scan']), + html.Div(t['hint_scan'], className='field-hint'), + html.Div(className='draw-actions', children=[ + dcc.Dropdown(id='scan-bus-select', options=[b['name'] for b in devices.load_config()[0]], + value=next((b['name'] for b in devices.load_config()[0]), None), clearable=False, + searchable=False, style={'minWidth': '200px'}), + html.Button(t['btn_scan'], id='scan-bus-btn', n_clicks=0, className='btn btn-small'), + ]), + dcc.Loading(html.Div(id='scan-result', className='status-list'), type='dot'), ] @@ -172,31 +269,18 @@ def mqtt_status_text(t): style={'color': 'var(--alarm)', 'fontWeight': 'bold'}) -def _rule_rows(): - return [{'scope': r['device_id'] or 0, 'metric': r['metric'], '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': '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}, - '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['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)), ] @@ -299,11 +383,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'), @@ -322,23 +412,26 @@ 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'), + State(_fields('zones'), '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, 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, 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)) @@ -348,6 +441,7 @@ def save_settings(n_clicks, values, lang, bus_rows, device_rows, rule_rows): if errors: return field_errors, t['msg_save_failed'], bad_style devices.save_config(bus_rows or [], device_rows or []) + zones.save(zone_rows or []) alerts.save_rules(rule_rows or []) restart_changed = any(settings.SCHEMA[k].get('restart') and settings.get(k) != before[k] for k in keys) @@ -356,87 +450,127 @@ 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('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(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, 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,) * 7 - _, _, errors = devices.validate_config(bus_rows or [], device_rows or []) - _, rule_errors = alerts.validate_rules(rule_rows or []) + 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, styles = [], {'buses': list(CELL_STATES), 'devices': list(CELL_STATES), 'rules': list(CELL_STATES)} - 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.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[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, 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.""" + 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, '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('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('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'), @@ -451,16 +585,29 @@ def refresh_device_status(n, tab, lang): return device_status_list(_t(lang)) @app.callback( - Output('alert-rule-table', 'data'), - Input('add-rule', 'n_clicks'), - State('alert-rule-table', 'data'), + Output('scan-result', 'children'), + Input('scan-bus-btn', 'n_clicks'), + [State('scan-bus-select', 'value'), + State('selected-language', 'data')], prevent_initial_call=True ) - def add_rule(n_clicks, rows): + def scan_bus_now(n_clicks, bus_name, lang): + """Welke slaves antwoorden op een opgeslagen bus (alleen ingelogd: opent de seriële poort).""" + t = _t(lang) if not auth.is_logged_in(): + return t['settings_locked'] + bus = next((b for b in devices.load_config()[0] if b['name'] == bus_name), None) + if bus is None: return no_update - return (rows or []) + [{'scope': 0, 'metric': 'temperature', 'min_value': None, 'max_value': None, - 'enabled': 'true'}] + try: + found = modbus_reader.scan_bus(bus, range(1, 31)) + except Exception as e: # interface weg of in gebruik door een ander programma + return html.Span(t['scan_failed'].format(error=e), style={'color': 'var(--alarm)'}) + if not found: + return t['scan_none'] + return html.Ul([html.Li(t['scan_found_error'].format(slave=f['slave_id'], error=f['error']) if 'error' in f + else t['scan_found'].format(slave=f['slave_id'], temperature=f'{f["temperature"]:.1f}', + humidity=f'{f["humidity"]:.1f}')) for f in found]) @app.callback( Output('test-status', 'children'), @@ -537,19 +684,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()) + return (f'theme-{layout.current_theme()}', settings.get('dashboard_view'), options, metric, + layout.mollier_style(), layout.wood_style()) @app.callback( [Output('pw-status', 'children'), diff --git a/test_app.py b/test_app.py index 6e9de8a..98daa47 100644 --- a/test_app.py +++ b/test_app.py @@ -47,6 +47,9 @@ 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(): alerts._outbox.get_nowait() @@ -389,6 +392,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() @@ -406,6 +429,72 @@ def test_poller_reads_signed_scaled_values_and_isolates_failures(): assert 'no answer' in devices.status(2)['error'] and 'out of range' in devices.status(3)['error'] +def test_scan_bus_finds_responding_slaves(): + """Scan: welke slave-adressen antwoorden (met waarden), zonder het uitlezen van dezelfde poort te storen.""" + sensors = {1: FakeInstrument({1: 182, 2: 667}), 5: FakeInstrument({1: -12, 2: 900})} + opened = [] + + def factory(bus_cfg, dev): + opened.append(dev['slave_id']) + return sensors.get(dev['slave_id'], FakeInstrument(fail=True)) + found = modbus_reader.scan_bus({**bus(), 'id': 1}, range(1, 7), factory) + assert opened == [1, 2, 3, 4, 5, 6] + assert found == [{'slave_id': 1, 'temperature': 18.2, 'humidity': 66.7}, + {'slave_id': 5, 'temperature': -1.2, 'humidity': 90.0}] + assert not modbus_reader.port_lock('/dev/ttyUSB0').locked() + + +def test_poller_waits_while_port_is_scanned(): + fresh_settings() + q = queue.Queue() + lock = modbus_reader.port_lock('/dev/ttyUSB0') + poller = modbus_reader.BusPoller({**bus(), 'id': 1}, [{**device(), 'id': 1}], q, + lambda bus_cfg, dev: FakeInstrument({1: 200, 2: 500})) + with lock: # scan bezig + poller.start() + time.sleep(0.6) + assert q.empty() and '/dev/ttyUSB0' in modbus_reader._active_ports + time.sleep(0.6) + poller.stop() + poller.join(timeout=3) + assert not q.empty() and '/dev/ttyUSB0' not in modbus_reader._active_ports + + +def test_scan_button_requires_login_and_lists_results(): + import dash + import settings_ui + fresh_settings() + devices.init_devices() + app = dash.Dash(__name__, suppress_callback_exceptions=True) + settings_ui.register_settings_callbacks(app) + scan = next(e['callback'].__wrapped__ for e in app.callback_map.values() + if getattr(e['callback'], '__wrapped__', None) and e['callback'].__wrapped__.__name__ == 'scan_bus_now') + original_scan, original_login = modbus_reader.scan_bus, auth.is_logged_in + calls = [] + modbus_reader.scan_bus = lambda bus_cfg, ids: calls.append(bus_cfg['port']) or [ + {'slave_id': 1, 'temperature': 18.2, 'humidity': 66.7}, {'slave_id': 3, 'error': 'Illegal data address'}] + try: + with app.server.test_request_context(): + assert scan(1, 'Bus 1', 'nl') == TRANSLATIONS_NL['settings_locked'] and calls == [] + auth.is_logged_in = lambda: True + result = str(scan(1, 'Bus 1', 'nl')) + finally: + modbus_reader.scan_bus, auth.is_logged_in = original_scan, original_login + assert calls == ['/dev/ttyTEST0'] and '18.2' in result and 'Illegal data address' in result + + +def test_settings_page_has_scan_button(): + import settings_ui + fresh_settings() + devices.init_devices() + original, auth.is_logged_in = auth.is_logged_in, lambda: True + try: + page = str(settings_ui.render_settings('nl')) + finally: + auth.is_logged_in = original + assert 'scan-bus-btn' in page and 'scan-bus-select' in page and 'scan-result' in page + + def test_poller_survives_missing_interface(): fresh_settings() calls = [] @@ -472,25 +561,390 @@ 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['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) >= {'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', 'edit_zones', '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_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 + 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_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 [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 + + +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_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', 'keep_outside', 'red', 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', 'keep_outside', 'red', 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', 'keep_outside', 'red', 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'] == 'keep_outside' and zone['color'] == 'red' + + +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 + assert 'draw-zone-mode' in rendered and 'draw-zone-color' 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_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_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_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_logo_in_header_and_as_favicon(): + import layout + fresh_settings() + assert os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'logo.svg')) + root = layout.create_layout() + assert any(getattr(c, 'src', None) == '/assets/logo.svg' for c in components(root)) + assert 'rel="icon"' in layout.HTML_TEMPLATE and '/assets/logo.svg' in layout.HTML_TEMPLATE + + +def test_saving_settings_does_not_repeat_active_alarms(): + """Opslaan zonder wijziging aan een regel of zone: actief alarm blijft, geen nieuwe melding.""" + import settings_ui + import zones + rule = {'scope': 0, 'metric': 'temperature', 'max_value': 8, 'enabled': 'true'} + names = setup_alerts([rule]) + assert settings.save({'alert_delay_seconds': 0}) == {} + assert zones.save([{'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': 9.0, 'humidity': 85.0}, now=1) + assert len(outbox_texts()) == 2 + assert alerts.save_rules([rule]) == {} # zelfde regel + assert zones.save(settings_ui.table_rows('zones')) == {} # zelfde zone (zelfde id) + alerts.evaluate(cel, {'temperature': 9.0, 'humidity': 85.0}, now=2) + assert outbox_texts() == [] and alerts.device_status(cel['id'])[0] == 'alarm' + assert alerts.save_rules([{**rule, 'max_value': 8.5}]) == {} # grens gewijzigd: opnieuw beoordelen + alerts.evaluate(cel, {'temperature': 9.0, 'humidity': 85.0}, now=3) + assert any('maximum van 8.5' 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 + 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 ----------------------------------------------------------------- @@ -552,8 +1006,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(): @@ -603,6 +1057,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() == [] @@ -743,6 +1198,214 @@ 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, 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 + 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) + + +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_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_wood_value_restarts_when_wood_settings_change(): + """Andere correctie of reactietijd: niet dagenlang naar de nieuwe waarde kruipen, maar opnieuw beginnen.""" + import wood + fresh_settings() + database.init_database() + wood._smooth.clear() + assert settings.save({'wood_response_hours': 24, 'wood_emc_offset': 0}) == {} + now = int(time.time()) + assert abs(wood.update(1, 20, 65, now=now) - 12.0) < 0.2 + assert settings.save({'wood_emc_offset': -2}) == {} + assert abs(wood.update(1, 20, 65, now=now + 60) - 10.0) < 0.2 + + +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..6cfeed3 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)', @@ -196,12 +198,14 @@ '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', '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', @@ -211,6 +215,78 @@ '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})', + 'lbl_scan': 'Bus scannen', + 'hint_scan': 'Zoekt welke sensoren (slave-adres 1-30) antwoorden op een opgeslagen bus. Het uitlezen van die bus pauzeert zolang de scan loopt (maximaal ongeveer 10 seconden).', + 'btn_scan': 'Scannen', + 'scan_found': 'Slave {slave}: {temperature} °C, {humidity} %', + 'scan_found_error': 'Slave {slave} antwoordt, maar niet op de XY-MD02-registers ({error})', + 'scan_none': 'Geen sensoren gevonden op slave 1-30.', + 'scan_failed': 'Scannen mislukt: {error}', + '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})', + '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. Kies of het een gewenste zone of gevarenzone is en een kleur. De zone geldt voor de sensor die bovenaan gekozen is (of alle sensoren); niveau en grenzen wijzig je 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.', + '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', + '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})', @@ -430,7 +506,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)', @@ -479,12 +557,14 @@ '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', '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', @@ -494,6 +574,78 @@ '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})', + 'lbl_scan': 'Scan bus', + 'hint_scan': 'Finds which sensors (slave address 1-30) respond on a saved bus. Reading that bus pauses while the scan runs (about 10 seconds at most).', + 'btn_scan': 'Scan', + 'scan_found': 'Slave {slave}: {temperature} °C, {humidity} %', + 'scan_found_error': 'Slave {slave} responds, but not to the XY-MD02 registers ({error})', + 'scan_none': 'No sensors found at slave 1-30.', + 'scan_failed': 'Scan failed: {error}', + '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})', + '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. Choose whether it is a desired or danger zone and a colour. The zone applies to the sensor selected above (or all sensors); change level and limits 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.', + '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', + '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})', @@ -713,7 +865,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)', @@ -762,12 +916,14 @@ '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', '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', @@ -777,6 +933,78 @@ '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})', + 'lbl_scan': 'Bus scannen', + 'hint_scan': 'Sucht, welche Sensoren (Slave-Adresse 1-30) an einem gespeicherten Bus antworten. Das Auslesen dieses Busses pausiert während des Scans (höchstens etwa 10 Sekunden).', + 'btn_scan': 'Scannen', + 'scan_found': 'Slave {slave}: {temperature} °C, {humidity} %', + 'scan_found_error': 'Slave {slave} antwortet, aber nicht auf die XY-MD02-Register ({error})', + 'scan_none': 'Keine Sensoren an Slave 1-30 gefunden.', + 'scan_failed': 'Scan fehlgeschlagen: {error}', + '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})', + '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. Wählen Sie Wunsch- oder Gefahrenzone und eine Farbe. Die Zone gilt für den oben gewählten Sensor (oder alle Sensoren); Stufe und Grenzen ändern Sie unter Settings > Zonen.', + '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.', + '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', + '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})', @@ -996,7 +1224,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)', @@ -1045,12 +1275,14 @@ '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', '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', @@ -1060,6 +1292,78 @@ '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})", + 'lbl_scan': 'Scanner le bus', + 'hint_scan': 'Cherche quels capteurs (adresse esclave 1-30) répondent sur un bus enregistré. La lecture de ce bus est suspendue pendant le scan (environ 10 secondes au maximum).', + 'btn_scan': 'Scanner', + 'scan_found': 'Esclave {slave} : {temperature} °C, {humidity} %', + 'scan_found_error': "L'esclave {slave} répond, mais pas aux registres XY-MD02 ({error})", + 'scan_none': 'Aucun capteur trouvé aux adresses 1-30.', + 'scan_failed': 'Échec du scan : {error}', + '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})', + '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. Choisissez zone souhaitée ou zone de danger et une couleur. La zone s’applique au capteur choisi en haut (ou à tous) ; modifiez le niveau et les limites 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.', + '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', + '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})', @@ -1279,7 +1583,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)', @@ -1328,12 +1634,14 @@ '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', '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', @@ -1343,6 +1651,78 @@ '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})', + 'lbl_scan': 'Escanear bus', + 'hint_scan': 'Busca qué sensores (dirección esclavo 1-30) responden en un bus guardado. La lectura de ese bus se detiene mientras dura el escaneo (unos 10 segundos como máximo).', + 'btn_scan': 'Escanear', + 'scan_found': 'Esclavo {slave}: {temperature} °C, {humidity} %', + 'scan_found_error': 'El esclavo {slave} responde, pero no a los registros XY-MD02 ({error})', + 'scan_none': 'No se encontraron sensores en 1-30.', + 'scan_failed': 'Error al escanear: {error}', + '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})', + '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. Elija zona deseada o de peligro y un color. La zona se aplica al sensor elegido arriba (o a todos); cambie el nivel y los límites 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.', + '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', + '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..9587c0b --- /dev/null +++ b/wood.py @@ -0,0 +1,144 @@ +"""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') + config = (hours, settings.get('wood_emc_offset')) + with _lock: + if not hours: + _smooth[device_id] = (now, live, config) + return live + # Eerste meting, of andere correctie/reactietijd: opnieuw beginnen bij de historie (niet dagen laten kruipen) + if device_id not in _smooth or _smooth[device_id][2] != config: + seed = _history_value(device_id, hours, now) + _smooth[device_id] = (now, live if seed is None else seed, config) + last, smooth, _ = _smooth[device_id] + smooth += (live - smooth) * (1 - math.exp(-max(now - last, 0) / (hours * 3600))) + _smooth[device_id] = (now, smooth, config) + 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, limits, t, k, areas=()): + """Diagram temperatuur × RV met lijnen van gelijk houtvocht, houtvochtgrenzen, zones en de sensoren. + + 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 limits: + 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 diff --git a/zones.py b/zones.py new file mode 100644 index 0000000..7b9b13d --- /dev/null +++ b/zones.py @@ -0,0 +1,183 @@ +"""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 + # Bestaande zone houdt zijn id (en daarmee zijn alarmtoestand) bij opnieuw opslaan + clean.append({'id': int(row['id']) if row.get('id') else None, '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 (id, device_id, name, color, mode, level, shape, points, enabled) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [(z['id'], 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)