Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion ros2-poc/nodes/bridge_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ def load_bridge_module(path: Path = BRIDGE_PY):
SCANIMAGE_CMD_TOPIC = "/scanimage/cmd"
SCANIMAGE_REPLY_TOPIC = "/scanimage/reply"
SCANIMAGE_STATUS_TOPIC = "/scanimage/status"
# The SiPM controller (nodes/sipm_node.py, read-only): its 1 Hz status carries bias/enable/offsets/
# temperature, the detector settings ScanImage's TIFF header does not have.
SIPM_CMD_TOPIC = "/sipm/cmd"
SIPM_REPLY_TOPIC = "/sipm/reply"
SIPM_STATUS_TOPIC = "/sipm/status"
# ScanImage's ZeroMQ frame stream as topics (nodes/scanimage_frames_node.py, LAB-303 Block R). STREAMS:
# recorded into the bag as received, like /bias/frames, and never derived into a run-log line (the
# telemetry rule: a string-tagged stream is not a behavior sample). The preview images are bounded at
Expand All @@ -155,6 +160,7 @@ def load_bridge_module(path: Path = BRIDGE_PY):
CONNECTOR_TOPICS = {
"bias": {"cmd": BIAS_CMD_TOPIC, "reply": BIAS_REPLY_TOPIC, "status": BIAS_STATUS_TOPIC},
"scanimage": {"cmd": SCANIMAGE_CMD_TOPIC, "reply": SCANIMAGE_REPLY_TOPIC, "status": SCANIMAGE_STATUS_TOPIC},
"sipm": {"cmd": SIPM_CMD_TOPIC, "reply": SIPM_REPLY_TOPIC, "status": SIPM_STATUS_TOPIC},
}
FRAME_TOPIC = "/arena/frame"
RAW_QOS = QoSProfile(
Expand Down Expand Up @@ -305,6 +311,7 @@ def topics(self) -> dict[str, str]:
SESSION_TOPIC: STRING_TYPE, BROWSER_TOPIC: STRING_TYPE, EVENT_TOPIC: STRING_TYPE,
KINEFLY_TOPIC: STRING_TYPE, BIAS_CMD_TOPIC: STRING_TYPE, BIAS_REPLY_TOPIC: STRING_TYPE, BIAS_STATUS_TOPIC: STRING_TYPE, BIAS_FRAMES_TOPIC: STRING_TYPE,
SCANIMAGE_CMD_TOPIC: STRING_TYPE, SCANIMAGE_REPLY_TOPIC: STRING_TYPE, SCANIMAGE_STATUS_TOPIC: STRING_TYPE,
SIPM_CMD_TOPIC: STRING_TYPE, SIPM_REPLY_TOPIC: STRING_TYPE, SIPM_STATUS_TOPIC: STRING_TYPE,
**STREAM_TOPICS,
}
if self._typed:
Expand Down Expand Up @@ -859,7 +866,8 @@ def on_log_open() -> None:
"record": {"bag": log.bag_name, "profile": log._profile, "typed": typed},
"topics": {"fictrac": FICTRAC_FRAME_TOPIC if typed else FICTRAC_TOPIC, "kinefly": KINEFLY_TOPIC,
"bias": [BIAS_CMD_TOPIC, BIAS_REPLY_TOPIC, BIAS_STATUS_TOPIC, BIAS_FRAMES_TOPIC],
"scanimage": [SCANIMAGE_CMD_TOPIC, SCANIMAGE_REPLY_TOPIC, SCANIMAGE_STATUS_TOPIC], "frame": FRAME_TOPIC,
"scanimage": [SCANIMAGE_CMD_TOPIC, SCANIMAGE_REPLY_TOPIC, SCANIMAGE_STATUS_TOPIC],
"sipm": [SIPM_CMD_TOPIC, SIPM_REPLY_TOPIC, SIPM_STATUS_TOPIC], "frame": FRAME_TOPIC,
"browser": BROWSER_TOPIC, "event": EVENT_TOPIC, "session": SESSION_TOPIC},
}
)
Expand Down
6 changes: 5 additions & 1 deletion ros2-poc/nodes/mcap_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
/kinefly/raw the adapter envelope (DERIVED stream) → ["kf", ms, seq, t_src, L, R, head, abd]
/bias/reply, /bias/status the BIAS node's JSON (DERIVED) → {"type":"connector","src":"bias",…}
/scanimage/reply, /scanimage/status the ScanImage node's JSON (DERIVED) → {"type":"connector","src":"scanimage",…}
/sipm/reply, /sipm/status the SiPM node's JSON (DERIVED) → {"type":"connector","src":"sipm",…}
/fictrac/raw | /fictrac/frame the adapter's FicTrac records → used only for level `full`

