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.
Files changed (47) hide show
  1. canopen_bench/__init__.py +43 -0
  2. canopen_bench/__main__.py +173 -0
  3. canopen_bench/app.py +288 -0
  4. canopen_bench/bus/__init__.py +3 -0
  5. canopen_bench/bus/canopen_bus.py +830 -0
  6. canopen_bench/bus/demo.py +583 -0
  7. canopen_bench/bus/interface.py +252 -0
  8. canopen_bench/core.py +7983 -0
  9. canopen_bench/data.py +171 -0
  10. canopen_bench/db.py +192 -0
  11. canopen_bench/eds_od.py +228 -0
  12. canopen_bench/flows/lss_standard.yaml +27 -0
  13. canopen_bench/instruments/__init__.py +265 -0
  14. canopen_bench/instruments/owon.py +175 -0
  15. canopen_bench/instruments/toellner.py +238 -0
  16. canopen_bench/lss.py +172 -0
  17. canopen_bench/panels/DemoDevice.panel.yaml +35 -0
  18. canopen_bench/panels/standard.panel.yaml +25 -0
  19. canopen_bench/panelspec.py +454 -0
  20. canopen_bench/plugin.py +782 -0
  21. canopen_bench/report.py +870 -0
  22. canopen_bench/seed/CiA301Base.eds +139 -0
  23. canopen_bench/seed/DemoDevice.eds +261 -0
  24. canopen_bench/static/app.js +2939 -0
  25. canopen_bench/static/fonts/IBMPlexMono-Medium.woff2 +0 -0
  26. canopen_bench/static/fonts/IBMPlexMono-Regular.woff2 +0 -0
  27. canopen_bench/static/fonts/IBMPlexMono-SemiBold.woff2 +0 -0
  28. canopen_bench/static/fonts/IBMPlexSans-Bold.woff2 +0 -0
  29. canopen_bench/static/fonts/IBMPlexSans-Medium.woff2 +0 -0
  30. canopen_bench/static/fonts/IBMPlexSans-Regular.woff2 +0 -0
  31. canopen_bench/static/fonts/IBMPlexSans-SemiBold.woff2 +0 -0
  32. canopen_bench/static/fonts/LICENSE.txt +93 -0
  33. canopen_bench/static/index.html +14 -0
  34. canopen_bench/static/styles.css +75 -0
  35. canopen_bench/static/vendor/preact-htm.module.js +16 -0
  36. canopen_bench/symbols.py +317 -0
  37. canopen_bench/testReportStyle.css +57 -0
  38. canopen_bench/testcases.py +700 -0
  39. canopen_bench/usersettings.py +58 -0
  40. canopen_bench/values.py +353 -0
  41. canopen_bench-1.0.170.dist-info/METADATA +243 -0
  42. canopen_bench-1.0.170.dist-info/RECORD +47 -0
  43. canopen_bench-1.0.170.dist-info/WHEEL +5 -0
  44. canopen_bench-1.0.170.dist-info/entry_points.txt +2 -0
  45. canopen_bench-1.0.170.dist-info/licenses/LICENSE +21 -0
  46. canopen_bench-1.0.170.dist-info/licenses/THIRD-PARTY-NOTICES.md +117 -0
  47. 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
@@ -0,0 +1,3 @@
1
+ from .interface import BusInterface, FoundDevice, Frame, SdoResult
2
+
3
+ __all__ = ["BusInterface", "FoundDevice", "Frame", "SdoResult"]