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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The checks that need the page actually drawn.
|
|
3
|
+
|
|
4
|
+
``pytest`` proves the server sends the right JSON, Biome parses the browser
|
|
5
|
+
half, :mod:`~devicectl.devtools.frontlint` resolves it across files and
|
|
6
|
+
:mod:`~devicectl.devtools.htmcheck` proves every template parses. All four
|
|
7
|
+
passed while a page was showing a drop-down with no usable entries, a switch
|
|
8
|
+
word rendered as the number 16, a unit sitting on its own line under its
|
|
9
|
+
input, a value column wide enough to push four columns off-screen, and a
|
|
10
|
+
model number split across two lines. None of those is a crash, so nothing
|
|
11
|
+
without a layout engine could see them.
|
|
12
|
+
|
|
13
|
+
This drives a real browser over a program's own UI and fails on the ones
|
|
14
|
+
that can be stated exactly:
|
|
15
|
+
|
|
16
|
+
* **A page error**, or an error on the console, on any tab.
|
|
17
|
+
* **A value broken mid-token.** A model number or a serial is one token and
|
|
18
|
+
splitting it makes it unreadable and unsearchable.
|
|
19
|
+
* **A table wider than the box it scrolls in**, which is a column pushed
|
|
20
|
+
off-screen rather than a table that scrolls.
|
|
21
|
+
* **A tab that rendered nothing**, which is how a wiring mistake looks from
|
|
22
|
+
outside.
|
|
23
|
+
* **A page that scrolls sideways**, which is a column, an input or a table
|
|
24
|
+
that has run off the side rather than wrapped or scrolled in its own box.
|
|
25
|
+
* **A setpoint that will not answer the arrow keys**, which is a control
|
|
26
|
+
that looks live, drags, draws correctly -- and cannot be set to the value
|
|
27
|
+
somebody actually wants, because a pixel of track is worth more than a
|
|
28
|
+
step.
|
|
29
|
+
* **A drag that does not hand the setpoint the keyboard**, which is the
|
|
30
|
+
same fault one step earlier: the arrow keys work, and nothing the pointer
|
|
31
|
+
does ever points them at anything.
|
|
32
|
+
|
|
33
|
+
Every tab, at four widths, because a layout is only correct at the width it
|
|
34
|
+
was checked at -- and the narrowest is a phone, where a two-column row has
|
|
35
|
+
to become one column or the value's editor ends up off the screen.
|
|
36
|
+
|
|
37
|
+
A program supplies two things and gets the rest: a context manager that
|
|
38
|
+
serves its UI on a given port, and the map of tab name to hash route. See
|
|
39
|
+
:func:`main`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import socket
|
|
46
|
+
import sys
|
|
47
|
+
from typing import Any, Callable, ContextManager, Mapping, Sequence
|
|
48
|
+
|
|
49
|
+
# The widths a layout has to survive: a desktop, a small laptop, a tablet in
|
|
50
|
+
# landscape, and a phone -- which is what is actually in your pocket when the
|
|
51
|
+
# alarm goes off.
|
|
52
|
+
WIDTHS = (1440, 1280, 1024, 420)
|
|
53
|
+
|
|
54
|
+
# Below this, a table is meant to be scrolled and a hyphenated label is meant
|
|
55
|
+
# to break: the two checks that ask whether something fits are asking a
|
|
56
|
+
# question the phone width has already answered no to, on purpose. What
|
|
57
|
+
# still holds there is that the page itself must not run off the side.
|
|
58
|
+
FITS_ABOVE = 700
|
|
59
|
+
|
|
60
|
+
# How long a tab gets to finish its own reads before it is judged.
|
|
61
|
+
SETTLE_MS = 2200
|
|
62
|
+
|
|
63
|
+
# Below this many characters a tab has not drawn anything: the chrome around
|
|
64
|
+
# it -- the header, the tab strip, the link pill -- is already more than this.
|
|
65
|
+
DREW_NOTHING = 100
|
|
66
|
+
|
|
67
|
+
# How tall the window is. Only the width is varied; the height changes
|
|
68
|
+
# nothing these checks ask about.
|
|
69
|
+
VIEWPORT_HEIGHT = 1000
|
|
70
|
+
|
|
71
|
+
# One client rect per line box is the only reliable line count: a table cell's
|
|
72
|
+
# height includes its padding, so height over line-height calls every cell a
|
|
73
|
+
# wrap. Elements holding several words are skipped -- prose is meant to wrap.
|
|
74
|
+
FIND_WRAPS = """() => {
|
|
75
|
+
const bad = [];
|
|
76
|
+
for (const el of document.querySelectorAll('.row > .v, .row > .k, td, th, .badge, h2, .dim')) {
|
|
77
|
+
const text = el.textContent.trim();
|
|
78
|
+
if (!text || /\\s/.test(text)) continue;
|
|
79
|
+
if (el.children.length || el.querySelector('input,select,button')) continue;
|
|
80
|
+
const range = document.createRange();
|
|
81
|
+
range.selectNodeContents(el);
|
|
82
|
+
if (range.getClientRects().length > 1) bad.push(text);
|
|
83
|
+
}
|
|
84
|
+
return bad;
|
|
85
|
+
}"""
|
|
86
|
+
|
|
87
|
+
# A page that scrolls sideways. Not a box that scrolls -- a table in its own
|
|
88
|
+
# scroller is fine and deliberate -- but the document itself, which is
|
|
89
|
+
# something that could not fit and was not told what to do about it. The
|
|
90
|
+
# element is named as well, or the answer is "somewhere on this page".
|
|
91
|
+
FIND_SIDEWAYS = """() => {
|
|
92
|
+
const room = document.documentElement.clientWidth;
|
|
93
|
+
if (document.documentElement.scrollWidth <= room + 1) return [];
|
|
94
|
+
const bad = [];
|
|
95
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
96
|
+
const box = el.getBoundingClientRect();
|
|
97
|
+
if (box.width === 0 || (box.right <= room + 1 && box.left >= -1)) continue;
|
|
98
|
+
let p = el.parentElement, held = false;
|
|
99
|
+
while (p) {
|
|
100
|
+
const flow = getComputedStyle(p).overflowX;
|
|
101
|
+
if (flow === 'auto' || flow === 'scroll' || flow === 'hidden') { held = true; break; }
|
|
102
|
+
p = p.parentElement;
|
|
103
|
+
}
|
|
104
|
+
if (!held) {
|
|
105
|
+
const name = el.className ? `.${String(el.className).split(' ')[0]}` : '';
|
|
106
|
+
bad.push(`${el.tagName.toLowerCase()}${name} reaches ${Math.round(box.right)}px`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...new Set(bad)].slice(0, 3);
|
|
110
|
+
}"""
|
|
111
|
+
|
|
112
|
+
# Every setpoint on every band, asked whether the keyboard moves it.
|
|
113
|
+
#
|
|
114
|
+
# A grip is a `role="slider"`, and the one thing every slider anywhere has
|
|
115
|
+
# to do is answer the arrow keys. They are also the only way to set the
|
|
116
|
+
# last decimal of one: a band three hundred pixels wide spans a volt, so a
|
|
117
|
+
# pixel is two millivolts and the pointer cannot ask for 3.451 at all. A
|
|
118
|
+
# grip that does not answer them is a control that is half dead in the one
|
|
119
|
+
# direction nothing else checks -- it draws, it fits, it drags -- and that
|
|
120
|
+
# is exactly how it got shipped: a band drawn to one register's decimals,
|
|
121
|
+
# holding another register whose value was written back rounded, so the
|
|
122
|
+
# keypress moved the setpoint and the round put it straight back.
|
|
123
|
+
#
|
|
124
|
+
# Both directions are tried before anything is reported, because a setpoint
|
|
125
|
+
# sitting less than a step from the bound its neighbour imposes really
|
|
126
|
+
# cannot move that way, and that is not a fault.
|
|
127
|
+
#
|
|
128
|
+
# This leaves the page holding an unsent draft, which is why it is asked
|
|
129
|
+
# last. Nothing is written: the card's own Apply is what writes.
|
|
130
|
+
NUDGE_GRIPS = """async () => {
|
|
131
|
+
const bad = [];
|
|
132
|
+
const settle = () => new Promise((done) => setTimeout(done, 25));
|
|
133
|
+
for (const grip of document.querySelectorAll('.band button.grip')) {
|
|
134
|
+
// A tab that keeps another tab's cards in the document has its bands
|
|
135
|
+
// in there too, and nothing hidden can be focused or dragged.
|
|
136
|
+
if (grip.checkVisibility && !grip.checkVisibility()) continue;
|
|
137
|
+
const name = grip.getAttribute('aria-label') || '(unnamed)';
|
|
138
|
+
const low = Number(grip.getAttribute('aria-valuemin'));
|
|
139
|
+
const high = Number(grip.getAttribute('aria-valuemax'));
|
|
140
|
+
const at = () => Number(grip.getAttribute('aria-valuenow'));
|
|
141
|
+
const before = at();
|
|
142
|
+
grip.focus();
|
|
143
|
+
if (document.activeElement !== grip) {
|
|
144
|
+
bad.push(`${name} will not take the keyboard`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const ways = [];
|
|
148
|
+
if (before < high) ways.push('ArrowRight');
|
|
149
|
+
if (before > low) ways.push('ArrowLeft');
|
|
150
|
+
let moved = ways.length === 0;
|
|
151
|
+
for (const key of ways) {
|
|
152
|
+
grip.dispatchEvent(
|
|
153
|
+
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
|
154
|
+
);
|
|
155
|
+
await settle();
|
|
156
|
+
if (at() !== before) { moved = true; break; }
|
|
157
|
+
}
|
|
158
|
+
if (!moved) {
|
|
159
|
+
bad.push(`${name} did not move for an arrow key (${before}, between ${low} and ${high})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return bad;
|
|
163
|
+
}"""
|
|
164
|
+
|
|
165
|
+
# Whether a setpoint is the thing the next keypress would go to.
|
|
166
|
+
HAS_THE_KEYBOARD = """() => {
|
|
167
|
+
const on = document.activeElement;
|
|
168
|
+
return Boolean(on && on.classList && on.classList.contains('grip'));
|
|
169
|
+
}"""
|
|
170
|
+
|
|
171
|
+
# Where on the screen to drag, for the first band on this tab that has
|
|
172
|
+
# something to drag. Asked in the page rather than through the engine's
|
|
173
|
+
# own element handling, which waits for a hidden element to become visible
|
|
174
|
+
# and a tab with a band behind a collapsed card has one that never will.
|
|
175
|
+
# `scrollIntoView` first, because a drag is in viewport coordinates and half
|
|
176
|
+
# a page is below the fold.
|
|
177
|
+
FIND_A_BAND = """(least) => {
|
|
178
|
+
for (const plot of document.querySelectorAll('.band .plot.settable')) {
|
|
179
|
+
if (!plot.querySelector('button.grip')) continue;
|
|
180
|
+
if (plot.checkVisibility && !plot.checkVisibility()) continue;
|
|
181
|
+
if (plot.getBoundingClientRect().width < least) continue;
|
|
182
|
+
plot.scrollIntoView({ block: 'center' });
|
|
183
|
+
const box = plot.getBoundingClientRect();
|
|
184
|
+
if (box.width < least || box.height <= 0) continue;
|
|
185
|
+
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}"""
|
|
189
|
+
|
|
190
|
+
# Narrower than this a band is not a control anybody drags, and a drag
|
|
191
|
+
# across it says nothing. It is also what a band collapsed to nothing by a
|
|
192
|
+
# layout fault measures, which is a question the other checks ask.
|
|
193
|
+
DRAGGABLE_PX = 60
|
|
194
|
+
|
|
195
|
+
FIND_WIDE_TABLES = """() => {
|
|
196
|
+
const bad = [];
|
|
197
|
+
for (const table of document.querySelectorAll('table')) {
|
|
198
|
+
const box = table.closest('.scroll') || table.parentElement;
|
|
199
|
+
if (!box) continue;
|
|
200
|
+
const over = table.getBoundingClientRect().width - box.clientWidth;
|
|
201
|
+
if (over > 1) {
|
|
202
|
+
const head = [...table.querySelectorAll('th')].map((th) => th.textContent.trim());
|
|
203
|
+
bad.push(`${Math.round(over)}px over in [${head.join(', ')}]`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return bad;
|
|
207
|
+
}"""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def free_port() -> int:
|
|
211
|
+
"""Ask the kernel for a port nothing else is on."""
|
|
212
|
+
with socket.socket() as sock:
|
|
213
|
+
sock.bind(("127.0.0.1", 0))
|
|
214
|
+
return sock.getsockname()[1]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def require_playwright(how: str) -> Any:
|
|
218
|
+
"""Return ``sync_playwright``, or say how to get it and stop.
|
|
219
|
+
|
|
220
|
+
``how`` is the command that was being run, so the message ends with the
|
|
221
|
+
line the reader was about to retype.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
from playwright.sync_api import sync_playwright
|
|
225
|
+
except ImportError:
|
|
226
|
+
raise SystemExit(
|
|
227
|
+
"playwright is not installed; it lives in the browser group:\n"
|
|
228
|
+
" uv sync --group browser\n"
|
|
229
|
+
" uv run python -m playwright install chromium # once\n"
|
|
230
|
+
f" {how}"
|
|
231
|
+
) from None
|
|
232
|
+
return sync_playwright
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _dragged_keeps_the_keyboard(page: Any, where: str, problems: list[str]) -> None:
|
|
236
|
+
"""Drag a setpoint and check the keyboard followed it.
|
|
237
|
+
|
|
238
|
+
The arrow keys moving a focused grip is worth nothing if no ordinary
|
|
239
|
+
use of the page ever focuses one. Somebody who has just dragged a
|
|
240
|
+
setpoint into place with the pointer is exactly the person who wants
|
|
241
|
+
the last three decimals, and they will reach for the keyboard where
|
|
242
|
+
their hand already is -- so the press has to aim it.
|
|
243
|
+
|
|
244
|
+
Which grip it lands on is not asked: a press on the track takes hold of
|
|
245
|
+
the nearest setpoint, and which one is nearest to the middle of a band
|
|
246
|
+
is a fact about that band rather than about this.
|
|
247
|
+
"""
|
|
248
|
+
box = page.evaluate(FIND_A_BAND, DRAGGABLE_PX)
|
|
249
|
+
if box is None:
|
|
250
|
+
return
|
|
251
|
+
middle = box["y"] + box["height"] / 2
|
|
252
|
+
page.mouse.move(box["x"] + box["width"] * 0.5, middle)
|
|
253
|
+
page.mouse.down()
|
|
254
|
+
page.mouse.move(box["x"] + box["width"] * 0.55, middle, steps=5)
|
|
255
|
+
page.mouse.up()
|
|
256
|
+
if not page.evaluate(HAS_THE_KEYBOARD):
|
|
257
|
+
problems.append(f"{where}: dragging a setpoint did not give it the keyboard")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _judge(
|
|
261
|
+
page: Any, where: str, width: int, problems: list[str], *, keys: bool = False
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Ask one drawn tab every question that can be asked of it.
|
|
264
|
+
|
|
265
|
+
``keys`` also works the setpoints, which is asked at one width only:
|
|
266
|
+
whether a slider answers the keyboard is not a question about how wide
|
|
267
|
+
the window is, and asking it four times over costs four times as long
|
|
268
|
+
and says the same thing.
|
|
269
|
+
"""
|
|
270
|
+
if len(page.inner_text("body")) < DREW_NOTHING:
|
|
271
|
+
problems.append(f"{where}: rendered almost nothing")
|
|
272
|
+
return
|
|
273
|
+
if width >= FITS_ABOVE:
|
|
274
|
+
for text in page.evaluate(FIND_WRAPS):
|
|
275
|
+
problems.append(f"{where}: {text!r} is broken across lines")
|
|
276
|
+
for detail in page.evaluate(FIND_WIDE_TABLES):
|
|
277
|
+
problems.append(f"{where}: a table overflows -- {detail}")
|
|
278
|
+
for detail in page.evaluate(FIND_SIDEWAYS):
|
|
279
|
+
problems.append(f"{where}: the page scrolls sideways -- {detail}")
|
|
280
|
+
if keys:
|
|
281
|
+
_dragged_keeps_the_keyboard(page, where, problems)
|
|
282
|
+
for detail in page.evaluate(NUDGE_GRIPS):
|
|
283
|
+
problems.append(f"{where}: {detail}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check(
|
|
287
|
+
url: str,
|
|
288
|
+
tabs: Mapping[str, str],
|
|
289
|
+
wanted: Sequence[str],
|
|
290
|
+
widths: Sequence[int],
|
|
291
|
+
*,
|
|
292
|
+
how: str = "rendercheck",
|
|
293
|
+
settle_ms: int = SETTLE_MS,
|
|
294
|
+
) -> list[str]:
|
|
295
|
+
"""Drive an already-serving page and return everything wrong with it."""
|
|
296
|
+
sync_playwright = require_playwright(how)
|
|
297
|
+
problems: list[str] = []
|
|
298
|
+
with sync_playwright() as pw:
|
|
299
|
+
browser = pw.chromium.launch()
|
|
300
|
+
for width in widths:
|
|
301
|
+
page = browser.new_page(
|
|
302
|
+
viewport={"width": width, "height": VIEWPORT_HEIGHT}
|
|
303
|
+
)
|
|
304
|
+
page.on(
|
|
305
|
+
"pageerror",
|
|
306
|
+
lambda exc, w=width: problems.append(f"{w}px: page error: {exc}"),
|
|
307
|
+
)
|
|
308
|
+
page.on(
|
|
309
|
+
"console",
|
|
310
|
+
lambda msg, w=width: (
|
|
311
|
+
problems.append(f"{w}px: console: {msg.text}")
|
|
312
|
+
if msg.type == "error"
|
|
313
|
+
else None
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
for name in wanted:
|
|
317
|
+
page.goto(url.rstrip("/") + "/#" + tabs[name])
|
|
318
|
+
page.wait_for_timeout(settle_ms)
|
|
319
|
+
_judge(
|
|
320
|
+
page,
|
|
321
|
+
f"{width}px #{name}",
|
|
322
|
+
width,
|
|
323
|
+
problems,
|
|
324
|
+
keys=width == widths[0],
|
|
325
|
+
)
|
|
326
|
+
page.close()
|
|
327
|
+
browser.close()
|
|
328
|
+
return list(dict.fromkeys(problems))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main(
|
|
332
|
+
serve: Callable[[int], ContextManager[Any]],
|
|
333
|
+
tabs: Mapping[str, str],
|
|
334
|
+
*,
|
|
335
|
+
program: str,
|
|
336
|
+
argv: Sequence[str] | None = None,
|
|
337
|
+
) -> int:
|
|
338
|
+
"""Parse the arguments, serve, run the checks, report.
|
|
339
|
+
|
|
340
|
+
``serve`` is handed a port and must yield once the UI answers on it.
|
|
341
|
+
"""
|
|
342
|
+
how = f"uv run tools/rendercheck.py # in {program}"
|
|
343
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
344
|
+
ap.add_argument(
|
|
345
|
+
"tabs", nargs="*", choices=[*tabs, []], help="which tabs (default: all)"
|
|
346
|
+
)
|
|
347
|
+
ap.add_argument(
|
|
348
|
+
"--width",
|
|
349
|
+
type=int,
|
|
350
|
+
action="append",
|
|
351
|
+
metavar="PX",
|
|
352
|
+
help=f"a viewport width to check (repeatable; default {', '.join(map(str, WIDTHS))})",
|
|
353
|
+
)
|
|
354
|
+
args = ap.parse_args(argv)
|
|
355
|
+
widths = tuple(args.width or WIDTHS)
|
|
356
|
+
wanted = list(args.tabs) or list(tabs)
|
|
357
|
+
# Fail on a missing engine before starting a server for it.
|
|
358
|
+
require_playwright(how)
|
|
359
|
+
port = free_port()
|
|
360
|
+
with serve(port):
|
|
361
|
+
problems = check(f"http://127.0.0.1:{port}/", tabs, wanted, widths, how=how)
|
|
362
|
+
if not problems:
|
|
363
|
+
print(f"rendercheck: clean ({len(wanted)} tabs at {len(widths)} widths)")
|
|
364
|
+
return 0
|
|
365
|
+
for line in problems:
|
|
366
|
+
print(f" {line}", file=sys.stderr)
|
|
367
|
+
print(f"rendercheck: {len(problems)} problem(s)", file=sys.stderr)
|
|
368
|
+
return 1
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
__all__ = [
|
|
372
|
+
"DRAGGABLE_PX",
|
|
373
|
+
"DREW_NOTHING",
|
|
374
|
+
"FIND_A_BAND",
|
|
375
|
+
"FITS_ABOVE",
|
|
376
|
+
"HAS_THE_KEYBOARD",
|
|
377
|
+
"NUDGE_GRIPS",
|
|
378
|
+
"SETTLE_MS",
|
|
379
|
+
"WIDTHS",
|
|
380
|
+
"check",
|
|
381
|
+
"free_port",
|
|
382
|
+
"main",
|
|
383
|
+
"require_playwright",
|
|
384
|
+
]
|
devicectl/doctor.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The shape of a health report: findings, weights, and what could not be read.
|
|
2
|
+
|
|
3
|
+
Every program here grows a ``doctor`` command that reads the device over
|
|
4
|
+
once and says what looks wrong. What it *checks* is entirely the program's
|
|
5
|
+
own -- cell voltages mean nothing to a wallbox -- but the shape of the answer
|
|
6
|
+
is not, and both the terminal renderer and the browser one are written
|
|
7
|
+
against that shape rather than against the checks.
|
|
8
|
+
|
|
9
|
+
A finding is one of three weights. ``error`` is the device telling us
|
|
10
|
+
something is wrong, or two of its own readings disagreeing; ``warning`` is a
|
|
11
|
+
reading outside the band the program believes is healthy; ``note`` is a
|
|
12
|
+
setting somebody chose that is worth being reminded of -- a disabled switch
|
|
13
|
+
is not a fault, and a doctor that called it one would be ignored.
|
|
14
|
+
|
|
15
|
+
:attr:`Report.unavailable` is deliberately not a finding. A register the
|
|
16
|
+
device does not carry produced no answer, which is a different sentence from
|
|
17
|
+
"healthy", and a report that blurred the two would be worth less than one
|
|
18
|
+
that admitted the gap.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
ERROR = "error"
|
|
26
|
+
WARNING = "warning"
|
|
27
|
+
NOTE = "note"
|
|
28
|
+
|
|
29
|
+
# Worst first, so a report reads top-down.
|
|
30
|
+
SEVERITY_ORDER = {ERROR: 0, WARNING: 1, NOTE: 2}
|
|
31
|
+
|
|
32
|
+
# Where an unrecognised weight sorts: after all the real ones, rather than
|
|
33
|
+
# raising in the middle of rendering a report.
|
|
34
|
+
UNKNOWN_SEVERITY_RANK = 9
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Finding:
|
|
39
|
+
"""One thing worth telling somebody about this device."""
|
|
40
|
+
|
|
41
|
+
severity: str
|
|
42
|
+
area: str
|
|
43
|
+
detail: str
|
|
44
|
+
fix: str | None = None
|
|
45
|
+
"""The command that would deal with it, when there is one."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Report:
|
|
50
|
+
"""Everything one pass found, and everything it could not look at.
|
|
51
|
+
|
|
52
|
+
Programs subclass this to carry the readings the checks were made from;
|
|
53
|
+
every field here has a default, so a subclass may add its own.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
findings: list[Finding] = field(default_factory=list)
|
|
57
|
+
unavailable: list[str] = field(default_factory=list)
|
|
58
|
+
"""Areas whose check could not run, with why."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def worst(self) -> str | None:
|
|
62
|
+
"""The highest severity present, or None on a clean report."""
|
|
63
|
+
if not self.findings:
|
|
64
|
+
return None
|
|
65
|
+
return min((f.severity for f in self.findings), key=_rank)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def ok(self) -> bool:
|
|
69
|
+
"""Whether nothing of ``error`` weight was found."""
|
|
70
|
+
return not any(f.severity == ERROR for f in self.findings)
|
|
71
|
+
|
|
72
|
+
def sorted(self) -> list[Finding]:
|
|
73
|
+
"""Return the findings worst first, then by area.
|
|
74
|
+
|
|
75
|
+
Ordered by weight and area rather than by the order the checks ran,
|
|
76
|
+
so the same device produces the same report however the checks are
|
|
77
|
+
arranged.
|
|
78
|
+
"""
|
|
79
|
+
return sorted(self.findings, key=lambda f: (_rank(f.severity), f.area))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rank(severity: str) -> int:
|
|
83
|
+
"""Where one weight sorts among the others."""
|
|
84
|
+
return SEVERITY_ORDER.get(severity, UNKNOWN_SEVERITY_RANK)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def finding_json(finding: Finding) -> dict[str, str | None]:
|
|
88
|
+
"""One finding, in the shape the shared ``HealthCard`` reads.
|
|
89
|
+
|
|
90
|
+
The card wants exactly these four keys and does the rest itself -- it
|
|
91
|
+
counts the weights and picks the worst client-side -- so this is the
|
|
92
|
+
finding and nothing more. Both programs' doctor endpoints serialise
|
|
93
|
+
through it, so the one component is handed one shape rather than two
|
|
94
|
+
that have drifted apart.
|
|
95
|
+
"""
|
|
96
|
+
return {
|
|
97
|
+
"severity": finding.severity,
|
|
98
|
+
"area": finding.area,
|
|
99
|
+
"detail": finding.detail,
|
|
100
|
+
"fix": finding.fix,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"ERROR",
|
|
106
|
+
"NOTE",
|
|
107
|
+
"SEVERITY_ORDER",
|
|
108
|
+
"WARNING",
|
|
109
|
+
"Finding",
|
|
110
|
+
"Report",
|
|
111
|
+
"finding_json",
|
|
112
|
+
]
|
devicectl/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The base class every error a program raises on purpose derives from.
|
|
2
|
+
|
|
3
|
+
A command fails for one of two reasons. Either the device, the link or the
|
|
4
|
+
input was not what it needed -- a setting that does not exist, a firmware
|
|
5
|
+
image for another model, something that will not answer -- which is ordinary
|
|
6
|
+
and gets one clear line on stderr. Or the program has a bug, which deserves
|
|
7
|
+
a traceback.
|
|
8
|
+
|
|
9
|
+
:class:`DeviceError` is the first kind. Each program derives one class of
|
|
10
|
+
its own from it (``AlfenError``, ``JkError``) and every module raises a
|
|
11
|
+
subclass of *that*, so a caller who cares can still tell a refused register
|
|
12
|
+
apart from a bad firmware file. What the shared code needs is only the
|
|
13
|
+
root: :func:`devicectl.cli.main` catches it, prints the message and exits,
|
|
14
|
+
and the web server turns it into a 400 -- neither has to know the list.
|
|
15
|
+
|
|
16
|
+
Errors that are specifically about a *value* also derive from
|
|
17
|
+
:class:`ValueError`, because that is what they are and callers already catch
|
|
18
|
+
them that way.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import traceback
|
|
24
|
+
|
|
25
|
+
# How much of a long message is worth showing in a UI ticker or a log line.
|
|
26
|
+
MESSAGE_LIMIT = 400
|
|
27
|
+
|
|
28
|
+
# Exception classes whose name adds nothing to their message: nobody needs
|
|
29
|
+
# to be told that "no such property" was a ValueError.
|
|
30
|
+
PLAIN = ("RuntimeError", "ValueError")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DeviceError(Exception):
|
|
34
|
+
"""An expected failure, reportable to the user as a single line."""
|
|
35
|
+
|
|
36
|
+
traceable: bool = False
|
|
37
|
+
"""Whether the device, or the link to it, is what failed.
|
|
38
|
+
|
|
39
|
+
A subclass for "the device did not answer" or "the device refused" sets
|
|
40
|
+
this, and the web layer then says so in the error reply -- which is what
|
|
41
|
+
lets the page start recording the wire on the one kind of failure a
|
|
42
|
+
recording of the wire can explain. A value out of range, a port nobody
|
|
43
|
+
chose, a busy worker: those leave it False.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def describe(exc: BaseException) -> str:
|
|
48
|
+
"""Return one readable line for an exception.
|
|
49
|
+
|
|
50
|
+
For an activity ticker, a job's ``error`` field, or anywhere else a
|
|
51
|
+
failure has to fit on a line beside the thing that failed.
|
|
52
|
+
"""
|
|
53
|
+
text = str(exc).strip()
|
|
54
|
+
if not text:
|
|
55
|
+
text = exc.__class__.__name__
|
|
56
|
+
elif exc.__class__.__name__ not in PLAIN:
|
|
57
|
+
text = f"{exc.__class__.__name__}: {text}"
|
|
58
|
+
return text.splitlines()[0][:MESSAGE_LIMIT]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_traceback(exc: BaseException) -> str:
|
|
62
|
+
"""Return the full traceback text, for ``--debug`` logging of a failure."""
|
|
63
|
+
return "".join(
|
|
64
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
65
|
+
).strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["DeviceError", "describe", "format_traceback"]
|