Clock and order rules (comparison.md §4, B1/B2):
Expand Down Expand Up @@ -76,11 +77,14 @@
TOPIC_BIAS_STATUS = "/bias/status"
TOPIC_SCANIMAGE_REPLY = "/scanimage/reply"
TOPIC_SCANIMAGE_STATUS = "/scanimage/status"
TOPIC_SIPM_REPLY = "/sipm/reply"
TOPIC_SIPM_STATUS = "/sipm/status"
# Connector device topics → (connector id, event kind): their JSON becomes a DERIVED
# {"type":"connector","src":<id>,"kind":…} line (mirror of bridge_node.CONNECTOR_TOPICS).
CONNECTOR_TOPIC_SRC = {
TOPIC_BIAS_REPLY: ("bias", "reply"), TOPIC_BIAS_STATUS: ("bias", "status"),
TOPIC_SCANIMAGE_REPLY: ("scanimage", "reply"), TOPIC_SCANIMAGE_STATUS: ("scanimage", "status"),
TOPIC_SIPM_REPLY: ("sipm", "reply"), TOPIC_SIPM_STATUS: ("sipm", "status"),
}


Expand Down Expand Up @@ -298,7 +302,7 @@ def fictrac_fields(rec: dict) -> tuple[int, list[float]] | None:

