frugal-iot-client 1.3.13 → 1.3.14
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.json +12 -0
- package/CARDS_PLAN.md +841 -0
- package/CARDS_UX.md +1022 -0
- package/CLAUDE.md +128 -14
- package/FLASH_PLAN.md +250 -9
- package/admin.js +1377 -0
- package/base/boards.json +6 -4
- package/base/bootloader/esp32c3.bin +0 -0
- package/base/bootloader/esp32s2.bin +0 -0
- package/base/bootloader/esp32s3.bin +0 -0
- package/cards.js +867 -0
- package/core.js +2696 -0
- package/dashboard.js +12 -0
- package/dashboard_example.html +2 -2
- package/flash.js +609 -0
- package/frugaliot.css +342 -14
- package/graph.js +475 -0
- package/index-old.html +37 -0
- package/index.html +6 -6
- package/nodeview.js +433 -0
- package/package.json +11 -4
- package/test/cards.test.js +227 -0
- package/test/cardui.test.js +435 -0
- package/test/datatree.test.js +171 -0
- package/test/fixtures/config.json +1416 -0
- package/test/fixtures/regenerate.js +40 -0
- package/test/grid.test.js +258 -0
- package/test/lightdom.test.js +57 -0
- package/test/mock.html +113 -0
- package/test/mock.js +269 -0
- package/test/modules.test.js +48 -0
- package/test/oldui.test.js +40 -0
- package/test/page.test.js +292 -0
- package/test/permissions.test.js +134 -0
- package/test/serialize.js +49 -0
- package/test/setup.js +54 -0
- package/test/smoke.test.js +43 -0
- package/test/snapshots/card-control-wired-back.txt +197 -0
- package/test/snapshots/card-control-wired-front.txt +60 -0
- package/test/snapshots/card-mixed-sensors-summary.txt +14 -0
- package/test/snapshots/card-one-device-summary.txt +14 -0
- package/test/snapshots/control-wired.txt +264 -0
- package/test/snapshots/default-front.txt +225 -0
- package/test/snapshots/mixed-sensors.txt +208 -0
- package/test/snapshots/no-readings.txt +54 -0
- package/test/snapshots/one-device.txt +154 -0
- package/test/snapshots/unknown-module.txt +117 -0
- package/webcomponents.js +15 -5541
- package/widgets.js +760 -0
package/CARDS_PLAN.md
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
# Device Cards — Implementation Plan
|
|
2
|
+
|
|
3
|
+
Step 2 of 3. The design is `CARDS_UX.md`; decisions D-1…D-33, deferrals L-1…L-7 and resolved plan
|
|
4
|
+
questions O-1…O-10 are settled there and are inputs here, not open questions. This document says
|
|
5
|
+
**how and in what order**, and sets the code rules the implementation must hold to.
|
|
6
|
+
|
|
7
|
+
Two questions are genuinely new and marked **P-n**. Everything else is a recommendation to follow.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. What "done" means
|
|
12
|
+
|
|
13
|
+
A new page renders every device in a project as a card with summary / front / back modes, arranged
|
|
14
|
+
by drag, remembered locally, gated by `WRITE`; and the project's own back carries the admin cards.
|
|
15
|
+
`index.html` and the existing node/group UI **still work unchanged** and are covered by regression
|
|
16
|
+
tests throughout.
|
|
17
|
+
|
|
18
|
+
Explicitly not done here: retiring `index.html` (a later step once the admin cards are proven), and
|
|
19
|
+
everything in `CARDS_UX.md` §14.
|
|
20
|
+
|
|
21
|
+
## 2. Sequence
|
|
22
|
+
|
|
23
|
+
Phases are ordered by what they unblock. Sizes are relative, not hours.
|
|
24
|
+
|
|
25
|
+
| # | Phase | Size | Gates | Ships alone? |
|
|
26
|
+
|---|---|---|---|---|
|
|
27
|
+
| 0 | Test infrastructure and mock harness | M | everything | **DONE** |
|
|
28
|
+
| 1 | `html-element-extended`: light-DOM rendering | S | 5 | **DONE** |
|
|
29
|
+
| 2 | Schema: `devices.yaml`, `width`, `units`, capabilities | M | 4, 8 | **DONE** |
|
|
30
|
+
| 3 | Split `webcomponents.js` | M | — (but easier before 4+) | **DONE** |
|
|
31
|
+
| 4 | Data tree: move roll-up down, add derived getters | M | 5, 6 | **DONE** |
|
|
32
|
+
| 5 | The card element — summary / front / back | L | 6, 7 | **DONE** |
|
|
33
|
+
| 6 | The grid — layout, drag, `localStorage` | M | 7 | **DONE** |
|
|
34
|
+
| 7 | The new page, project front/back, admin cards | L | — | **DONE** |
|
|
35
|
+
| 8 | Permissions: `WRITE`, `OTAFLASH` | S | — | **DONE** |
|
|
36
|
+
| 9 | Polish: sheets, motion, i18n sweep, visual pass | M | — | yes |
|
|
37
|
+
|
|
38
|
+
Phases 1 and 2 are independent of everything and of each other — start them whenever. Phase 0 comes
|
|
39
|
+
first because its value is protecting phases 3–7, and because it is the only way to see the work
|
|
40
|
+
without hardware.
|
|
41
|
+
|
|
42
|
+
## 3. Code design criteria
|
|
43
|
+
|
|
44
|
+
Rules for this work. The first group is house style from `CLAUDE.md`, restated because these are the
|
|
45
|
+
ones a large new surface tends to erode; the second is specific to cards.
|
|
46
|
+
|
|
47
|
+
### 3.1 Existing house rules that this work must not erode
|
|
48
|
+
|
|
49
|
+
- 2-space indent. Callback-style async (`async` library), not promise chains.
|
|
50
|
+
- Comments say **why**, one line, never restating the code. No trailing prose summaries.
|
|
51
|
+
- **Derived values are getters on the data tree**, not logic in a view. If a card computes something
|
|
52
|
+
by walking `node → group → topic`, that walk belongs on `MqttTopic`/`MqttTopicNode` as a getter and
|
|
53
|
+
the card reads it. This rule does most of the work in phase 4 — treat a traversal inside a card as
|
|
54
|
+
a defect.
|
|
55
|
+
- Every new UI string goes in all four languages (EN, FR, HI, ID), even if the text is identical.
|
|
56
|
+
- Translation only reaches `textContent` **attributes** — `el('p', {textContent: "…"})`, never
|
|
57
|
+
`el('p', {}, ["…"])`. Pass `i8n: false` for names, ids and per-row data.
|
|
58
|
+
- No new `XXY` calls. Use `XXX` for states that should not happen, and leave the calls in.
|
|
59
|
+
|
|
60
|
+
### 3.2 New rules for the card work
|
|
61
|
+
|
|
62
|
+
- **The card renders a resolved row list, never a group traversal.** `nodeMt.frontRows` returns an
|
|
63
|
+
ordered array of `{kind: 'reading'|'actuator'|'control', mt|groupMt, label}`; the card maps it to
|
|
64
|
+
DOM. All the §4.2 precedence — device `front:` list, else `graphable` readings then actuators then
|
|
65
|
+
controls, minus `insidefrugaliot`, minus control-owned topics — lives in that one getter, where it
|
|
66
|
+
can be unit-tested with no DOM.
|
|
67
|
+
- **Light DOM for card and grid, shadow DOM for widgets.** `mqtt-devicecard` and `mqtt-devicegrid`
|
|
68
|
+
extend `HTMLElementExtendedMinimum`; `mqtt-bar` and friends are untouched. Card CSS classes are
|
|
69
|
+
prefixed `fi-` since there is no shadow boundary to protect them.
|
|
70
|
+
- **No `<slot>` in a light-DOM element, and nothing it did not render may live inside it.**
|
|
71
|
+
`renderAndReplace()` clears its render root, which for these elements is the element itself. The
|
|
72
|
+
grid renders once via `render0()` and mutates in place as devices arrive; anything reaching for a
|
|
73
|
+
slot has picked the wrong base class.
|
|
74
|
+
- **Cross-boundary styling is CSS custom properties, never `:host-context`.** Define the palette,
|
|
75
|
+
spacing and type scale as `--fi-*` properties on `:root`; widgets read them. Custom properties
|
|
76
|
+
pierce shadow boundaries, which is the whole reason to use them here.
|
|
77
|
+
- **One permission gate.** A single `get canWrite()` (on the wrapper, from `server_config.user`),
|
|
78
|
+
read wherever it is needed. Never a scattered `hasPermission('WRITE')` in each widget — that is how
|
|
79
|
+
a control ends up editable on one card and not another.
|
|
80
|
+
- **Numbers go through one formatter.** `mt.formatted` applies `width`, appends the unit symbol, and
|
|
81
|
+
never truncates. No `toFixed` anywhere else.
|
|
82
|
+
- **`localStorage` access is wrapped and versioned.** One module, try/catch on every read and write
|
|
83
|
+
(private browsing throws), a `v:` field in the stored object, unknown/absent → sane default. A
|
|
84
|
+
storage failure must degrade to "no remembered layout", never to a broken grid.
|
|
85
|
+
- **Core does not import widgets.** `MqttTopic.createElement()` reaches widgets by tag name
|
|
86
|
+
(`el('mqtt-bar', …)`), not by class import. Preserve that: it is what keeps `core.js` loadable on
|
|
87
|
+
its own and `index-embedded.html` working.
|
|
88
|
+
- **No silent schema fallbacks.** A topic with no `display`, an unknown module, a `front:` entry
|
|
89
|
+
naming a twig that does not exist — `XXX` and render something visibly wrong-looking, rather than
|
|
90
|
+
quietly skipping. Silent skips are how a device ends up with a blank card and no clue why.
|
|
91
|
+
|
|
92
|
+
## 4. Phases
|
|
93
|
+
|
|
94
|
+
### Phase 0 — Test infrastructure and mock harness
|
|
95
|
+
|
|
96
|
+
The seam is already there and it is a good one. `MqttClient` dispatches messages in six lines at
|
|
97
|
+
`webcomponents.js:2013`:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
for (let o of mqtt_subscriptions) {
|
|
101
|
+
if (topicMatches(o.topic, topic)) { o.cb(topic, msg); }
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Extract that into an exported `mqtt_deliver(topic, msg)`** and the mock needs no fake broker, no
|
|
106
|
+
network and no `mqtt` module — it calls `mqtt_deliver` with canned messages and the entire tree, old
|
|
107
|
+
UI and new, behaves exactly as it does against a real broker.
|
|
108
|
+
|
|
109
|
+
Three tiers, in descending order of how often they get used:
|
|
110
|
+
|
|
111
|
+
**Tier 0 — `test/mock.html`, a browser harness.** Loads a canned scenario and renders it. This is the
|
|
112
|
+
thing to open to see progress, and to review a card against the mockups in `CARDS_UX.md` §3. Cheap,
|
|
113
|
+
and it is what makes the rest of the work reviewable without flashing a device.
|
|
114
|
+
|
|
115
|
+
**Tier 1 — data-tree tests under `node:test`.** Headless mode already exists
|
|
116
|
+
(`<mqtt-wrapper headless>` builds the full tree with no elements), so the getters from phase 4 —
|
|
117
|
+
`frontRows`, `summaryChips`, `formatted`, `usableName`, staleness — are testable without a browser.
|
|
118
|
+
This is where most assertions should live, because it is where most of the logic will live if §3.2 is
|
|
119
|
+
followed.
|
|
120
|
+
|
|
121
|
+
> **Correction, found while building this.** An earlier draft said tier 1 needs "no jsdom, no
|
|
122
|
+
> browser". Wrong: `webcomponents.js` has ~25 `customElements.define` calls plus `Chart.register`
|
|
123
|
+
> and `_adapters._date.override` at module scope, so merely importing it requires `customElements`
|
|
124
|
+
> and `document` to exist. jsdom supplies them, and once it does the *same* run can also render and
|
|
125
|
+
> snapshot the DOM — so tiers 1 and 2 largely merge, and Playwright is not needed yet.
|
|
126
|
+
|
|
127
|
+
**Tier 2 — rendered-DOM snapshots**, of the same scenarios. **These cover the old node/group UI as
|
|
128
|
+
well as the cards** — one scenario set, both layouts, so phases 3–7 have a regression check for the
|
|
129
|
+
existing UX rather than a hand-check at the end. Descend into shadow roots, record tags, meaningful
|
|
130
|
+
attributes and text, and normalise timestamps.
|
|
131
|
+
|
|
132
|
+
Assert on rendered output, not internals; internals are what phases 3 and 4 deliberately change.
|
|
133
|
+
|
|
134
|
+
**Deferred: bare specifiers.** Converting `/node_modules/…` imports was listed here, and is not
|
|
135
|
+
done. Each package resolves differently in node and in the browser (`mqtt` in particular has separate
|
|
136
|
+
node and browser builds), so it is its own change with its own verification, and mixing it into a
|
|
137
|
+
refactor whose contract is "byte-identical output" would have undermined that. The test resolve hook
|
|
138
|
+
stays until then.
|
|
139
|
+
|
|
140
|
+
**A second obstacle worth recording**: the client's imports are server-root-relative
|
|
141
|
+
(`/node_modules/html-element-extended/…`), which node cannot resolve at all — only a web server can.
|
|
142
|
+
`test/setup.js` installs a `module.registerHooks` resolve hook that maps them onto the real
|
|
143
|
+
`node_modules`. **Phase 3 should convert them to bare specifiers** and add the missing importmap
|
|
144
|
+
entries, at which point the hook can go; the absolute paths are also why the client cannot be served
|
|
145
|
+
from a subpath.
|
|
146
|
+
|
|
147
|
+
**Scenarios the canned set must include**, because these are the states that never show up when you
|
|
148
|
+
happen to be watching a live broker: device live / stale / offline / never-seen; device discovered
|
|
149
|
+
with no readings; reading out of range; control wired and unwired; actuator driven by a control;
|
|
150
|
+
`manual` set; no `WRITE`; one device; twelve devices; a module absent from `modules.yaml`.
|
|
151
|
+
|
|
152
|
+
**P-1 is now moot for this phase.** jsdom turned out to handle custom elements and shadow DOM well
|
|
153
|
+
enough to snapshot both UIs, so it is the only new devDependency. Playwright stays worth considering
|
|
154
|
+
later, for the things jsdom genuinely cannot do — pointer-event drag (phase 6), the bottom sheets and
|
|
155
|
+
transitions (phase 9), and real CSS layout. Decide it when phase 6 starts, not now.
|
|
156
|
+
|
|
157
|
+
**Delivered:**
|
|
158
|
+
|
|
159
|
+
| File | Role |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `test/setup.js` | jsdom globals + the `/node_modules/…` resolve hook |
|
|
162
|
+
| `test/mock.js` | scenarios and `runScenario()`, replayed through `mqtt_deliver` |
|
|
163
|
+
| `test/serialize.js` | rendered-tree serializer that descends into shadow roots |
|
|
164
|
+
| `test/fixtures/regenerate.js` | rebuilds `fixtures/config.json` from the real server schema |
|
|
165
|
+
| `test/datatree.test.js` | 15 data-tree tests |
|
|
166
|
+
| `test/oldui.test.js` | 5 rendered snapshots of the existing UI |
|
|
167
|
+
| `test/snapshots/*.txt` | the committed snapshots |
|
|
168
|
+
| `test/mock.html` | browser harness — pick a scenario, see it render |
|
|
169
|
+
|
|
170
|
+
The harness is at **`/dashboard/test/mock.html`** — the client is served under `/dashboard`, not the
|
|
171
|
+
server root. It needs no login and never connects to a broker: the schema comes from the checked-in
|
|
172
|
+
fixture and every value from `mqtt_deliver`.
|
|
173
|
+
|
|
174
|
+
Source changes: `mqtt_deliver()` extracted from `MqttClient`'s `on('message')`; `configSet()` added
|
|
175
|
+
and used by both existing `server_config = json` sites; the export list extended with the data-tree
|
|
176
|
+
classes and the subscription functions a headless consumer needs.
|
|
177
|
+
|
|
178
|
+
One real bug fell out of running the harness. `CssUrl` was `'./frugaliot.css'` and the graph icons
|
|
179
|
+
`'images/icon_graph.svg'` — both resolved against the **document**, so from a page one directory down
|
|
180
|
+
every shadow root's stylesheet link 404'd and the UI rendered entirely unstyled. Since nearly all of
|
|
181
|
+
the UI lives inside shadow roots, the client only worked when served from the one directory holding
|
|
182
|
+
`frugaliot.css`. Both now resolve against the module's own URL:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
const CssUrl = new URL('./frugaliot.css', import.meta.url).href;
|
|
186
|
+
const ImagesUrl = new URL('./images/', import.meta.url).href;
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Same family as the `/node_modules/…` imports, and the same lesson for phase 3: nothing in the client
|
|
190
|
+
should assume the document's directory.
|
|
191
|
+
|
|
192
|
+
Scenarios deliberately **not** included yet: `live/stale/offline/never-seen` and `manual`. They
|
|
193
|
+
depend on `nodeMt.status`, which arrives in phase 4; faking them now would test the fake. Add them
|
|
194
|
+
with that phase, driven by an injectable clock rather than real timers.
|
|
195
|
+
|
|
196
|
+
### Phase 1 — `html-element-extended`: light-DOM rendering
|
|
197
|
+
|
|
198
|
+
In `~/git/github_mitra42/html-element-extended` — **a repo outside the frugal-iot tree; I will call
|
|
199
|
+
out the edit when I make it.**
|
|
200
|
+
|
|
201
|
+
Done as a `renderRoot` getter returning `this.shadowRoot || this`, with `renderAndReplace()` using
|
|
202
|
+
it in all three places. A named getter rather than an inline `||` so a subclass can see where its
|
|
203
|
+
output goes, and so the constraint below has somewhere to be documented.
|
|
204
|
+
|
|
205
|
+
Backwards compatible by construction: every existing consumer extends `HTMLElementExtended`, so
|
|
206
|
+
`shadowRoot` is truthy and behaviour is byte-identical. Nothing in `webcomponents.js` references
|
|
207
|
+
`shadowRoot`. Confirmed by the phase 0 snapshots passing unchanged against the linked library.
|
|
208
|
+
|
|
209
|
+
**Constraint this creates, and it matters for phases 5–6.** A light-DOM element renders into itself,
|
|
210
|
+
so `<slot>` means nothing to it and `renderAndReplace()` **destroys any children it did not render**.
|
|
211
|
+
The existing UI leans on slots heavily; cards cannot. Two consequences:
|
|
212
|
+
|
|
213
|
+
- `mqtt-devicecard` builds all its own children from the data tree — no slots, no author children.
|
|
214
|
+
- `mqtt-devicegrid` appends cards as devices are discovered, so a later full re-render would wipe
|
|
215
|
+
them. Use `render0()` — which the library already supports, rendering once — and mutate in place
|
|
216
|
+
afterwards, rather than re-rendering the grid.
|
|
217
|
+
|
|
218
|
+
**`npm link` needed a workaround.** The global npm prefix is `/usr/local/lib/node_modules`, owned by
|
|
219
|
+
root, so `npm link` fails without sudo. `npm run link:hee` does the local half directly:
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
ln -sfn ../../html-element-extended node_modules/html-element-extended
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`package.json` is untouched (still `^0.1.6`), and the published copy is kept alongside as
|
|
226
|
+
`html-element-extended.published-0.1.6`. **`npm install` replaces the symlink**, so re-run
|
|
227
|
+
`npm run link:hee` after one; `npm run unlink:hee` goes back to the published package. The library
|
|
228
|
+
change is `0.1.7 (unreleased)` in its CHANGELOG — publish once the card work settles.
|
|
229
|
+
|
|
230
|
+
Verified by `test/lightdom.test.js`: a Minimum subclass renders into itself with no shadow root and
|
|
231
|
+
re-renders replace rather than append; an HTMLElementExtended subclass still renders into its shadow
|
|
232
|
+
root with nothing leaking into its light DOM.
|
|
233
|
+
|
|
234
|
+
### Phase 2 — Schema
|
|
235
|
+
|
|
236
|
+
Master is `frugal-iot-server/config.d/schema/`; propagate to the three
|
|
237
|
+
`frugal-iot-logger/examples/*/config.d/schema/` copies. Never the reverse.
|
|
238
|
+
|
|
239
|
+
1. **`devices.yaml`** (new). Keys match by prefix (§4.6): device id, then exact OTA key, then the
|
|
240
|
+
longest key the OTA key starts with. The OTA key is `PREFIX_SUFFIX` where the suffix is the board,
|
|
241
|
+
so one entry per *application* covers every board it is built for:
|
|
242
|
+
```yaml
|
|
243
|
+
sht30: # matches sht30_c3_pico, sht30_d1_mini, sht30_s2_mini_4x, …
|
|
244
|
+
front: [sht/temperature, sht/humidity]
|
|
245
|
+
sonoff: # matches sonoff_r4
|
|
246
|
+
front: [controlhysteresis, relay/on]
|
|
247
|
+
summary: [controlhysteresis] # optional — defaults to the first two of front
|
|
248
|
+
esp8266-fb94bb: # one device overriding its build
|
|
249
|
+
front: [sht/temperature]
|
|
250
|
+
```
|
|
251
|
+
Roughly one entry per example application, so the file stays small.
|
|
252
|
+
2. **`topics.yaml`**: add `width:` to `float` and `exponential` topics only — **25 of 54**, not all
|
|
253
|
+
(D-35); ints derive their field width from `min`/`max`. Fill in missing `units:`. Mechanical but
|
|
254
|
+
needs per-sensor judgement, so its own reviewed pass.
|
|
255
|
+
3. **`modules.yaml`**: `summary: true` on the modules that should contribute a summary.
|
|
256
|
+
4. **Server**: *no change needed.* `readConfigFromDir` maps `config.d/<dir>/<file>.yaml` onto
|
|
257
|
+
`config.<dir>.<file>`, so dropping the file in makes it `config.schema.devices` and it reaches the
|
|
258
|
+
client in `/config.json` for free. Confirmed while building phase 0's fixture, which mirrors that
|
|
259
|
+
mapping.
|
|
260
|
+
5. **`frugal-iot-server/scripts/check-schema.js`** — extended, not written. It already existed, in
|
|
261
|
+
the server beside the master schema; I looked only in the logger's `scripts/` and wrongly
|
|
262
|
+
concluded it was missing, then wrote a second one there. The duplicate is deleted and its checks
|
|
263
|
+
merged into the original, which already had several the new one lacked — a module topic
|
|
264
|
+
overriding a field its base topic does not define, a `duplicates` rule that sets neither
|
|
265
|
+
threshold, a topic that is not a set of settings, and per-type reasoning about what `log:` would
|
|
266
|
+
default to. Added to it: `width` missing / present on a type with no decimals / too small for its
|
|
267
|
+
own range; `units` missing on a numeric topic; `devices.yaml` entries naming a module or leaf that
|
|
268
|
+
does not exist (an **error**, since it silently loses a row); prefix shadowing between device
|
|
269
|
+
keys; `summary: true` being a no-op; and `--resolve <otakey>`. Its "warnings never block" contract
|
|
270
|
+
is kept — errors exit 1, and both call sites already guard with `|| true`.
|
|
271
|
+
6. **`frugal-iot-server/scripts/copy-schema-to-examples.zsh`** — likewise already existed, and had
|
|
272
|
+
just been left behind: it copied a hard-coded `topics.yaml modules.yaml` and so never copied the
|
|
273
|
+
new `devices.yaml`. Changed to glob every `*.yaml` in the schema directory, so the next file added
|
|
274
|
+
is not silently missed too. My duplicate `propagate-schema.js` is deleted.
|
|
275
|
+
|
|
276
|
+
**Outcome.** `width` on all 25 float/exponential topics, derived so decimals stay steady as a value
|
|
277
|
+
moves; `summary: false` on six modules (D-37); `devices.yaml` with 11 application entries; the
|
|
278
|
+
checker and propagator; `check-schema.js` reports OK. `defaults.h` is unaffected — the generator
|
|
279
|
+
extracts only `color`/`min`/`max`.
|
|
280
|
+
|
|
281
|
+
**Judgement calls, resolved:**
|
|
282
|
+
|
|
283
|
+
- `pressure` had `min: 0, max: 99` — a placeholder that all three bmx280 modules overrode. The base
|
|
284
|
+
is now `300..1100` with `units: hPa`, and those three overrides are deleted as redundant. Note
|
|
285
|
+
`ms5803` never overrode it and so now inherits the atmospheric range, which suits it as a
|
|
286
|
+
barometer but not as a depth sensor.
|
|
287
|
+
- `soil` now uses `units: "%"`. SenML has only `%RH`, which is relative humidity and not this; the
|
|
288
|
+
registry is restrictive and unmaintained, so we do not follow it here.
|
|
289
|
+
- `gps` contributes to summaries after all — its `summary: false` is removed.
|
|
290
|
+
- `satellites` had `units: m/s`, copied from `speed`; corrected to `count`. **Its type is still
|
|
291
|
+
`float` for what is a count** — changing that touches logging and firmware, so it is flagged, not
|
|
292
|
+
changed.
|
|
293
|
+
- `altitude` gained `units: m`. `latitude`/`longitude` keep `lat`/`lon`, which are real SenML units
|
|
294
|
+
despite looking like field names.
|
|
295
|
+
- Still no units, and each needs a human: `loadcell` (depends on the cell), `hdop` (dimensionless),
|
|
296
|
+
`controlfloat` (a generic template).
|
|
297
|
+
|
|
298
|
+
Deliberately **not** here: `control: true` (deferred L-4 — with `climate` deleted the name-prefix
|
|
299
|
+
test is correct for every module that exists).
|
|
300
|
+
|
|
301
|
+
### Phase 3 — Split `webcomponents.js`
|
|
302
|
+
|
|
303
|
+
5,723 lines in one file. Mechanical, no behaviour change, verified by running the phase 0 snapshots
|
|
304
|
+
before and after and requiring identical output.
|
|
305
|
+
|
|
306
|
+
| File | Lines | Imports | Contents |
|
|
307
|
+
|---|---|---|---|
|
|
308
|
+
| `core.js` | 2214 | **none** | mqtt plumbing, `mqtt_deliver`, topic helpers, the `MqttTopic*` data tree, `Watchdog`, i18n + `languages` + `el`, `server_config`, `MqttClient`, `MqttWrapper`, `LanguagePicker` |
|
|
309
|
+
| `widgets.js` | 717 | core | `MqttElement`…`MqttChooseTopic` — the leaf display and control elements |
|
|
310
|
+
| `graph.js` | 475 | core, widgets | `MqttGraph`, `MqttGraphDataset`, the inlined Luxon adapter — pulls Chart.js |
|
|
311
|
+
| `nodeview.js` | 457 | core, widgets | `MqttProject`, `MqttNode`, `MqttGroup` + subclasses — **retires with `index.html`**, said so in its header |
|
|
312
|
+
| `flash.js` | 609 | core | `MqttFlash` and the partition helpers — pulls esptool-js |
|
|
313
|
+
| `admin.js` | 1331 | core | `MqttAdmin`, `MqttLogin`, `TabbedDisplay` |
|
|
314
|
+
| `webcomponents.js` | 19 | all | the entry every page already loads: `export * from './core.js'` plus side-effect imports |
|
|
315
|
+
|
|
316
|
+
`cards.js` and its entry point arrive with phases 5–7; there was nothing to put in them yet.
|
|
317
|
+
|
|
318
|
+
**No HTML changed.** Every page already loads `webcomponents.js` as its entry, and
|
|
319
|
+
`dashboard_example.html` imports four helpers from it, so keeping it as the shim left all five pages
|
|
320
|
+
untouched — a better outcome than the planned separate `index.js`.
|
|
321
|
+
|
|
322
|
+
**Verification: the five old-UI snapshots are byte-identical**, which is what "mechanical, no
|
|
323
|
+
behaviour change" was supposed to mean. `test/modules.test.js` now also pins the shape: core imports
|
|
324
|
+
nothing, widgets needs only core, graph and nodeview need only core and widgets, and importing
|
|
325
|
+
`core.js` alone registers no display widgets — so `index-embedded.html`'s core+widgets case (O-9)
|
|
326
|
+
cannot silently regress.
|
|
327
|
+
|
|
328
|
+
**Four behaviour-preserving edits were needed first**, because the module graph was not a DAG:
|
|
329
|
+
|
|
330
|
+
- `MqttTopic.graph` called `MqttGraph.graph` directly — a core→graph cycle. It now goes by tag name,
|
|
331
|
+
`customElements.get('mqtt-graph')`, the same way `createElement` reaches the widgets. This is the
|
|
332
|
+
rule in §3.2 and it is now what keeps core standalone.
|
|
333
|
+
- `MqttWrapper` borrowed `MqttReceiver.observedAttributes`, a core→widgets reference evaluated at
|
|
334
|
+
`customElements.define` time. Both now share a `RECEIVER_ATTRIBUTES` constant in core.
|
|
335
|
+
- `MqttChooseTopic` did `++unique_id` on a core variable — an imported binding cannot be assigned to.
|
|
336
|
+
It calls `nextUniqueId()`, and the label and select now share one captured id instead of reading
|
|
337
|
+
the counter twice.
|
|
338
|
+
- Two locals shadowed module-level names — `let el` inside `elementsForEach` and `let graph` inside
|
|
339
|
+
`createGraph` — which hid a real import behind a false negative. Renamed to `elx` and `graphEl`.
|
|
340
|
+
|
|
341
|
+
**Two things the generator nearly lost, worth recording** because both would have failed only at
|
|
342
|
+
runtime: `Chart.register(...registerables)` lived in the file preamble, outside every declaration
|
|
343
|
+
block; and `import { DateTime } from 'luxon'` sits *mid-file* at line 53, inside the copied
|
|
344
|
+
chartjs-adapter-luxon block, so it was swept into a core-owned block instead of following the adapter
|
|
345
|
+
into `graph.js`.
|
|
346
|
+
|
|
347
|
+
### Phase 4 — Data tree
|
|
348
|
+
|
|
349
|
+
**4a. Move the summary roll-up down to `MqttTopicGroup`.** `summaryText()` currently lives only on
|
|
350
|
+
the `MqttGroup*` *element* subclasses (`webcomponents.js:5245-5317`), which exist only when not
|
|
351
|
+
headless. So a headless card page cannot call it — the card would have no summary at all. Move the
|
|
352
|
+
logic to `MqttTopicGroup`, reading `this.topics[leaf].state.value` directly rather than relying on
|
|
353
|
+
values being mirrored up as element attributes. The `MqttGroup*` element subclasses become thin
|
|
354
|
+
wrappers that call it, and disappear with `nodeview.js`.
|
|
355
|
+
|
|
356
|
+
This is the one architectural change of consequence in the plan, it is squarely within "derived
|
|
357
|
+
values are getters on the data tree", and it makes every summary unit-testable with no DOM.
|
|
358
|
+
|
|
359
|
+
**4a turned out easier than feared.** `groupMt.state[leafAttr]` was *already* being mirrored on
|
|
360
|
+
every message, in both the element path (`MqttReceiver.topicValueSet`) and the headless one
|
|
361
|
+
(`MqttTopic.message_received`). So `MqttTopicGroup` already held exactly the state the element
|
|
362
|
+
subclasses were reading, and the summary logic moved across essentially verbatim. What was needed
|
|
363
|
+
around it:
|
|
364
|
+
|
|
365
|
+
- `MqttTopicGroup` subclasses in `core.js` (`…Relay`, `…Soil`, `…Ota`, `…Battery`, `…DS18B20`,
|
|
366
|
+
`…Ht`, `…ControlHysteresis`), chosen by a `topicGroupClasses` registry keyed on module id — the
|
|
367
|
+
data-tree counterpart of looking up `mqtt-group${groupId}` as a custom element.
|
|
368
|
+
- `groupMt.nodeMt` and `groupMt.twig` set at creation, so `projectMt` resolves (the control summary
|
|
369
|
+
needs it to name what its inputs are wired to) and `topicPath` is right for a group.
|
|
370
|
+
- The group element now links to its group topic (`el.mt` / `mt.element`), which it never did.
|
|
371
|
+
- `MqttSummaryGroup.summaryText()` delegates to `this.mt.summaryText()`; the per-module element
|
|
372
|
+
subclasses keep only what is genuinely presentational — `MqttGroupLedbuiltin.renderSummary()`
|
|
373
|
+
draws a coloured dot rather than returning text, so it stays.
|
|
374
|
+
|
|
375
|
+
**4b, all done and all tested with no DOM:** `mt.decimals`, `mt.formatted`, `mt.outOfRange`,
|
|
376
|
+
`unitSymbol`/`unitSuffix`, `nodeMt.status`, `nodeMt.age`, `nodeMt.otaKey`, `nodeMt.deviceConfig`,
|
|
377
|
+
`nodeMt.orderedGroupIds`, `nodeMt.labelFor`, `nodeMt.resolveEntry`, `nodeMt.frontRows`,
|
|
378
|
+
`nodeMt.defaultFrontEntries`, `nodeMt.summaryChips`.
|
|
379
|
+
|
|
380
|
+
Three details worth knowing:
|
|
381
|
+
|
|
382
|
+
- **Status needs no timer.** `noteMessage()` learns the reporting interval as messages arrive, the
|
|
383
|
+
same smoothing `Watchdog` does, but derives `live | stale | offline | never` from arithmetic rather
|
|
384
|
+
than a `setTimeout`. That makes it testable, and `setClock()` in core lets a test decide what "now"
|
|
385
|
+
is. The old UI keeps its timer-driven `Watchdog` until `nodeview.js` retires.
|
|
386
|
+
- **Group order comes from `modules.yaml`, not from message arrival**, or a card would reorder itself
|
|
387
|
+
between loads depending on which sensor reported first.
|
|
388
|
+
- **A declared row is dropped when the device lacks that module.** `devices.yaml` is keyed by
|
|
389
|
+
application, so it lists what the application *can* have; a device whose control never reported
|
|
390
|
+
must not get an empty row. Pinned by a test.
|
|
391
|
+
|
|
392
|
+
### Phase 5 — The card
|
|
393
|
+
|
|
394
|
+
**Summary mode is done.** `cards.js` holds `mqtt-devicecard`, light DOM, reading only data-tree
|
|
395
|
+
getters. Four things came out of reviewing it in the harness:
|
|
396
|
+
|
|
397
|
+
- A bare `47%` beside the device name says nothing about *what* is 47%. It is now the battery level
|
|
398
|
+
icon alone (`images/Battery0-6.png`, already in the repo and reached for by the old UI's dead
|
|
399
|
+
`topicChanged` code) — the summary is a glanceable view and the level is in the icon's shape, so
|
|
400
|
+
the number is clutter. The percentage is on the icon's title and will be text on the front. The age
|
|
401
|
+
shows whenever the device is not live (D-39).
|
|
402
|
+
- An unwired control is left off the summary entirely (D-40): a device often carries a control nobody
|
|
403
|
+
wired up, and it should not take one of the four places. It still appears on the front.
|
|
404
|
+
- The summary cap of two was too tight — an ENS160 device wants temperature, humidity and air
|
|
405
|
+
quality, and a control besides. Raised to four for the fallbacks; a declared `summary:` list is
|
|
406
|
+
uncapped (D-36).
|
|
407
|
+
- A control was missing from the summary entirely, because `sht30`'s `summary:` list stopped short
|
|
408
|
+
of it. Those redundant lists are deleted from `devices.yaml` so the default picks the control up,
|
|
409
|
+
and a control now contributes a **chip** — `Relay ✓` — rather than its whole rule (D-38).
|
|
410
|
+
- The hand-written module summaries read raw state and hardcoded `°C`/`%RH`, so a summary showed
|
|
411
|
+
`30.142857°C`. They now go through `mt.formatted`, which gives them width-based rounding and the
|
|
412
|
+
schema's own units, and removes the hardcoded units D-23 complained about. The old UI gets the
|
|
413
|
+
same improvement: `3940` became `3940 mV` and a lone `✓` became `Relay ✓`.
|
|
414
|
+
|
|
415
|
+
**Front mode is done.** Three row kinds off `nodeMt.frontRows`, built from the widgets the client
|
|
416
|
+
already has, plus the header buttons (⌄ collapse, ⚙ back) and the battery / last-seen footer.
|
|
417
|
+
|
|
418
|
+
- **Widgets are pre-bound (`el.mt`) but deliberately not registered as `mt.element`.** Only one
|
|
419
|
+
element can hold that at a time, so binding would have this card competing with a graph, a second
|
|
420
|
+
card, or the old UI for the same topic. The card pushes values into its own children from
|
|
421
|
+
`refresh()` instead — which is also why the "clear the back-reference when rebuilding" footgun in
|
|
422
|
+
CLAUDE.md does not apply here.
|
|
423
|
+
- **The `label` attribute never worked.** Every widget rendered `this.mt.name` regardless, so
|
|
424
|
+
`dashboard_example.html`'s `label:` has always been ignored. A `displayLabel` getter now prefers
|
|
425
|
+
the attribute, which is what lets a card show "Soil Temperature" where the topic is called
|
|
426
|
+
"Temperature".
|
|
427
|
+
- **The bar showed the raw value** — `30.142857` — which made `width` pointless. `mqtt-bar` and
|
|
428
|
+
`mqtt-text` now display `mt.formatted`, and the bar gained the min/max end labels §9 asked for.
|
|
429
|
+
Both improvements land on the old UI too: `3940` → `3940 mV`, `30.142857` → `30.1°C`, and every
|
|
430
|
+
bar now says what its range is.
|
|
431
|
+
|
|
432
|
+
**Back mode is done**, so phase 5 is complete. Flat sections in fixed order — Device, Controls,
|
|
433
|
+
readings by module, Advanced — with `<details>` used exactly once, for Advanced. The control section
|
|
434
|
+
is the compact `[>] [ 32.0 ] ± [ 3.0 ]` row lifted from `dashboard_example.html` (D-19), with the
|
|
435
|
+
input and output as the topics' own widgets so their wiring dropdowns come for free. Every module
|
|
436
|
+
gets a section, including ones the front leaves out — which is how a device's relay stays reachable
|
|
437
|
+
when its `devices.yaml` entry does not list it.
|
|
438
|
+
|
|
439
|
+
Editing is **not** gated yet: `WRITE` arrives in phase 8, and until then the back is as editable as
|
|
440
|
+
the existing UI is.
|
|
441
|
+
|
|
442
|
+
Reviewing the back turned up six more, and one of them was a genuine headless crash:
|
|
443
|
+
|
|
444
|
+
- **`renderWiredName` reached `wiredTopic.node`** — the `MqttNode` *element*, which a headless page
|
|
445
|
+
does not have. It threw inside `connectedCallback`, so the widget rendered nothing at all and the
|
|
446
|
+
wiring chooser simply appeared to be missing. A `mt.fullName` getter now goes through `nodeMt`.
|
|
447
|
+
There is a test for the failure *mode* as well as the bug: no widget on a card may render empty.
|
|
448
|
+
- **`labels="<,>"` rendered a two-option `<select>`.** D-19 says a comparison should flip in one tap,
|
|
449
|
+
and I had described the existing behaviour wrongly when writing it. It is a button showing the
|
|
450
|
+
current symbol, and clicking it flips.
|
|
451
|
+
- **A wireable field inside the compact row wrapped itself in a `<details>`**, so a stray disclosure
|
|
452
|
+
sat above the limit and revealed a second copy of the label and value when opened. There is now a
|
|
453
|
+
`wiring` attribute: `none` for a row that lays out and labels its own fields, `open` for one whose
|
|
454
|
+
purpose is to wire something.
|
|
455
|
+
- **The output's chooser was there but collapsed**, which is the same thing as absent when the row
|
|
456
|
+
exists to be wired. `wiring="open"` on input and output.
|
|
457
|
+
- **Every chooser claimed "Unused".** `renderDropdown` read the `wired` *attribute*, which is only
|
|
458
|
+
ever set on the element path, so on a headless card it saw null — each chooser was misreporting
|
|
459
|
+
its own state. It reads `mt.wired` now.
|
|
460
|
+
- **The wired source was named twice**, once by the chooser and once as text beside the value, and
|
|
461
|
+
the text then sat stale until the broker echoed a change back, so the two disagreed. The chooser
|
|
462
|
+
names it; the text only appears where `wiring="none"` means there is no chooser to do so.
|
|
463
|
+
- **My first attempt at the overflow was wrong in a way the design criteria already warned about.**
|
|
464
|
+
I wrote `.fi-when input { max-width }` from the page stylesheet, but those inputs are inside the
|
|
465
|
+
widgets' shadow roots, so it never applied. It is a custom property now — which §3.2 says is the
|
|
466
|
+
only way to style across that boundary.
|
|
467
|
+
- **The gear on the back did nothing** — the back *is* where the gear goes. It is gone, and `✕`
|
|
468
|
+
returns to the front rather than all the way to the summary.
|
|
469
|
+
- **Out-of-range readings were only red on the front**, and the hysteresis field overflowed the card.
|
|
470
|
+
|
|
471
|
+
Two more pre-existing defects surfaced while building it:
|
|
472
|
+
|
|
473
|
+
- **`label=""` was indistinguishable from no label.** `displayLabel` used `||`, so an explicitly
|
|
474
|
+
empty label fell back to the topic name and the back read "Name Node Name [input]". It now
|
|
475
|
+
treats any string, including empty, as the caller labelling it themselves.
|
|
476
|
+
- **`min`/`max` were stringified unconditionally**, so a text topic got `min="undefined"` in the
|
|
477
|
+
attribute and `min="NaN"` on the input it rendered.
|
|
478
|
+
|
|
479
|
+
Reviewing the front turned up four more, three of them in code that predates the cards:
|
|
480
|
+
|
|
481
|
+
- **A bar whose reading was below min showed a stub of fill, not an empty bar.** `MqttBar.width`
|
|
482
|
+
returned a negative percentage, which is not a width, so the fill shrank to fit its own text.
|
|
483
|
+
Clamped to 0..100, so under-range empties the bar and over-range fills it, with the true value
|
|
484
|
+
still printed. The `out-of-range` scenario now carries one of each.
|
|
485
|
+
- **The range-end labels rendered as "0100".** `mqtt-bar`'s host is `display: inline` by default, so
|
|
486
|
+
its shadow content shrink-wraps and `justify-content: space-between` had no width to work across.
|
|
487
|
+
The host is a block now.
|
|
488
|
+
- **`frugaliot.css` had a stray `}`**, at what was line 263, leaving every rule after it parsed at
|
|
489
|
+
brace depth −1. It has presumably been there a while.
|
|
490
|
+
- **The collapse control was a `⌄`**, which reads as "there is more below" — the opposite of what it
|
|
491
|
+
does. It is now a `✕`, and sits last so it lands in the corner.
|
|
492
|
+
|
|
493
|
+
**And one real bug that change exposed.** In `MqttReceiver.topicValueSet` the group roll-up
|
|
494
|
+
(`setAttribute`, which triggers `reSummarize`) ran *before* `valueSet` mirrored the value into the
|
|
495
|
+
data tree. Any summary built from the data tree was therefore one message behind — and empty for a
|
|
496
|
+
topic that reports only once. The tree is now updated first, before anything is asked to re-render,
|
|
497
|
+
which is the right order regardless.
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
`mqtt-devicecard`, light DOM, `mode="summary|front|back"`, bound to its `MqttTopicNode` by the
|
|
502
|
+
existing `el.mt` / `mt.element` convention (pre-bind before `appendChild`, per `CLAUDE.md`).
|
|
503
|
+
|
|
504
|
+
Build in mode order — summary, then front, then back — with each reviewed in `test/mock.html` against
|
|
505
|
+
the §3 mockups before starting the next. The back is the biggest single piece; its control row lifts
|
|
506
|
+
`wireUpDashboard` from `dashboard_example.html` rather than reimplementing it (D-19).
|
|
507
|
+
|
|
508
|
+
Reuse and do not rewrite: `mqtt-bar`, `mqtt-text`, `mqtt-toggle`, `mqtt-choosetopic`, `Watchdog`, and
|
|
509
|
+
the graph panel.
|
|
510
|
+
|
|
511
|
+
### Phase 6 — The grid
|
|
512
|
+
|
|
513
|
+
`mqtt-devicegrid`, light DOM, CSS grid `repeat(auto-fill, minmax(300px, 1fr))`.
|
|
514
|
+
|
|
515
|
+
- Drag-to-reorder on Pointer Events, not HTML5 DnD (unreliable on Android). Long-press to lift on
|
|
516
|
+
touch, plus always-visible ▲▼ buttons (D-13), plus keyboard grab/move/drop.
|
|
517
|
+
- Layout store: `frugaliot:layout:v1:{org}/{project}` → `{v, order, mode, pinned}`, per §3.2's
|
|
518
|
+
wrapping rule.
|
|
519
|
+
- Apply the stored order **per card arrival**, not once at load (O-10) — cards appear as discovery
|
|
520
|
+
messages land. Append unknown devices; re-sort only on an explicit drag.
|
|
521
|
+
|
|
522
|
+
### Phase 6 — done
|
|
523
|
+
|
|
524
|
+
`mqtt-devicegrid`, light DOM, rendering once via `render0()` and mutating in place — which phase 1
|
|
525
|
+
said it would have to, since `renderAndReplace` on a light-DOM element clears its own children, and
|
|
526
|
+
here those are the cards.
|
|
527
|
+
|
|
528
|
+
**Reordering is a model, not three implementations.** `moveBy`, `moveTo` and `moveBefore` on the
|
|
529
|
+
grid are what the ▲▼ buttons, the keyboard and the pointer drag all end up calling, through neutral
|
|
530
|
+
`frugaliot:cardmove` / `cardmovebefore` events — a card says "the user asked to move me" and the grid
|
|
531
|
+
decides what that means. That also makes the behaviour testable: jsdom has neither layout nor
|
|
532
|
+
pointers, so a drag cannot be exercised, but everything a drag *does* can be.
|
|
533
|
+
|
|
534
|
+
- **Pointer Events, not HTML5 drag-and-drop** (unreliable on Android). Mouse drags after 6px; touch
|
|
535
|
+
needs a 400ms hold and abandons the drag if the finger moves first, so the page still scrolls.
|
|
536
|
+
- **Keyboard**: Space to pick up, arrows to move, Space or Escape to put down.
|
|
537
|
+
- **The order is applied per arrival**, so a device discovered late lands where the layout says
|
|
538
|
+
rather than at the end (O-10). A remembered device that no longer exists is skipped, not left as a
|
|
539
|
+
hole; one the layout has never seen goes to the end, disturbing nothing.
|
|
540
|
+
- **Storage is wrapped and versioned.** A layout written by another version is ignored rather than
|
|
541
|
+
half-read, rubbish in the key does not throw, and a `localStorage` that throws outright — private
|
|
542
|
+
browsing does — loses the remembered layout and nothing else. There is a test that builds and
|
|
543
|
+
reorders a grid with storage replaced by something that throws on every call.
|
|
544
|
+
- jsdom only provides `localStorage` with `--localstorage-file`, so `test/setup.js` installs an
|
|
545
|
+
in-memory one.
|
|
546
|
+
|
|
547
|
+
The harness grew a "Forget layout" button, since a remembered order otherwise outlives the scenario
|
|
548
|
+
you are trying to look at.
|
|
549
|
+
|
|
550
|
+
**Dragging needed four fixes before it worked at all.** The fourth is the one worth remembering:
|
|
551
|
+
|
|
552
|
+
- **`el({onpointerdown: …})` never wired anything up.** `EL` assigns only `textContent`, `style`,
|
|
553
|
+
`innerHTML`, `action`, `onsubmit`, `onclick` and `onchange` as properties; **any other function it
|
|
554
|
+
is handed is put into `el.state` instead**, silently. The handler was correct and simply never
|
|
555
|
+
connected. It is bound with `addEventListener` on the host now, which also survives re-renders.
|
|
556
|
+
Worth knowing generally: `onclick` and `onchange` work, and nothing else does — a grep says those
|
|
557
|
+
are the only two the client uses, so nothing else is affected today.
|
|
558
|
+
|
|
559
|
+
The other three would not have shown up in a test that only exercised the model:
|
|
560
|
+
|
|
561
|
+
- **The hit test found the card being dragged.** It is under the pointer, so `elementFromPoint`
|
|
562
|
+
returned it rather than the card beneath. It now takes itself out of hit-testing for the one call.
|
|
563
|
+
- **Dragging downwards was a no-op.** The target's index was read *after* the dragged card had been
|
|
564
|
+
taken out of the list: remove A from `[A,B,C]` and B is at index 0, so inserting A at 0 puts it
|
|
565
|
+
straight back. The index is read first now — `moveBefore` became `moveOver`, since for a downward
|
|
566
|
+
drag "where B is" means after B, not before it.
|
|
567
|
+
- **The first successful move killed the drag.** Reordering re-appends the cards, so the dragged one
|
|
568
|
+
is disconnected and reconnected, and `disconnectedCallback` was calling `endDrag()`. The drag now
|
|
569
|
+
lives on `document` listeners so it survives being re-parented, `disconnectedCallback` leaves it
|
|
570
|
+
alone, and `setPointerCapture` is gone — capture is lost on re-parent anyway, and the document
|
|
571
|
+
listeners do the same job.
|
|
572
|
+
|
|
573
|
+
The pointer test now **dispatches real events rather than calling the handlers**, which is what
|
|
574
|
+
makes the unwired-handler bug catchable — the previous version called `card.onPointerDown(...)`
|
|
575
|
+
directly and so passed against code that was never connected to the DOM at all. `elementFromPoint`
|
|
576
|
+
is stubbed to behave as a browser does, returning the dragged card until it removes itself from
|
|
577
|
+
hit-testing. Verified by reverting each fix in turn and watching the test fail.
|
|
578
|
+
|
|
579
|
+
### Phase 7 — The page, project front/back, admin cards
|
|
580
|
+
|
|
581
|
+
A new page beside `index.html` (O-3) — nothing in `index.html` or `nodeview.js` is modified.
|
|
582
|
+
|
|
583
|
+
- Sticky header: org and project selectors, connection status, language picker, app gear.
|
|
584
|
+
- Project front = the device grid. Project gear flips to the project back = admin cards.
|
|
585
|
+
- Admin cards, each shown only with its capability (D-28, and the §11 table): OTA `OTAUPDATE`,
|
|
586
|
+
Flash `OTAFLASH`, Permissions `ADMIN`, Projects `ADMIN`, Publish Message `ADMIN`, Nodes `ADMIN`,
|
|
587
|
+
API `ADMIN`.
|
|
588
|
+
- **Omit the project gear entirely when no card would appear** (D-29).
|
|
589
|
+
- API is wrapped, not redesigned (D-30). Flash moves out of the OTA tab's `OTAUPDATE` gating, which
|
|
590
|
+
is a relocation of `mqtt-flash`, not just a new condition.
|
|
591
|
+
- The eight §8.1 empty and error states are part of this phase, not afterthoughts.
|
|
592
|
+
|
|
593
|
+
**P-2 resolved: `dashboard.html`** — it names the job rather than the implementation, and is what it
|
|
594
|
+
becomes when `index.html` retires. Nothing in `index.html` or `nodeview.js` was touched.
|
|
595
|
+
|
|
596
|
+
**The page is thin, because the wrapper already does most of it.** `<mqtt-wrapper headless>` supplies
|
|
597
|
+
the organization and project selectors, the broker connection and its status, and the data tree;
|
|
598
|
+
`mqtt-dashboard` only decides what fills the space beneath — the grid, the project's back, or one of
|
|
599
|
+
the states where there is nothing to show yet. It renders once (`render0`), since rebuilding it would
|
|
600
|
+
drop the connection the wrapper holds.
|
|
601
|
+
|
|
602
|
+
**The admin cards reuse `MqttAdmin` rather than reimplementing it** (D-30). Its tab contents were
|
|
603
|
+
already separate methods, so the change was small: one description of the sections, a `section`
|
|
604
|
+
attribute that renders just one of them without the tab strip, and a guard so several admin elements
|
|
605
|
+
on a page do not each fetch `/config.json`. Flash gets its own card, and `otaRestContent` omits its
|
|
606
|
+
copy when rendering as a section so it does not appear twice.
|
|
607
|
+
|
|
608
|
+
Capability gating is **exactly what the tabs already had** — OTA on `OTAUPDATE`, Permissions, Nodes
|
|
609
|
+
and API on `ADMIN` — plus Flash on the new `OTAFLASH`. Widening who can see an organization's
|
|
610
|
+
inventory is not something a card redesign should do quietly. The gear is omitted entirely when that
|
|
611
|
+
would leave nothing (D-29).
|
|
612
|
+
|
|
613
|
+
**A new event, `frugaliot:projectchanged`**, fired by `MqttWrapper.addProject`. `topicschanged` only
|
|
614
|
+
fires once a node arrives, which is too late for a project that has none yet — and "project chosen,
|
|
615
|
+
waiting for devices" is one of the states §8.1 asks for.
|
|
616
|
+
|
|
617
|
+
**Two rounds of review on the page itself**, both about styling the new page inheriting the old
|
|
618
|
+
one's assumptions:
|
|
619
|
+
|
|
620
|
+
- **The header rendered in the browser's serif default at the phone-width sizes.** The page had no
|
|
621
|
+
typography of its own, and `frugaliot.css`'s `@media (max-width: 1001px)` bumps — `xxx-large` on a
|
|
622
|
+
dropdown, `xx-large` on a select, `x-large` on the language picker — all applied. Those rules live
|
|
623
|
+
inside shadow roots, so they now read `var(--fi-chrome-font, …)` and keep their old values
|
|
624
|
+
everywhere else; `.fi-header` and `.fi-admincard` set it to `1rem`. Same for `float: right`, which
|
|
625
|
+
was fighting the flex header.
|
|
626
|
+
- **The header wrapped onto two lines** because the wrapper was `flex: 1 1 auto` and took the whole
|
|
627
|
+
row. It is content-sized now, with the language picker pushed right by `margin-left: auto`.
|
|
628
|
+
- **An admin card has a summary and a back, and no front** (D-41), which is the shape the review
|
|
629
|
+
asked for: nothing in an OTA uploader is worth watching at a glance. Collapsed to its name until
|
|
630
|
+
opened, content built on first open and then kept, so moving between cards does not discard a
|
|
631
|
+
half-filled form — and an unopened card costs nothing.
|
|
632
|
+
|
|
633
|
+
**A round of review on the harness and the schema, after phase 7:**
|
|
634
|
+
|
|
635
|
+
- **The harness now shows a scenario's messages**, behind a disclosure under the controls. When a
|
|
636
|
+
card looks wrong the first question is always what it was told, and that was previously only
|
|
637
|
+
answerable by reading `mock.js`.
|
|
638
|
+
- **A module the schema does not know now reaches the back of the card.** It was being logged and
|
|
639
|
+
dropped, so a developer adding a module saw nothing at all. `MqttTopicNode` keeps unrecognised
|
|
640
|
+
twigs and the back lists them under "Not in the schema" — the readings appear before
|
|
641
|
+
`modules.yaml` catches up, which is exactly when they are most needed.
|
|
642
|
+
- **Two scenarios added**, plus one rewritten: a module whose group exists but whose reading never
|
|
643
|
+
arrives (a row with no value — distinct from a device that has said nothing at all), and every
|
|
644
|
+
module in the schema at once, alphabetically, for scanning every sensor and control in one pass.
|
|
645
|
+
The latter is built when it runs rather than declared, since it needs the schema to exist, so
|
|
646
|
+
`runScenario` now accepts a function for `messages`.
|
|
647
|
+
- **`devices.yaml` grew from 11 entries to 16.** The eight applications missing were not all
|
|
648
|
+
deliberate: `commonground`, `datalogger`, `gps`, `loramesher` and `power` now have entries. Three
|
|
649
|
+
remain absent on purpose and the file says why — `all` is every sensor at once with no order worth
|
|
650
|
+
declaring, `gsheets` carries only a logging control, and `lcd_sht` shows another node's readings
|
|
651
|
+
rather than its own. And `remotedisplay` was never missing: it is built with
|
|
652
|
+
`SYSTEM_OTA_PREFIX=sht30`, so two applications share one key and one card layout, which the file
|
|
653
|
+
now notes.
|
|
654
|
+
|
|
655
|
+
**A second review round on the harness turned up a real labelling bug**, which is what the
|
|
656
|
+
every-module scenario was for:
|
|
657
|
+
|
|
658
|
+
- **A module with two readings labelled both of them with the module name.** An AHT20 showed
|
|
659
|
+
"AHT20" twice, with nothing to say which was the temperature. D-8's rule — use the module's name
|
|
660
|
+
when two readings collide — only works when the module contributes *one* reading, where
|
|
661
|
+
"Soil Temperature" describes what is measured. With more than one it now reads
|
|
662
|
+
"AHT20 Temperature" / "AHT20 Humidity", and a single-reading module still uses its own name.
|
|
663
|
+
- **Timestamps now come from the injectable clock**, not `Date.now()`, in both the element and
|
|
664
|
+
headless paths. A replayed history piled every reading onto one instant, so a graph had nothing to
|
|
665
|
+
draw. A scenario message may now carry the moment it arrived, and `graph-history` uses that for
|
|
666
|
+
144 readings across a day.
|
|
667
|
+
- **`twelve-devices` became `every-device`** — one device per `devices.yaml` entry, announcing that
|
|
668
|
+
entry's OTA key, so every configured layout can be seen at once instead of twelve copies of the
|
|
669
|
+
same one. The grid tests no longer assume a count.
|
|
670
|
+
- **`remotedisplay` had its own bug**, in the node repo rather than here: it was built with
|
|
671
|
+
`SYSTEM_OTA_PREFIX=sht30`, which is why it looked absent. Fixed there, and it now has an entry —
|
|
672
|
+
17 in all. The note about two applications sharing a key is gone with it.
|
|
673
|
+
- The device-id override example was lost from `devices.yaml` in an earlier edit and is not being
|
|
674
|
+
put back: naming a particular device in a schema everyone receives is odd. The mechanism is
|
|
675
|
+
documented in the file header and covered by a test that supplies its own config.
|
|
676
|
+
|
|
677
|
+
**Not done here:** the disconnected banner, which needs `MqttClient` to say when its status changes;
|
|
678
|
+
and Publish Message, still superuser-gated inside the Admin section — moving it to `ADMIN` is D-32,
|
|
679
|
+
which belongs with the rest of the permissions work in phase 8.
|
|
680
|
+
|
|
681
|
+
### Phase 8 — Permissions
|
|
682
|
+
|
|
683
|
+
- `WRITE` and `OTAFLASH` into the capability dropdown at `webcomponents.js:3060`, which today offers
|
|
684
|
+
`OTAUPDATE`/`ADMIN`/`READ`. No server or DB change — capabilities are free text in
|
|
685
|
+
`permissions(id, capability, org)`.
|
|
686
|
+
- One `canWrite` gate (§3.2); without it every control degrades to its display form per §10.1, and
|
|
687
|
+
ADVANCED's actions are omitted.
|
|
688
|
+
- Card order and open/closed state are local presentation and stay available without `WRITE`.
|
|
689
|
+
- Nothing here is a security boundary and no code comment or UI text may imply it is (D-27, L-7).
|
|
690
|
+
|
|
691
|
+
### Phase 8 — done
|
|
692
|
+
|
|
693
|
+
**One gate, in one place.** `mt.canWrite` on the data tree asks `hasCapability(organization, 'WRITE')`,
|
|
694
|
+
and `renderMaybeWired` — the single point where every widget decides between an input and a display —
|
|
695
|
+
now asks it alongside `mt.rw`. That one change degrades every control on both UIs at once: an
|
|
696
|
+
editable field becomes its value, an actuator stops being tappable, and the wiring chooser goes,
|
|
697
|
+
because rewiring is a change like any other. Nothing was gated per widget, which is what §3.2 was
|
|
698
|
+
guarding against.
|
|
699
|
+
|
|
700
|
+
The back is still shown without `WRITE` (D-26): a reader needs last seen, battery and OTA key to
|
|
701
|
+
diagnose a quiet device, and none of that is on the front. Card order and open/closed state are local
|
|
702
|
+
presentation and stay available to everyone (§10.2), which has a test.
|
|
703
|
+
|
|
704
|
+
**The dropdown now offers `READ`, `WRITE`, `OTAUPDATE`, `OTAFLASH`, `ADMIN`** — no server or database
|
|
705
|
+
change, since capabilities are free text in `permissions(id, capability, org)`.
|
|
706
|
+
|
|
707
|
+
**Publish Message moved to `ADMIN`** (D-32), from `user.id !== 1`. Worth seeing plainly: that is a
|
|
708
|
+
widening, from superuser-only to any organization admin.
|
|
709
|
+
|
|
710
|
+
**Nothing here is a security boundary**, and the comment on `hasCapability` says so at length: every
|
|
711
|
+
user of an organization shares one set of broker credentials, which reach the browser in
|
|
712
|
+
`/config.json`, so anyone without `WRITE` still holds working publish credentials. Real enforcement
|
|
713
|
+
is L-7.
|
|
714
|
+
|
|
715
|
+
**Two of my own tests were passing for the wrong reason** and are fixed: `card.querySelector('mqtt-choosetopic')`
|
|
716
|
+
can never match, because the chooser is inside the widget's shadow root — so "the chooser goes"
|
|
717
|
+
asserted null against something that was always null. Shadow content is not in `textContent` either.
|
|
718
|
+
|
|
719
|
+
**Two more found by using the real page:**
|
|
720
|
+
|
|
721
|
+
- **An admin card loaded no data at all** — the Permissions card said "Nobody added for this
|
|
722
|
+
organization yet" and made no request. `setOrganization` fetches only for `activeTabTitle`, which
|
|
723
|
+
is set by the tab strip's `tabchange` event; with a single section there is no tab strip, so it
|
|
724
|
+
stayed at its default of "Dashboard" and the section's own data was never asked for. Setting
|
|
725
|
+
`activeTabTitle` from the `section` attribute fixes it, and `setDefaultOrganization` no longer
|
|
726
|
+
guesses over an organization it was handed.
|
|
727
|
+
- **A random `Cannot read properties of null (reading 'append')`** on load was not a race in the code:
|
|
728
|
+
`/node_modules` is served `immutable, max-age=86400`, so a browser holding the pre-`renderRoot`
|
|
729
|
+
copy of `html-element-extended` keeps it for a day, and whether a given load hits that cache is
|
|
730
|
+
what varied. A hard reload clears it. `cards.js` now checks for `renderRoot` at load and says so
|
|
731
|
+
plainly, since the null append names nothing useful. Worth knowing while the library is linked
|
|
732
|
+
rather than published: **`immutable` on a file that is being edited is a trap**.
|
|
733
|
+
|
|
734
|
+
I also put the section handler in the wrong class first — `MqttLogin` and `MqttAdmin` both have a
|
|
735
|
+
`changeAttribute` that handles `"lang"`, and the anchor matched the first.
|
|
736
|
+
|
|
737
|
+
### Phase 9 — Polish
|
|
738
|
+
|
|
739
|
+
**Branding first** (D-42). The greens come from `frugaliot.naturalinnovation.org`'s own stylesheet —
|
|
740
|
+
`#2ecc71` leaf, `#27ae60` deeper — but used as a pale tint behind near-black text rather than a fill
|
|
741
|
+
under white the way the main site's hero panels do. These screens are read in direct sun and in dim
|
|
742
|
+
sheds; `#1a1a1a` on `#f3faf6` is about 16:1 and survives both. The logo is the icon the main site
|
|
743
|
+
uses in its own header, top left, linking back to it.
|
|
744
|
+
|
|
745
|
+
**The phone-width bumps, for the fourth time.** `@media (max-width: 1001px)` rules across
|
|
746
|
+
`frugaliot.css` inflate fonts and icons for the node/group UI, where a node is one wide block. Inside
|
|
747
|
+
a card they made every input and select far wider than the card, and on a phone the back's content
|
|
748
|
+
ran off the screen entirely. Every one of them now reads `var(--fi-chrome-font, …)` and the card page
|
|
749
|
+
sets it once. Two more fixes went with it: a field row stacks its label above its value below 560px
|
|
750
|
+
rather than keeping a fixed column that cannot shrink, and the grid uses
|
|
751
|
+
`minmax(min(100%, 300px), 1fr)` so a phone narrower than 300px does not get a column wider than the
|
|
752
|
+
screen.
|
|
753
|
+
|
|
754
|
+
That block of media queries is now parameterised rather than replaced. Replacing it wholesale is
|
|
755
|
+
still the right end state, and belongs with retiring `nodeview.js`.
|
|
756
|
+
|
|
757
|
+
**Audit before deploying — what the old UI does that the new page does not.**
|
|
758
|
+
|
|
759
|
+
Reachable on `dashboard.html`, checked element by element: the cards and grid, every leaf widget,
|
|
760
|
+
the graph, `mqtt-admin`'s four sections, `mqtt-flash`, the language picker and the connection. Two
|
|
761
|
+
gaps were found and closed:
|
|
762
|
+
|
|
763
|
+
- **A graph had nowhere to land.** `MqttGraph` looks for `#graph-container` and otherwise appends to
|
|
764
|
+
the end of the document, which on this page is below everything. The page now provides one, below
|
|
765
|
+
the grid, which is where §6 says it belongs.
|
|
766
|
+
- **A device known to the server but not currently reporting read as "never seen".** The old UI
|
|
767
|
+
listed those from `/config.json` with their last-seen time; the headless path added the node but
|
|
768
|
+
dropped the timestamp, so a device asleep since before the page loaded claimed never to have been
|
|
769
|
+
heard from. `lastMessageAt` is seeded from config now, so it reads as offline with a real age.
|
|
770
|
+
|
|
771
|
+
`dashboard.html` also got its own entry, `dashboard.js`, importing everything except `nodeview.js` —
|
|
772
|
+
the one module it genuinely replaces.
|
|
773
|
+
|
|
774
|
+
**Deployment shape settled.** The card page is now `index.html`, so `/dashboard/` serves it and
|
|
775
|
+
login lands there; the node/group UI moved to `index-old.html` and the project's home page has a
|
|
776
|
+
"Previous dashboard" card pointing at it. Nobody has to know a URL to get the new one, and nobody
|
|
777
|
+
loses the old one.
|
|
778
|
+
|
|
779
|
+
**A logout, at last.** There was no `/logout` route and no link in either UI. There is now a route on
|
|
780
|
+
the server and a button in the header — it matters more since a permissions change ends the session.
|
|
781
|
+
|
|
782
|
+
**Two things needed no work at all**, which is worth recording so nobody builds them:
|
|
783
|
+
|
|
784
|
+
- *The disconnected banner.* `MqttClient.setStatus` already re-renders, so the header switches
|
|
785
|
+
between "connected" and "disconnected" by itself. MQTT in the field drops and returns constantly,
|
|
786
|
+
and a banner for each would be worse than the word changing quietly.
|
|
787
|
+
- *Removing a line from a graph.* Chart.js's legend does this natively — click a label to hide its
|
|
788
|
+
dataset — and the legend is already enabled.
|
|
789
|
+
|
|
790
|
+
**Still outstanding, and worth deciding before deploying:**
|
|
791
|
+
|
|
792
|
+
| | |
|
|
793
|
+
|---|---|
|
|
794
|
+
| `nodeview.js` retirement | `index-old.html` is the only thing that still loads it. Retiring both is a decision, not more work |
|
|
795
|
+
| Publishing `html-element-extended` 0.1.7 | It is linked, not published. Until it is, a deployment needs the linked copy, and the service worker's `CACHE_NAME` has to be bumped |
|
|
796
|
+
|
|
797
|
+
**Not a bug in our code:** the page intermittently failing to load was
|
|
798
|
+
`frugal-iot-server/public/service-worker.js`, registered at scope `/` and cache-first with no
|
|
799
|
+
revalidation, holding `/node_modules/html-element-extended/htmlelementextended.js` from before the
|
|
800
|
+
`renderRoot` change. Devtools "disable cache" and force-reload bypass the HTTP cache but not a
|
|
801
|
+
service worker answering from Cache Storage. `CACHE_NAME` gets bumped at release, which is the
|
|
802
|
+
designed mechanism; during development, unregister the worker.
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
Bottom sheets for the card back and the mobile graph (O-8); 150–200ms transitions honouring
|
|
806
|
+
`prefers-reduced-motion`; `Intl.RelativeTimeFormat` for "2h ago" (O-7); the four-language sweep for
|
|
807
|
+
every new string; the §9 visual pass including `--fi-*` tokens and fixing the invalid
|
|
808
|
+
`border: 1px,black,solid` declarations.
|
|
809
|
+
|
|
810
|
+
## 5. Risks
|
|
811
|
+
|
|
812
|
+
**A bug found and fixed while doing this.** The control summary ended with
|
|
813
|
+
`trueFalseSymbol(this.state.on)`, but no control module has an `on` leaf. The firmware's
|
|
814
|
+
`Control_Hysteresis` publishes an `OUTbool` named **`out`** (`src/control/hysteresis.cpp:58`), and
|
|
815
|
+
the `on` topic in `topics.yaml` is `rw: w` — an *actuator's* leaf, as on `relay` and `ledbuiltin`.
|
|
816
|
+
So that symbol had always rendered `?`. Fixed in three places:
|
|
817
|
+
|
|
818
|
+
- `MqttTopicGroupControlHysteresis.summaryText()` reads `state.out`;
|
|
819
|
+
- the element's `observedAttributes`/`boolAttributes` observe `out` rather than `on`, or the summary
|
|
820
|
+
would not refresh when the output changed;
|
|
821
|
+
- `dashboard_example.html` had the same bug twice — its on/off dot read `groupMt.state.on`, so it has
|
|
822
|
+
never lit.
|
|
823
|
+
|
|
824
|
+
The snapshot records the fix: `Relay = SHT:Temperature > 32 +/- 3 ✓`.
|
|
825
|
+
|
|
826
|
+
| Risk | Bites when | Mitigation |
|
|
827
|
+
|---|---|---|
|
|
828
|
+
| Old UI regresses unnoticed | phases 3–4 change shared code | tier 2 snapshots cover both layouts from phase 0 |
|
|
829
|
+
| `html-element-extended` change breaks other sites | on publish | `npm link` during development; publish only at the end; the change is a no-op when a shadow root exists |
|
|
830
|
+
| Schema edits diverge across 4 files | every phase 2 touch | one script edits master then copies; `md5` the four files as the check |
|
|
831
|
+
| The split changes behaviour by accident | phase 3 | snapshots must be byte-identical before and after |
|
|
832
|
+
| `frontRows` precedence grows into a tangle | phase 4 | it is one getter with one test file; if it needs a second place, the design is wrong |
|
|
833
|
+
| Chart.js / esptool-js slow the card page | phase 3 | dynamic `import()` for both |
|
|
834
|
+
| `width`/`units` pass stalls the UI work | phase 2 | defaults exist (1dp float, 0 int, no unit); cards look wrong but work |
|
|
835
|
+
|
|
836
|
+
## 6. Open plan questions
|
|
837
|
+
|
|
838
|
+
- **P-1** Playwright for tier 2 browser snapshots, or tier 0+1 only?
|
|
839
|
+
- ~~**P-2** New page name~~ — resolved: `dashboard.html`.
|
|
840
|
+
|
|
841
|
+
Neither blocks starting phase 0.
|