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.
@@ -0,0 +1,190 @@
1
+ """A virtual board in Renode, driven through Renode's monitor port.
2
+
3
+ It implements the same interface as a real probe, so the safety gate, the
4
+ test runner, the CLI and the MCP tools work on it unchanged.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import glob
10
+ import os
11
+ import re
12
+ import shutil
13
+ import socket
14
+ import subprocess
15
+ import tempfile
16
+ import time
17
+ from importlib import resources
18
+ from pathlib import Path
19
+
20
+ from ..errors import HardwareError
21
+ from ..image import Segment
22
+
23
+ PROMPT = re.compile(r"\((?:monitor|machine-\d+)\) $")
24
+ TELNET_IAC = re.compile(rb"\xff[\xfb-\xfe].|\xff[\xf0-\xfa]")
25
+
26
+
27
+ def find_renode(explicit: str | None = None) -> str:
28
+ candidates = [explicit, os.environ.get("RENODE"), shutil.which("renode")]
29
+ candidates += sorted(glob.glob(os.path.expanduser("~/.local/opt/renode-*/renode")), reverse=True)
30
+ for c in candidates:
31
+ if c and Path(c).is_file():
32
+ return c
33
+ raise HardwareError(
34
+ "Renode not found. Install it (https://github.com/renode/renode/releases) "
35
+ "or set RENODE=/path/to/renode."
36
+ )
37
+
38
+
39
+ def platform_file(board: str) -> Path:
40
+ res = resources.files("siligent.boards") / "renode" / f"{board}.repl"
41
+ if not res.is_file():
42
+ raise HardwareError(f"no Renode platform for '{board}' yet")
43
+ return Path(str(res))
44
+
45
+
46
+ def _free_port() -> int:
47
+ with socket.socket() as s:
48
+ s.bind(("127.0.0.1", 0))
49
+ return s.getsockname()[1]
50
+
51
+
52
+ class RenodeBackend:
53
+ persistent = False # nothing to back up: the virtual board starts fresh each time
54
+
55
+ def __init__(self, board: str = "stm32l476rg", renode: str | None = None, mips: int = 4):
56
+ self.board = board
57
+ self.mips = mips
58
+ self.renode = find_renode(renode)
59
+ self.unique_id = f"renode-virtual-{board}"
60
+ self.description = f"Renode virtual {board}"
61
+ self._proc: subprocess.Popen | None = None
62
+ self._sock: socket.socket | None = None
63
+ self._running = False
64
+ self._tmp = tempfile.TemporaryDirectory(prefix="siligent-renode-")
65
+
66
+ # Monitor connection ---------------------------------------------------------
67
+
68
+ def open(self) -> None:
69
+ port = _free_port()
70
+ self._proc = subprocess.Popen(
71
+ [self.renode, "--disable-gui", "--plain", "--hide-log", "--port", str(port)],
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ )
75
+ deadline = time.monotonic() + 60
76
+ while True:
77
+ try:
78
+ self._sock = socket.create_connection(("127.0.0.1", port), timeout=1)
79
+ break
80
+ except OSError:
81
+ if self._proc.poll() is not None or time.monotonic() > deadline:
82
+ self.close()
83
+ raise HardwareError("Renode did not start")
84
+ time.sleep(0.2)
85
+ self._read_until_prompt(timeout=60)
86
+ self.command("mach create")
87
+ self.command(f"machine LoadPlatformDescription @{platform_file(self.board)}", timeout=60)
88
+ # One instruction per clock at the chip's 4 MHz start-up clock, so the cycle
89
+ # counter lands near the real chip's (Renode's default is 100 MIPS).
90
+ self.command(f"cpu PerformanceInMips {self.mips}")
91
+
92
+ def close(self) -> None:
93
+ if self._sock is not None:
94
+ try:
95
+ self._sock.sendall(b"quit\n")
96
+ except OSError:
97
+ pass
98
+ self._sock.close()
99
+ self._sock = None
100
+ if self._proc is not None:
101
+ try:
102
+ self._proc.wait(timeout=20)
103
+ except subprocess.TimeoutExpired:
104
+ self._proc.kill()
105
+ self._proc = None
106
+ self._tmp.cleanup()
107
+
108
+ def _read_until_prompt(self, timeout: float = 30) -> str:
109
+ assert self._sock is not None
110
+ buf = b""
111
+ deadline = time.monotonic() + timeout
112
+ self._sock.settimeout(0.5)
113
+ while time.monotonic() < deadline:
114
+ try:
115
+ chunk = self._sock.recv(65536)
116
+ except socket.timeout:
117
+ continue
118
+ if not chunk:
119
+ raise HardwareError("Renode closed the monitor connection")
120
+ buf += chunk
121
+ text = TELNET_IAC.sub(b"", buf).decode(errors="replace")
122
+ if PROMPT.search(text):
123
+ return text
124
+ raise HardwareError("Renode did not answer in time")
125
+
126
+ def command(self, line: str, timeout: float = 30) -> str:
127
+ """Run one monitor command and return its output (without the echo and prompt)."""
128
+ if self._sock is None:
129
+ raise HardwareError("virtual board is not open")
130
+ self._sock.sendall(line.encode() + b"\n")
131
+ text = self._read_until_prompt(timeout)
132
+ body = PROMPT.sub("", text).replace("\r", "")
133
+ lines = [ln for ln in body.split("\n") if ln.strip() and ln.strip() != line]
134
+ out = "\n".join(lines).strip()
135
+ if out.startswith("There was an error") or "Could not find" in out:
136
+ raise HardwareError(f"Renode: {out}")
137
+ return out
138
+
139
+ # Probe interface --------------------------------------------------------------
140
+
141
+ def read32(self, address: int) -> int:
142
+ return int(self.command(f"sysbus ReadDoubleWord {address:#x}"), 16)
143
+
144
+ def read_memory(self, address: int, length: int) -> bytes:
145
+ out = self.command(f"sysbus ReadBytes {address:#x} {length}")
146
+ values = re.findall(r"0x([0-9A-Fa-f]{1,2})", out)
147
+ if len(values) != length:
148
+ raise HardwareError(f"Renode returned {len(values)} bytes, expected {length}")
149
+ return bytes(int(v, 16) for v in values)
150
+
151
+ def write_memory(self, address: int, data: bytes) -> None:
152
+ i = 0
153
+ while i < len(data):
154
+ a = address + i
155
+ if a % 4 == 0 and i + 4 <= len(data):
156
+ word = int.from_bytes(data[i : i + 4], "little")
157
+ self.command(f"sysbus WriteDoubleWord {a:#x} {word:#x}")
158
+ i += 4
159
+ else:
160
+ self.command(f"sysbus WriteByte {a:#x} {data[i]:#x}")
161
+ i += 1
162
+
163
+ def program(self, segments: list[Segment]) -> None:
164
+ self.halt()
165
+ for n, seg in enumerate(segments):
166
+ path = Path(self._tmp.name) / f"segment{n}.bin"
167
+ path.write_bytes(seg.data)
168
+ self.command(f"sysbus LoadBinary @{path} {seg.address:#x}")
169
+ self.reset(halt=True)
170
+
171
+ def reset(self, halt: bool = False) -> None:
172
+ # The platform mirrors flash at address 0, so the core boots from the
173
+ # vector table there, as the real chip does.
174
+ self.halt()
175
+ self.command("machine Reset")
176
+ if not halt:
177
+ self.resume()
178
+
179
+ def halt(self) -> None:
180
+ if self._running:
181
+ self.command("pause")
182
+ self._running = False
183
+
184
+ def resume(self) -> None:
185
+ if not self._running:
186
+ self.command("start")
187
+ self._running = True
188
+
189
+ def state(self) -> str:
190
+ return "running" if self._running else "halted"
siligent/rules.py ADDED
@@ -0,0 +1,150 @@
1
+ """Board safety rules, loaded from TOML files in siligent/boards/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from importlib import resources
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Region:
13
+ name: str
14
+ start: int
15
+ size: int
16
+ reason: str = ""
17
+
18
+ @property
19
+ def end(self) -> int:
20
+ return self.start + self.size
21
+
22
+ def contains(self, addr: int, length: int = 1) -> bool:
23
+ return self.start <= addr and addr + length <= self.end
24
+
25
+ def overlaps(self, addr: int, length: int) -> bool:
26
+ return addr < self.end and self.start < addr + length
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ForbiddenWord:
31
+ value: int
32
+ reason: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ProtectedPin:
37
+ port: str
38
+ pin: int
39
+ required_mode: int
40
+ reason: str
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class BoardRules:
45
+ name: str
46
+ chip: str
47
+ dev_id: int
48
+ idcode_address: int
49
+ pyocd_target: str
50
+ flash: Region
51
+ ram: tuple[Region, ...]
52
+ allow_write: tuple[Region, ...]
53
+ forbid_write: tuple[Region, ...]
54
+ forbid_image_words: tuple[ForbiddenWord, ...]
55
+ optr_address: int
56
+ rdp_mask: int
57
+ rdp_level0: int
58
+ gpio_base: int
59
+ gpio_stride: int
60
+ gpio_ports: str
61
+ protected_pins: tuple[ProtectedPin, ...] = field(default_factory=tuple)
62
+ vdd_min_mv: int | None = None
63
+ vdd_max_mv: int | None = None
64
+ source: str = ""
65
+
66
+ def moder_address(self, port: str) -> int:
67
+ return self.gpio_base + self.gpio_ports.index(port.upper()) * self.gpio_stride
68
+
69
+ def summary(self) -> dict:
70
+ """A plain description an agent or a person can read."""
71
+ hexs = lambda r: {"name": r.name, "start": hex(r.start), "end": hex(r.end)} # noqa: E731
72
+ return {
73
+ "board": self.name,
74
+ "chip": self.chip,
75
+ "dev_id": hex(self.dev_id),
76
+ "flash": hexs(self.flash),
77
+ "ram": [hexs(r) for r in self.ram],
78
+ "host_writes_allowed_in": [hexs(r) for r in self.allow_write],
79
+ "host_writes_forbidden_in": [hexs(r) | {"reason": r.reason} for r in self.forbid_write],
80
+ "firmware_refused_if_it_contains": [
81
+ {"word": hex(w.value), "reason": w.reason} for w in self.forbid_image_words
82
+ ],
83
+ "protected_pins": [
84
+ {"pin": f"P{p.port}{p.pin}", "reason": p.reason} for p in self.protected_pins
85
+ ],
86
+ "flash_requires": f"read protection level 0 (RDP byte {hex(self.rdp_level0)})",
87
+ "supply_voltage_mv": (
88
+ {"min": self.vdd_min_mv, "max": self.vdd_max_mv} if self.vdd_max_mv else "no meter may power this board"
89
+ ),
90
+ }
91
+
92
+
93
+ def _region(d: dict, default_name: str = "") -> Region:
94
+ return Region(d.get("name", default_name), int(d["start"]), int(d["size"]), d.get("reason", ""))
95
+
96
+
97
+ def parse_rules(data: dict, source: str = "") -> BoardRules:
98
+ board, mem, prot, gpio = data["board"], data["memory"], data["protection"], data.get("gpio", {})
99
+ power = data.get("power", {})
100
+ return BoardRules(
101
+ name=board["name"],
102
+ chip=board["chip"],
103
+ dev_id=int(board["dev_id"]),
104
+ idcode_address=int(board["idcode_address"]),
105
+ pyocd_target=board["pyocd_target"],
106
+ flash=_region(mem["flash"], "Flash"),
107
+ ram=tuple(_region(r) for r in mem["ram"]),
108
+ allow_write=tuple(_region(r) for r in data.get("allow_write", [])),
109
+ forbid_write=tuple(_region(r) for r in data.get("forbid_write", [])),
110
+ forbid_image_words=tuple(
111
+ ForbiddenWord(int(w["value"]), w["reason"]) for w in data.get("forbid_image_word", [])
112
+ ),
113
+ optr_address=int(prot["optr_address"]),
114
+ rdp_mask=int(prot["rdp_mask"]),
115
+ rdp_level0=int(prot["rdp_level0"]),
116
+ gpio_base=int(gpio.get("base", 0)),
117
+ gpio_stride=int(gpio.get("stride", 0)),
118
+ gpio_ports=gpio.get("ports", ""),
119
+ protected_pins=tuple(
120
+ ProtectedPin(p["port"].upper(), int(p["pin"]), int(p["required_mode"]), p["reason"])
121
+ for p in data.get("protected_pin", [])
122
+ ),
123
+ vdd_min_mv=power.get("vdd_min_mv"),
124
+ vdd_max_mv=power.get("vdd_max_mv"),
125
+ source=source,
126
+ )
127
+
128
+
129
+ def builtin_boards() -> list[str]:
130
+ return sorted(p.name[:-5] for p in resources.files("siligent.boards").iterdir() if p.name.endswith(".toml"))
131
+
132
+
133
+ def load_rules(name_or_path: str) -> BoardRules:
134
+ """Load rules by built-in name (e.g. 'stm32l476rg') or by file path."""
135
+ path = Path(name_or_path)
136
+ if path.suffix == ".toml" and path.exists():
137
+ return parse_rules(tomllib.loads(path.read_text()), str(path))
138
+ res = resources.files("siligent.boards") / f"{name_or_path.lower()}.toml"
139
+ if not res.is_file():
140
+ raise FileNotFoundError(f"no rules for board '{name_or_path}'; built-in: {', '.join(builtin_boards())}")
141
+ return parse_rules(tomllib.loads(res.read_text()), f"builtin:{name_or_path.lower()}")
142
+
143
+
144
+ def rules_for_dev_id(dev_id: int) -> BoardRules | None:
145
+ """Find built-in rules whose chip matches a device ID read from the chip."""
146
+ for name in builtin_boards():
147
+ rules = load_rules(name)
148
+ if rules.dev_id == dev_id:
149
+ return rules
150
+ return None
siligent/runner.py ADDED
@@ -0,0 +1,134 @@
1
+ """Run on-device tests and collect pass/fail, cycles and energy.
2
+
3
+ The firmware (see firmware/) writes its results to a RAM "mailbox" at the
4
+ symbol `bp_results`. The host never talks to the chip while it measures
5
+ energy: debug traffic adds current, so it waits a quiet period first and only
6
+ then polls the mailbox for the result.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ import time
13
+ from dataclasses import asdict, dataclass
14
+
15
+ from .errors import SiligentError
16
+ from .image import Image
17
+ from .meters.base import PowerMeter
18
+ from .probes.guarded import GuardedProbe
19
+
20
+ MAGIC = 0x42504C54 # "BPLT"
21
+ VERSION = 1
22
+ HEADER = struct.Struct("<8I") # magic, version, state, count, passed, failed, clock_hz, current
23
+ ENTRY = struct.Struct("<20sIII") # name, status, cycles, fail_line
24
+ MAX_TESTS = 32
25
+ MAILBOX_SIZE = HEADER.size + MAX_TESTS * ENTRY.size
26
+
27
+ STATES = {0: "idle", 1: "running", 2: "done", 3: "crashed"}
28
+ STATUS = {0: "not run", 1: "pass", 2: "fail", 3: "crashed"}
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TestResult:
33
+ name: str
34
+ status: str
35
+ cycles: int
36
+ time_us: float | None
37
+ fail_line: int | None
38
+
39
+
40
+ def parse_mailbox(data: bytes) -> dict:
41
+ magic, version, state, count, passed, failed, clock_hz, current = HEADER.unpack_from(data, 0)
42
+ if magic != MAGIC:
43
+ return {"state": "no results", "detail": "the firmware did not write a results mailbox"}
44
+ if version != VERSION:
45
+ return {"state": "unsupported", "detail": f"mailbox version {version}, expected {VERSION}"}
46
+ tests = []
47
+ for i in range(min(count, MAX_TESTS)):
48
+ name, status, cycles, line = ENTRY.unpack_from(data, HEADER.size + i * ENTRY.size)
49
+ tests.append(
50
+ TestResult(
51
+ name=name.split(b"\0", 1)[0].decode(errors="replace"),
52
+ status=STATUS.get(status, f"unknown ({status})"),
53
+ cycles=cycles,
54
+ time_us=round(cycles / clock_hz * 1e6, 3) if clock_hz else None,
55
+ fail_line=line if status == 2 else None,
56
+ )
57
+ )
58
+ result = {
59
+ "state": STATES.get(state, f"unknown ({state})"),
60
+ "passed": passed,
61
+ "failed": failed,
62
+ "clock_hz": clock_hz,
63
+ "tests": [asdict(t) for t in tests],
64
+ }
65
+ if state == 3:
66
+ result["crashed_in"] = tests[current].name if current < len(tests) else f"test #{current}"
67
+ return result
68
+
69
+
70
+ def run_tests(
71
+ probe: GuardedProbe,
72
+ image: Image,
73
+ meter: PowerMeter | None = None,
74
+ timeout_s: float = 10.0,
75
+ quiet_s: float = 0.5,
76
+ ) -> dict:
77
+ if "bp_results" not in image.symbols:
78
+ raise SiligentError(
79
+ "this firmware has no 'bp_results' symbol; build it with the Siligent test harness (firmware/)"
80
+ )
81
+ mailbox = image.symbols["bp_results"][0]
82
+
83
+ flashed = probe.flash(image)
84
+ probe.reset(halt=True)
85
+ probe.write_memory(mailbox, bytes(HEADER.size)) # clear any old results
86
+ if meter:
87
+ meter.start()
88
+ started = time.monotonic()
89
+ probe.resume()
90
+
91
+ if meter:
92
+ time.sleep(quiet_s)
93
+ state = "timeout"
94
+ while time.monotonic() - started < timeout_s:
95
+ header = HEADER.unpack(probe.read_memory(mailbox, HEADER.size))
96
+ if header[0] == MAGIC and header[2] in (2, 3):
97
+ state = STATES[header[2]]
98
+ break
99
+ time.sleep(0.05)
100
+ elapsed = time.monotonic() - started
101
+ reading = meter.stop() if meter else None
102
+
103
+ # Read the final results with the core halted, so the read can be trusted
104
+ # whatever state the firmware left the chip in.
105
+ probe.halt()
106
+ result = parse_mailbox(probe.read_memory(mailbox, MAILBOX_SIZE))
107
+ probe.resume()
108
+ if state == "timeout" and result.get("state") in ("done", "crashed"):
109
+ state = result["state"] # finished, but polling could not see it
110
+ if state == "timeout":
111
+ result["state"] = "timeout"
112
+ result["detail"] = f"tests did not finish within {timeout_s} s"
113
+ result["ok"] = result["state"] == "done" and result.get("failed", 1) == 0
114
+ result["wall_time_s"] = round(elapsed, 3)
115
+ result["image_sha256"] = flashed["sha256"]
116
+ virtual = not getattr(probe.backend, "persistent", True)
117
+ if reading:
118
+ result["energy"] = reading.to_dict()
119
+ elif virtual:
120
+ result["energy"] = {"measured": False, "detail": "virtual board: energy is not simulated yet"}
121
+ else:
122
+ result["energy"] = {"measured": False, "detail": "no power meter configured in bench.toml"}
123
+ if virtual:
124
+ result["virtual"] = True
125
+ result["note"] = "simulated in Renode: cycle counts come from simulated time, not the real chip"
126
+ probe.log.record(
127
+ "run_tests",
128
+ result["state"],
129
+ passed=result.get("passed"),
130
+ failed=result.get("failed"),
131
+ sha256=flashed["sha256"],
132
+ energy_uj=reading.energy_uj if reading else None,
133
+ )
134
+ return result
siligent/safety.py ADDED
@@ -0,0 +1,173 @@
1
+ """The safety gate: every write and every flash is checked here before it reaches a probe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .errors import SafetyRefusal
8
+ from .image import Image
9
+ from .rules import BoardRules
10
+
11
+
12
+ @dataclass
13
+ class BenchState:
14
+ """Facts about the physical bench that software cannot see for itself.
15
+
16
+ `flash_allowed` must be set to True by a person in bench.toml, after
17
+ checking the bench is safe to flash (for example, that test equipment
18
+ which could be damaged is unplugged). Unset (None) or False refuses.
19
+ """
20
+
21
+ flash_allowed: bool | None = None
22
+
23
+
24
+ class SafetyGate:
25
+ def __init__(self, rules: BoardRules, bench: BenchState | None = None) -> None:
26
+ self.rules = rules
27
+ self.bench = bench or BenchState()
28
+
29
+ # Bench preconditions -------------------------------------------------
30
+
31
+ def check_bench_for_flash(self) -> None:
32
+ if self.bench.flash_allowed is not True:
33
+ raise SafetyRefusal(
34
+ "bench.flash_not_allowed",
35
+ "A person has not confirmed this bench is safe to flash. Check the bench "
36
+ "(for example, unplug test equipment that must not be attached while flashing), "
37
+ "then set flash_allowed = true in bench.toml.",
38
+ flash_allowed=self.bench.flash_allowed,
39
+ )
40
+
41
+ # Host memory writes --------------------------------------------------
42
+
43
+ def check_host_write(self, address: int, data: bytes) -> None:
44
+ length = len(data)
45
+ if length == 0:
46
+ return
47
+ for region in self.rules.forbid_write:
48
+ if region.overlaps(address, length):
49
+ raise SafetyRefusal(
50
+ "write.forbidden_region",
51
+ f"Writes to {region.name} are not allowed. {region.reason}",
52
+ address=hex(address),
53
+ length=length,
54
+ region=region.name,
55
+ )
56
+ if not any(r.contains(address, length) for r in self.rules.allow_write):
57
+ raise SafetyRefusal(
58
+ "write.outside_allowed",
59
+ "Host writes are only allowed in RAM, peripherals and core debug registers. "
60
+ "Use the flash tool to change flash.",
61
+ address=hex(address),
62
+ length=length,
63
+ )
64
+ self._check_protected_pins(address, data)
65
+
66
+ def _check_protected_pins(self, address: int, data: bytes) -> None:
67
+ for pin in self.rules.protected_pins:
68
+ moder = self.rules.moder_address(pin.port)
69
+ if not (address < moder + 4 and moder < address + len(data)):
70
+ continue
71
+ # Only a whole, aligned 32-bit write can be checked bit by bit.
72
+ if address > moder or address + len(data) < moder + 4:
73
+ raise SafetyRefusal(
74
+ "pin.partial_moder_write",
75
+ f"Partial write to GPIO{pin.port}_MODER cannot be checked; write the whole register.",
76
+ address=hex(address),
77
+ )
78
+ off = moder - address
79
+ value = int.from_bytes(data[off : off + 4], "little")
80
+ mode = (value >> (2 * pin.pin)) & 0b11
81
+ if mode != pin.required_mode:
82
+ raise SafetyRefusal(
83
+ "pin.protected",
84
+ f"P{pin.port}{pin.pin} must stay in mode {pin.required_mode}: {pin.reason}",
85
+ pin=f"P{pin.port}{pin.pin}",
86
+ requested_mode=mode,
87
+ )
88
+
89
+ # Firmware images -----------------------------------------------------
90
+
91
+ def check_image(self, image: Image) -> None:
92
+ flash = self.rules.flash
93
+ for seg in image.segments:
94
+ if not flash.contains(seg.address, len(seg.data)):
95
+ raise SafetyRefusal(
96
+ "image.outside_flash",
97
+ f"Firmware has data at {hex(seg.address)}-{hex(seg.end)}, outside flash "
98
+ f"({hex(flash.start)}-{hex(flash.end)}).",
99
+ address=hex(seg.address),
100
+ length=len(seg.data),
101
+ )
102
+ self._check_vector_table(image)
103
+ self._check_forbidden_words(image)
104
+
105
+ def _check_vector_table(self, image: Image) -> None:
106
+ start = self.rules.flash.start
107
+ sp, reset = image.word_at(start), image.word_at(start + 4)
108
+ if sp is None or reset is None:
109
+ raise SafetyRefusal(
110
+ "image.no_vector_table",
111
+ f"Firmware does not start with a vector table at {hex(start)}, so the chip would not boot it.",
112
+ )
113
+ if not any(r.start < sp <= r.end for r in self.rules.ram):
114
+ raise SafetyRefusal(
115
+ "image.bad_stack_pointer",
116
+ f"Initial stack pointer {hex(sp)} is not inside RAM.",
117
+ stack_pointer=hex(sp),
118
+ )
119
+ if not (reset & 1) or not self.rules.flash.contains(reset & ~1):
120
+ raise SafetyRefusal(
121
+ "image.bad_reset_vector",
122
+ f"Reset handler {hex(reset)} must be an odd (Thumb) address inside flash.",
123
+ reset_vector=hex(reset),
124
+ )
125
+
126
+ def _check_forbidden_words(self, image: Image) -> None:
127
+ for word in self.rules.forbid_image_words:
128
+ needle = word.value.to_bytes(4, "little")
129
+ for seg in image.segments:
130
+ pos = seg.data.find(needle)
131
+ if pos >= 0:
132
+ raise SafetyRefusal(
133
+ "image.forbidden_word",
134
+ word.reason,
135
+ word=hex(word.value),
136
+ address=hex(seg.address + pos),
137
+ )
138
+
139
+ # Power ------------------------------------------------------------------
140
+
141
+ def check_supply_voltage(self, voltage_mv: int) -> None:
142
+ """A meter that powers the board may only apply a voltage inside the board's range."""
143
+ lo, hi = self.rules.vdd_min_mv, self.rules.vdd_max_mv
144
+ if lo is None or hi is None:
145
+ raise SafetyRefusal(
146
+ "power.no_limits",
147
+ f"The rules for {self.rules.name} give no supply range, so no meter may power it.",
148
+ )
149
+ if not lo <= voltage_mv <= hi:
150
+ raise SafetyRefusal(
151
+ "power.voltage_out_of_range",
152
+ f"{voltage_mv} mV is outside the {self.rules.chip} supply range ({lo}-{hi} mV).",
153
+ voltage_mv=voltage_mv,
154
+ )
155
+
156
+ # Chip state ------------------------------------------------------------
157
+
158
+ def check_read_protection(self, optr: int) -> None:
159
+ if optr == 0:
160
+ # Reserved bits read as 1 on a real chip, so all zeros means the read failed.
161
+ raise SafetyRefusal(
162
+ "chip.unreadable",
163
+ "The option register read as all zeros, which a real chip never reports. The core may "
164
+ "be asleep or the probe link is bad; protection state is unknown, so flashing is refused.",
165
+ )
166
+ rdp = optr & self.rules.rdp_mask
167
+ if rdp != self.rules.rdp_level0:
168
+ raise SafetyRefusal(
169
+ "chip.read_protection",
170
+ f"Read protection is not at level 0 (RDP byte {hex(rdp)}, expected {hex(self.rules.rdp_level0)}). "
171
+ "Flashing could mass-erase or the chip may be locked; a person must handle this.",
172
+ optr=hex(optr),
173
+ )