def convert(records: list[dict], level: str | None = None, order: str = "seq", connectors=None) -> tuple[list, dict]:
"""→ (lines as JSON values, report). `level` None = the level the run was opened with."""
stats = {"frames": 0, "browser_lines": 0, "rows": 0, "events": 0, "kf": 0, "bias": 0, "scanimage": 0,
stats = {"frames": 0, "browser_lines": 0, "rows": 0, "events": 0, "kf": 0, "bias": 0, "scanimage": 0, "sipm": 0,
"connector_cmds": 0, "skipped": 0, "unordered_inserts": 0}
opens = [r for r in records if r["topic"] == TOPIC_SESSION]
session_open = None
Expand Down
6 changes: 6 additions & 0 deletions ros2-poc/nodes/rig.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
pixi run rig -- --scanimage # + the ScanImage node (JSON server on this PC); --si-mock for a stand-in
pixi run rig -- --foxglove # + foxglove_bridge (:8766) from the `viz` environment
pixi run rig -- --scanimage-frames # + the ScanImage ZMQ frame-stream node (SUB tcp://127.0.0.1:65179; Bergamo, Block R)
pixi run rig -- --sipm --sipm-args "--dll-dir C:\\…\\Release" # + the read-only SiPM controller node (Bergamo, Block S)

Order: the router first (nodes retry until it answers on 127.0.0.1:7447), then every
adapter/node. Each child's stderr is prefixed with its name. Ctrl-C stops everything in
Expand Down Expand Up @@ -141,6 +142,8 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--scanimage", action="store_true", help="also run the ScanImage node (talks to ScanImage's JSON server, default 127.0.0.1:5555)")
p.add_argument("--scanimage-frames", action="store_true", help="also run the ScanImage frame-stream node (ZMQ SUB on ScanImage's ZMQAddress → /scanimage/frame_meta, frame_stats, preview/ch<id>)")
p.add_argument("--scanimage-frames-args", default="", help="extra args for scanimage_frames_node.py (e.g. --address tcp://127.0.0.1:65179 --preview-hz 5 --dump frames.jsonl)")
p.add_argument("--sipm", action="store_true", help="also run the read-only SiPM controller node (nodes/sipm_node.py; needs --sipm-args \"--dll-dir …\" or \"--fake\")")
p.add_argument("--sipm-args", default="", help="extra args for sipm_node.py (e.g. --dll-dir <ThorSiPM Release folder> --params-file sipm_params.json)")
p.add_argument("--si-mock", action="store_true", help="also run tests/si_json_mock.py (a stand-in for ScanImage's JSON server, port 5555)")
p.add_argument("--sim", action="store_true", help="also run the FicTrac simulator (random walk, 50 Hz)")
p.add_argument("--viz-args", default="", help="extra args for viz_node.py (--deg-per-frame, --frame-dir, --feature-az0, --frames)")
Expand Down Expand Up @@ -212,6 +215,9 @@ def main(argv: list[str] | None = None) -> int:
if args.scanimage_frames:
# Connects (never binds), so no port pre-flight: ScanImage binds at each acquisition start.
nodes.append(Child("si-frame", [py, str(NODES / "scanimage_frames_node.py"), *split_args(args.scanimage_frames_args)]))
if args.sipm:
# Owns the COM port while it runs: ThorSiPM_Control.exe must be closed (one program per port).
nodes.append(Child("sipm", [py, str(NODES / "sipm_node.py"), *split_args(args.sipm_args)]))
bridge = Child("bridge", [py, str(NODES / "bridge_node.py"), *split_args(args.bridge_args)])
nodes.append(bridge)
if foxglove:
Expand Down
100 changes: 100 additions & 0 deletions ros2-poc/nodes/sipm_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""sipm_cmd.py — send one command to sipm_node.py and print its reply (all commands are read-only).

pixi run sipm-cmd -- status
pixi run sipm-cmd -- get --name PARAM_PMT1_ENABLE
pixi run sipm-cmd -- get --id 1201
pixi run sipm-cmd -- info --id 1201
pixi run sipm-cmd -- scan --from 0 --to 2000 --out sipm_scan.json
pixi run sipm-cmd -- disconnect # free the COM port (e.g. to reopen ThorSiPM_Control.exe)
pixi run sipm-cmd -- reconnect

Exit 0 on `ok`, 1 on an error reply, 2 on no reply.
"""

from __future__ import annotations

import argparse
import json
import sys
import threading
import time
import uuid
from pathlib import Path

import rclpy
from rclpy.node import Node
from rclpy.qos import QoSDurabilityPolicy, QoSHistoryPolicy, QoSProfile, QoSReliabilityPolicy
from std_msgs.msg import String

QOS = QoSProfile(
reliability=QoSReliabilityPolicy.RELIABLE,
history=QoSHistoryPolicy.KEEP_LAST,
depth=50,
durability=QoSDurabilityPolicy.VOLATILE,
)


def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("command", help="ping | status | get | info | scan | disconnect | reconnect")
p.add_argument("--id", type=int, help="get/info: parameter ID")
p.add_argument("--name", help="get/info: a --params-file name or a PARAM_* name")
p.add_argument("--from", dest="first", type=int, help="scan: first ID (default 0)")
p.add_argument("--to", dest="last", type=int, help="scan: last ID (default 2000)")
p.add_argument("--timeout", type=float, default=30.0, help="seconds to wait for the reply (default: 30; a full scan takes a while)")
p.add_argument("--json", action="store_true", help="print the raw reply JSON")
p.add_argument("--out", help="also write the reply JSON to this file")
args, ros_args = p.parse_known_args(argv)
params = {k: v for k, v in (("id", args.id), ("name", args.name), ("from", args.first), ("to", args.last)) if v is not None}
cid = str(uuid.uuid4())[:8]

rclpy.init(args=ros_args)
node = Node("sipm_cmd_" + cid)
got: dict = {}
done = threading.Event()

def on_reply(msg: String) -> None:
try:
rep = json.loads(msg.data)
except json.JSONDecodeError:
return
if isinstance(rep, dict) and rep.get("id") == cid:
got.update(rep)
done.set()

node.create_subscription(String, "/sipm/reply", on_reply, QOS)
pub = node.create_publisher(String, "/sipm/cmd", QOS)
time.sleep(0.5) # discovery
m = String()
m.data = json.dumps({"id": cid, "command": args.command, "params": params})
pub.publish(m)
deadline = time.monotonic() + args.timeout
while not done.is_set() and time.monotonic() < deadline:
rclpy.spin_once(node, timeout_sec=0.1)
node.destroy_node()
try:
rclpy.try_shutdown()
except Exception: # rclpy's own SIGINT handler may already have shut the context down (races in Jazzy)
pass
if not got:
print(f"[sipm-cmd] no reply within {args.timeout:g}s (is `pixi run sipm-node` running?)", file=sys.stderr)
return 2
if args.out:
Path(args.out).write_text(json.dumps(got, indent=1), encoding="utf-8")
if args.json:
print(json.dumps(got, indent=1))
else:
v = got.get("value")
if args.command == "scan" and isinstance(v, dict):
for r in v.get("available", []):
print(f" {r['id']:5d} {r.get('name') or '?':40s} value={r.get('value')!r}")
print(f"{len(v.get('available', []))} available; not in the GUI enum: {v.get('not_in_enum')}")
print(f"{got.get('command')} → {got.get('outcome')} ({got.get('rtt_ms')} ms)"
+ (f" value={json.dumps(v)[:300]}" if v not in (None, "") and args.command != "scan" else "")
+ (f" error={got.get('error')}" if got.get("error") else ""))
return 0 if got.get("ok") else 1


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading