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
|
@@ -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
|
+
)
|