siligent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
siligent/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Siligent: safe, measured access to real hardware for AI agents."""
2
+
3
+ __version__ = "0.1.0"
siligent/api.py ADDED
@@ -0,0 +1,127 @@
1
+ """Plain functions shared by the CLI and the MCP server. Each returns a JSON-ready dict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .bench import Bench, BenchConfig
6
+ from .detect import scan_meters, scan_usb
7
+ from .errors import SiligentError, SafetyRefusal
8
+ from .image import load_image
9
+ from .rules import load_rules
10
+ from .runner import run_tests as _run_tests
11
+ from .safety import BenchState, SafetyGate
12
+
13
+
14
+ def _guard(fn):
15
+ """Turn refusals and errors into structured results an agent can act on."""
16
+
17
+ def wrapper(*args, **kwargs):
18
+ try:
19
+ return fn(*args, **kwargs)
20
+ except SafetyRefusal as e:
21
+ return e.to_dict()
22
+ except SiligentError as e:
23
+ return {"ok": False, "error": str(e)}
24
+ except Exception as e: # hardware and library errors: report, don't crash the agent's tool call
25
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
26
+
27
+ wrapper.__name__ = fn.__name__
28
+ wrapper.__doc__ = fn.__doc__
29
+ return wrapper
30
+
31
+
32
+ @_guard
33
+ def detect(config: BenchConfig | None = None, connect: bool = True) -> dict:
34
+ """List debug probes and, for supported ones, identify the board and its rules."""
35
+ probes = [p.to_dict() for p in scan_usb()]
36
+ result: dict = {"ok": True, "probes": probes, "meters": scan_meters()}
37
+ if connect and any(p["supported"] for p in probes):
38
+ try:
39
+ with Bench(config) as bench:
40
+ result["board"] = {
41
+ "name": bench.rules.name,
42
+ "chip": bench.rules.chip,
43
+ "dev_id": hex(bench.probe.device_id()),
44
+ "probe_uid": bench.probe.backend.unique_id,
45
+ "rules": bench.rules.source,
46
+ }
47
+ except SafetyRefusal as e:
48
+ result["board"] = e.to_dict()
49
+ return result
50
+
51
+
52
+ @_guard
53
+ def rules(board: str = "stm32l476rg") -> dict:
54
+ """Show the safety rules for a board."""
55
+ return {"ok": True, **load_rules(board).summary()}
56
+
57
+
58
+ @_guard
59
+ def check_image(path: str, board: str = "stm32l476rg", config: BenchConfig | None = None) -> dict:
60
+ """Check a firmware image against a board's rules without touching hardware."""
61
+ cfg = config or BenchConfig.load()
62
+ image = load_image(path)
63
+ gate = SafetyGate(load_rules(cfg.board or board), BenchState(cfg.flash_allowed))
64
+ gate.check_image(image)
65
+ return {"ok": True, "image": path, "sha256": image.sha256, "bytes": image.size, "detail": "passes image checks"}
66
+
67
+
68
+ @_guard
69
+ def flash(path: str, config: BenchConfig | None = None) -> dict:
70
+ """Flash a firmware image through the safety gate."""
71
+ with Bench(config) as bench:
72
+ return bench.probe.flash(load_image(path))
73
+
74
+
75
+ @_guard
76
+ def restore(path: str, config: BenchConfig | None = None) -> dict:
77
+ """Put a saved full-flash snapshot (raw .bin from the start of flash) back on the chip.
78
+
79
+ The same safety checks apply as for any flash. Trailing erased bytes are skipped.
80
+ """
81
+ from pathlib import Path
82
+
83
+ from .image import Image, Segment
84
+
85
+ raw = Path(path).read_bytes().rstrip(b"\xff")
86
+ raw += b"\xff" * (-len(raw) % 8) # flash programs in 8-byte double words
87
+ with Bench(config) as bench:
88
+ image = Image(path, [Segment(bench.rules.flash.start, raw)])
89
+ return bench.probe.flash(image)
90
+
91
+
92
+ @_guard
93
+ def run_tests(path: str, timeout_s: float = 10.0, config: BenchConfig | None = None) -> dict:
94
+ """Flash test firmware, run it, and return pass/fail, cycles and energy."""
95
+ with Bench(config) as bench:
96
+ return _run_tests(bench.probe, load_image(path), meter=bench.meter, timeout_s=timeout_s)
97
+
98
+
99
+ @_guard
100
+ def read_memory(address: int, length: int, config: BenchConfig | None = None) -> dict:
101
+ """Read memory from the chip."""
102
+ if not 0 < length <= 4096:
103
+ raise SiligentError("length must be between 1 and 4096 bytes")
104
+ with Bench(config) as bench:
105
+ data, halted = bench.probe.read_memory_checked(address, length)
106
+ out = {"ok": True, "address": hex(address), "length": length, "hex": data.hex()}
107
+ if halted:
108
+ out["note"] = "the core was asleep, so it was halted briefly for this read and then resumed"
109
+ return out
110
+
111
+
112
+ @_guard
113
+ def write_memory(address: int, data_hex: str, config: BenchConfig | None = None) -> dict:
114
+ """Write bytes to RAM or a peripheral register, subject to the board's rules."""
115
+ data = bytes.fromhex(data_hex)
116
+ with Bench(config) as bench:
117
+ bench.probe.write_memory(address, data)
118
+ return {"ok": True, "address": hex(address), "length": len(data)}
119
+
120
+
121
+ @_guard
122
+ def action_log(n: int = 20, config: BenchConfig | None = None) -> dict:
123
+ """The most recent bench actions, allowed and refused."""
124
+ cfg = config or BenchConfig.load()
125
+ from .log import ActionLog
126
+
127
+ return {"ok": True, "entries": ActionLog(cfg.log_path).tail(n)}
siligent/bench.py ADDED
@@ -0,0 +1,180 @@
1
+ """The bench: configuration plus the pieces wired together (probe, rules, meter, log)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .errors import SiligentError, SafetyRefusal
11
+ from .log import ActionLog
12
+ from .meters.base import PowerMeter
13
+ from .probes.guarded import GuardedProbe
14
+ from .rules import BoardRules, load_rules, rules_for_dev_id
15
+ from .safety import BenchState, SafetyGate
16
+
17
+ DATA_DIR = Path(os.environ.get("SILIGENT_HOME", "~/.local/share/siligent")).expanduser()
18
+ CONFIG_PATHS = [
19
+ *([Path(os.environ["SILIGENT_CONFIG"]).expanduser()] if os.environ.get("SILIGENT_CONFIG") else []),
20
+ Path("bench.toml"),
21
+ Path("~/.config/siligent/bench.toml").expanduser(),
22
+ ]
23
+
24
+
25
+ @dataclass
26
+ class BenchConfig:
27
+ flash_allowed: bool | None = None
28
+ probe_uid: str | None = None
29
+ board: str | None = None # rules name or path; None = detect from the chip
30
+ meter: dict = field(default_factory=lambda: {"type": "none"})
31
+ log_path: Path = DATA_DIR / "actions.jsonl"
32
+ snapshot_dir: Path = DATA_DIR / "snapshots"
33
+ virtual: bool = False # use a Renode virtual board instead of real hardware
34
+ renode_path: str | None = None
35
+ source: str = "defaults"
36
+
37
+ @classmethod
38
+ def load(cls, path: str | Path | None = None) -> BenchConfig:
39
+ candidates = [Path(path)] if path else CONFIG_PATHS
40
+ for p in candidates:
41
+ if p.is_file():
42
+ d = tomllib.loads(p.read_text())
43
+ bench = d.get("bench", {})
44
+ return cls(
45
+ flash_allowed=bench.get("flash_allowed"),
46
+ probe_uid=bench.get("probe_uid"),
47
+ board=bench.get("board"),
48
+ meter=d.get("meter", {"type": "none"}),
49
+ log_path=Path(bench.get("log_path", DATA_DIR / "actions.jsonl")).expanduser(),
50
+ snapshot_dir=Path(bench.get("snapshot_dir", DATA_DIR / "snapshots")).expanduser(),
51
+ virtual=bool(bench.get("virtual", False)),
52
+ renode_path=bench.get("renode_path"),
53
+ source=str(p),
54
+ )
55
+ if path:
56
+ raise SiligentError(f"no bench config at {path}")
57
+ return cls()
58
+
59
+
60
+ METER_PLUGIN_GROUP = "siligent.meters"
61
+
62
+
63
+ def meter_plugins() -> dict:
64
+ from importlib.metadata import entry_points
65
+
66
+ return {ep.name: ep for ep in entry_points(group=METER_PLUGIN_GROUP)}
67
+
68
+
69
+ def make_meter(cfg: dict, gate: SafetyGate | None) -> PowerMeter | None:
70
+ """Build the configured meter. A meter that powers the board ("mode = source")
71
+ is checked against the board's supply range here, before any plugin code runs."""
72
+ kind = cfg.get("type", "none").lower()
73
+ if kind == "none":
74
+ return None
75
+ if cfg.get("mode") == "source":
76
+ if gate is None:
77
+ raise SiligentError("connect to the board first: its rules decide the allowed supply voltage")
78
+ if "voltage_mv" not in cfg:
79
+ raise SiligentError("a meter in source mode needs voltage_mv in bench.toml")
80
+ gate.check_supply_voltage(int(cfg["voltage_mv"]))
81
+ if kind == "ina219":
82
+ from .meters.ina219 import INA219, LinuxI2CBus
83
+
84
+ return INA219(
85
+ LinuxI2CBus(int(cfg["i2c_bus"])),
86
+ address=int(cfg.get("address", 0x40)),
87
+ shunt_ohms=float(cfg.get("shunt_ohms", 0.1)),
88
+ )
89
+ plugins = meter_plugins()
90
+ if kind in plugins:
91
+ return plugins[kind].load()(cfg)
92
+ available = ", ".join(["none", "ina219", *sorted(plugins)])
93
+ raise SiligentError(f"unknown meter type '{kind}' (available: {available})")
94
+
95
+
96
+ class Bench:
97
+ """Open a probe, work out which board it is on, and apply that board's rules."""
98
+
99
+ def __init__(self, config: BenchConfig | None = None) -> None:
100
+ self.config = config or BenchConfig.load()
101
+ self.log = ActionLog(self.config.log_path)
102
+ self.probe: GuardedProbe | None = None
103
+ self.rules: BoardRules | None = None
104
+ self._backend = None
105
+ self._meter: PowerMeter | None = None
106
+ self._meter_made = False
107
+
108
+ @property
109
+ def meter(self) -> PowerMeter | None:
110
+ if self.config.virtual:
111
+ return None # a physical meter cannot measure a simulated chip
112
+ if not self._meter_made:
113
+ self._meter = make_meter(self.config.meter, self.probe.gate if self.probe else None)
114
+ self._meter_made = True
115
+ return self._meter
116
+
117
+ def connect(self) -> GuardedProbe:
118
+ if self.config.virtual:
119
+ return self._connect_virtual()
120
+ from .probes.pyocd_backend import PyOCDBackend
121
+
122
+ # Read the chip ID with a generic Cortex-M connection, then load the matching rules.
123
+ hint = load_rules(self.config.board) if self.config.board else None
124
+ backend = PyOCDBackend(
125
+ target=hint.pyocd_target if hint else "cortex_m", unique_id=self.config.probe_uid
126
+ )
127
+ backend.open()
128
+ try:
129
+ rules = hint
130
+ if rules is None:
131
+ # DBGMCU_IDCODE lives at 0xE0042000 on STM32 F4/L4; other families add their own rules.
132
+ dev_id = backend.read32(0xE0042000) & 0xFFF
133
+ rules = rules_for_dev_id(dev_id)
134
+ if rules is None:
135
+ raise SafetyRefusal(
136
+ "board.unknown",
137
+ f"No safety rules for this chip (device ID {hex(dev_id)}), so only reads are allowed.",
138
+ dev_id=hex(dev_id),
139
+ )
140
+ backend.close()
141
+ backend = PyOCDBackend(target=rules.pyocd_target, unique_id=backend.unique_id)
142
+ backend.open()
143
+ except Exception:
144
+ backend.close()
145
+ raise
146
+ gate = SafetyGate(rules, BenchState(flash_allowed=self.config.flash_allowed))
147
+ self._backend = backend
148
+ self.rules = rules
149
+ self.probe = GuardedProbe(backend, rules, gate, self.log, self.config.snapshot_dir)
150
+ self.log.record("connect", "ok", probe=backend.unique_id, board=rules.name)
151
+ return self.probe
152
+
153
+ def _connect_virtual(self) -> GuardedProbe:
154
+ from .probes.renode_backend import RenodeBackend
155
+
156
+ board = self.config.board or "stm32l476rg"
157
+ rules = load_rules(board)
158
+ backend = RenodeBackend(board=Path(board).stem, renode=self.config.renode_path)
159
+ backend.open()
160
+ # Every rule applies as on real hardware, except the bench check:
161
+ # nothing physical is flashed, so no equipment can be harmed.
162
+ gate = SafetyGate(rules, BenchState(flash_allowed=True))
163
+ self._backend = backend
164
+ self.rules = rules
165
+ self.probe = GuardedProbe(backend, rules, gate, self.log, self.config.snapshot_dir)
166
+ self.log.record("connect", "ok", probe=backend.unique_id, board=rules.name, virtual=True)
167
+ return self.probe
168
+
169
+ def close(self) -> None:
170
+ if self._backend is not None:
171
+ self._backend.close()
172
+ self._backend = None
173
+ self.probe = None
174
+
175
+ def __enter__(self) -> Bench:
176
+ self.connect()
177
+ return self
178
+
179
+ def __exit__(self, *exc) -> None:
180
+ self.close()
File without changes
@@ -0,0 +1,41 @@
1
+ // Virtual STM32L476RG for Renode. Renode ships no STM32L4 model, so this
2
+ // describes just enough of the chip to run Siligent test firmware:
3
+ // the Cortex-M4F core, NVIC, cycle counter, flash, SRAM and the ID registers.
4
+ // Peripherals (GPIO, UART, timers, RCC) are not modelled yet.
5
+ //
6
+ // Cycle counts here come from simulated time, not from timing real
7
+ // instructions, so they are not the same as on the real chip.
8
+
9
+ cpu: CPU.CortexM @ sysbus
10
+ cpuType: "cortex-m4f"
11
+ nvic: nvic
12
+
13
+ nvic: IRQControllers.NVIC @ sysbus 0xE000E000
14
+ priorityMask: 0xF0
15
+ systickFrequency: 4000000
16
+ IRQ -> cpu@0
17
+
18
+ // The real chip starts on its 4 MHz MSI clock.
19
+ dwt: Miscellaneous.DWT @ sysbus 0xE0001000
20
+ frequency: 4000000
21
+
22
+ // At boot the real chip mirrors flash at address 0, where the core fetches its vector table.
23
+ flash: Memory.MappedMemory @ {
24
+ sysbus 0x08000000;
25
+ sysbus 0x00000000
26
+ }
27
+ size: 0x100000
28
+
29
+ sram1: Memory.MappedMemory @ sysbus 0x20000000
30
+ size: 0x18000
31
+
32
+ sram2: Memory.MappedMemory @ sysbus 0x10000000
33
+ size: 0x8000
34
+
35
+ sysbus:
36
+ init:
37
+ // DBGMCU_IDCODE: revision 0x1007, device ID 0x415 (as read from the real board)
38
+ Tag <0xE0042000, 0xE0042003> "DBGMCU_IDCODE" 0x10076415
39
+ Tag <0xE0042004, 0xE0042007> "DBGMCU_CR" 0x0
40
+ // FLASH_OPTR with read protection at level 0, as on the real board
41
+ Tag <0x40022020, 0x40022023> "FLASH_OPTR" 0xFFEFF8AA
@@ -0,0 +1,92 @@
1
+ # Safety rules for the NUCLEO-L476RG (STM32L476RG).
2
+ # Addresses from the STM32L4x6 reference manual (RM0351).
3
+
4
+ [board]
5
+ name = "NUCLEO-L476RG"
6
+ chip = "STM32L476RG"
7
+ dev_id = 0x415 # DBGMCU_IDCODE[11:0]
8
+ idcode_address = 0xE0042000 # DBGMCU_IDCODE
9
+ pyocd_target = "stm32l476rgtx"
10
+
11
+ [memory]
12
+ flash = { start = 0x08000000, size = 0x100000 } # 1 MB
13
+ ram = [
14
+ { name = "SRAM1", start = 0x20000000, size = 0x18000 }, # 96 KB
15
+ { name = "SRAM2", start = 0x10000000, size = 0x8000 }, # 32 KB
16
+ ]
17
+
18
+ # Supply voltage a power meter may apply when it powers the chip (VDD, datasheet range).
19
+ [power]
20
+ vdd_min_mv = 1710
21
+ vdd_max_mv = 3600
22
+
23
+ # Flashing is refused unless read protection is at level 0 (RDP byte 0xAA).
24
+ # This is the lesson from the locked F401RE.
25
+ [protection]
26
+ optr_address = 0x40022020 # FLASH_OPTR
27
+ rdp_mask = 0xFF
28
+ rdp_level0 = 0xAA
29
+
30
+ # Host writes (from an agent, the CLI or the Python API) are allowed only here.
31
+ [[allow_write]]
32
+ name = "SRAM1"
33
+ start = 0x20000000
34
+ size = 0x18000
35
+
36
+ [[allow_write]]
37
+ name = "SRAM2"
38
+ start = 0x10000000
39
+ size = 0x8000
40
+
41
+ [[allow_write]]
42
+ name = "Peripherals"
43
+ start = 0x40000000
44
+ size = 0x20000000
45
+
46
+ [[allow_write]]
47
+ name = "Core debug (cycle counter, breakpoints)"
48
+ start = 0xE0000000
49
+ size = 0x100000
50
+
51
+ # Forbidden ranges win over allowed ranges.
52
+ [[forbid_write]]
53
+ name = "FLASH registers"
54
+ start = 0x40022000
55
+ size = 0x400
56
+ reason = "Flash control, option bytes, write and read protection. Only the flash tool may program flash, and it never touches option bytes."
57
+
58
+ [[forbid_write]]
59
+ name = "System memory, OTP and option bytes"
60
+ start = 0x1FFF0000
61
+ size = 0x10000
62
+ reason = "OTP writes are permanent and option bytes can lock the chip."
63
+
64
+ # Firmware containing these words is refused. It is a heuristic: it catches
65
+ # firmware that would unlock the option bytes at run time, which no address
66
+ # check can see. A compiler can split a constant across two instructions,
67
+ # so the read-protection check before every flash is the real backstop.
68
+ [[forbid_image_word]]
69
+ value = 0x08192A3B
70
+ reason = "Option-byte unlock key 1 (FLASH_OPTKEYR). Firmware that unlocks option bytes can set read protection and lock the chip."
71
+
72
+ [[forbid_image_word]]
73
+ value = 0x4C5D6E7F
74
+ reason = "Option-byte unlock key 2 (FLASH_OPTKEYR). Firmware that unlocks option bytes can set read protection and lock the chip."
75
+
76
+ # Pins whose mode a host write may not change. Mode 2 = alternate function.
77
+ [gpio]
78
+ base = 0x48000000
79
+ stride = 0x400
80
+ ports = "ABCDEFGH"
81
+
82
+ [[protected_pin]]
83
+ port = "A"
84
+ pin = 13
85
+ required_mode = 2
86
+ reason = "SWDIO. Changing its mode cuts the debug link."
87
+
88
+ [[protected_pin]]
89
+ port = "A"
90
+ pin = 14
91
+ required_mode = 2
92
+ reason = "SWCLK. Changing its mode cuts the debug link."
siligent/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ """Command line: `siligent <command>`. Every command prints JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import api
10
+ from .bench import BenchConfig
11
+
12
+
13
+ def _int(s: str) -> int:
14
+ return int(s, 0)
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ p = argparse.ArgumentParser(prog="siligent", description="Safe, measured access to real hardware.")
19
+ p.add_argument("--config", help="path to bench.toml (default: ./bench.toml or ~/.config/siligent/bench.toml)")
20
+ p.add_argument("--virtual", action="store_true", help="use a Renode virtual board instead of real hardware")
21
+ sub = p.add_subparsers(dest="cmd", required=True)
22
+
23
+ d = sub.add_parser("detect", help="find probes and identify the board")
24
+ d.add_argument("--no-connect", action="store_true", help="only scan USB; do not open the probe")
25
+ r = sub.add_parser("rules", help="show a board's safety rules")
26
+ r.add_argument("board", nargs="?", default="stm32l476rg")
27
+ c = sub.add_parser("check", help="check a firmware image against the rules, without hardware")
28
+ c.add_argument("image")
29
+ f = sub.add_parser("flash", help="flash a firmware image")
30
+ f.add_argument("image")
31
+ rs = sub.add_parser("restore", help="put a full-flash snapshot (.bin) back on the chip")
32
+ rs.add_argument("snapshot")
33
+ t = sub.add_parser("test",help="flash test firmware, run it, report pass/fail and cycles (plus energy if a power meter is set up)")
34
+ t.add_argument("image")
35
+ t.add_argument("--timeout", type=float, default=10.0)
36
+ rd = sub.add_parser("read", help="read memory")
37
+ rd.add_argument("address", type=_int)
38
+ rd.add_argument("length", type=_int)
39
+ w = sub.add_parser("write", help="write bytes (hex) to RAM or a peripheral")
40
+ w.add_argument("address", type=_int)
41
+ w.add_argument("data_hex")
42
+ lg = sub.add_parser("log", help="show recent bench actions")
43
+ lg.add_argument("-n", type=int, default=20)
44
+
45
+ a = p.parse_args(argv)
46
+ cfg = BenchConfig.load(a.config)
47
+ if a.virtual:
48
+ cfg.virtual = True
49
+ if a.cmd == "detect":
50
+ out = api.detect(cfg, connect=not a.no_connect)
51
+ elif a.cmd == "rules":
52
+ out = api.rules(a.board)
53
+ elif a.cmd == "check":
54
+ out = api.check_image(a.image, config=cfg)
55
+ elif a.cmd == "flash":
56
+ out = api.flash(a.image, config=cfg)
57
+ elif a.cmd == "restore":
58
+ out = api.restore(a.snapshot, config=cfg)
59
+ elif a.cmd == "test":
60
+ out = api.run_tests(a.image, timeout_s=a.timeout, config=cfg)
61
+ elif a.cmd == "read":
62
+ out = api.read_memory(a.address, a.length, config=cfg)
63
+ elif a.cmd == "write":
64
+ out = api.write_memory(a.address, a.data_hex, config=cfg)
65
+ else:
66
+ out = api.action_log(a.n, config=cfg)
67
+ json.dump(out, sys.stdout, indent=2)
68
+ print()
69
+ return 0 if out.get("ok") else 1
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())
siligent/detect.py ADDED
@@ -0,0 +1,84 @@
1
+ """Find debug probes from their USB IDs, without opening them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ # (vendor ID, product ID) -> probe kind. A product ID of None matches any product.
9
+ KNOWN_PROBES: dict[tuple[int, int | None], str] = {
10
+ (0x0483, 0x3748): "ST-LINK/V2",
11
+ (0x0483, 0x374B): "ST-LINK/V2-1",
12
+ (0x0483, 0x374E): "STLINK-V3",
13
+ (0x0483, 0x374F): "STLINK-V3",
14
+ (0x0483, 0x3753): "STLINK-V3",
15
+ (0x0483, 0x3754): "STLINK-V3",
16
+ (0x1366, None): "SEGGER J-Link",
17
+ (0x0D28, 0x0204): "CMSIS-DAP (DAPLink)",
18
+ (0x2E8A, 0x000C): "Raspberry Pi Debug Probe (CMSIS-DAP)",
19
+ }
20
+
21
+ # Families Siligent can drive today. Others are reported but not opened.
22
+ SUPPORTED_KINDS = {"ST-LINK/V2", "ST-LINK/V2-1", "STLINK-V3"}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class UsbProbe:
27
+ kind: str
28
+ vendor_id: str
29
+ product_id: str
30
+ serial: str
31
+ product: str
32
+ supported: bool
33
+
34
+ def to_dict(self) -> dict:
35
+ return asdict(self)
36
+
37
+
38
+ def _read(path: Path) -> str:
39
+ try:
40
+ return path.read_text().strip()
41
+ except OSError:
42
+ return ""
43
+
44
+
45
+ def scan_meters(sysfs_root: str | Path = "/sys/bus/usb/devices") -> list[dict]:
46
+ """List USB power meters Siligent knows, by their USB product name."""
47
+ root = Path(sysfs_root)
48
+ if not root.is_dir():
49
+ return []
50
+ known = {"PPK2": "Nordic Power Profiler Kit II (plugin: siligent-ppk2)"}
51
+ return [
52
+ {"kind": known[product], "serial": _read(dev / "serial")}
53
+ for dev in sorted(root.iterdir())
54
+ if (product := _read(dev / "product")) in known
55
+ ]
56
+
57
+
58
+ def probe_kind(vid: int, pid: int) -> str | None:
59
+ return KNOWN_PROBES.get((vid, pid)) or KNOWN_PROBES.get((vid, None))
60
+
61
+
62
+ def scan_usb(sysfs_root: str | Path = "/sys/bus/usb/devices") -> list[UsbProbe]:
63
+ """List known debug probes by reading Linux sysfs. Opens no device."""
64
+ found = []
65
+ root = Path(sysfs_root)
66
+ if not root.is_dir():
67
+ return found
68
+ for dev in sorted(root.iterdir()):
69
+ vid_s, pid_s = _read(dev / "idVendor"), _read(dev / "idProduct")
70
+ if not vid_s or not pid_s:
71
+ continue
72
+ kind = probe_kind(int(vid_s, 16), int(pid_s, 16))
73
+ if kind:
74
+ found.append(
75
+ UsbProbe(
76
+ kind=kind,
77
+ vendor_id=vid_s,
78
+ product_id=pid_s,
79
+ serial=_read(dev / "serial"),
80
+ product=_read(dev / "product"),
81
+ supported=kind in SUPPORTED_KINDS,
82
+ )
83
+ )
84
+ return found
siligent/errors.py ADDED
@@ -0,0 +1,32 @@
1
+ """Errors raised by Siligent."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SiligentError(Exception):
7
+ """Base class for Siligent errors."""
8
+
9
+
10
+ class SafetyRefusal(SiligentError):
11
+ """An action was refused by the safety gate before it reached the hardware.
12
+
13
+ `rule` names the rule that fired, so an agent can tell refusals apart and
14
+ explain them, instead of retrying blindly.
15
+ """
16
+
17
+ def __init__(self, rule: str, reason: str, **detail: object) -> None:
18
+ super().__init__(f"refused by rule '{rule}': {reason}")
19
+ self.rule = rule
20
+ self.reason = reason
21
+ self.detail = detail
22
+
23
+ def to_dict(self) -> dict:
24
+ return {"ok": False, "refused": True, "rule": self.rule, "reason": self.reason, "detail": self.detail}
25
+
26
+
27
+ class HardwareError(SiligentError):
28
+ """The hardware did not respond as expected."""
29
+
30
+
31
+ class ImageError(SiligentError):
32
+ """A firmware image could not be read."""