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 +3 -0
- siligent/api.py +127 -0
- siligent/bench.py +180 -0
- siligent/boards/__init__.py +0 -0
- siligent/boards/renode/stm32l476rg.repl +41 -0
- siligent/boards/stm32l476rg.toml +92 -0
- siligent/cli.py +73 -0
- siligent/detect.py +84 -0
- siligent/errors.py +32 -0
- siligent/image.py +93 -0
- siligent/log.py +25 -0
- siligent/mcp_server.py +89 -0
- siligent/meters/__init__.py +5 -0
- siligent/meters/base.py +58 -0
- siligent/meters/ina219.py +88 -0
- siligent/probes/__init__.py +6 -0
- siligent/probes/base.py +35 -0
- siligent/probes/guarded.py +143 -0
- siligent/probes/pyocd_backend.py +91 -0
- siligent/probes/renode_backend.py +190 -0
- siligent/rules.py +150 -0
- siligent/runner.py +134 -0
- siligent/safety.py +173 -0
- siligent-0.1.0.dist-info/METADATA +133 -0
- siligent-0.1.0.dist-info/RECORD +29 -0
- siligent-0.1.0.dist-info/WHEEL +5 -0
- siligent-0.1.0.dist-info/entry_points.txt +3 -0
- siligent-0.1.0.dist-info/licenses/LICENSE +202 -0
- siligent-0.1.0.dist-info/top_level.txt +1 -0
siligent/__init__.py
ADDED
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."""
|