From 1b60775419e85e13ccd4901b5b6ca8330a52361b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:52:35 +0000 Subject: [PATCH 1/4] W5: scope captures of several nodes on one trigger - ScopeClient.multi_capture: sets up every node, arms the other nodes on a condition that never holds and the trigger node on the user's condition; the others trigger on its cymon.TriggerEvent. trigger_ports checks that the nodes publish and subscribe the trigger on one subject; set_trigger_ports writes it (default 220). - API: GET/POST /api/scope/trigger (the setup stores and restarts the nodes, operator only, refused while a node is engaged) and POST /api/scope/capture for several nodes; captures during a run are stored in it, one CSV per node. - Scope view: nodes with their variables, the trigger node, the shared trigger check with "Set up the shared trigger", all channels in one plot on one time axis (each node on its own trigger sample), CSV export. - Demo device: trigger ports, publishes its trigger event and triggers on the events of others. - SimInstance.start after a stop: new pseudo terminals are waited for. - Tests: two demo devices (core, API with a run, browser) and two native_sim drives joined by an slcan bridge, with the trigger delay of the second drive measured from the trigger events (3 to 20 ms over the Python bridge). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WEh6hu8r51ZDjgfECmELvM --- README.md | 9 +- docs/web_plan.md | 4 +- src/cypilot/core/scope.py | 132 ++++++++++++++++++++++++-- src/cypilot/demo.py | 46 ++++++++- src/cypilot/simulation.py | 3 + src/cypilot/web/app.py | 64 +++++++++++++ src/cypilot/web/static/app.js | 113 +++++++++++++++------- src/cypilot/web/static/index.html | 45 ++++++--- tests/browser/test_scope_ui.py | 47 ++++++++++ tests/sim/test_scope_sim.py | 150 ++++++++++++++++++++++++++++++ tests/unit/test_scope.py | 38 ++++++++ tests/unit/test_web.py | 48 ++++++++++ 12 files changed, 641 insertions(+), 58 deletions(-) create mode 100644 tests/browser/test_scope_ui.py create mode 100644 tests/sim/test_scope_sim.py diff --git a/README.md b/README.md index e41fbf1..d64e0d7 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,14 @@ bottom bar): enabled without a click. - **Plots:** the published subjects of any nodes on one time axis, a window, pause, CSV export; commands, stops and register writes are markers. -- **Scope:** cymon captures with trigger and pre-trigger, CSV export. +- **Scope:** cymon captures with trigger and pre-trigger on one node or on + several at once: one node triggers on its condition, and the others trigger + on its `cymon.TriggerEvent` (about 1 to 2 ms later on the bus). For that the + nodes need the shared trigger subject (`uavcan.pub.cymon_trigger.id` and + `uavcan.sub.cymon_trigger.id`, disabled by default on UNIMOC); the view sets + it (subject 220), stores it and restarts the nodes. All channels in one plot, + each node aligned on its own trigger sample; CSV export; captures taken + during a run are stored in the run. - **Simulation:** the virtual clock of a simulated drive (pause, run, advance, reset) and its plant inputs (`unimoc.sim.*`). - **Runs:** see below. diff --git a/docs/web_plan.md b/docs/web_plan.md index 00152d2..26afe7d 100644 --- a/docs/web_plan.md +++ b/docs/web_plan.md @@ -1,6 +1,6 @@ # One app: cypilot with the cymon features and a web front end -Status (2026-10-25): **decided; W1 to W4 done** (section 8). It changes decisions +Status (2026-10-25): **decided; W1 to W5 done** (section 8). It changes decisions of [plan.md](plan.md) (section 2: the Qt GUI, and cymon as a separate tool the GUI depends on) and replaces its phases G5 to G7 by the phases W0 to W7 below. The firmware contract (plan sections 5.1, 6, 7.1, 9) stays as it is. @@ -221,7 +221,7 @@ connected, and a browser that connects mid-run sees it. | W2 | **Done 2026-10-25 (the page is a placeholder until W3):** **Web server:** `cypilot.web` (FastAPI, event hub, static files), cymon's API plus control, profiles, simulation, plots; auth (PIN or token) and the control lease | API tests (httpx) against the simulation, lease loss stops a drive | | W3 | **Done 2026-10-25 (replay of recordings moves to W4):** **Web UI** with the G2 to G4 functions: bus, nodes with chips, registers with the description file, control, plots, simulation, log; responsive for the phone; then the **Qt GUI is removed** | Browser tests with Playwright (headless Chromium) against the simulation, also at phone size | | W4 | **Done 2026-10-25 (the telemetry rate stays as configured):** **Run log:** runs started and stopped by the user, raw frames plus decoded channels plus `run.json`, list, download and delete in the web app, replay, free-space limit | A run of an engage, a setpoint step and a trip in the simulation, downloaded, replayed and deleted from the UI | -| W5 | **Scope** (old G5): cymon captures, several nodes on one shared trigger, overlay, stored in the run | Capture of two simulated drives on one trigger | +| W5 | **Done 2026-10-25:** **Scope** (old G5): cymon captures, several nodes on one shared trigger, overlay, stored in the run | Capture of two simulated drives on one trigger | | W6 | **Raspberry Pi:** setup script, systemd service, access point, CAN FD HAT; load test (four drives, all subjects recorded, two browsers) | Runs a day on the cart bench with the drives | | W7 | **Packaging and the end of cymon:** wheel, Windows and Linux executables without Qt, README workflows; `Tecnologic/cymon` archived | Install on a clean Windows machine and a fresh Pi | diff --git a/src/cypilot/core/scope.py b/src/cypilot/core/scope.py index 68225ae..f16862d 100644 --- a/src/cypilot/core/scope.py +++ b/src/cypilot/core/scope.py @@ -6,12 +6,21 @@ service whose ``.type`` is ``cymon..0.1`` (``docs/plan.md``, section 8.2). A capture is set up, armed, polled until complete and read out page by page; the time axis is relative to the trigger sample. + +**Several nodes on one trigger** (:meth:`ScopeClient.multi_capture`): a node +publishes ``cymon.TriggerEvent`` when its capture triggers, and an armed node +that subscribes to it triggers at its next sample. With the trigger ports +(``uavcan.pub.cymon_trigger.id``, ``uavcan.sub.cymon_trigger.id``) on one +subject on every node, the other nodes are armed on a condition that never +holds, then the trigger node on the user's condition. Their trigger samples are +late by the time the event takes over the bus (about 1 to 2 ms on UNIMOC). """ from __future__ import annotations import asyncio import contextlib +import dataclasses from dataclasses import dataclass from typing import Any @@ -39,6 +48,11 @@ 9: "bad config", } _COMPLETE = 4 +TRIGGER_PORTS = ("uavcan.pub.cymon_trigger.id", "uavcan.sub.cymon_trigger.id") +DEFAULT_TRIGGER_SUBJECT = 220 +"""The subject of the shared trigger that cypilot offers to set (free on UNIMOC).""" +_NEVER = 3.0e38 +"""A level no variable reaches: the followers fire only on the trigger event.""" _PAGE = 255 """Frames asked for per ReadSamples request; the node answers with as many as fit.""" @@ -79,6 +93,7 @@ def __init__(self, session: Any, events: EventHub | None = None) -> None: self._events = events or EventHub() self._service_ids: dict[int, dict[str, int]] = {} self._clients: dict[tuple[int, str], Any] = {} + self._periods: dict[int, float] = {} async def discover(self, node_id: int, refresh: bool = False) -> dict[str, int]: """The service-IDs of the cymon services by key; empty when the node has none.""" @@ -183,10 +198,109 @@ async def disarm(self, node_id: int) -> None: async def capture(self, node_id: int, request: CaptureRequest) -> Capture: """Set up, arm, wait for completion, read out; emits ``capture`` events on the way.""" - import cymon + if request.trigger_mode not in TRIGGER_MODES: + raise ScopeError(f"unknown trigger mode {request.trigger_mode!r}") + await self._setup(node_id, request) + await self._arm(node_id, request) + loop = asyncio.get_running_loop() + await self._wait(node_id, loop.time() + request.timeout_s, request.timeout_s) + return await self.read(node_id) + async def multi_capture( + self, channels: dict[int, list[int]], trigger_node: int, request: CaptureRequest + ) -> dict[int, Capture]: + """One capture on several nodes, triggered by ``trigger_node`` on the condition of ``request``. + + ``channels``: the variable IDs per node (``request.channels`` is ignored); the + period, the length and the pre-trigger part are those of ``request`` on every node. + The nodes need the shared trigger subject (:meth:`trigger_ports`). + """ + if trigger_node not in channels: + raise ScopeError(f"the trigger node {trigger_node} takes part in the capture") if request.trigger_mode not in TRIGGER_MODES: raise ScopeError(f"unknown trigger mode {request.trigger_mode!r}") + if len(channels) > 1: + state = await self.trigger_ports(list(channels)) + if not state["shared"]: + raise ScopeError(f"the nodes have no shared trigger subject: {state['problem']}") + followers = [node for node in channels if node != trigger_node] + for node, node_channels in channels.items(): + await self._setup(node, dataclasses.replace(request, channels=node_channels)) + armed: list[int] = [] + try: + for node in followers: + never = dataclasses.replace( + request, trigger_mode="rising", trigger_source=channels[node][0], trigger_level=_NEVER + ) + await self._arm(node, never) + armed.append(node) + await self._arm(trigger_node, request) + armed.append(trigger_node) + deadline = asyncio.get_running_loop().time() + request.timeout_s + for node in [trigger_node, *followers]: + await self._wait(node, deadline, request.timeout_s) + except BaseException: + for node in armed: + with contextlib.suppress(ScopeError): + await self.disarm(node) + raise + return {node: await self.read(node) for node in [trigger_node, *followers]} + + async def trigger_ports(self, node_ids: list[int]) -> dict[str, Any]: + """The trigger subjects of the nodes and whether they share one (published and subscribed).""" + nodes: dict[int, dict[str, int | None]] = {} + for node_id in node_ids: + client = self._session.registers(node_id) + try: + registers = await client.scan(list(TRIGGER_PORTS)) + except RegisterError as error: + raise ScopeError(f"cannot read the trigger ports of node {node_id}: {error}") from error + finally: + client.close() + values: dict[str, int | None] = {"pub": None, "sub": None} + for register in registers: + content = register.value.content + if register.value.kind != "empty" and isinstance(content, list) and content: + values["pub" if ".pub." in register.name else "sub"] = int(content[0]) + nodes[node_id] = values + problem = "" + subjects = {v["pub"] for v in nodes.values()} | {v["sub"] for v in nodes.values()} + missing = [n for n, v in nodes.items() if v["pub"] is None or v["sub"] is None] + if missing: + problem = f"node {missing[0]} has no trigger ports" + elif len(subjects) != 1 or next(iter(subjects)) is None or int(next(iter(subjects)) or 0) > 8191: + problem = "the trigger ports differ or are disabled: " + ", ".join( + f"{n} pub {v['pub']} sub {v['sub']}" for n, v in nodes.items() + ) + shared = next(iter(subjects)) if not problem else None + return { + "nodes": {str(n): v for n, v in nodes.items()}, + "shared": not problem, + "subject": shared, + "problem": problem, + } + + async def set_trigger_ports(self, node_ids: list[int], subject: int = DEFAULT_TRIGGER_SUBJECT) -> None: + """Writes the shared trigger subject into the nodes; it takes effect after a store and a restart.""" + from cypilot.core.registers import RegisterValue + + if not 0 <= subject <= 8191: + raise ScopeError(f"a subject-ID is 0 .. 8191, not {subject}") + for node_id in node_ids: + client = self._session.registers(node_id) + try: + for name in TRIGGER_PORTS: + result = await client.write(name, RegisterValue("natural16", [subject])) + if not result.accepted: + raise ScopeError(f"node {node_id} refused {name} = {subject}") + except RegisterError as error: + raise ScopeError(f"cannot write the trigger ports of node {node_id}: {error}") from error + finally: + client.close() + + async def _setup(self, node_id: int, request: CaptureRequest) -> None: + import cymon + setup = await self._call( node_id, "setup", @@ -198,6 +312,11 @@ async def capture(self, node_id: int, request: CaptureRequest) -> Capture: ), ) _check(setup.result.value, "SetupCapture") + self._periods[node_id] = float(setup.actual_sample_period_us) + + async def _arm(self, node_id: int, request: CaptureRequest) -> None: + import cymon + arm = await self._call( node_id, "arm", @@ -210,20 +329,16 @@ async def capture(self, node_id: int, request: CaptureRequest) -> Capture: ), ) _check(arm.result.value, "ArmTrigger") - self._events.emit( - "capture", node_id=node_id, state="armed", period_us=float(setup.actual_sample_period_us) - ) + self._events.emit("capture", node_id=node_id, state="armed", period_us=self._periods.get(node_id)) + async def _wait(self, node_id: int, deadline: float, timeout_s: float) -> None: loop = asyncio.get_running_loop() - deadline = loop.time() + request.timeout_s state = "armed" while state != "complete": if loop.time() > deadline: with contextlib.suppress(ScopeError): await self.disarm(node_id) - raise ScopeError( - f"capture on node {node_id} did not complete within {request.timeout_s} s ({state})" - ) + raise ScopeError(f"capture on node {node_id} did not complete within {timeout_s} s ({state})") await asyncio.sleep(0.05) new_state = (await self.status(node_id))["state"] if new_state != state: @@ -231,7 +346,6 @@ async def capture(self, node_id: int, request: CaptureRequest) -> Capture: self._events.emit("capture", node_id=node_id, state=state) if state in ("idle", "configured"): raise ScopeError(f"capture on node {node_id} was disarmed") - return await self.read(node_id) async def read(self, node_id: int) -> Capture: """Reads a complete capture again.""" diff --git a/src/cypilot/demo.py b/src/cypilot/demo.py index aaf72ae..3ad78d2 100644 --- a/src/cypilot/demo.py +++ b/src/cypilot/demo.py @@ -35,7 +35,7 @@ import uavcan.diagnostic import uavcan.node import uavcan.primitive.array -from pycyphal.application.register import Bit, Natural16, Real32, Value +from pycyphal.application.register import Bit, Natural16, Real32, String, Value from cypilot import __version__ @@ -86,6 +86,8 @@ def __init__( self.prev = 0.0 self.awaiting_low = False self.trigger_requested = False + self.trigger_event: tuple[int, int, float] | None = None + """Source, mode and value of the last trigger, until published as ``cymon.TriggerEvent``.""" def setup( self, channels: list[int], period_us: float, num_samples: int, pretrigger: int @@ -122,6 +124,7 @@ def arm(self, arm: bool, source: int, mode: int, level: float, band: float) -> i self.prev = self.read(source) if source < len(self.variables) else 0.0 self.awaiting_low = mode == HYSTERESIS and self.prev >= level self.pre, self.post, self.counter, self.trigger_requested = [], [], 0, False + self.trigger_event = None self.state = ARMED return 0 @@ -150,6 +153,7 @@ def tick(self) -> None: filled = len(self.pre) >= self.pretrigger if filled and (self._fires(value) or self.trigger_requested): self.state = TRIGGERED + self.trigger_event = (self.source, self.mode, value) self.prev = value frame = [self.read(c) for c in self.channels] if self.state == ARMED: @@ -172,7 +176,9 @@ def __init__( transport: pycyphal.transport.Transport, name: str = "org.tecnologic.cypilot.demo", tick_period_us: float = 100.0, + trigger_subject: int = 65535, ) -> None: + """``trigger_subject``: the ports of ``cymon.TriggerEvent`` (65535: disabled, as on UNIMOC).""" env = {f"UAVCAN__SRV__{k.upper()}__ID": str(v) for k, v in SERVICE_IDS.items()} env["UAVCAN__PUB__TELEMETRY__ID"] = str(TELEMETRY_SUBJECT) env["UAVCAN__SUB__READINESS__ID"] = str(READINESS_SUBJECT) @@ -199,6 +205,11 @@ def __init__( registers["demo.engaged"] = lambda: Value(natural16=Natural16([int(self.engaged)])) registers["demo.store_count"] = lambda: Value(natural16=Natural16([self.store_count])) registers["unimoc.settings.pending"] = lambda: Value(bit=Bit([False])) + for name in ("uavcan.pub.cymon_trigger", "uavcan.sub.cymon_trigger"): + registers[f"{name}.id"] = Value(natural16=Natural16([trigger_subject])) + registers[f"{name}.type"] = Value(string=String("cymon.TriggerEvent.0.1")) + self._trigger_pub: Any = None + self._trigger_sub: Any = None variables = [("sine", "A"), ("cosine", "A"), ("ramp", ""), ("engaged", "")] self.capture = CaptureEngine(variables, self._read_variable, tick_period_us) @@ -232,8 +243,38 @@ def _read_variable(self, index: int) -> float: float(self.engaged), ][index] + def _apply_trigger_ports(self) -> None: + """(Re)creates the trigger ports from their registers, as UNIMOC does at start.""" + for port in (self._trigger_pub, self._trigger_sub): + if port is not None: + port.close() + self._trigger_pub = self._trigger_sub = None + registry = self.node.registry + pub = int(registry["uavcan.pub.cymon_trigger.id"]) + sub = int(registry["uavcan.sub.cymon_trigger.id"]) + if pub <= 8191: + self._trigger_pub = self.node.presentation.make_publisher(cymon_dsdl.TriggerEvent_0_1, pub) + if sub <= 8191: + self._trigger_sub = self.node.presentation.make_subscriber(cymon_dsdl.TriggerEvent_0_1, sub) + self._trigger_sub.receive_in_background(self._on_trigger_event) + + async def _on_trigger_event(self, _: Any, transfer: Any) -> None: + if transfer.source_node_id != self.node.id and self.capture.state == ARMED: + self.capture.trigger_requested = True + + async def _publish_trigger(self) -> None: + event, self.capture.trigger_event = self.capture.trigger_event, None + if event is not None and self._trigger_pub is not None: + source, mode, value = event + await self._trigger_pub.publish( + cymon_dsdl.TriggerEvent_0_1( + source_variable_id=source, mode=cymon_dsdl.TriggerMode_0_1(mode), trigger_value=value + ) + ) + def start(self) -> None: self.node.start() + self._apply_trigger_ports() loop = asyncio.get_running_loop() self._tasks = [loop.create_task(self._sampling()), loop.create_task(self._publishing())] @@ -253,6 +294,8 @@ async def _sampling(self) -> None: for _ in range(ticks): self.t += self._tick_period_s self.capture.tick() + if self.capture.trigger_event is not None: + await self._publish_trigger() async def _publishing(self) -> None: while True: @@ -304,6 +347,7 @@ async def _on_command(self, request: Any, _: Any) -> Any: if request.command == 65535: self.estopped = False self._update_status() + self._apply_trigger_ports() return Status(status=Status.STATUS_SUCCESS) return Status(status=Status.STATUS_BAD_COMMAND) diff --git a/src/cypilot/simulation.py b/src/cypilot/simulation.py index e972135..e340a97 100644 --- a/src/cypilot/simulation.py +++ b/src/cypilot/simulation.py @@ -133,6 +133,9 @@ def slcan_interfaces(self) -> list[str]: return [f"slcan:{path}" for name, path in sorted(self.ptys.items()) if name != "uart"] def start(self, timeout: float = 15.0) -> None: + """Starts the simulation (again after :meth:`stop`: new pseudo terminals, the same flash).""" + self.ptys.clear() + self._started.clear() command = [str(self.executable), f"-flash={self.flash}", "-no-color"] if self.node_id is not None: command.append(f"-node-id={self.node_id}") diff --git a/src/cypilot/web/app.py b/src/cypilot/web/app.py index 5a26cf1..1b5dbc2 100644 --- a/src/cypilot/web/app.py +++ b/src/cypilot/web/app.py @@ -130,6 +130,25 @@ class PublishRequest(BaseModel): priority: str = "nominal" +class MultiCaptureBody(BaseModel): + nodes: dict[int, list[int]] = Field(min_length=1, max_length=16) + """Variable IDs per node.""" + trigger_node: int + sample_period_us: float = Field(gt=0) + num_samples: int = Field(ge=1, le=65535) + pretrigger_samples: int = Field(0, ge=0, le=65534) + trigger_mode: str = "immediate" + trigger_source: int = Field(0, ge=0, le=255) + trigger_level: float = 0.0 + hysteresis_band: float = 0.0 + timeout_s: float = Field(30.0, gt=0, le=3600) + + +class TriggerSetup(BaseModel): + nodes: list[int] = Field(min_length=1, max_length=16) + subject: int = Field(220, ge=0, le=8191) + + class RunStart(BaseModel): name: str = Field("", max_length=200) notes: str = Field("", max_length=20000) @@ -591,6 +610,51 @@ async def scope_capture(node_id: int, body: CaptureBody) -> dict[str, Any]: hub.recording.run.add_capture(node_id, capture, [names.get(c, f"ch{c}") for c in body.channels]) return vars(capture) + @api.get("/scope/trigger") + async def scope_trigger(nodes: str) -> dict[str, Any]: + """Whether the nodes (``nodes=10,11``) share the trigger subject.""" + ids = [int(n) for n in nodes.split(",") if n.strip()] + return dict(await scope().trigger_ports(ids)) + + @api.post("/scope/trigger") + async def setup_scope_trigger(body: TriggerSetup, request: Request) -> dict[str, Any]: + """Writes the shared trigger subject, stores it and restarts the nodes (ports apply at start).""" + controller = hub.require_operator(client_of(request)) + engaged = sorted(set(body.nodes) & controller.engaged) + if engaged: + raise HTTPException(409, f"disable node {engaged[0]} first: it restarts") + for node_id in body.nodes: + known(node_id) + await scope().set_trigger_ports(body.nodes, body.subject) + session = hub.require_session() + for node_id in body.nodes: + stored = await session.execute_command(node_id, COMMANDS["store"]) + if stored != 0: + raise HTTPException( + 409, f"node {node_id} did not store: {COMMAND_STATUS.get(stored, stored)}" + ) + hub.events.emit("command", node_id=node_id, command=COMMANDS["store"], status="success") + for node_id in body.nodes: + await session.execute_command(node_id, COMMANDS["restart"]) + hub.events.emit("command", node_id=node_id, command=COMMANDS["restart"], status="success") + scope().forget(node_id) + return {"subject": body.subject, "nodes": body.nodes} + + @api.post("/scope/capture") + async def scope_multi_capture(body: MultiCaptureBody) -> dict[str, Any]: + """One capture on several nodes on the trigger of one of them.""" + for node_id in body.nodes: + known(node_id) + fields = body.model_dump(exclude={"nodes", "trigger_node"}) + request = CaptureRequest(channels=[], **fields) + captures = await scope().multi_capture(body.nodes, body.trigger_node, request) + if hub.recording is not None: + for node_id, capture in captures.items(): + names = {v["id"]: v["name"] for v in (await scope().info(node_id))["variables"]} + labels = [names.get(c, f"ch{c}") for c in body.nodes[node_id]] + hub.recording.run.add_capture(node_id, capture, labels) + return {"trigger_node": body.trigger_node, "captures": {str(n): vars(c) for n, c in captures.items()}} + @api.post("/nodes/{node_id}/scope/read") async def scope_read(node_id: int) -> dict[str, Any]: return vars(await scope().read(node_id)) diff --git a/src/cypilot/web/static/app.js b/src/cypilot/web/static/app.js index 3c50b8a..a144459 100644 --- a/src/cypilot/web/static/app.js +++ b/src/cypilot/web/static/app.js @@ -132,8 +132,9 @@ window.cypilot = function () { pubForm: { subject_id: null, type: '', rate_hz: 10, lease_s: 1, value: '{}' }, publications: [], scope: { - info: null, error: '', channels: [], period: 100, samples: 200, pretrigger: 20, - mode: 'immediate', source: 0, level: 0, band: 0, busy: false, state: '', result: null, + infos: {}, picked: {}, trigger_node: 0, shared: null, loading: false, periodSet: false, + period: 100, samples: 200, pretrigger: 20, mode: 'immediate', source: 0, level: 0, band: 0, + busy: false, state: '', result: null, }, sim: { status: null, error: '', cycles: 16000 }, log: [], @@ -205,7 +206,7 @@ window.cypilot = function () { const newConnection = status.connected && old?.connection !== status.connection; if (!status.connected || newConnection) { this.nodes = []; this.registers = []; this.target = null; this.publications = []; - this.plotSources = []; this.dropAllChannels(); this.scope.info = null; this.sim.status = null; + this.plotSources = []; this.dropAllChannels(); this.scope.infos = {}; this.scope.picked = {}; this.sim.status = null; } if (newConnection) { try { @@ -278,7 +279,7 @@ window.cypilot = function () { if (this.view === 'registers' && !this.registers.length) this.loadRegisters(); if (this.view === 'control' && !this.target) this.loadTarget(); if (this.view === 'plots' && !this.plotSources.length) this.loadSources(); - if (this.view === 'scope' && !this.scope.info) this.loadScope(); + if (this.view === 'scope' && !Object.keys(this.scope.infos).length) this.loadScope(); if (this.view === 'sim') { this.loadSim(); if (!this.registers.length) this.loadRegisters(); } if (this.view === 'plots') this.redraw(); if (this.view === 'runs') this.loadRuns(); @@ -368,7 +369,7 @@ window.cypilot = function () { if (id === this.selected) return; this.selected = id; this.registers = []; this.target = null; this.targetError = ''; this.plotSources = []; - this.scope.info = null; this.scope.result = null; this.sim.status = null; this.sim.error = ''; + this.sim.status = null; this.sim.error = ''; this.$nextTick(() => this.onView()); }, async command(name) { @@ -637,59 +638,105 @@ window.cypilot = function () { async loadScope() { const sc = this.scope; - sc.info = null; sc.error = ''; sc.channels = []; - if (this.selected === null) return; - try { - sc.info = await api('GET', `/api/nodes/${this.selected}/scope?refresh=true`); - const tick = sc.info.buffer.tick_period_us; - if (tick > 0) sc.period = tick; - } catch (e) { sc.error = e.message; } + sc.loading = true; + const infos = {}; + for (const n of this.nodes) { + try { infos[n.node_id] = await api('GET', `/api/nodes/${n.node_id}/scope?refresh=true`); } catch { /* no cymon */ } + } + sc.infos = infos; + for (const id of Object.keys(sc.picked)) if (!infos[id]) delete sc.picked[id]; + if (!infos[sc.trigger_node]) sc.trigger_node = Number(Object.keys(infos)[0] ?? 0); + const tick = infos[sc.trigger_node]?.buffer.tick_period_us; + if (tick > 0 && !sc.periodSet) sc.period = tick; + sc.loading = false; + await this.checkTrigger(); + }, + scopeNodes() { return Object.keys(this.scope.infos).map(Number).sort((a, b) => a - b); }, + picked(node) { return this.scope.picked[node] ?? []; }, + participants() { return this.scopeNodes().filter(n => this.picked(n).length); }, + togglePick(node, id) { + const ch = this.picked(node); + this.scope.picked[node] = ch.includes(id) ? ch.filter(x => x !== id) : [...ch, id]; + this.checkTrigger(); }, maxSamples() { - if (!this.scope.info) return 0; - return Math.floor(this.scope.info.buffer.max_frames / Math.max(1, this.scope.channels.length)); + const nodes = this.participants(); + if (!nodes.length) return 0; + return Math.min(...nodes.map(n => Math.floor(this.scope.infos[n].buffer.max_frames / this.picked(n).length))); + }, + async checkTrigger() { + const nodes = this.participants(); + this.scope.shared = null; + if (nodes.length < 2) return; + try { this.scope.shared = await api('GET', `/api/scope/trigger?nodes=${nodes.join(',')}`); } catch (e) { this.fail(e); } + }, + async setupTrigger() { + const nodes = this.participants(); + if (!confirm(`Set the shared trigger subject on nodes ${nodes.join(', ')}, store and restart them?`)) return; + this.busy = 'setting up the shared trigger ...'; + try { + await api('POST', '/api/scope/trigger', { nodes }); + await new Promise(resolve => setTimeout(resolve, 3000)); // the nodes restart + await this.loadScope(); + } catch (e) { this.fail(e); } + this.busy = ''; }, - toggleScopeChannel(id) { - const ch = this.scope.channels; - this.scope.channels = ch.includes(id) ? ch.filter(x => x !== id) : [...ch, id]; + canCapture() { + const nodes = this.participants(); + return nodes.length > 0 && nodes.includes(Number(this.scope.trigger_node)) && (nodes.length === 1 || this.scope.shared?.shared); }, async capture() { const sc = this.scope; sc.busy = true; sc.state = 'setting up'; try { - const res = await api('POST', `/api/nodes/${this.selected}/scope/capture`, { - channels: sc.channels, sample_period_us: Number(sc.period), num_samples: Number(sc.samples), + const nodes = {}; + for (const n of this.participants()) nodes[n] = this.picked(n); + const res = await api('POST', '/api/scope/capture', { + nodes, trigger_node: Number(sc.trigger_node), sample_period_us: Number(sc.period), num_samples: Number(sc.samples), pretrigger_samples: Number(sc.pretrigger), trigger_mode: sc.mode, trigger_source: Number(sc.source), trigger_level: Number(sc.level), hysteresis_band: Number(sc.band), }); - sc.result = res; - sc.state = `${res.t.length} samples at ${res.sample_period_us} µs`; + sc.result = { ...res, picked: JSON.parse(JSON.stringify(nodes)) }; + const first = res.captures[res.trigger_node]; + sc.state = `${first.t.length} samples at ${first.sample_period_us} µs on ${Object.keys(res.captures).length} node(s)`; this.$nextTick(() => this.drawScope()); } catch (e) { this.fail(e); sc.state = 'error'; } sc.busy = false; }, - async disarm() { await api('POST', `/api/nodes/${this.selected}/scope/disarm`).catch(e => this.fail(e)); }, + async disarm() { + for (const n of this.participants()) await api('POST', `/api/nodes/${n}/scope/disarm`).catch(e => this.fail(e)); + }, scopeLabels() { - const vars = this.scope.info?.variables ?? []; - return this.scope.result.channels.map((_, i) => { - const id = this.scope.channels[i]; - const v = vars.find(x => x.id === id); - return v ? `${v.name}${v.unit ? ' [' + v.unit + ']' : ''}` : `#${id}`; - }); + const labels = []; + for (const [node, capture] of Object.entries(this.scope.result.captures)) { + const vars = this.scope.infos[node]?.variables ?? []; + capture.channels.forEach((_, i) => { + const id = this.scope.result.picked[node][i]; + const v = vars.find(x => x.id === id); + labels.push(`${node} ${v ? v.name + (v.unit ? ' [' + v.unit + ']' : '') : '#' + id}`); + }); + } + return labels; + }, + scopeData() { + // Each node on its own trigger sample (t = 0); uPlot.join puts the nodes on one time axis. + const tables = Object.values(this.scope.result.captures).map(c => [c.t, ...c.channels]); + return tables.length === 1 ? tables[0] : uPlot.join(tables); }, drawScope() { const el = document.getElementById('scope-plot'); if (!el || !this.scope.result) return; if (plots.scope) plots.scope.destroy(); plots.scope = makePlot(el, this.scopeLabels(), window.innerHeight < 700 ? 260 : 420); - plots.scope.setData([this.scope.result.t, ...this.scope.result.channels]); + plots.scope.setData(this.scopeData()); }, exportScope() { - const res = this.scope.result; - const header = ['t_s', ...this.scopeLabels()].join(','); - const rows = res.t.map((t, i) => [t, ...res.channels.map(c => c[i])].join(',')); - download(`cypilot_scope_node${this.selected}_${stamp()}.csv`, header + '\n' + rows.join('\n') + '\n', 'text/csv'); + const data = this.scopeData(); + const header = ['t_s', ...this.scopeLabels().map(l => l.replace(/[ ,]/g, '_'))].join(','); + const rows = Array.from(data[0]).map((t, i) => [t, ...data.slice(1).map(col => col[i] ?? '')].join(',')); + const nodes = Object.keys(this.scope.result.captures).join('-'); + download(`cypilot_scope_nodes${nodes}_${stamp()}.csv`, header + '\n' + rows.join('\n') + '\n', 'text/csv'); }, // --- Simulation --------------------------------------------------------------- diff --git a/src/cypilot/web/static/index.html b/src/cypilot/web/static/index.html index afee117..abc2561 100644 --- a/src/cypilot/web/static/index.html +++ b/src/cypilot/web/static/index.html @@ -58,7 +58,7 @@

cypilot

-
+