canopen-bench 1.0.170__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.
- canopen_bench/__init__.py +43 -0
- canopen_bench/__main__.py +173 -0
- canopen_bench/app.py +288 -0
- canopen_bench/bus/__init__.py +3 -0
- canopen_bench/bus/canopen_bus.py +830 -0
- canopen_bench/bus/demo.py +583 -0
- canopen_bench/bus/interface.py +252 -0
- canopen_bench/core.py +7983 -0
- canopen_bench/data.py +171 -0
- canopen_bench/db.py +192 -0
- canopen_bench/eds_od.py +228 -0
- canopen_bench/flows/lss_standard.yaml +27 -0
- canopen_bench/instruments/__init__.py +265 -0
- canopen_bench/instruments/owon.py +175 -0
- canopen_bench/instruments/toellner.py +238 -0
- canopen_bench/lss.py +172 -0
- canopen_bench/panels/DemoDevice.panel.yaml +35 -0
- canopen_bench/panels/standard.panel.yaml +25 -0
- canopen_bench/panelspec.py +454 -0
- canopen_bench/plugin.py +782 -0
- canopen_bench/report.py +870 -0
- canopen_bench/seed/CiA301Base.eds +139 -0
- canopen_bench/seed/DemoDevice.eds +261 -0
- canopen_bench/static/app.js +2939 -0
- canopen_bench/static/fonts/IBMPlexMono-Medium.woff2 +0 -0
- canopen_bench/static/fonts/IBMPlexMono-Regular.woff2 +0 -0
- canopen_bench/static/fonts/IBMPlexMono-SemiBold.woff2 +0 -0
- canopen_bench/static/fonts/IBMPlexSans-Bold.woff2 +0 -0
- canopen_bench/static/fonts/IBMPlexSans-Medium.woff2 +0 -0
- canopen_bench/static/fonts/IBMPlexSans-Regular.woff2 +0 -0
- canopen_bench/static/fonts/IBMPlexSans-SemiBold.woff2 +0 -0
- canopen_bench/static/fonts/LICENSE.txt +93 -0
- canopen_bench/static/index.html +14 -0
- canopen_bench/static/styles.css +75 -0
- canopen_bench/static/vendor/preact-htm.module.js +16 -0
- canopen_bench/symbols.py +317 -0
- canopen_bench/testReportStyle.css +57 -0
- canopen_bench/testcases.py +700 -0
- canopen_bench/usersettings.py +58 -0
- canopen_bench/values.py +353 -0
- canopen_bench-1.0.170.dist-info/METADATA +243 -0
- canopen_bench-1.0.170.dist-info/RECORD +47 -0
- canopen_bench-1.0.170.dist-info/WHEEL +5 -0
- canopen_bench-1.0.170.dist-info/entry_points.txt +2 -0
- canopen_bench-1.0.170.dist-info/licenses/LICENSE +21 -0
- canopen_bench-1.0.170.dist-info/licenses/THIRD-PARTY-NOTICES.md +117 -0
- canopen_bench-1.0.170.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""CANopen Bench — web tool to test and control CANopen devices."""
|
|
2
|
+
import re
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _read_version() -> str:
|
|
9
|
+
"""The one place the version is read from.
|
|
10
|
+
|
|
11
|
+
pyproject.toml first: this project bumps the version on every commit
|
|
12
|
+
and does not tag releases, so a source checkout is the normal way to
|
|
13
|
+
run it — and the installed metadata of an editable install goes stale
|
|
14
|
+
until the next `pip install -e`, happily reporting an old number.
|
|
15
|
+
Wheel installs have no pyproject next to the package and fall through
|
|
16
|
+
to the (then correct) metadata.
|
|
17
|
+
|
|
18
|
+
The file is scanned for the version line rather than parsed as TOML:
|
|
19
|
+
``tomllib`` only exists from Python 3.11, and on 3.10 falling back to
|
|
20
|
+
the metadata would quietly reintroduce exactly the staleness this
|
|
21
|
+
function exists to avoid. Adding a ``tomli`` dependency to read one
|
|
22
|
+
line is not worth it.
|
|
23
|
+
|
|
24
|
+
Kept in this module rather than in ``core`` so that reading the
|
|
25
|
+
version costs no imports beyond the standard library.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
pp = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
29
|
+
for line in pp.read_text(encoding="utf-8").splitlines():
|
|
30
|
+
# first `version = "..."` in the file; [project] declares it
|
|
31
|
+
# before any other table can (build-system has no version key)
|
|
32
|
+
match = re.match(r'\s*version\s*=\s*["\'](?P<v>[^"\']+)["\']', line)
|
|
33
|
+
if match:
|
|
34
|
+
return match.group("v")
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
return _pkg_version("canopen-bench")
|
|
39
|
+
except PackageNotFoundError: # bare checkout without install
|
|
40
|
+
return "dev"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__version__ = _read_version()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Run with: python -m canopen_bench [--host 0.0.0.0] [--port 8000]
|
|
2
|
+
|
|
3
|
+
Hardware adapters (CPC-USB / IXXAT / PCAN) talk to the bus via
|
|
4
|
+
python-can/canopen; pick the adapter on the Setup page. Demo mode needs no
|
|
5
|
+
hardware — it generates virtual DUTs from the uploaded EDS files. The
|
|
6
|
+
CPC-USB adapter additionally needs the cob-cpcusb driver package.
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import uvicorn
|
|
16
|
+
|
|
17
|
+
from . import usersettings
|
|
18
|
+
from .app import create_app
|
|
19
|
+
|
|
20
|
+
#: How long uvicorn may wait for open connections when stopping. The state
|
|
21
|
+
#: channel is a WebSocket per browser tab, and a tab left open on another
|
|
22
|
+
#: screen never closes it — with uvicorn's default of "wait as long as it
|
|
23
|
+
#: takes", Ctrl+C began a shutdown that never ended. The process stayed
|
|
24
|
+
#: alive holding the supply's serial port, so the next start reported no
|
|
25
|
+
#: power supply found, and the way out was the task manager.
|
|
26
|
+
SHUTDOWN_S = 5
|
|
27
|
+
|
|
28
|
+
#: Name of the lock file inside a data folder. Dot-prefixed, so it stays out
|
|
29
|
+
#: of the workspace list (Bench._workspace_names skips those).
|
|
30
|
+
LOCK_NAME = ".bench.lock"
|
|
31
|
+
|
|
32
|
+
#: Held open for the lifetime of the process: the lock lives on the open
|
|
33
|
+
#: file, so the operating system drops it the moment the process is gone,
|
|
34
|
+
#: crash and kill included — there is no stale lock to clean up and no pid
|
|
35
|
+
#: to test for liveness. Module level rather than a local in main(), or the
|
|
36
|
+
#: garbage collector closes the file and hands the folder to the next
|
|
37
|
+
#: starter while this bench is still running.
|
|
38
|
+
_lock_handle = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lock(f) -> None:
|
|
42
|
+
"""Exclusive, non-blocking lock, per open file rather than per process.
|
|
43
|
+
|
|
44
|
+
Both calls below lock a single open file rather than the process, so a
|
|
45
|
+
second attempt from *this* process is refused the same way another
|
|
46
|
+
process would be — which is what makes it testable without spawning
|
|
47
|
+
one. POSIX ``lockf`` would not: its record locks belong to the process,
|
|
48
|
+
so a second claim inside one test run would quietly succeed.
|
|
49
|
+
"""
|
|
50
|
+
if os.name == "nt":
|
|
51
|
+
import msvcrt
|
|
52
|
+
f.seek(0)
|
|
53
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
54
|
+
else:
|
|
55
|
+
import fcntl
|
|
56
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def claim_data_folder(root: Path) -> int | None:
|
|
60
|
+
"""Claim a data folder for this process. None if it is ours now,
|
|
61
|
+
otherwise the pid of the bench already holding it (0 if unreadable).
|
|
62
|
+
|
|
63
|
+
Two benches on one data folder share a database, a plugin directory and
|
|
64
|
+
whatever hardware is plugged into the machine. The damage is not loud:
|
|
65
|
+
the second one to start takes the serial port, and the first one's
|
|
66
|
+
search then finds nothing and reports "no known power supply found" —
|
|
67
|
+
which sends the reader to the cable, the port and the instrument, when
|
|
68
|
+
what was wrong was that the bench was running twice.
|
|
69
|
+
|
|
70
|
+
The HTTP port needs no guard of its own: the operating system already
|
|
71
|
+
refuses the second bind. The data folder is the resource with no such
|
|
72
|
+
protection, and it is also the right granularity — a release bench on
|
|
73
|
+
its own folder and port is meant to run beside an everyday one.
|
|
74
|
+
"""
|
|
75
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
f = open(root / LOCK_NAME, "a+", encoding="utf-8")
|
|
77
|
+
try:
|
|
78
|
+
_lock(f)
|
|
79
|
+
except OSError:
|
|
80
|
+
# Only the first byte is locked on Windows, so the pid behind it
|
|
81
|
+
# stays readable — the message can name what to stop.
|
|
82
|
+
f.seek(1)
|
|
83
|
+
held = f.read().strip()
|
|
84
|
+
f.close()
|
|
85
|
+
return int(held) if held.isdigit() else 0
|
|
86
|
+
f.seek(0)
|
|
87
|
+
f.truncate()
|
|
88
|
+
f.write(f"\n{os.getpid()}") # byte 0 is the locked one, the pid follows
|
|
89
|
+
f.flush()
|
|
90
|
+
global _lock_handle
|
|
91
|
+
_lock_handle = f
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def take_over(root: Path, pid: int, timeout_s: float = 8.0) -> bool:
|
|
96
|
+
"""Stop the bench holding this data folder, then claim it.
|
|
97
|
+
|
|
98
|
+
Safe to do by pid: the folder is locked, and only the process holding
|
|
99
|
+
that lock ever wrote the pid behind it, so it cannot be a number the
|
|
100
|
+
system has since handed to something else. That is the whole reason
|
|
101
|
+
the pid lives in the lock file rather than in one of its own.
|
|
102
|
+
|
|
103
|
+
Windows has no polite signal for this — os.kill terminates there
|
|
104
|
+
whatever is passed — so the bench does not get to run its shutdown.
|
|
105
|
+
It does not need to: the port and the bus are handles, and handles go
|
|
106
|
+
when the process does.
|
|
107
|
+
"""
|
|
108
|
+
if not pid:
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
os.kill(pid, signal.SIGTERM)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass # gone already, or not ours to signal — the wait below decides
|
|
114
|
+
deadline = time.monotonic() + timeout_s
|
|
115
|
+
while time.monotonic() < deadline:
|
|
116
|
+
if claim_data_folder(root) is None:
|
|
117
|
+
return True
|
|
118
|
+
time.sleep(0.2)
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main() -> None:
|
|
123
|
+
ap = argparse.ArgumentParser(description="CANopen Bench web tool")
|
|
124
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
125
|
+
ap.add_argument("--port", type=int, default=8000)
|
|
126
|
+
ap.add_argument("--data", default=None,
|
|
127
|
+
help="this start's data folder, for this start only and not remembered: "
|
|
128
|
+
"a second bench beside the everyday one, a mounted Docker volume. "
|
|
129
|
+
"Normally it is chosen under Setup > Folders")
|
|
130
|
+
ap.add_argument("--db", default=None,
|
|
131
|
+
help="expert override: explicit sqlite workspace db path. Normally "
|
|
132
|
+
"workspaces live as subfolders of the data folder (Setup > Folders, "
|
|
133
|
+
"or --data) "
|
|
134
|
+
"and are selected on the Setup page. Skips the one-bench-per-data-"
|
|
135
|
+
"folder check, since there is no data folder to speak of")
|
|
136
|
+
ap.add_argument("--takeover", action="store_true",
|
|
137
|
+
help="stop the bench already running on this data folder and take it "
|
|
138
|
+
"over, instead of refusing to start. What it is really for is the "
|
|
139
|
+
"one left behind by a laptop that slept: it still holds the "
|
|
140
|
+
"supply's serial port, and no other program can hand that back")
|
|
141
|
+
args = ap.parse_args()
|
|
142
|
+
|
|
143
|
+
data = Path(args.data) if args.data else None
|
|
144
|
+
if not args.db:
|
|
145
|
+
root = data or usersettings.data_root()
|
|
146
|
+
held_by = claim_data_folder(root)
|
|
147
|
+
if held_by is not None and args.takeover:
|
|
148
|
+
print(f"Stopping the bench on {root.resolve()} (process {held_by}) …")
|
|
149
|
+
if take_over(root, held_by):
|
|
150
|
+
held_by = None
|
|
151
|
+
else:
|
|
152
|
+
print(f"Process {held_by} did not stop.", file=sys.stderr)
|
|
153
|
+
if held_by is not None:
|
|
154
|
+
who = f"as process {held_by}" if held_by else "already"
|
|
155
|
+
print(f"CANopen Bench is running {who} on {root.resolve()}.\n"
|
|
156
|
+
f"Two of them share that folder's database, plugins and the hardware "
|
|
157
|
+
f"plugged into this machine.\n"
|
|
158
|
+
f"Stop that one first, or start with --takeover to stop it from here.\n"
|
|
159
|
+
f"To run both, give this one a folder and a port of its own:\n"
|
|
160
|
+
f" canopen-bench --data <folder> --port <other port>",
|
|
161
|
+
file=sys.stderr)
|
|
162
|
+
raise SystemExit(1)
|
|
163
|
+
# said before anybody goes looking for a workspace in another one;
|
|
164
|
+
# Setup names it too, and changes it (usersettings.data_root)
|
|
165
|
+
where = "--data, this start only" if data else f"remembered in {usersettings.settings_file()}"
|
|
166
|
+
print(f"CANopen Bench data folder: {root.resolve()} ({where})", flush=True)
|
|
167
|
+
|
|
168
|
+
uvicorn.run(create_app(args.db, data=data), host=args.host, port=args.port,
|
|
169
|
+
timeout_graceful_shutdown=SHUTDOWN_S)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
canopen_bench/app.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Starlette application: serves the frontend, one command endpoint, one state WebSocket.
|
|
2
|
+
|
|
3
|
+
Workspaces: without an explicit db path the app runs in multi-workspace
|
|
4
|
+
mode — every subfolder of the data folder (chosen under Setup, see
|
|
5
|
+
usersettings; or ``--data`` for one start, e.g. a mounted Docker volume) is
|
|
6
|
+
one workspace holding its own db, EDS files, traces and flows. The active
|
|
7
|
+
workspace is remembered in ``<root>/active-workspace`` and can be switched
|
|
8
|
+
from the Setup page at runtime; switching swaps the whole Bench instance.
|
|
9
|
+
An explicit ``--db`` path keeps the old single-file behaviour (tests,
|
|
10
|
+
embedding, experts).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from starlette.applications import Starlette
|
|
20
|
+
from starlette.requests import Request
|
|
21
|
+
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
|
22
|
+
from starlette.routing import Mount, Route, WebSocketRoute
|
|
23
|
+
from starlette.staticfiles import StaticFiles
|
|
24
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
25
|
+
|
|
26
|
+
from . import __version__, usersettings
|
|
27
|
+
from .bus.interface import BusInterface
|
|
28
|
+
from .core import Bench
|
|
29
|
+
from .db import Db
|
|
30
|
+
from .plugin import BenchPlugin
|
|
31
|
+
|
|
32
|
+
STATIC = Path(__file__).parent / "static"
|
|
33
|
+
DEFAULT_WORKSPACE = "default"
|
|
34
|
+
#: the page and its files are asked for again on every load, answered with a
|
|
35
|
+
#: 304 while unchanged. Without it the browser guesses how long a file stays
|
|
36
|
+
#: fresh — and after an update it kept the old stylesheet beside the new
|
|
37
|
+
#: script, a page laid out for a version that was no longer running.
|
|
38
|
+
REVALIDATE = {"Cache-Control": "no-cache"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RevalidatedFiles(StaticFiles):
|
|
42
|
+
async def get_response(self, path, scope):
|
|
43
|
+
response = await super().get_response(path, scope)
|
|
44
|
+
response.headers.update(REVALIDATE)
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _active_workspace(root: Path) -> str:
|
|
49
|
+
try:
|
|
50
|
+
name = (root / "active-workspace").read_text(encoding="utf-8").strip()
|
|
51
|
+
except OSError:
|
|
52
|
+
return DEFAULT_WORKSPACE
|
|
53
|
+
return name if name and (root / name).is_dir() else DEFAULT_WORKSPACE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_app(db_path: str | None = None, bus: BusInterface | None = None,
|
|
57
|
+
plugins: list[BenchPlugin] | None = None, data: Path | None = None) -> Starlette:
|
|
58
|
+
"""``data``: this start's data folder (``--data``), not the remembered one."""
|
|
59
|
+
if db_path:
|
|
60
|
+
root: Path | None = None
|
|
61
|
+
path = Path(db_path)
|
|
62
|
+
else:
|
|
63
|
+
root = Path(data) if data else usersettings.data_root()
|
|
64
|
+
path = root / _active_workspace(root) / "canopen-bench.db"
|
|
65
|
+
|
|
66
|
+
clients: set[WebSocket] = set()
|
|
67
|
+
holder: dict = {"bench": None, "ticker": None}
|
|
68
|
+
|
|
69
|
+
async def broadcast() -> None:
|
|
70
|
+
if not clients:
|
|
71
|
+
return
|
|
72
|
+
snap = holder["bench"].snapshot()
|
|
73
|
+
dead = []
|
|
74
|
+
# over a snapshot of the set, not the set itself: sending is an
|
|
75
|
+
# await, and a browser connecting or a tab closing in that window
|
|
76
|
+
# mutates `clients` mid-iteration. That raised "Set changed size
|
|
77
|
+
# during iteration" *out of the whole broadcast*, so every client
|
|
78
|
+
# after the one being sent to missed that update — and since this
|
|
79
|
+
# runs as a fire-and-forget task, the only trace was an unretrieved
|
|
80
|
+
# exception on the console while a screen quietly went stale.
|
|
81
|
+
for ws in tuple(clients):
|
|
82
|
+
try:
|
|
83
|
+
await ws.send_json({"type": "state", "state": snap})
|
|
84
|
+
except Exception:
|
|
85
|
+
dead.append(ws)
|
|
86
|
+
for ws in dead:
|
|
87
|
+
clients.discard(ws)
|
|
88
|
+
|
|
89
|
+
async def safe_broadcast() -> None:
|
|
90
|
+
"""Push state, and never let failing to push break anything else.
|
|
91
|
+
|
|
92
|
+
The tick loop awaits this every tick and the Bench fires it after
|
|
93
|
+
every action. A raise out of here used to take the whole tick loop
|
|
94
|
+
with it — and since nothing closes the WebSocket, the browser kept
|
|
95
|
+
a healthy socket that simply never received another message. No
|
|
96
|
+
reconnect, no error on screen: a run that had finished still read
|
|
97
|
+
"Running…".
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
await broadcast()
|
|
101
|
+
except Exception as exc: # noqa: BLE001 — the point is to catch all
|
|
102
|
+
bench = holder.get("bench")
|
|
103
|
+
if bench is not None:
|
|
104
|
+
bench.log(f"APP state not pushed — {type(exc).__name__}: {exc}", "emcy0")
|
|
105
|
+
|
|
106
|
+
def _build_bench(db: Db) -> Bench:
|
|
107
|
+
bench = Bench(db, bus=bus, plugins=plugins, workspaces_root=root,
|
|
108
|
+
data_fixed=data is not None)
|
|
109
|
+
bench.set_notifier(safe_broadcast)
|
|
110
|
+
if root is not None:
|
|
111
|
+
bench.on_workspace_switch = lambda name: asyncio.ensure_future(_switch(name))
|
|
112
|
+
bench.on_plugin_reload = lambda: asyncio.ensure_future(_reload_plugins())
|
|
113
|
+
return bench
|
|
114
|
+
|
|
115
|
+
async def _rebuild(next_db: Db, new_workspace: str | None) -> None:
|
|
116
|
+
"""Shared teardown/boot for both workspace switching and a plugin
|
|
117
|
+
reload: stop the tick loop, tear down the bus, hand off to a fresh
|
|
118
|
+
Bench (fresh plugin discovery included — Bench.__init__ always
|
|
119
|
+
re-runs load_plugins()), restart the tick loop. `new_workspace` is
|
|
120
|
+
the workspace name to persist as active, or None to stay put (a
|
|
121
|
+
plugin reload never changes which workspace is active)."""
|
|
122
|
+
old: Bench = holder["bench"]
|
|
123
|
+
holder["ticker"].cancel()
|
|
124
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
125
|
+
await holder["ticker"]
|
|
126
|
+
old.shutdown()
|
|
127
|
+
if new_workspace is not None:
|
|
128
|
+
old.db.close()
|
|
129
|
+
(root / "active-workspace").write_text(new_workspace, encoding="utf-8")
|
|
130
|
+
bench = _build_bench(next_db)
|
|
131
|
+
holder["bench"] = bench
|
|
132
|
+
app.state.bench = bench
|
|
133
|
+
bench.startup()
|
|
134
|
+
holder["ticker"] = asyncio.create_task(bench.tick_loop())
|
|
135
|
+
await broadcast()
|
|
136
|
+
|
|
137
|
+
async def _switch(name: str) -> None:
|
|
138
|
+
"""Swap the whole Bench for another workspace: closes the old db,
|
|
139
|
+
opens the new workspace's."""
|
|
140
|
+
await _rebuild(Db(root / name / "canopen-bench.db"), new_workspace=name)
|
|
141
|
+
|
|
142
|
+
async def _reload_plugins() -> None:
|
|
143
|
+
"""Rebuild the Bench after a plugin install/removal, same workspace
|
|
144
|
+
and the same live db connection — only the plugin set changes."""
|
|
145
|
+
await _rebuild(holder["bench"].db, new_workspace=None)
|
|
146
|
+
|
|
147
|
+
holder["bench"] = _build_bench(Db(path))
|
|
148
|
+
|
|
149
|
+
@contextlib.asynccontextmanager
|
|
150
|
+
async def lifespan(app: Starlette):
|
|
151
|
+
holder["bench"].startup()
|
|
152
|
+
holder["ticker"] = asyncio.create_task(holder["bench"].tick_loop())
|
|
153
|
+
yield
|
|
154
|
+
holder["ticker"].cancel()
|
|
155
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
156
|
+
await holder["ticker"]
|
|
157
|
+
# Close the state channels rather than waiting for the browsers to
|
|
158
|
+
# do it. uvicorn's graceful shutdown waits for open connections,
|
|
159
|
+
# and a tab left open on another screen never closes this one — so
|
|
160
|
+
# Ctrl+C started a shutdown that never finished, the process stayed
|
|
161
|
+
# alive holding the supply's serial port, and the next start found
|
|
162
|
+
# no power supply. __main__ caps that wait as well; this is what
|
|
163
|
+
# makes the normal case end at once instead of at the cap.
|
|
164
|
+
for ws in tuple(clients):
|
|
165
|
+
with contextlib.suppress(Exception):
|
|
166
|
+
await ws.close()
|
|
167
|
+
clients.clear()
|
|
168
|
+
holder["bench"].shutdown()
|
|
169
|
+
holder["bench"].db.close()
|
|
170
|
+
|
|
171
|
+
# the version in the names of the page's files: a browser that holds a
|
|
172
|
+
# copy from before an update asks for them as new files, rather than
|
|
173
|
+
# trusting its guess about the old ones (REVALIDATE only reaches it once
|
|
174
|
+
# it asks again)
|
|
175
|
+
page = (STATIC / "index.html").read_text(encoding="utf-8")
|
|
176
|
+
for name in ("styles.css", "app.js"):
|
|
177
|
+
page = page.replace(f'/static/{name}"', f'/static/{name}?v={__version__}"')
|
|
178
|
+
|
|
179
|
+
async def index(request: Request) -> HTMLResponse:
|
|
180
|
+
return HTMLResponse(page, headers=REVALIDATE)
|
|
181
|
+
|
|
182
|
+
async def state(request: Request) -> JSONResponse:
|
|
183
|
+
return JSONResponse(holder["bench"].snapshot())
|
|
184
|
+
|
|
185
|
+
async def action(request: Request) -> JSONResponse:
|
|
186
|
+
"""Parsed by hand rather than through a validation library: one
|
|
187
|
+
endpoint, two fields. `dispatch` validates the action name and its
|
|
188
|
+
params anyway, and raises ValueError with a message for the UI."""
|
|
189
|
+
try:
|
|
190
|
+
body = await request.json()
|
|
191
|
+
except Exception:
|
|
192
|
+
return JSONResponse({"ok": False, "error": "body must be JSON"}, status_code=400)
|
|
193
|
+
if not isinstance(body, dict) or not isinstance(body.get("action"), str):
|
|
194
|
+
return JSONResponse({"ok": False, "error": "missing 'action'"}, status_code=400)
|
|
195
|
+
params = body.get("params") or {}
|
|
196
|
+
if not isinstance(params, dict):
|
|
197
|
+
return JSONResponse({"ok": False, "error": "'params' must be an object"},
|
|
198
|
+
status_code=400)
|
|
199
|
+
try:
|
|
200
|
+
holder["bench"].dispatch(body["action"], params)
|
|
201
|
+
except ValueError as e:
|
|
202
|
+
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
|
|
203
|
+
return JSONResponse({"ok": True})
|
|
204
|
+
|
|
205
|
+
# Plain GET downloads, deliberately outside the action/WebSocket state
|
|
206
|
+
# machine: a browser download is a different concern (Content-Disposition,
|
|
207
|
+
# not JSON) and doesn't mutate any state, so it doesn't need dispatch().
|
|
208
|
+
def _download(body: str, media_type: str, suffix: str) -> Response:
|
|
209
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
210
|
+
return Response(content=body, media_type=media_type, headers={
|
|
211
|
+
"Content-Disposition": f'attachment; filename="trace_{stamp}.{suffix}"'})
|
|
212
|
+
|
|
213
|
+
#: Most rows one request may ask for. A window is what fits on a screen
|
|
214
|
+
#: plus a little either side; anything larger is someone asking for the
|
|
215
|
+
#: whole buffer a page at a time, which is what the exports are for.
|
|
216
|
+
TRACE_PAGE_MAX = 2000
|
|
217
|
+
|
|
218
|
+
async def trace_rows(request: Request) -> JSONResponse:
|
|
219
|
+
"""The slice of the trace the panel is scrolled to, newest first.
|
|
220
|
+
|
|
221
|
+
A GET rather than an action, for the same reason as the exports: it
|
|
222
|
+
reads, it does not mutate, and the answer belongs to the one panel
|
|
223
|
+
that asked rather than in the snapshot every browser receives ten
|
|
224
|
+
times a second.
|
|
225
|
+
"""
|
|
226
|
+
def _int(name: str, default: int, lo: int, hi: int) -> int:
|
|
227
|
+
try:
|
|
228
|
+
return max(lo, min(hi, int(request.query_params[name])))
|
|
229
|
+
except (KeyError, TypeError, ValueError):
|
|
230
|
+
return default
|
|
231
|
+
|
|
232
|
+
return JSONResponse(holder["bench"]._trace_page(
|
|
233
|
+
_int("end", 0, 0, 100_000_000), _int("n", 200, 1, TRACE_PAGE_MAX)))
|
|
234
|
+
|
|
235
|
+
async def export_csv(request: Request) -> Response:
|
|
236
|
+
return _download(holder["bench"]._trace_csv(), "text/csv", "csv")
|
|
237
|
+
|
|
238
|
+
async def export_candump(request: Request) -> Response:
|
|
239
|
+
return _download(holder["bench"]._trace_candump(), "text/plain", "candump.log")
|
|
240
|
+
|
|
241
|
+
async def report(request: Request) -> Response:
|
|
242
|
+
"""One file out of the results folder.
|
|
243
|
+
|
|
244
|
+
The reports are written as HTML that links to each other and to a
|
|
245
|
+
stylesheet by bare file name, so they are served under one prefix
|
|
246
|
+
and those relative links keep working — a summary opened here can
|
|
247
|
+
still reach its per-case pages.
|
|
248
|
+
|
|
249
|
+
Only a plain name is accepted, and only from that folder: the name
|
|
250
|
+
arrives from the URL, and a results folder is a path the operator
|
|
251
|
+
chose, so "whatever is under it" must not become "whatever is on
|
|
252
|
+
the disk".
|
|
253
|
+
"""
|
|
254
|
+
name = request.path_params["name"]
|
|
255
|
+
folder = holder["bench"]._results_dir().resolve()
|
|
256
|
+
target = (folder / name).resolve()
|
|
257
|
+
if (Path(name).name != name or target.parent != folder
|
|
258
|
+
or target.suffix.lower() not in (".html", ".json", ".css")
|
|
259
|
+
or not target.is_file()):
|
|
260
|
+
return Response("not found", status_code=404)
|
|
261
|
+
return FileResponse(target)
|
|
262
|
+
|
|
263
|
+
async def ws(websocket: WebSocket) -> None:
|
|
264
|
+
await websocket.accept()
|
|
265
|
+
clients.add(websocket)
|
|
266
|
+
try:
|
|
267
|
+
await websocket.send_json({"type": "state", "state": holder["bench"].snapshot()})
|
|
268
|
+
while True:
|
|
269
|
+
await websocket.receive_text() # client sends pings only
|
|
270
|
+
except WebSocketDisconnect:
|
|
271
|
+
pass
|
|
272
|
+
finally:
|
|
273
|
+
clients.discard(websocket)
|
|
274
|
+
|
|
275
|
+
app = Starlette(lifespan=lifespan, routes=[
|
|
276
|
+
Route("/", index),
|
|
277
|
+
Route("/api/state", state),
|
|
278
|
+
Route("/api/action", action, methods=["POST"]),
|
|
279
|
+
Route("/api/trace/rows", trace_rows),
|
|
280
|
+
Route("/api/trace/export.csv", export_csv),
|
|
281
|
+
Route("/api/trace/export/candump", export_candump),
|
|
282
|
+
Route("/api/report/{name}", report),
|
|
283
|
+
WebSocketRoute("/ws", ws),
|
|
284
|
+
Mount("/static", _RevalidatedFiles(directory=STATIC), name="static"),
|
|
285
|
+
])
|
|
286
|
+
app.state.bench = holder["bench"]
|
|
287
|
+
app.state.ws_clients = clients # for tests; the app itself closes over it
|
|
288
|
+
return app
|