frugal-iot-client 1.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +28 -0
- package/CLAUDE.md +263 -0
- package/HEADLESS_PLAN.md +167 -0
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/dashboard_example.html +458 -0
- package/frugaliot.css +271 -0
- package/images/Battery0.png +0 -0
- package/images/Battery1.png +0 -0
- package/images/Battery2.png +0 -0
- package/images/Battery3.png +0 -0
- package/images/Battery4.png +0 -0
- package/images/Battery5.png +0 -0
- package/images/Battery6.png +0 -0
- package/images/icon_graph.svg +1 -0
- package/index-embedded.html +56 -0
- package/index-project.html +26 -0
- package/index.html +36 -0
- package/login.html +27 -0
- package/package.json +34 -0
- package/webcomponents.js +5003 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta name="theme-color" content="#007bff">
|
|
7
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
9
|
+
<meta name="apple-mobile-web-app-title" content="Frugal IoT Dashboard">
|
|
10
|
+
<link rel="apple-touch-icon" href="/images/icon-192x192.png">
|
|
11
|
+
<link rel="manifest" href="/manifest.json">
|
|
12
|
+
<title>Frugal IoT Control Dashboard 2</title>
|
|
13
|
+
<link href="./frugaliot.css" rel="stylesheet" type="text/css">
|
|
14
|
+
<script type="importmap">
|
|
15
|
+
{
|
|
16
|
+
"imports": {
|
|
17
|
+
"@kurkle/color": "/node_modules/@kurkle/color/dist/color.esm.js",
|
|
18
|
+
"chart.js": "/node_modules/chart.js/dist/Chart.js",
|
|
19
|
+
"dial-gauge": "/node_modules/dial-gauge/dial-gauge.js",
|
|
20
|
+
"luxon": "/node_modules/luxon/src/luxon.js",
|
|
21
|
+
"chartjs-adapter-luxon": "/node_modules/chartjs-adapter-luxon/dist/chartjs-adapter-luxon.esm.js",
|
|
22
|
+
"csv-parse": "/node_modules/csv-parse/dist/esm/index.js"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
<script src="./webcomponents.js" type="module"></script>
|
|
27
|
+
<style>
|
|
28
|
+
* { box-sizing: border-box; }
|
|
29
|
+
body { font-family: sans-serif; margin: 0; background: #f5f5f5; }
|
|
30
|
+
|
|
31
|
+
/* ── Header ── */
|
|
32
|
+
#app-header {
|
|
33
|
+
background: #007bff; color: white;
|
|
34
|
+
padding: 10px 16px;
|
|
35
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
36
|
+
position: sticky; top: 0; z-index: 100;
|
|
37
|
+
}
|
|
38
|
+
#app-title { margin: 0; font-size: 1.15em; font-weight: bold; }
|
|
39
|
+
#settings-btn {
|
|
40
|
+
background: transparent; border: 2px solid white; color: white;
|
|
41
|
+
border-radius: 50%; width: 38px; height: 38px;
|
|
42
|
+
font-size: 1.2em; cursor: pointer;
|
|
43
|
+
display: flex; align-items: center; justify-content: center;
|
|
44
|
+
transition: background 0.15s, color 0.15s;
|
|
45
|
+
}
|
|
46
|
+
#settings-btn.active { background: white; color: #007bff; }
|
|
47
|
+
|
|
48
|
+
/* ── Panels ── */
|
|
49
|
+
.panel { padding: 12px; }
|
|
50
|
+
.panel.hidden { display: none; }
|
|
51
|
+
|
|
52
|
+
/* ── Cards ── */
|
|
53
|
+
.card {
|
|
54
|
+
background: white; border-radius: 8px; padding: 12px 14px;
|
|
55
|
+
margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1);
|
|
56
|
+
}
|
|
57
|
+
.card h3 { margin: 0 0 8px; font-size: 0.9em; color: #888; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
58
|
+
|
|
59
|
+
/* ── Waiting message ── */
|
|
60
|
+
#waiting-msg { text-align: center; padding: 48px 20px; color: #666; }
|
|
61
|
+
#waiting-msg p { font-size: 1.05em; margin-bottom: 12px; }
|
|
62
|
+
#waiting-msg button {
|
|
63
|
+
background: #007bff; color: white; border: none;
|
|
64
|
+
padding: 8px 18px; border-radius: 4px; font-size: 1em; cursor: pointer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ── Connection info ── */
|
|
68
|
+
#connection-info { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; font-size: 0.92em; }
|
|
69
|
+
.info-label { color: #888; }
|
|
70
|
+
.info-value { font-weight: bold; word-break: break-all; }
|
|
71
|
+
|
|
72
|
+
/* ── Status indicator ── */
|
|
73
|
+
#status-row { display: flex; align-items: center; gap: 12px; }
|
|
74
|
+
#status-dot {
|
|
75
|
+
width: 22px; height: 22px; border-radius: 50%;
|
|
76
|
+
background: #ccc; border: 2px solid #aaa; flex-shrink: 0;
|
|
77
|
+
transition: background 0.3s, border-color 0.3s;
|
|
78
|
+
}
|
|
79
|
+
#status-dot.on { background: #2ecc71; border-color: #27ae60; }
|
|
80
|
+
#status-dot.off { background: #e74c3c; border-color: #c0392b; }
|
|
81
|
+
#status-text { font-size: 1.1em; font-weight: bold; color: #333; }
|
|
82
|
+
|
|
83
|
+
/* ── Controls row ── */
|
|
84
|
+
#dashboard-controls {
|
|
85
|
+
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px;
|
|
86
|
+
}
|
|
87
|
+
.ctrl-field { display: flex; flex-direction: column; }
|
|
88
|
+
#dashboard-controls mqtt-text { --mqtt-text-val-width: 90px; }
|
|
89
|
+
|
|
90
|
+
/* ── Settings: hide the mqtt-client connection-status widget (headless mode has no mqtt-project) ── */
|
|
91
|
+
#settings-panel > mqtt-wrapper > mqtt-client { display: none !important; }
|
|
92
|
+
|
|
93
|
+
/* ── Settings: chooser section ── */
|
|
94
|
+
.settings-intro {
|
|
95
|
+
background: #e8f0fe; border-left: 4px solid #007bff;
|
|
96
|
+
padding: 8px 12px; margin-bottom: 10px;
|
|
97
|
+
border-radius: 0 6px 6px 0; font-size: 0.88em; color: #444;
|
|
98
|
+
}
|
|
99
|
+
#chooser-section {
|
|
100
|
+
background: white; border-radius: 8px;
|
|
101
|
+
padding: 12px 14px; margin-bottom: 10px;
|
|
102
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.1);
|
|
103
|
+
}
|
|
104
|
+
#chooser-section.hidden { display: none; }
|
|
105
|
+
mqtt-choosetopic { display: block; margin-bottom: 8px; }
|
|
106
|
+
mqtt-choosetopic::part(row) { display: flex; align-items: center; gap: 8px; }
|
|
107
|
+
mqtt-choosetopic::part(label) { font-size: 0.9em; color: #555; width: 72px; flex-shrink: 0; }
|
|
108
|
+
mqtt-choosetopic::part(select) { flex: 1; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 0.92em; }
|
|
109
|
+
</style>
|
|
110
|
+
</head>
|
|
111
|
+
<body>
|
|
112
|
+
|
|
113
|
+
<div id="app-header">
|
|
114
|
+
<h1 id="app-title">Control Dashboard</h1>
|
|
115
|
+
<button id="settings-btn" title="Settings" onclick="window._dashToggle()">⚙</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<!-- ═══ Dashboard panel ═══ -->
|
|
119
|
+
<div id="dashboard-panel" class="panel">
|
|
120
|
+
|
|
121
|
+
<div id="waiting-msg">
|
|
122
|
+
<p>No control module found yet.</p>
|
|
123
|
+
<p>Open Settings and select your device.</p>
|
|
124
|
+
<button onclick="window._dashToggle()">Open Settings ⚙</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div id="dashboard-content" style="display:none">
|
|
128
|
+
|
|
129
|
+
<!-- Sensor / actuator names -->
|
|
130
|
+
<div class="card">
|
|
131
|
+
<div id="connection-info">
|
|
132
|
+
<span class="info-label">Sensor</span>
|
|
133
|
+
<span class="info-value" id="sensor-name">—</span>
|
|
134
|
+
<span class="info-label">Actuator</span>
|
|
135
|
+
<span class="info-value" id="actuator-name">—</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<!-- On/off status -->
|
|
140
|
+
<div class="card">
|
|
141
|
+
<div id="status-row">
|
|
142
|
+
<div id="status-dot"></div>
|
|
143
|
+
<span id="status-text">Status unknown</span>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- Sensor bar — mqtt-bar inserted by JS -->
|
|
148
|
+
<!--TODO check its adding via creating element rather than raw bar?-->
|
|
149
|
+
<div class="card" id="bar-container"></div>
|
|
150
|
+
|
|
151
|
+
<!-- Control parameters — inserted by JS -->
|
|
152
|
+
<div class="card">
|
|
153
|
+
<h3>Control Parameters</h3>
|
|
154
|
+
<div id="dashboard-controls"></div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- History graph — mqtt-graph placed here by MqttGraph.graph via topic.createGraph() -->
|
|
158
|
+
<div class="card">
|
|
159
|
+
<h3>History</h3>
|
|
160
|
+
<div id="graph-container"></div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<!-- ═══ Settings panel ═══ -->
|
|
167
|
+
<div id="settings-panel" class="panel hidden">
|
|
168
|
+
<div class="settings-intro">
|
|
169
|
+
Select organisation and project, then choose your control module, sensor, and actuator.
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- mqtt-wrapper in headless mode: builds the data tree only, no DOM for project/node/group/leaf. -->
|
|
173
|
+
<mqtt-wrapper headless></mqtt-wrapper>
|
|
174
|
+
|
|
175
|
+
<language-picker></language-picker>
|
|
176
|
+
|
|
177
|
+
<!-- Choosers are built by JS once control groups are discovered via frugaliot:controlgroup. -->
|
|
178
|
+
<div id="chooser-section" class="hidden">
|
|
179
|
+
<div id="control-chooser-container"></div>
|
|
180
|
+
<div id="sensor-chooser-container"></div>
|
|
181
|
+
<div id="actuator-chooser-container"></div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<script type="module">
|
|
186
|
+
import { el, getString, addVocabulary, preferedLanguageSet } from './webcomponents.js';
|
|
187
|
+
|
|
188
|
+
// Seed the language from ?lang= before any el()/getString() calls, so translated strings
|
|
189
|
+
// are correct from the first render rather than waiting for mqtt-wrapper's async config fetch.
|
|
190
|
+
const _langParam = new URL(window.location.href).searchParams.get('lang');
|
|
191
|
+
if (_langParam) _langParam.split(',').reverse().forEach(l => preferedLanguageSet(l.toUpperCase()));
|
|
192
|
+
|
|
193
|
+
addVocabulary(`
|
|
194
|
+
EN:
|
|
195
|
+
OFF: OFF
|
|
196
|
+
ON: ON
|
|
197
|
+
Status unknown: Status unknown
|
|
198
|
+
Sensor: Sensor
|
|
199
|
+
Actuator: Actuator
|
|
200
|
+
FR:
|
|
201
|
+
OFF: Éteint
|
|
202
|
+
ON: Allumé
|
|
203
|
+
Status unknown: Statut inconnu
|
|
204
|
+
Sensor: Capteur
|
|
205
|
+
Actuator: Actionneur
|
|
206
|
+
HI:
|
|
207
|
+
OFF: बंद
|
|
208
|
+
ON: चालू
|
|
209
|
+
Status unknown: स्थिति अज्ञात
|
|
210
|
+
Sensor: सेंसर
|
|
211
|
+
Actuator: एक्चुएटर
|
|
212
|
+
ID:
|
|
213
|
+
OFF: Mati
|
|
214
|
+
ON: Nyala
|
|
215
|
+
Status unknown: Status tidak diketahui
|
|
216
|
+
Sensor: Sensor
|
|
217
|
+
Actuator: Aktuator
|
|
218
|
+
`);
|
|
219
|
+
|
|
220
|
+
/* ── Tab switching ── */
|
|
221
|
+
let settingsOpen = false;
|
|
222
|
+
window._dashToggle = function () {
|
|
223
|
+
settingsOpen = !settingsOpen;
|
|
224
|
+
document.getElementById('dashboard-panel').classList.toggle('hidden', settingsOpen);
|
|
225
|
+
document.getElementById('settings-panel').classList.toggle('hidden', !settingsOpen);
|
|
226
|
+
document.getElementById('settings-btn').classList.toggle('active', settingsOpen);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/* ── Dashboard state ── */
|
|
230
|
+
let currentEntry = null; // { nodeMt, groupId, topics: [MqttTopic] } — the active control group
|
|
231
|
+
let choosersBuilt = false; // Sensor/actuator choosers are built once and reused
|
|
232
|
+
let prevSensorMt = null; // Track the last graphed sensor to remove its line on sensor change
|
|
233
|
+
let prevOutMt = null; // Track the last graphed actuator to remove its line on control change
|
|
234
|
+
let prevBarMt = null; // MqttTopic whose .element is our sensor bar (cleared on rebuild)
|
|
235
|
+
let prevControlMts = []; // MqttTopics whose .element is a control input (cleared on rebuild)
|
|
236
|
+
|
|
237
|
+
/* ── Helpers ── */
|
|
238
|
+
function getWrapper() { return document.querySelector('mqtt-wrapper'); }
|
|
239
|
+
|
|
240
|
+
/* ── Status dot ── */
|
|
241
|
+
function updateOnOff(on) {
|
|
242
|
+
const dot = document.getElementById('status-dot');
|
|
243
|
+
const text = document.getElementById('status-text');
|
|
244
|
+
if (on === undefined || on === null) {
|
|
245
|
+
dot.className = ''; text.textContent = getString('Status unknown');
|
|
246
|
+
} else if (on) {
|
|
247
|
+
dot.className = 'on'; text.textContent = getString('ON');
|
|
248
|
+
} else {
|
|
249
|
+
dot.className = 'off'; text.textContent = getString('OFF');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ── Connection info card ── */
|
|
254
|
+
function updateConnectionInfo(sensorMt, actuatorMt) {
|
|
255
|
+
document.getElementById('sensor-name').textContent = sensorMt ? sensorMt.usableName || '-' : '—';
|
|
256
|
+
document.getElementById('actuator-name').textContent = actuatorMt ? actuatorMt.usableName || '-' : '—';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ── Build/rebuild the chooser section from the data tree ──
|
|
260
|
+
Called on every frugaliot:controlgroup event so the control dropdown stays current.
|
|
261
|
+
Sensor/actuator choosers are built once; only their selected values are updated. */
|
|
262
|
+
function buildChoosers() {
|
|
263
|
+
const projectMt = getWrapper()?.projectMt;
|
|
264
|
+
if (!projectMt) return;
|
|
265
|
+
|
|
266
|
+
document.getElementById('chooser-section').classList.remove('hidden');
|
|
267
|
+
|
|
268
|
+
// ── Control chooser: type="control" uses the data tree via MqttTopic.controlGroupList ──
|
|
269
|
+
const controlContainer = document.getElementById('control-chooser-container');
|
|
270
|
+
controlContainer.innerHTML = '';
|
|
271
|
+
controlContainer.appendChild(el('mqtt-choosetopic', {
|
|
272
|
+
name: 'Control',
|
|
273
|
+
type: 'control',
|
|
274
|
+
projectMt,
|
|
275
|
+
value: currentEntry ? `${currentEntry.nodeMt.nodeId}/${currentEntry.groupId}` : '',
|
|
276
|
+
onchange: (e) => {
|
|
277
|
+
if (!e.target.value) return;
|
|
278
|
+
const [nodeId, groupId] = e.target.value.split('/');
|
|
279
|
+
const nodeMt = getWrapper().projectMt.nodes[nodeId];
|
|
280
|
+
if (!nodeMt) return;
|
|
281
|
+
selectControl({ nodeMt, groupId, topics: nodeMt.controlGroups[groupId] || [] });
|
|
282
|
+
}
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
if (!choosersBuilt) {
|
|
286
|
+
choosersBuilt = true;
|
|
287
|
+
|
|
288
|
+
// ── Sensor chooser: float read topics across the whole project ──
|
|
289
|
+
document.getElementById('sensor-chooser-container').appendChild(el('mqtt-choosetopic', {
|
|
290
|
+
name: 'Sensor',
|
|
291
|
+
type: 'float',
|
|
292
|
+
rw: 'r',
|
|
293
|
+
projectMt,
|
|
294
|
+
value: '',
|
|
295
|
+
onchange: (e) => {
|
|
296
|
+
if (!e.target.value || !currentEntry) return;
|
|
297
|
+
const nowMt = currentEntry.topics.find(t => t.leaf === 'now');
|
|
298
|
+
if (nowMt) nowMt.publishWired(e.target.value);
|
|
299
|
+
}
|
|
300
|
+
}));
|
|
301
|
+
|
|
302
|
+
// ── Actuator chooser: bool write topics across the whole project ──
|
|
303
|
+
document.getElementById('actuator-chooser-container').appendChild(el('mqtt-choosetopic', {
|
|
304
|
+
name: 'Actuator',
|
|
305
|
+
type: 'bool',
|
|
306
|
+
rw: 'w',
|
|
307
|
+
projectMt,
|
|
308
|
+
value: '',
|
|
309
|
+
onchange: (e) => {
|
|
310
|
+
if (!e.target.value || !currentEntry) return;
|
|
311
|
+
const outMt = currentEntry.topics.find(t => t.leaf === 'out');
|
|
312
|
+
if (outMt) outMt.publishWired(e.target.value);
|
|
313
|
+
}
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
syncWiringChoosers();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ── Sync sensor/actuator chooser values with the current control's wiring ── */
|
|
321
|
+
function syncWiringChoosers() {
|
|
322
|
+
if (!currentEntry) return;
|
|
323
|
+
const nowMt = currentEntry.topics.find(t => t.leaf === 'now');
|
|
324
|
+
const outMt = currentEntry.topics.find(t => t.leaf === 'out');
|
|
325
|
+
const sensorChooser = document.querySelector('#sensor-chooser-container mqtt-choosetopic');
|
|
326
|
+
const actuatorChooser = document.querySelector('#actuator-chooser-container mqtt-choosetopic');
|
|
327
|
+
if (sensorChooser && nowMt) sensorChooser.setAttribute('value', nowMt.wired || '');
|
|
328
|
+
if (actuatorChooser && outMt) actuatorChooser.setAttribute('value', outMt.wired || '');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* ── Select a control group entry and update the dashboard ── */
|
|
332
|
+
function selectControl(entry) {
|
|
333
|
+
currentEntry = entry;
|
|
334
|
+
wireUpDashboard(entry);
|
|
335
|
+
syncWiringChoosers();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* ── Build the dashboard for a control entry ──
|
|
339
|
+
Derives all topic paths from the data tree (MqttTopic objects) rather than DOM traversal.
|
|
340
|
+
Falls back gracefully if specific leaves are absent (e.g. older firmware). */
|
|
341
|
+
function wireUpDashboard({ nodeMt, groupId, topics }) {
|
|
342
|
+
// Locate specific leaf topics from the control group's topic list
|
|
343
|
+
const nowMt = topics.find(t => t.leaf === 'now');
|
|
344
|
+
const outMt = topics.find(t => t.leaf === 'out');
|
|
345
|
+
const greaterMt = topics.find(t => t.leaf === 'greater');
|
|
346
|
+
const limitMt = topics.find(t => t.leaf === 'limit');
|
|
347
|
+
const hystMt = topics.find(t => t.leaf === 'hysteresis' || t.leaf === 'hysterisis');
|
|
348
|
+
|
|
349
|
+
// Sensor is what 'now' is wired to; fall back to the local 'now' reading if unwired
|
|
350
|
+
const sensorMt = (nowMt && nowMt.wiredTopic) || nowMt;
|
|
351
|
+
// Actuator display is the topic 'out' is wired to (may be null)
|
|
352
|
+
const actuatorMt = outMt && outMt.wiredTopic;
|
|
353
|
+
|
|
354
|
+
document.getElementById('app-title').textContent =
|
|
355
|
+
nodeMt.usableName || 'Dashboard';
|
|
356
|
+
document.getElementById('waiting-msg').style.display = 'none';
|
|
357
|
+
document.getElementById('dashboard-content').style.display = '';
|
|
358
|
+
|
|
359
|
+
updateConnectionInfo(sensorMt, actuatorMt);
|
|
360
|
+
|
|
361
|
+
// ── Sensor bar ──
|
|
362
|
+
// Disconnect the old bar from its MqttTopic before clearing so stale messages
|
|
363
|
+
// don't try to update a detached element, and so the new bar can take ownership.
|
|
364
|
+
const barContainer = document.getElementById('bar-container');
|
|
365
|
+
if (prevBarMt) prevBarMt.element = null;
|
|
366
|
+
prevBarMt = null;
|
|
367
|
+
barContainer.innerHTML = '';
|
|
368
|
+
if (sensorMt) {
|
|
369
|
+
const barEl = el('mqtt-bar', {
|
|
370
|
+
topic: sensorMt.topicPath,
|
|
371
|
+
label: sensorMt.usableName,
|
|
372
|
+
type: 'float',
|
|
373
|
+
color: 'steelblue',
|
|
374
|
+
min: String(sensorMt.min ?? 0),
|
|
375
|
+
max: String(sensorMt.max ?? 100),
|
|
376
|
+
});
|
|
377
|
+
// Bind to the existing data-tree MqttTopic: prevents createTopic() from creating a
|
|
378
|
+
// new topic with a wrong (twig-only) subscription path, and enables the node-level
|
|
379
|
+
// wildcard routing to reach this element when new values arrive.
|
|
380
|
+
barEl.mt = sensorMt;
|
|
381
|
+
sensorMt.element = barEl;
|
|
382
|
+
prevBarMt = sensorMt;
|
|
383
|
+
barContainer.appendChild(barEl);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Control inputs ──
|
|
387
|
+
// Disconnect old control elements from their MqttTopics before rebuilding.
|
|
388
|
+
const controlsEl = document.getElementById('dashboard-controls');
|
|
389
|
+
prevControlMts.forEach(mt => { if (mt) mt.element = null; });
|
|
390
|
+
prevControlMts = [];
|
|
391
|
+
controlsEl.innerHTML = '';
|
|
392
|
+
// Bind each control element to the data-tree topic so createTopic() is skipped and
|
|
393
|
+
// wildcard routing reaches the element when the broker delivers retained/new values.
|
|
394
|
+
const val = mt => mt.state.value != null ? String(mt.state.value) : undefined;
|
|
395
|
+
function addCtrl(child, mt) {
|
|
396
|
+
child.mt = mt;
|
|
397
|
+
mt.element = child;
|
|
398
|
+
prevControlMts.push(mt);
|
|
399
|
+
controlsEl.appendChild(el('div', { class: 'ctrl-field' }, [child]));
|
|
400
|
+
}
|
|
401
|
+
if (greaterMt) addCtrl(el('mqtt-toggle', { topic: greaterMt.topicPath, label: '', type: 'bool', labels: '<,>', value: val(greaterMt) }), greaterMt);
|
|
402
|
+
if (limitMt) addCtrl(el('mqtt-text', { topic: limitMt.topicPath, label: '', type: 'float', color: 'orange', value: val(limitMt) }), limitMt);
|
|
403
|
+
if (hystMt) addCtrl(el('mqtt-text', { topic: hystMt.topicPath, label: '±', type: 'float', color: '#666', value: val(hystMt) }), hystMt);
|
|
404
|
+
|
|
405
|
+
// ── Graph: remove old lines if sensor or actuator changed, then add the new ones ──
|
|
406
|
+
if (prevSensorMt && prevSensorMt !== sensorMt) prevSensorMt.removeFromGraph();
|
|
407
|
+
if (prevOutMt && prevOutMt !== actuatorMt) prevOutMt.removeFromGraph();
|
|
408
|
+
prevSensorMt = sensorMt || null;
|
|
409
|
+
prevOutMt = actuatorMt || null;
|
|
410
|
+
if (sensorMt) sensorMt.createGraph();
|
|
411
|
+
if (actuatorMt) actuatorMt.createGraph();
|
|
412
|
+
|
|
413
|
+
// ── Read current on/off state from the data tree ──
|
|
414
|
+
// Changes are delivered via frugaliot:groupchanged (see listener below); no MutationObserver needed.
|
|
415
|
+
const groupMt = nodeMt.groups[groupId];
|
|
416
|
+
if (groupMt) updateOnOff(groupMt.state.on);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/* ── React to control groups appearing via the topic data tree ──
|
|
420
|
+
Fired by MqttNode.addGroupFromTemplate when a control group is created. */
|
|
421
|
+
document.addEventListener('frugaliot:controlgroup', (e) => {
|
|
422
|
+
buildChoosers(); // Rebuild control dropdown to include the new group
|
|
423
|
+
if (!currentEntry) {
|
|
424
|
+
// Auto-select the first control group discovered
|
|
425
|
+
selectControl(e.detail);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
/* ── React to group state changes (on/off, wiring) via the data tree ──
|
|
430
|
+
Fired by MqttReceiver.topicValueSet whenever a group-level state field changes.
|
|
431
|
+
Replaces the MutationObserver that previously watched the group DOM element. */
|
|
432
|
+
document.addEventListener('frugaliot:groupchanged', (e) => {
|
|
433
|
+
const { nodeMt, groupId, groupMt, changed } = e.detail;
|
|
434
|
+
if (!currentEntry || nodeMt !== currentEntry.nodeMt || groupId !== currentEntry.groupId) return;
|
|
435
|
+
if (changed.some(attr => attr === 'now_wired' || attr === 'out_wired')) {
|
|
436
|
+
wireUpDashboard(currentEntry);
|
|
437
|
+
syncWiringChoosers();
|
|
438
|
+
} else if (changed.includes('on')) {
|
|
439
|
+
updateOnOff(groupMt.state.on);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
/* ── Rebuild sensor/actuator choosers and re-resolve wiring when new topics are discovered ──
|
|
444
|
+
Fired by MqttProject.rebuildTopicDropdowns whenever nodes or groups are added.
|
|
445
|
+
Also re-runs wireUpDashboard so that if the wired sensor's node arrived after the
|
|
446
|
+
frugaliot:groupchanged event (causing wiredTopic to resolve to null at that point),
|
|
447
|
+
the connection-info labels and sensor bar are updated once the sensor is in the tree. */
|
|
448
|
+
document.addEventListener('frugaliot:topicschanged', () => {
|
|
449
|
+
const sc = document.querySelector('#sensor-chooser-container mqtt-choosetopic');
|
|
450
|
+
const ac = document.querySelector('#actuator-chooser-container mqtt-choosetopic');
|
|
451
|
+
if (sc) sc.rebuildDropdown();
|
|
452
|
+
if (ac) ac.rebuildDropdown();
|
|
453
|
+
if (currentEntry) wireUpDashboard(currentEntry);
|
|
454
|
+
});
|
|
455
|
+
</script>
|
|
456
|
+
|
|
457
|
+
</body>
|
|
458
|
+
</html>
|