devicectl-core 0.1.0__py3-none-any.whl

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.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,400 @@
1
+ /* What the plots on a page have in common: where the pointer is over one,
2
+ * and the reading that follows it.
3
+ *
4
+ * These charts draw an SVG stretched to the card's width
5
+ * (`preserveAspectRatio: none`), which is what lets them be laid out in
6
+ * CSS and keep their stroke weights -- and which also means nothing drawn
7
+ * inside them keeps its shape. A circle marking the hovered sample would
8
+ * come out an ellipse, and a differently-shaped ellipse in every card. So
9
+ * everything that has to stay round or stay thin is HTML positioned over
10
+ * the plot in percentages, and the SVG holds only what is allowed to
11
+ * stretch: the line and the area under it.
12
+ */
13
+
14
+ import { fmt } from '/core/js/ui.js';
15
+ import { html, useRef, useState } from '/core/vendor/preact-htm.module.js';
16
+
17
+ /* How far from either edge the floating label is allowed to be centred.
18
+ * Past this it stops following the pointer and leans in, so a reading
19
+ * taken at the very start or end of a plot is still inside the card. */
20
+ const TIP_EDGE = 0.14;
21
+
22
+ /* Where the pointer is across a plot, as a fraction of its width.
23
+ *
24
+ * `null` when it is not over one at all, which is most of the time and is
25
+ * what tells every caller to draw none of this. Touch counts as a
26
+ * pointer: a finger dragged along the line reads it the same way, which
27
+ * on a phone is the only way to read it at all.
28
+ */
29
+ export function useHover() {
30
+ const [at, setAt] = useState(null);
31
+ const box = useRef(null);
32
+
33
+ const track = (event) => {
34
+ const rect = box.current?.getBoundingClientRect();
35
+ if (!rect || rect.width <= 0) return;
36
+ const point = event.touches?.[0] || event;
37
+ const x = point.clientX - rect.left;
38
+ setAt(Math.min(1, Math.max(0, x / rect.width)));
39
+ };
40
+
41
+ return {
42
+ at,
43
+ box,
44
+ /* Spread onto whatever element owns the plot's width. */
45
+ on: {
46
+ onMouseMove: track,
47
+ onMouseLeave: () => setAt(null),
48
+ onTouchStart: track,
49
+ onTouchMove: track,
50
+ onTouchEnd: () => setAt(null),
51
+ onTouchCancel: () => setAt(null),
52
+ },
53
+ };
54
+ }
55
+
56
+ /* Where the pointer is across the *drawing*, rather than across the box
57
+ * that holds it.
58
+ *
59
+ * A chart that insets what it draws by a few units at each end and
60
+ * then asks `useHover` -- which measures the whole box -- which sample the
61
+ * pointer was over. So the pointer's 0 was the box's left edge while the
62
+ * first sample sat a pad in from it, and the two disagreed by a pad's
63
+ * worth all the way across: at the left of the chart the marker was to the
64
+ * right of the cursor, at the right of it the marker was to the left, and
65
+ * in the middle they met. This is the same fraction measured against the
66
+ * region the samples are actually in.
67
+ *
68
+ * Clamped, because the pad itself is outside that region and a pointer in
69
+ * it is still pointing at the nearest end.
70
+ */
71
+ export function acrossPlot(at, pad, width) {
72
+ if (at === null || at === undefined) return null;
73
+ const inner = width - 2 * pad;
74
+ if (inner <= 0) return null;
75
+ return Math.min(1, Math.max(0, (at * width - pad) / inner));
76
+ }
77
+
78
+ /* The rule under the pointer, and the reading it is pointing at.
79
+ *
80
+ * `at` is where the pointer is; `x` is where the *sample* is, which is not
81
+ * the same thing -- the rule snaps to the reading it is naming, or the
82
+ * chart would claim a value at a moment nothing was measured. `y`, when a
83
+ * caller has one, puts a dot on the line itself.
84
+ *
85
+ * `rule` is for a chart that already shows what the pointer is on. A line
86
+ * has nothing to mark the moment with, so it gets the vertical rule; a bar
87
+ * chart lights up the bar itself, and a rule down the middle of a lit bar
88
+ * is a second answer to a question already answered -- one that, on a wide
89
+ * bar, sits half a bar away from the cursor and reads as a mistake.
90
+ *
91
+ * `below` puts the label under the plot instead of over it. The rule is
92
+ * the same either way: cover the least. A plot is a tall rectangle full of
93
+ * the shape being explained, so the label goes above it; a band is a strip
94
+ * eight pixels high with a card of readings above it and its own scale
95
+ * underneath, so the label goes below and covers two numbers that are
96
+ * printed on the label itself.
97
+ */
98
+ export function Hovered({ x, y, heading, lines, rule = true, below = false }) {
99
+ const lean = Math.min(1 - TIP_EDGE, Math.max(TIP_EDGE, x));
100
+ return html`<div class="hover" aria-hidden="true">
101
+ ${rule && html`<span class="rule" style=${`left:${(x * 100).toFixed(2)}%`}></span>`}
102
+ ${y !== null &&
103
+ y !== undefined &&
104
+ html`<span
105
+ class="spot"
106
+ style=${`left:${(x * 100).toFixed(2)}%;top:${(y * 100).toFixed(2)}%`}
107
+ ></span>`}
108
+ <span class=${below ? 'tip below' : 'tip'} style=${`left:${(lean * 100).toFixed(2)}%`}>
109
+ <span class="when">${heading}</span>
110
+ ${lines.map((line) => html`<span class="what" key=${line}>${line}</span>`)}
111
+ </span>
112
+ </div>`;
113
+ }
114
+
115
+ /* A clock time, in the reader's own zone: a chart of the last half hour is
116
+ * read against the wall, not against an ISO stamp. */
117
+ export function clockTime(seconds) {
118
+ return new Date(seconds * 1000).toLocaleTimeString();
119
+ }
120
+
121
+ /* --- a reading over time -------------------------------------------------
122
+ *
123
+ * The one plot both programs draw: what a number has been doing for as long
124
+ * as the page has been watching it, with the moments something was written
125
+ * marked on it. It answers the question no single live number can --
126
+ * "I changed something, did anything happen" -- because the device itself
127
+ * keeps no history a browser can ask for. Both had written it out, and the
128
+ * copies had not aged alike: one grew an area fill, a hover reading, a key
129
+ * and a scale, and the other was still four lines of SVG drawn in colours
130
+ * that no longer existed in either palette, which is a line the browser
131
+ * resolves to black and a chart that appears not to work at all.
132
+ *
133
+ * What is *not* here is where the samples come from. One program keeps its
134
+ * own in the tab as the charger reports; the other reads a window the
135
+ * server has been recording all along. That is the half that is genuinely
136
+ * different, and it stays with each of them.
137
+ *
138
+ * A chart may carry a second reading (`also`), drawn in its own colour on
139
+ * its own scale. Both programs wanted the same second one -- the
140
+ * temperature beside the power -- and both would otherwise have had to
141
+ * spend a whole card on a single flat line. They cannot share an axis,
142
+ * since a kilowatt and a degree have no common scale, so the key under the
143
+ * plot names each line and prints the range it is drawn against.
144
+ */
145
+
146
+ /* The drawing's own units. It is stretched to the card's width, so these
147
+ * are a shape rather than a size -- see the note at the top of this file. */
148
+ const W = 600;
149
+ const H = 120;
150
+ const PAD = 6;
151
+
152
+ /* The narrowest window the chart will draw. Without it the first three
153
+ * samples would be stretched across the whole width, and a line whose shape
154
+ * changes because more of it arrived is a line that lies twice. */
155
+ const MIN_SPAN_S = 180;
156
+
157
+ /* The sample nearest a moment, which is what the pointer is really asking
158
+ * for. A chart of one reading every three seconds has gaps in it -- a
159
+ * paused refresh, a device that took a while to answer -- and a cursor that
160
+ * interpolated across one would be inventing a measurement. This names a
161
+ * reading that was actually taken. */
162
+ function nearest(samples, when) {
163
+ let best = samples[0];
164
+ for (const sample of samples) {
165
+ if (Math.abs(sample.t - when) < Math.abs(best.t - when)) best = sample;
166
+ }
167
+ return best;
168
+ }
169
+
170
+
171
+ /* Only the readings that are numbers, oldest first. A device that did not
172
+ * answer for a field leaves a hole, and a hole drawn as zero is a fault the
173
+ * pack never had. */
174
+ function numbers(samples) {
175
+ return (samples || []).filter((s) => typeof s.v === 'number');
176
+ }
177
+
178
+ /* The scale one trace is drawn on.
179
+ *
180
+ * Two rules, because there are two kinds of reading here. A *flow* -- the
181
+ * power into and out of a pack -- is drawn against zero: which side of the
182
+ * line it is on is half of what it says, and the area is filled from there.
183
+ * A *level* -- a temperature -- has no zero worth drawing: a pack sitting
184
+ * between 18 and 24 degrees on an axis that starts at zero is a flat line
185
+ * in the top quarter of the box, which is a chart of nothing. So a level
186
+ * gets a scale rounded outwards to the nearest step around what it did,
187
+ * and the scale is printed underneath, because a plot with a floating
188
+ * baseline exaggerates everything on it unless it says so.
189
+ */
190
+ function scaleOf(values, { step, floor, zero }) {
191
+ const hi = Math.max(...values);
192
+ const lo = Math.min(...values);
193
+ if (zero) {
194
+ const top = Math.max(floor, Math.ceil(Math.max(hi, 0) / step) * step) || step;
195
+ return { top, bottom: lo < 0 ? -Math.ceil(-lo / step) * step : 0 };
196
+ }
197
+ const top = Math.ceil(hi / step) * step;
198
+ const bottom = Math.floor(lo / step) * step;
199
+ return top > bottom ? { top, bottom } : { top: bottom + step, bottom };
200
+ }
201
+
202
+ /* One trace, worked out: what of it is in the window, the scale it needs,
203
+ * where a value sits on that scale, and how to say one in words. `null`
204
+ * when there is not enough of it in the window to draw a line at all,
205
+ * which is what a second reading the device stopped answering for does. */
206
+ function plotted(spec, x, from) {
207
+ const shown = spec.points.filter((s) => s.t >= from);
208
+ if (shown.length < 2) return null;
209
+ const values = shown.map((s) => s.v);
210
+ const { top, bottom } = scaleOf(values, spec);
211
+ const y = (v) => H - PAD - ((v - bottom) / (top - bottom)) * (H - 2 * PAD);
212
+ return {
213
+ ...spec,
214
+ shown,
215
+ values,
216
+ top,
217
+ bottom,
218
+ y,
219
+ say: (v) => `${fmt(v, spec.digits)}${spec.unit ? ` ${spec.unit}` : ''}`,
220
+ /* A whole-numbered step means a whole-numbered axis: "2 kW full scale"
221
+ * rather than "2.00 kW full scale" under a line read to two places. */
222
+ scaleDigits: Number.isInteger(spec.step) ? 0 : spec.digits,
223
+ line: shown.map((s) => `${x(s.t).toFixed(1)},${y(s.v).toFixed(1)}`).join(' '),
224
+ };
225
+ }
226
+
227
+ /* What a trace's end of the scale says, under the plot. */
228
+ function scaleSaid(p) {
229
+ return p.zero && p.bottom >= 0
230
+ ? `${fmt(p.top, p.scaleDigits)} ${p.unit} full scale`
231
+ : `${fmt(p.bottom, p.scaleDigits)}–${fmt(p.top, p.scaleDigits)} ${p.unit}`;
232
+ }
233
+
234
+ /* What the pointer is over, if it is over anything: the reading nearest the
235
+ * moment under it, placed by where that reading actually is rather than by
236
+ * where the pointer is. */
237
+ export function Series({
238
+ samples,
239
+ marks = [],
240
+ unit = '',
241
+ digits = 1,
242
+ /* What the full scale is rounded up to. A ceiling that does not twitch:
243
+ * rounding to a whole kilowatt means the line's height means the same
244
+ * thing between one look and the next, instead of rescaling every time
245
+ * the car takes another 40 W. */
246
+ step = 1,
247
+ /* The smallest full scale. A pack drawing 40 W on an axis that ends at
248
+ * 40 W is a chart of noise drawn as a mountain range. */
249
+ floor = 0,
250
+ /* A second reading on the same time axis and its own scale: the same
251
+ * `{samples, unit, digits, step}`, plus the `label` the key names it by.
252
+ *
253
+ * On the same picture rather than in a card of its own because the
254
+ * question it answers is a question about the first one -- did that hour
255
+ * of charging warm anything up -- and two charts one above the other are
256
+ * read by remembering the top one. What keeps them apart is colour and
257
+ * the key under them, since they cannot share an axis: a kilowatt and a
258
+ * degree have no common scale, and pretending otherwise would be the one
259
+ * dishonest thing a chart can do. */
260
+ also = null,
261
+ label = '',
262
+ minSpanS = MIN_SPAN_S,
263
+ empty,
264
+ what = 'the reading',
265
+ }) {
266
+ /* Every hook first, and unconditionally: the empty chart below returns
267
+ * before the drawing does, and a hook behind an early return belongs to a
268
+ * different slot on the render that has samples. */
269
+ const hover = useHover();
270
+ const main = numbers(samples);
271
+ const other = also ? numbers(also.samples) : [];
272
+ if (main.length < 2) {
273
+ return html`<div class="chart empty-chart">${empty}</div>`;
274
+ }
275
+
276
+ /* The window is the widest either trace needs. Measured across both so
277
+ * that a second reading the device only started answering for a minute
278
+ * ago does not crop an hour of the first one. */
279
+ const ends = [main[main.length - 1].t, ...(other.length ? [other[other.length - 1].t] : [])];
280
+ const starts = [main[0].t, ...(other.length ? [other[0].t] : [])];
281
+ const now = Math.max(...ends);
282
+ const span = Math.max(minSpanS, now - Math.min(...starts));
283
+ const from = now - span;
284
+
285
+ const x = (t) => PAD + ((t - from) / span) * (W - 2 * PAD);
286
+ const first = plotted(
287
+ { points: main, unit, digits, step, floor, zero: true, label: label || what },
288
+ x,
289
+ from
290
+ );
291
+ const second = also
292
+ ? plotted(
293
+ {
294
+ points: other,
295
+ unit: also.unit || '',
296
+ digits: also.digits ?? 1,
297
+ step: also.step ?? 1,
298
+ floor: 0,
299
+ zero: false,
300
+ label: also.label || 'the second reading',
301
+ },
302
+ x,
303
+ from
304
+ )
305
+ : null;
306
+ if (!first) {
307
+ return html`<div class="chart empty-chart">${empty}</div>`;
308
+ }
309
+
310
+ const base = first.y(0).toFixed(1);
311
+ const left = x(first.shown[0].t).toFixed(1);
312
+ const area = `${left},${base} ${first.line} ${x(now).toFixed(1)},${base}`;
313
+ const ticks = marks.filter((m) => m.at >= from && m.at <= now);
314
+
315
+ const across = acrossPlot(hover.at, PAD, W);
316
+ const when = across === null ? null : from + across * span;
317
+ const under = when === null ? null : nearest(first.shown, when);
318
+ const beside = when === null || !second ? null : nearest(second.shown, when);
319
+
320
+ /* The plot is its own box so that the pointer's fraction across it is a
321
+ * fraction across the SVG, and so the cursor and the label -- which are
322
+ * HTML, because nothing drawn inside a stretched SVG keeps its shape --
323
+ * can be positioned in percentages of exactly the same rectangle. The
324
+ * scale under it is not part of that rectangle. */
325
+ return html`<div class="chart">
326
+ <div class="plot" ref=${hover.box} ...${hover.on}>
327
+ <svg
328
+ viewBox=${`0 0 ${W} ${H}`}
329
+ preserveAspectRatio="none"
330
+ role="img"
331
+ aria-label=${[
332
+ `${what} over the last ${Math.round(span / 60)} minutes, between ${first.say(
333
+ Math.min(...first.values)
334
+ )} and ${first.say(Math.max(...first.values))}`,
335
+ second &&
336
+ `${second.label} between ${second.say(Math.min(...second.values))} and ${second.say(
337
+ Math.max(...second.values)
338
+ )}`,
339
+ ]
340
+ .filter(Boolean)
341
+ .join('; ')}
342
+ >
343
+ <polygon class="area" points=${area} />
344
+ ${first.bottom < 0 &&
345
+ html`<line
346
+ class="axis"
347
+ x1=${PAD}
348
+ x2=${W - PAD}
349
+ y1=${base}
350
+ y2=${base}
351
+ vector-effect="non-scaling-stroke"
352
+ />`}
353
+ <polyline class="line" points=${first.line} vector-effect="non-scaling-stroke" />
354
+ ${second &&
355
+ html`<polyline
356
+ class="line alt"
357
+ points=${second.line}
358
+ vector-effect="non-scaling-stroke"
359
+ />`}
360
+ ${ticks.map(
361
+ (tick) => html`<line
362
+ class="mark"
363
+ key=${tick.at}
364
+ x1=${x(tick.at)}
365
+ x2=${x(tick.at)}
366
+ y1=${PAD}
367
+ y2=${H - PAD}
368
+ vector-effect="non-scaling-stroke"
369
+ >
370
+ <title>${tick.what}</title>
371
+ </line>`
372
+ )}
373
+ </svg>
374
+ ${under &&
375
+ html`<${Hovered}
376
+ x=${x(under.t) / W}
377
+ y=${first.y(under.v) / H}
378
+ heading=${clockTime(under.t)}
379
+ lines=${[
380
+ second ? `${first.label} ${first.say(under.v)}` : first.say(under.v),
381
+ beside ? `${second.label} ${second.say(beside.v)}` : null,
382
+ ].filter(Boolean)}
383
+ />`}
384
+ </div>
385
+ <div class=${second ? 'ends keyed' : 'ends'}>
386
+ <span>${Math.round(span / 60)} min ago</span>
387
+ ${second
388
+ ? html`<span class="key"><i class="swatch"></i>${first.label}, ${scaleSaid(first)}</span>
389
+ <span class="key"
390
+ ><i class="swatch alt"></i>${second.label}, ${scaleSaid(second)}</span
391
+ >`
392
+ : null}
393
+ ${ticks.length > 0 &&
394
+ html`<span class="key"
395
+ ><i class="tick"></i>${ticks.length} setting${ticks.length === 1 ? '' : 's'} written</span
396
+ >`}
397
+ ${second ? null : html`<span>${scaleSaid(first)}</span>`}
398
+ </div>
399
+ </div>`;
400
+ }