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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 20 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
168 changes: 168 additions & 0 deletions assets/zone_editor.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
});
}());
Loading
Loading