diff --git a/CHANGELOG.md b/CHANGELOG.md index c46129a..376f2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ All notable changes to Climate Monitor. Older versions: see the [GitHub releases ### 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. +- **Zones** in the wood diagram: coloured areas of temperature × humidity with a notification when a sensor leaves a desired zone or enters a danger zone. A zone starts as a square; drag the corners, drag the dot in the middle of a side to add a corner, double-click a corner to remove it. Click a zone to change or delete it. +- **Limits in the time chart** as dashed lines (yellow = warning, red = alarm). - **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. diff --git a/README.md b/README.md index 0684756..4ca91c3 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ A real-time climate monitoring dashboard for Modbus RTU temperature and humidity - Comfort Score (0-6, based on Humidex) - **Psychrometric chart**: Mollier diagram with live indicator and comfort zone visualization - **Wood moisture** (optional): equilibrium moisture content per sensor, a diagram with zones from your limits, warning and alarm levels -- **Zones**: your own coloured areas of temperature × humidity in the Mollier and wood diagram (from min/max or drawn freely), with a notification when a sensor leaves a desired zone or enters a danger zone +- **Zones**: your own coloured areas of temperature × humidity in the wood diagram, shaped by dragging corners, with a notification when a sensor leaves a desired zone or enters a danger zone +- **Limits in the chart**: the limits of the shown quantity appear as dashed lines in the time chart - **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 @@ -102,11 +103,14 @@ 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, comfort zone and your own zones (here a desired comfort zone and a mould-risk danger zone).* +*Current state of every sensor in its own colour, with saturation line, RH curves and comfort 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.* +*Equilibrium wood moisture: lines of equal wood moisture, the 18 % warning and 20 % alarm limits and your zones (here a desired comfort zone and a mould-risk danger zone).* + +![Zone editor](img/zone-editor.png) +*Zone editor: drag the corners, drag a dot in the middle of a side to add a corner.* #### Historical replay ![Historical range](img/history-range.png) @@ -291,7 +295,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 +├── assets/ # Logo (header and favicon) and the zone editor script, served by Dash ├── img/ # README screenshots ├── .env # Configuration (not in git) ├── .env.example # Example configuration @@ -331,13 +335,14 @@ 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 and your zones. Select one -sensor in the chart to enable *Choose range for historical analysis*. +**Mollier diagram and replay**: shows the current state of all sensors. 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. +**Zones** (wood diagram): click **+ Zone** to start with a square. Drag the corners into place; drag +the dot in the middle of a side to add a corner there (square → pentagon → …); double-click a corner +to remove it. Works with mouse and touch. Give the zone a name, choose the sensor, *desired zone* / +*danger zone* / *display only*, the level and a colour, and save (requires being logged in). To +change or delete a zone, click it in the diagram or pick it from *Edit zone…*. ### Configuration @@ -410,9 +415,9 @@ its detailed view and historical replay. - 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. + - *Zones*: an area of temperature × humidity with a colour, shaped in the wood diagram. + *Desired zone* notifies when a sensor leaves it, *danger zone* when a sensor enters it, + *display only* just draws it. Settings lists the zones to change their other properties. - *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 (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. @@ -658,7 +663,7 @@ python test_app.py - 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 +- Dashboard: form fields, zone editor, limits in the chart, theme toggle, alarm window, phone layout - MQTT: topics, payloads, reconnect behaviour - Translations: same placeholders in every language, no untranslated text in the layout @@ -673,7 +678,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 and wood diagram, drawing zones, alarm window, theme) +- **callbacks.py**: dashboard (board/meters, chart, status, Mollier and wood diagram, zone editor, 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 diff --git a/assets/zone_editor.js b/assets/zone_editor.js new file mode 100644 index 0000000..0fa9e01 --- /dev/null +++ b/assets/zone_editor.js @@ -0,0 +1,168 @@ +/* Zone-editor voor het houtdiagram (temperatuur × RV). + * + * Tekent over het Plotly-diagram een vorm met sleepbare hoeken (grote bollen) en op elke zijde een + * middenbol. Een middenbol slepen voegt daar een hoek toe; dubbelklik op een hoek verwijdert hem + * (minimaal 3). Werkt met muis en touch (pointer events). Na elke wijziging gaan de punten naar de + * Dash-store 'zone-edit-points'; de server slaat ze op. + * + * Aangestuurd door de clientside callback zones.edit met de inhoud van de store 'zone-edit' + * ({id, points} of null = editor uit). + */ +(function () { + 'use strict'; + var SVG_NS = 'http://www.w3.org/2000/svg'; + var GRAPH_ID = 'wood-chart'; + var editor = { points: null, svg: null, plot: null, drag: null }; + + function plotElement() { + var graph = document.getElementById(GRAPH_ID); + return graph ? graph.querySelector('.js-plotly-plot') : null; + } + + function axes(plot) { + var layout = plot && plot._fullLayout; + return layout && layout.xaxis && layout.yaxis ? layout : null; + } + + function toPixels(layout, point) { + return [layout._size.l + layout.xaxis.l2p(point[0]), layout._size.t + layout.yaxis.l2p(point[1])]; + } + + function toData(layout, x, y) { + var temp = layout.xaxis.p2l(x - layout._size.l); + var rh = layout.yaxis.p2l(y - layout._size.t); + return [Math.round(temp * 10) / 10, Math.round(Math.min(100, Math.max(0, rh)) * 10) / 10]; + } + + function commit() { + if (window.dash_clientside && window.dash_clientside.set_props) { + window.dash_clientside.set_props('zone-edit-points', { data: editor.points.map(function (p) { return p.slice(); }) }); + } + } + + function circle(cx, cy, radius, className) { + var c = document.createElementNS(SVG_NS, 'circle'); + c.setAttribute('cx', cx); + c.setAttribute('cy', cy); + c.setAttribute('r', radius); + c.setAttribute('class', className); + return c; + } + + function startDrag(event, index) { + event.preventDefault(); + event.stopPropagation(); + editor.drag = index; + } + + function draw() { + var plot = plotElement(); + var layout = axes(plot); + if (!editor.points || !layout) { + return; + } + var host = plot.querySelector('.svg-container'); + if (!editor.svg || editor.svg.parentNode !== host) { + editor.svg = document.createElementNS(SVG_NS, 'svg'); + editor.svg.setAttribute('class', 'zone-editor'); + host.appendChild(editor.svg); + } + var svg = editor.svg; + svg.setAttribute('width', layout.width); + svg.setAttribute('height', layout.height); + while (svg.firstChild) { + svg.removeChild(svg.firstChild); + } + var pixels = editor.points.map(function (p) { return toPixels(layout, p); }); + var shape = document.createElementNS(SVG_NS, 'polygon'); + shape.setAttribute('points', pixels.map(function (p) { return p.join(','); }).join(' ')); + shape.setAttribute('class', 'zone-editor-shape'); + svg.appendChild(shape); + + // Middenbollen: slepen voegt een hoek toe tussen hoek i en i + 1 + pixels.forEach(function (p, i) { + var next = pixels[(i + 1) % pixels.length]; + var mid = circle((p[0] + next[0]) / 2, (p[1] + next[1]) / 2, 6, 'zone-editor-mid'); + mid.addEventListener('pointerdown', function (event) { + editor.points.splice(i + 1, 0, toData(layout, (p[0] + next[0]) / 2, (p[1] + next[1]) / 2)); + startDrag(event, i + 1); + draw(); + }); + svg.appendChild(mid); + }); + // Hoeken: slepen; dubbelklik verwijdert (minimaal 3 hoeken) + pixels.forEach(function (p, i) { + var corner = circle(p[0], p[1], 8, 'zone-editor-corner'); + corner.addEventListener('pointerdown', function (event) { startDrag(event, i); }); + corner.addEventListener('dblclick', function (event) { + event.preventDefault(); + event.stopPropagation(); + if (editor.points.length > 3) { + editor.points.splice(i, 1); + draw(); + commit(); + } + }); + svg.appendChild(corner); + }); + } + + function onMove(event) { + if (editor.drag === null || !editor.points) { + return; + } + var plot = plotElement(); + var layout = axes(plot); + if (!layout) { + return; + } + var box = plot.querySelector('.svg-container').getBoundingClientRect(); + editor.points[editor.drag] = toData(layout, event.clientX - box.left, event.clientY - box.top); + draw(); + } + + function onUp() { + if (editor.drag !== null) { + editor.drag = null; + commit(); + } + } + + function stop() { + editor.points = null; + editor.drag = null; + if (editor.svg && editor.svg.parentNode) { + editor.svg.parentNode.removeChild(editor.svg); + } + editor.svg = null; + } + + function watchPlot() { + // Na elke (her)tekening of zoom van Plotly de bollen opnieuw plaatsen + var plot = plotElement(); + if (plot && plot !== editor.plot && plot.on) { + editor.plot = plot; + plot.on('plotly_afterplot', draw); + } + } + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + + window.dash_clientside = Object.assign({}, window.dash_clientside, { + zones: { + edit: function (editing) { + stop(); + if (editing && editing.points) { + editor.points = editing.points.map(function (p) { return [Number(p[0]), Number(p[1])]; }); + watchPlot(); + // Het diagram wordt mogelijk net opnieuw opgebouwd: tekenen na de volgende frame + window.requestAnimationFrame(function () { watchPlot(); draw(); }); + commit(); + } + return window.dash_clientside.no_update; + } + } + }); +}()); diff --git a/callbacks.py b/callbacks.py index 21fa106..78a9517 100644 --- a/callbacks.py +++ b/callbacks.py @@ -3,7 +3,7 @@ import math import pandas as pd from datetime import datetime, timedelta -from dash import ALL, Patch, callback_context, html, no_update, Input, Output, State +from dash import ALL, ClientsideFunction, callback_context, html, no_update, Input, Output, State import plotly.graph_objects as go import alerts @@ -17,7 +17,7 @@ from translations import TRANSLATIONS from database import get_all_measurement_tables, build_union_query, DB_FILE from modbus_reader import derived_values -from psychrometric import add_device_points, add_zones, path_to_points, create_psychrometric_chart, create_psychrometric_chart_historical +from psychrometric import add_device_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. @@ -33,6 +33,7 @@ hover='#eceee8', ok='#0b8a0b', warn='#c98500', alarm='#d03b3b', offline='#7d838a'), } FONT = 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, sans-serif' +NEW_ZONE_SQUARE = ((15, 40), (25, 40), (25, 60), (15, 60)) # nieuwe zone begint als vierkant (°C, % RV) METRIC_UNITS = {'temperature': '°C', 'humidity': '%', 'dewpoint': '°C', 'absolute_humidity': 'g/m³', 'comfort': '', 'wood_emc': '%'} METRIC_KEYS = {'temperature': 'temperature', 'humidity': 'humidity', 'dewpoint': 'dewpoint', @@ -120,8 +121,11 @@ def humidex_score(temp, hum): return 0 -def metric_figure(df, names, colors, metric, t, mode): - """Eén grootheid, één lijn per sensor (of één sensor), in de kleuren van het thema.""" +def metric_figure(df, names, colors, metric, t, mode, limits=()): + """Eén grootheid, één lijn per sensor (of één sensor), in de kleuren van het thema. + + limits: [(min, max, niveau)] uit de alarmregels, als gestippelde lijnen (geel = waarschuwing, rood = alarm). + """ fig = go.Figure() unit = METRIC_UNITS[metric] for device_id, name in names.items(): @@ -140,6 +144,14 @@ def metric_figure(df, names, colors, metric, t, mode): else f'{name}: %{{y}}')) style_figure(fig, mode) fig.update_layout(showlegend=len(fig.data) > 1) + k = THEMES[mode] + for low, high, level in limits: + color = k['warn'] if level == 'warning' else k['alarm'] + for value in (low, high): + if value is not None: + fig.add_hline(y=value, line=dict(color=color, width=1.5, dash='dash'), + annotation_text=f"{t['opt_level_' + level]} {value:g}", annotation_position='top left', + annotation_font=dict(color=color, size=11)) title = t[METRIC_KEYS[metric]] + (f' ({unit})' if unit else '') fig.update_yaxes(title_text=title) if metric == 'humidity': @@ -149,13 +161,18 @@ 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.""" +def limit_rules(metric, device_id): + """Grenzen van één grootheid 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'] + return [(r['min_value'], r['max_value'], r['level']) for r in rules if r['metric'] == metric] + + +def wood_zones(device_id): + """Houtvochtgrenzen uit de alarmregels (voor het houtdiagram).""" + return limit_rules('wood_emc', device_id) def theme_mollier(fig, mode, point_color=None): @@ -332,83 +349,19 @@ def update_graph(n, time_range_minutes, relayout_data, lang, device_sel, metric, fig.update_yaxes(visible=False) return fig, count_text, stored_relayout df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s').dt.tz_localize('UTC').dt.tz_convert(settings.get('timezone')).dt.tz_localize(None) - fig = metric_figure(df, names, device_colors(mode), metric, t, mode) + fig = metric_figure(df, names, device_colors(mode), metric, t, mode, limit_rules(metric, device_filter)) 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')], - State('zone-drawing', 'data') + Input('device-selector', 'value')] ) - def update_psychrometric_chart(n, lang, device_sel, drawing): + def update_psychrometric_chart(n, lang, device_sel): """Update psychrometrisch diagram met actuele meetwaarden (alle devices of één device)""" - if not settings.get('show_mollier') or drawing: # tijdens tekenen niet verversen: wist de tekening + if not settings.get('show_mollier'): return no_update if lang is None: lang = 'nl' @@ -422,30 +375,150 @@ def update_psychrometric_chart(n, lang, device_sel, drawing): 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 add_zones(fig, zones.visible(device_sel)) # na theme_mollier: dat kleurt vlakken als comfortzone + return fig - @app.callback( - Output('wood-chart', 'figure'), - [Input('graph-update', 'n_intervals'), - Input('selected-language', 'data'), - Input('device-selector', 'value')], - State('main-tabs', 'value') - ) - def update_wood_chart(n, lang, device_sel, tab): - """Houtvochtdiagram: actuele punten per sensor en de zones uit de houtvochtregels""" - if not settings.get('show_wood') or (callback_context.triggered_id == 'graph-update' and (n % 5 or tab == 'settings')): - return no_update + def wood_chart(lang, device_sel, editing): + """Houtvochtdiagram: actuele punten per sensor, houtvochtgrenzen en zones (zone in bewerking: door de editor).""" 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)) + areas = [z for z in zones.visible(device_sel) if not editing or z['id'] != editing.get('id')] + fig = wood.figure(points, wood_zones(device_sel), t, THEMES[mode], areas) fig = style_figure(fig, mode, height=520) fig.update_layout(hovermode='closest', margin=dict(l=60, r=50, t=30, b=50)) return fig + @app.callback( + Output('wood-chart', 'figure'), + [Input('graph-update', 'n_intervals'), + Input('selected-language', 'data'), + Input('device-selector', 'value')], + [State('zone-edit', 'data'), + State('main-tabs', 'value')] + ) + def update_wood_chart(n, lang, device_sel, editing, tab): + """Live houtvochtdiagram (elke 5 s); tijdens het bewerken van een zone stil.""" + if not settings.get('show_wood') or editing: + return no_update + if callback_context.triggered_id == 'graph-update' and (n % 5 or tab == 'settings'): + return no_update + return wood_chart(lang, device_sel, editing) + + @app.callback( + Output('wood-chart', 'figure', allow_duplicate=True), + Input('zone-edit', 'data'), + [State('selected-language', 'data'), + State('device-selector', 'value')], + prevent_initial_call=True + ) + def wood_chart_for_editing(editing, lang, device_sel): + """Editor open of dicht: diagram zonder (of weer met) de zone in bewerking. + + Een eigen callback: Dash vervangt een lopend verzoek door een nieuwer verzoek van dezelfde callback, dus + via update_wood_chart zou de ververs-tik van elke seconde dit diagram telkens overschrijven. + """ + if not settings.get('show_wood'): + return no_update + return wood_chart(lang, device_sel, editing) + + @app.callback( + [Output('zone-edit', 'data'), + Output('zone-edit-bar', 'style'), + Output('zone-edit-name', 'value'), + Output('zone-edit-scope', 'value'), + Output('zone-edit-mode', 'value'), + Output('zone-edit-level', 'value'), + Output('zone-edit-color', 'value'), + Output('zone-edit-scope', 'options'), + Output('zone-edit-points', 'data')], + [Input('zone-add-btn', 'n_clicks'), + Input('zone-edit-select', 'value')], + [State('device-selector', 'value'), + State('selected-language', 'data')], + prevent_initial_call=True + ) + def start_zone_edit(add_clicks, zone_id, device_sel, lang): + """Nieuwe zone (vierkant) of een bestaande zone openen in de editor.""" + t = TRANSLATIONS.get(lang) or TRANSLATIONS['en'] + if callback_context.triggered_id == 'zone-add-btn': + zone = {'id': None, 'points': [list(p) for p in NEW_ZONE_SQUARE], + 'name': f"{t['zone']} {len(zones.load()) + 1}", 'device_id': device_sel or None, + 'mode': 'keep_outside', 'level': 'warning', 'color': 'red'} + else: + zone = next((z for z in zones.load() if z['id'] == zone_id), None) + if zone is None: + return (no_update,) * 9 + return ({'id': zone['id'], 'points': zone['points']}, {}, zone['name'], zone['device_id'] or 0, zone['mode'], + zone['level'], zone['color'], layout.scope_options(t), None) + + @app.callback( + [Output('zone-edit-msg', 'children'), + Output('zone-edit', 'data', allow_duplicate=True), + Output('zone-edit-bar', 'style', allow_duplicate=True), + Output('zone-edit-select', 'value'), + Output('zone-edit-select', 'options')], + [Input('zone-save-btn', 'n_clicks'), + Input('zone-delete-btn', 'n_clicks'), + Input('zone-cancel-btn', 'n_clicks')], + [State('zone-edit', 'data'), + State('zone-edit-points', 'data'), + State('zone-edit-name', 'value'), + State('zone-edit-scope', 'value'), + State('zone-edit-mode', 'value'), + State('zone-edit-level', 'value'), + State('zone-edit-color', 'value'), + State('selected-language', 'data')], + prevent_initial_call=True + ) + def finish_zone_edit(save_clicks, delete_clicks, cancel_clicks, editing, points, name, scope, mode, level, color, + lang): + """Opslaan, verwijderen of annuleren (opslaan en verwijderen alleen ingelogd: het dashboard kan publiek zijn).""" + t = TRANSLATIONS.get(lang) or TRANSLATIONS['en'] + closed = ({'display': 'none'}, None) + trigger = callback_context.triggered_id + if trigger == 'zone-cancel-btn' or not editing: + return '', None, *closed, no_update + if not auth.is_logged_in(): + return t['draw_login'], no_update, no_update, no_update, no_update + rows = settings_ui.table_rows('zones') + if trigger == 'zone-delete-btn': + rows = [r for r in rows if r['id'] != editing['id']] + message = t['zone_deleted'] + else: + existing = next((r for r in rows if editing['id'] and r['id'] == editing['id']), None) + row = {'id': editing['id'], 'scope': scope or 0, 'name': (name or '').strip() or t['zone'], 'color': color, + 'mode': mode, 'level': level, 'shape': 'polygon', 'points': json.dumps(points or editing['points']), + 'enabled': existing['enabled'] if existing else 'true'} + rows = [row if r is existing else r for r in rows] if existing else rows + [row] + message = t['draw_saved'] + if zones.save(rows): + return t['msg_save_failed'], no_update, no_update, no_update, no_update + return message, None, *closed, layout.zone_options() + + @app.callback( + Output('zone-edit-select', 'value', allow_duplicate=True), + Input('wood-chart', 'clickData'), + State('wood-chart', 'figure'), + prevent_initial_call=True + ) + def open_clicked_zone(click, figure): + """Klik op een zone in het houtdiagram opent die zone in de editor (zone-id staat in trace.meta).""" + try: + trace = figure['data'][click['points'][0]['curveNumber']] + except (TypeError, KeyError, IndexError): + return no_update + return trace.get('meta') if trace.get('hoveron') == 'fills' and trace.get('meta') else no_update + + # De editor zelf draait in de browser (assets/zone_editor.js): sleepbare hoeken en middenbollen + app.clientside_callback( + ClientsideFunction(namespace='zones', function_name='edit'), + Output('zone-editor-ready', 'data'), + Input('zone-edit', 'data') + ) + # Callback voor modal open/close @app.callback( Output('range-modal', 'style'), @@ -964,11 +1037,16 @@ def update_device_overview(n, lang, view, tab): Output('custom-end-date', 'placeholder'), Output('custom-start-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('zone-add-btn', 'children'), + Output('zone-edit-select', 'placeholder'), + Output('zone-edit-hint', 'children'), + Output('zone-edit-name', 'placeholder'), + Output('zone-edit-mode', 'options'), + Output('zone-edit-level', 'options'), + Output('zone-edit-color', 'options'), + Output('zone-save-btn', 'children'), + Output('zone-delete-btn', 'children'), + Output('zone-cancel-btn', 'children')] + [Output(f'preset-{key}', 'children') for key in PRESET_BUTTONS], Input('selected-language', 'data') ) @@ -977,5 +1055,6 @@ 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['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] + t['hour_placeholder'], t['btn_new_zone'], t['lbl_edit_zone'], t['hint_edit_zone'], t['lbl_zone_name'], + layout.zone_mode_options(t), layout.level_options(t), layout.zone_color_options(t), t['btn_save_zone'], + t['btn_delete_zone'], t['btn_cancel']] + [t[f'btn_{key}'] for key in PRESET_BUTTONS] diff --git a/img/mollier.png b/img/mollier.png index 0bdf5b3..64cc1b7 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 a0c3a90..88df6a4 100644 Binary files a/img/settings-limits.png and b/img/settings-limits.png differ diff --git a/img/wood-diagram.png b/img/wood-diagram.png index 8860b91..3c121f9 100644 Binary files a/img/wood-diagram.png and b/img/wood-diagram.png differ diff --git a/img/zone-editor.png b/img/zone-editor.png new file mode 100644 index 0000000..4529832 Binary files /dev/null and b/img/zone-editor.png differ diff --git a/layout.py b/layout.py index 34fef7f..e0a2724 100644 --- a/layout.py +++ b/layout.py @@ -152,6 +152,13 @@ .draw-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .draw-actions .input { width: 220px; max-width: 100%; } .draw-select { width: 230px; max-width: 100%; } + .zone-editor { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 5; } + .zone-editor-shape { fill: var(--accent); fill-opacity: .14; stroke: var(--accent); stroke-width: 2; stroke-dasharray: 6 4; } + .zone-editor-corner { fill: var(--panel); stroke: var(--accent); stroke-width: 2.5; pointer-events: all; cursor: grab; touch-action: none; } + .zone-editor-corner:hover { fill: var(--accent); } + .zone-editor-mid { fill: var(--accent); fill-opacity: .55; pointer-events: all; cursor: copy; touch-action: none; } + .zone-editor-mid:hover { fill-opacity: 1; } + @media (pointer: coarse) { .zone-editor-corner { r: 13px; } .zone-editor-mid { r: 10px; } } .slider-box { padding: 4px 16px 18px; } .slider-time { font-family: var(--mono); font-size: 15px; margin-bottom: 8px; } @@ -341,6 +348,20 @@ def current_theme(): return mode if mode in THEMES else 'dark' +def zone_options(): + """Keuzelijst 'Zone bewerken…' in het houtpaneel.""" + return [{'label': z['name'], 'value': z['id']} for z in zones.load()] + + +def level_options(t): + return [{'label': t[f'opt_level_{level}'], 'value': level} for level in ('warning', 'alarm')] + + +def scope_options(t): + return [{'label': t['opt_all_devices'], 'value': 0}] + [ + {'label': f"#{d['id']} {d['name']}", 'value': d['id']} for d in devices.list_devices()] + + def zone_mode_options(t): return [{'label': t[f'opt_mode_{mode}'], 'value': mode} for mode in zones.MODES] @@ -409,24 +430,6 @@ 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})), @@ -446,6 +449,37 @@ 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='draw-actions', children=[ + dcc.Dropdown(id='zone-edit-select', options=zone_options(), value=None, placeholder=t['lbl_edit_zone'], + clearable=False, searchable=False, className='draw-select'), + html.Button(t['btn_new_zone'], id='zone-add-btn', n_clicks=0, className='btn btn-small'), + ]), + ]), + # Zone-editor: assets/zone_editor.js tekent sleepbare hoeken en middenbollen over het diagram en + # schrijft de punten in zone-edit-points; zone-edit = {'id', 'points'} van de zone in bewerking + dcc.Store(id='zone-edit'), + dcc.Store(id='zone-edit-points'), + dcc.Store(id='zone-editor-ready'), + html.Div(id='zone-edit-bar', className='draw-bar', style={'display': 'none'}, children=[ + html.Div(t['hint_edit_zone'], id='zone-edit-hint', className='hint'), + html.Div(className='draw-actions', children=[ + dcc.Input(id='zone-edit-name', type='text', placeholder=t['lbl_zone_name'], className='input', + maxLength=50), + dcc.Dropdown(id='zone-edit-scope', options=[], value=0, clearable=False, searchable=False, + className='draw-select'), + dcc.Dropdown(id='zone-edit-mode', options=zone_mode_options(t), value='keep_outside', + clearable=False, searchable=False, className='draw-select'), + dcc.Dropdown(id='zone-edit-level', options=level_options(t), value='warning', + clearable=False, searchable=False, className='draw-select'), + dcc.Dropdown(id='zone-edit-color', options=zone_color_options(t), value='red', + clearable=False, searchable=False, className='draw-select'), + ]), + html.Div(className='draw-actions', children=[ + html.Button(t['btn_save_zone'], id='zone-save-btn', n_clicks=0, className='btn btn-primary btn-small'), + html.Button(t['btn_delete_zone'], id='zone-delete-btn', n_clicks=0, className='btn btn-small'), + html.Button(t['btn_cancel'], id='zone-cancel-btn', n_clicks=0, className='btn btn-small btn-link'), + html.Span(id='zone-edit-msg', className='hint'), + ]), ]), html.Div(className='panel-body', children=dcc.Graph( id='wood-chart', config={'displaylogo': False, 'displayModeBar': False})), diff --git a/psychrometric.py b/psychrometric.py index 82b9965..4b1462f 100644 --- a/psychrometric.py +++ b/psychrometric.py @@ -315,35 +315,3 @@ def add_device_points(fig, points): )) 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/settings_ui.py b/settings_ui.py index 1e11235..38b5be7 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -26,8 +26,7 @@ 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'] +ZONE_COLUMNS = ['name', 'scope', 'mode', 'level', 'color', 'enabled'] # vorm: in het houtdiagram (zone-editor) 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 @@ -83,8 +82,8 @@ 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()] + 'color': z['color'], 'shape': 'polygon', 'points': json.dumps(z['points']), + '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'} @@ -162,12 +161,10 @@ def row_form(table, rows, t, bus_names=None): 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 + if table == 'zones' and field == 'enabled': # vorm: alleen in het houtdiagram aan te passen + cells.append(html.Div(className='cfg-cell wide', children=[ + html.Span(t['col_shape'], className='cfg-label'), + html.Span(t['zone_shape_in_diagram'], className='cfg-static')])) 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 @@ -202,10 +199,6 @@ def _new_row(table, rows, bus_names): 'slave_id': next(i for i in range(1, 248) if i not in used), 'function_code': 4, 'register_temp': 1, 'register_humidity': 2, 'data_type': 'int16', 'byte_order': 'ABCD', 'scale_temp': 0.1, 'scale_humidity': 0.1, 'offset_temp': 0, 'offset_humidity': 0, 'enabled': 'true'} - if table == 'zones': - return {'id': None, 'scope': 0, 'name': f'Zone {len(rows) + 1}', 'mode': 'keep_inside', 'level': 'warning', - 'color': 'green', 'shape': 'rect', 'points': None, 'temp_min': 18, 'temp_max': 24, 'rh_min': 40, - 'rh_max': 60, 'enabled': 'true'} return {'id': None, 'scope': 0, 'metric': 'temperature', 'level': 'alarm', 'min_value': None, 'max_value': None, 'enabled': 'true'} @@ -279,7 +272,6 @@ def _alerts_section(t): 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)), @@ -549,13 +541,13 @@ def edit_rules(n_clicks, remove_clicks, values, lang): @app.callback( Output('zones-form', 'children'), - [Input('add-zone', 'n_clicks'), - Input({'type': 'cfg-remove', 'table': 'zones', 'row': ALL}, '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): + def edit_zones(remove_clicks, values, lang): + """Zone verwijderen uit de lijst (nieuwe zones en de vorm: in het houtdiagram).""" if not auth.is_logged_in(): return no_update return edit_rows('zones', _t(lang), bus_names=[]) diff --git a/test_app.py b/test_app.py index 98daa47..b8bb1ee 100644 --- a/test_app.py +++ b/test_app.py @@ -572,7 +572,7 @@ def test_settings_callbacks_require_login(): assert callbacks['change_password'](1, 'x', 'y', 'y', 'nl')[0] == TRANSLATIONS_NL['settings_locked'] assert 'admin' not in str(callbacks['show_settings']('settings', 'nl')) # alleen het inlogscherm assert callbacks['edit_rules'](1, [], [], 'nl') is dash.no_update - assert callbacks['edit_zones'](1, [], [], 'nl') is dash.no_update + assert callbacks['edit_zones']([], [], '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'] @@ -610,7 +610,7 @@ def test_config_forms_round_trip(): def test_zone_form_round_trip(): - """Zones in Settings: rechthoek via min/max-velden, getekende vorm blijft behouden (geen min/max-velden).""" + """Zones in Settings: eigenschappen bewerken, de vorm (in het houtdiagram gemaakt) blijft behouden.""" import settings_ui import zones from dash import html @@ -622,8 +622,13 @@ def test_zone_form_round_trip(): '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) + assert not any(c.id['field'] in ('temp_min', 'temp_max', 'rh_min', 'rh_max') for c in fields) + assert str(form).count(TRANSLATIONS_NL['zone_shape_in_diagram']) == 2 + original, auth.is_logged_in = auth.is_logged_in, lambda: True + try: + assert 'add-zone' not in str(settings_ui.render_settings('nl')) # nieuwe zone: in het houtdiagram + finally: + auth.is_logged_in = original 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]], @@ -682,7 +687,69 @@ def test_zone_alarms_on_leaving_and_entering(): assert len(outbox_texts()) == 2 and alerts.device_status(cel['id'])[0] == 'ok' -def test_zones_drawn_in_mollier_and_wood_diagram(): +def test_zones_only_in_wood_diagram(): + """Zones horen bij het houtdiagram (temperatuur × RV); het Mollier-diagram toont alleen de vaste comfortzone.""" + import callbacks + import layout + 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) + mollier = run_callback(app, 'psychrometric-chart.figure', 0, 'nl', 0, trigger='selected-language.data') + assert 'Opslag' not in [trace.name for trace in mollier.data] + assert 'draw-zone-btn' not in str(layout.create_layout()) + wood = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, 'dashboard', trigger='selected-language.data') + zone = next(trace for trace in wood.data if trace.name == 'Opslag') + assert zone.fill == 'toself' and zone.line.color == zones.COLORS['blue'] + + +def test_zone_editor_add_save_edit_delete(): + """Zone-editor in het houtdiagram: nieuw = vierkant, hoeken/middenbollen slepen (JS), opslaan alleen ingelogd.""" + import callbacks + import layout + import zones + from dash import Dash + setup_alerts([]) + assert settings.save({'show_wood': ['on']}) == {} + rendered = str(layout.create_layout()) + for component_id in ('zone-add-btn', 'zone-edit-select', 'zone-edit-bar', 'zone-edit-points'): + assert component_id in rendered, component_id + app = Dash(__name__) + callbacks.register_callbacks(app) + start = '..zone-edit.data...zone-edit-bar.style' + edit, bar, *_ = run_callback(app, start, 1, None, 0, 'nl', trigger='zone-add-btn.n_clicks') + assert edit['id'] is None and len(edit['points']) == 4 and bar == {} # begint als vierkant + shape = edit['points'][:2] + [[22.0, 70.0]] + edit['points'][2:] # middenbol versleept: vijfhoek + finish = 'zone-edit-msg.children' + fields = ('Nat', 0, 'keep_outside', 'alarm', 'red', 'nl') + with app.server.test_request_context(): + msg = run_callback(app, finish, 1, 0, 0, edit, shape, *fields, trigger='zone-save-btn.n_clicks')[0] + 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: + msg, edit_after, bar_after, *_ = run_callback(app, finish, 1, 0, 0, edit, shape, *fields, + trigger='zone-save-btn.n_clicks') + zone = zones.load()[0] + assert msg == TRANSLATIONS_NL['draw_saved'] and edit_after is None and bar_after == {'display': 'none'} + assert zone['points'] == shape and zone['name'] == 'Nat' and zone['mode'] == 'keep_outside' + edit, bar, name, *_ = run_callback(app, start, 0, zone['id'], 0, 'nl', trigger='zone-edit-select.value') + assert edit == {'id': zone['id'], 'points': shape} and name == 'Nat' # bestaande zone bewerken + run_callback(app, finish, 0, 1, 0, edit, None, *fields, trigger='zone-delete-btn.n_clicks', value=1) + assert zones.load() == [] + finally: + auth.is_logged_in = original + + +def test_zone_editing_redraw_is_not_overwritten_by_the_interval(): + """Editor open: eigen callback tekent het diagram zonder de zone in bewerking; de seconde-tik doet niets. + + Dash vervangt een lopend verzoek door een nieuwer verzoek van dezelfde callback: zat het hertekenen in de + ververs-callback, dan overschreef de tik het steeds (de oude zone bleef onder de editor staan). + """ import callbacks import zones from dash import Dash @@ -690,13 +757,54 @@ def test_zones_drawn_in_mollier_and_wood_diagram(): 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'}]) == {} + editing = {'id': zones.load()[0]['id'], 'points': [[10, 50], [20, 50], [20, 70], [10, 70]]} 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 + key = next(k for k, v in app.callback_map.items() + if 'wood-chart.figure' in k and [i['id'] for i in v['inputs']] == ['zone-edit']) + fig = app.callback_map[key]['callback'].__wrapped__(editing, 'nl', 0) + assert 'Opslag' not in [trace.name for trace in fig.data] + assert run_callback(app, 'wood-chart.figure', 5, 'nl', 0, editing, 'dashboard', + trigger='graph-update.n_intervals') is callbacks.no_update + + +def test_click_zone_in_wood_diagram_opens_editor(): + """Klik op een zone in het houtdiagram: die zone gaat open in de editor (zonder keuzelijst).""" + 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'}]) == {} + zone_id = zones.load()[0]['id'] + app = Dash(__name__) + callbacks.register_callbacks(app) + fig = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, 'dashboard', trigger='selected-language.data') + curve = next(i for i, trace in enumerate(fig.data) if trace.name == 'Opslag') + assert fig.data[curve].hoveron == 'fills' # klikbaar op het vlak zelf + figure = fig.to_plotly_json() + assert run_callback(app, 'zone-edit-select.value@', {'points': [{'curveNumber': curve}]}, figure, + trigger='wood-chart.clickData') == zone_id + assert run_callback(app, 'zone-edit-select.value@', {'points': [{'curveNumber': len(fig.data) - 1}]}, figure, + trigger='wood-chart.clickData') is callbacks.no_update # sensorpunt: geen zone + + +def test_zone_editor_script_is_wired(): + """De sleep-editor (assets/zone_editor.js) is de clientside callback zones.edit en levert de punten aan Dash. + + Het slepen zelf (hoeken, middenbollen, dubbelklik) wordt in de browser getest. + """ + import callbacks + from dash import Dash + script = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'zone_editor.js'), + encoding='utf-8').read() + for needle in ('zones:', 'edit: function', "set_props('zone-edit-points'", 'zone-editor-mid', 'zone-editor-corner', + 'dblclick', 'pointerdown', 'plotly_afterplot'): + assert needle in script, needle + app = Dash(__name__) + callbacks.register_callbacks(app) + assert 'zone-editor-ready.data' in app.callback_map def test_zone_alarm_has_margin_at_the_edge(): @@ -725,59 +833,6 @@ def test_zone_alarm_has_margin_at_the_edge(): assert any('Schimmel' in x for x in outbox_texts()) -def test_drawn_mollier_path_becomes_zone_points(): - """Plotly tekenpad (x = °C, y = g/kg) naar zonepunten in °C × RV.""" - import psychrometric - points = psychrometric.path_to_points('M20,7.29L30,13.31L25,40Z') - assert [round(t) for t, _ in points] == [20, 30, 25] - assert abs(points[0][1] - 50) < 1 and abs(points[1][1] - 50) < 1 and points[2][1] == 100 # boven verzadiging: 100 % - - -def test_save_drawn_zone_only_when_logged_in(): - import callbacks - import zones - from dash import Dash - setup_alerts([]) - app = Dash(__name__) - callbacks.register_callbacks(app) - path = 'M20,7.29L30,13.31L25,4Z' - with app.server.test_request_context(): - msg, drawing, _ = run_callback(app, 'draw-zone-msg.children', 1, path, 'Droog', '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 @@ -1318,10 +1373,10 @@ def test_wood_chart_and_board_follow_setting(): 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 \ + assert run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, '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') + fig = run_callback(app, 'wood-chart.figure', 0, 'nl', 0, None, '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) @@ -1389,6 +1444,22 @@ def test_wood_value_restarts_when_wood_settings_change(): assert abs(wood.update(1, 20, 65, now=now + 60) - 10.0) < 0.2 +def test_limits_shown_in_time_chart(): + """Grenswaarden van de getoonde grootheid als lijnen in de tijdgrafiek (alleen die grootheid).""" + import callbacks + import pandas as pd + setup_alerts([{'scope': 0, 'metric': 'temperature', 'max_value': 8, 'level': 'alarm', 'enabled': 'true'}, + {'scope': 0, 'metric': 'temperature', 'min_value': 2, 'max_value': 6, 'level': 'warning', + 'enabled': 'true'}, + {'scope': 0, 'metric': 'humidity', 'max_value': 80, 'enabled': 'true'}]) + df = pd.DataFrame({'timestamp': [1, 2], 'temperature': [5.0, 7.0], 'humidity': [60.0, 70.0], 'device_id': [1, 1]}) + limits = callbacks.limit_rules('temperature', None) + fig = callbacks.metric_figure(df, {1: 'Kas'}, {}, 'temperature', TRANSLATIONS['nl'], 'dark', limits=limits) + lines = sorted((shape.y0, shape.line.color) for shape in fig.layout.shapes) + k = callbacks.THEMES['dark'] + assert lines == [(2, k['warn']), (6, k['warn']), (8, k['alarm'])] + + def test_warning_and_alarm_levels(): names = setup_alerts([{'scope': 0, 'metric': 'wood_emc', 'max_value': 18, 'level': 'warning', 'enabled': 'true'}, {'scope': 0, 'metric': 'wood_emc', 'max_value': 20, 'level': 'alarm', 'enabled': 'true'}]) diff --git a/translations.py b/translations.py index 6cfeed3..ecfe568 100644 --- a/translations.py +++ b/translations.py @@ -217,6 +217,12 @@ '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})', + 'btn_new_zone': '+ Zone', + 'lbl_edit_zone': 'Zone bewerken…', + 'hint_edit_zone': 'Sleep de hoeken naar de gewenste plek. Sleep een bolletje midden op een zijde om een hoek toe te voegen; dubbelklik een hoek om hem te verwijderen.', + 'btn_delete_zone': 'Verwijderen', + 'zone_deleted': 'Zone verwijderd.', + 'zone_shape_in_diagram': 'Vorm aanpassen in het houtdiagram', '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', @@ -241,8 +247,8 @@ '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.', + 'table_zones': 'Zones (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. De vorm maak en wijzig je in het houtdiagram.', 'btn_add_zone': '+ Zone toevoegen', 'col_color': 'Kleur', 'col_mode': 'Melding', @@ -576,6 +582,12 @@ '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})', + 'btn_new_zone': '+ Zone', + 'lbl_edit_zone': 'Edit zone…', + 'hint_edit_zone': 'Drag the corners into place. Drag a dot in the middle of a side to add a corner; double-click a corner to remove it.', + 'btn_delete_zone': 'Delete', + 'zone_deleted': 'Zone deleted.', + 'zone_shape_in_diagram': 'Edit the shape in the wood diagram', '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', @@ -600,8 +612,8 @@ '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.', + 'table_zones': 'Zones (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. Create and shape zones in the wood diagram.', 'btn_add_zone': '+ Add zone', 'col_color': 'Colour', 'col_mode': 'Notification', @@ -935,6 +947,12 @@ '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})', + 'btn_new_zone': '+ Zone', + 'lbl_edit_zone': 'Zone bearbeiten…', + 'hint_edit_zone': 'Ziehen Sie die Ecken an die gewünschte Stelle. Ziehen Sie einen Punkt in der Mitte einer Seite, um eine Ecke hinzuzufügen; Doppelklick auf eine Ecke entfernt sie.', + 'btn_delete_zone': 'Löschen', + 'zone_deleted': 'Zone gelöscht.', + 'zone_shape_in_diagram': 'Form im Holzdiagramm bearbeiten', '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', @@ -959,8 +977,8 @@ '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.', + 'table_zones': 'Zonen (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. Die Form erstellen und ändern Sie im Holzdiagramm.', 'btn_add_zone': '+ Zone hinzufügen', 'col_color': 'Farbe', 'col_mode': 'Meldung', @@ -1294,6 +1312,12 @@ '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})", + 'btn_new_zone': '+ Zone', + 'lbl_edit_zone': 'Modifier une zone…', + 'hint_edit_zone': 'Faites glisser les coins à la position voulue. Faites glisser un point au milieu d’un côté pour ajouter un coin ; double-cliquez sur un coin pour le supprimer.', + 'btn_delete_zone': 'Supprimer', + 'zone_deleted': 'Zone supprimée.', + 'zone_shape_in_diagram': 'Modifier la forme dans le diagramme du bois', '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', @@ -1318,8 +1342,8 @@ '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.', + 'table_zones': 'Zones (diagramme 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. La forme se crée et se modifie dans le diagramme du bois.', 'btn_add_zone': '+ Ajouter une zone', 'col_color': 'Couleur', 'col_mode': 'Notification', @@ -1653,6 +1677,12 @@ '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})', + 'btn_new_zone': '+ Zona', + 'lbl_edit_zone': 'Editar zona…', + 'hint_edit_zone': 'Arrastre las esquinas a su sitio. Arrastre un punto en el centro de un lado para añadir una esquina; haga doble clic en una esquina para quitarla.', + 'btn_delete_zone': 'Eliminar', + 'zone_deleted': 'Zona eliminada.', + 'zone_shape_in_diagram': 'Editar la forma en el diagrama de la madera', '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', @@ -1677,8 +1707,8 @@ '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.', + 'table_zones': 'Zonas (diagrama 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. La forma se crea y se modifica en el diagrama de la madera.', 'btn_add_zone': '+ Añadir zona', 'col_color': 'Color', 'col_mode': 'Aviso', diff --git a/wood.py b/wood.py index 9587c0b..89755ba 100644 --- a/wood.py +++ b/wood.py @@ -111,7 +111,9 @@ def figure(points, limits, t, k, 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')) + line=dict(color=zones.COLORS[zone['color']], width=1.5), + # klikbaar op het vlak: opent de zone in de editor (meta = zone-id) + hoveron='fills', hoverinfo='name', meta=zone.get('id'))) 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: