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/image.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Read firmware images (ELF or raw binary) into loadable segments and symbols."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .errors import ImageError
|
|
11
|
+
|
|
12
|
+
PT_LOAD = 1
|
|
13
|
+
SHT_SYMTAB = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Segment:
|
|
18
|
+
address: int
|
|
19
|
+
data: bytes
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def end(self) -> int:
|
|
23
|
+
return self.address + len(self.data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Image:
|
|
28
|
+
path: str
|
|
29
|
+
segments: list[Segment]
|
|
30
|
+
symbols: dict[str, tuple[int, int]] = field(default_factory=dict) # name -> (address, size)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def sha256(self) -> str:
|
|
34
|
+
h = hashlib.sha256()
|
|
35
|
+
for s in sorted(self.segments, key=lambda s: s.address):
|
|
36
|
+
h.update(s.address.to_bytes(4, "little"))
|
|
37
|
+
h.update(s.data)
|
|
38
|
+
return h.hexdigest()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def size(self) -> int:
|
|
42
|
+
return sum(len(s.data) for s in self.segments)
|
|
43
|
+
|
|
44
|
+
def word_at(self, address: int) -> int | None:
|
|
45
|
+
for s in self.segments:
|
|
46
|
+
if s.address <= address and address + 4 <= s.end:
|
|
47
|
+
off = address - s.address
|
|
48
|
+
return int.from_bytes(s.data[off : off + 4], "little")
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_image(path: str | Path, base_address: int | None = None) -> Image:
|
|
53
|
+
"""Load an .elf, or a raw .bin placed at `base_address`."""
|
|
54
|
+
path = Path(path)
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
raise ImageError(f"no such file: {path}")
|
|
57
|
+
raw = path.read_bytes()
|
|
58
|
+
if raw[:4] == b"\x7fELF":
|
|
59
|
+
return _load_elf(raw, str(path))
|
|
60
|
+
if base_address is None:
|
|
61
|
+
raise ImageError("a raw binary needs a base address (for example the start of flash)")
|
|
62
|
+
return Image(str(path), [Segment(base_address, raw)])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_elf(raw: bytes, path: str) -> Image:
|
|
66
|
+
if raw[4] != 1 or raw[5] != 1:
|
|
67
|
+
raise ImageError("only 32-bit little-endian ELF files are supported")
|
|
68
|
+
(e_phoff, e_shoff) = struct.unpack_from("<II", raw, 0x1C)
|
|
69
|
+
(e_phentsize, e_phnum, e_shentsize, e_shnum) = struct.unpack_from("<HHHH", raw, 0x2A)
|
|
70
|
+
|
|
71
|
+
segments = []
|
|
72
|
+
for i in range(e_phnum):
|
|
73
|
+
p_type, p_offset, _vaddr, p_paddr, p_filesz = struct.unpack_from("<IIIII", raw, e_phoff + i * e_phentsize)
|
|
74
|
+
if p_type == PT_LOAD and p_filesz:
|
|
75
|
+
# The physical (load) address is where the bytes live in flash.
|
|
76
|
+
segments.append(Segment(p_paddr, raw[p_offset : p_offset + p_filesz]))
|
|
77
|
+
|
|
78
|
+
symbols: dict[str, tuple[int, int]] = {}
|
|
79
|
+
sections = [struct.unpack_from("<IIIIIIIIII", raw, e_shoff + i * e_shentsize) for i in range(e_shnum)]
|
|
80
|
+
for sh in sections:
|
|
81
|
+
if sh[1] != SHT_SYMTAB:
|
|
82
|
+
continue
|
|
83
|
+
sym_off, sym_size, link, entsize = sh[4], sh[5], sh[6], sh[9] or 16
|
|
84
|
+
str_off = sections[link][4]
|
|
85
|
+
for j in range(sym_size // entsize):
|
|
86
|
+
st_name, st_value, st_size = struct.unpack_from("<III", raw, sym_off + j * entsize)
|
|
87
|
+
if st_name:
|
|
88
|
+
end = raw.index(b"\0", str_off + st_name)
|
|
89
|
+
symbols[raw[str_off + st_name : end].decode(errors="replace")] = (st_value, st_size)
|
|
90
|
+
|
|
91
|
+
if not segments:
|
|
92
|
+
raise ImageError("the ELF file has no loadable segments")
|
|
93
|
+
return Image(path, segments, symbols)
|
siligent/log.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""An append-only log of every action taken on the bench, refused or not."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ActionLog:
|
|
11
|
+
def __init__(self, path: str | Path) -> None:
|
|
12
|
+
self.path = Path(path).expanduser()
|
|
13
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
def record(self, action: str, outcome: str, **fields: object) -> dict:
|
|
16
|
+
entry = {"time": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "action": action, "outcome": outcome, **fields}
|
|
17
|
+
with self.path.open("a") as f:
|
|
18
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
19
|
+
return entry
|
|
20
|
+
|
|
21
|
+
def tail(self, n: int = 20) -> list[dict]:
|
|
22
|
+
if not self.path.exists():
|
|
23
|
+
return []
|
|
24
|
+
lines = self.path.read_text().splitlines()[-n:]
|
|
25
|
+
return [json.loads(line) for line in lines if line.strip()]
|
siligent/mcp_server.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""MCP server: exposes the bench to AI agents (Claude Code, Cursor, others) over stdio.
|
|
2
|
+
|
|
3
|
+
Refusals come back as normal results with "refused": true, the rule that fired
|
|
4
|
+
and the reason, so the agent can explain or change course instead of retrying.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mcp.server.mcpserver import MCPServer
|
|
10
|
+
|
|
11
|
+
from . import api
|
|
12
|
+
from .bench import BenchConfig
|
|
13
|
+
|
|
14
|
+
INSTRUCTIONS = """\
|
|
15
|
+
Siligent controls a real hardware bench. Every write and flash passes a safety gate.
|
|
16
|
+
If a result has "refused": true, do not retry the same action: read "rule" and "reason",
|
|
17
|
+
then explain it to the user or choose a different approach. Reads are always allowed.
|
|
18
|
+
Start with detect_hardware, then board_rules to see what is allowed on this board.
|
|
19
|
+
Use check_image before flash or run_tests to catch problems without touching hardware.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
server = MCPServer("siligent", instructions=INSTRUCTIONS)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cfg(virtual: bool = False) -> BenchConfig:
|
|
26
|
+
cfg = BenchConfig.load()
|
|
27
|
+
if virtual:
|
|
28
|
+
cfg.virtual = True
|
|
29
|
+
return cfg
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@server.tool()
|
|
33
|
+
def detect_hardware() -> dict:
|
|
34
|
+
"""List connected debug probes and identify the board and the safety rules that apply."""
|
|
35
|
+
return api.detect(_cfg())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@server.tool()
|
|
39
|
+
def board_rules(board: str = "stm32l476rg") -> dict:
|
|
40
|
+
"""Show what is allowed and forbidden on a board: memory regions, protected pins, flash preconditions."""
|
|
41
|
+
return api.rules(board)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@server.tool()
|
|
45
|
+
def check_image(path: str) -> dict:
|
|
46
|
+
"""Check a firmware file (.elf) against the board's rules without touching hardware."""
|
|
47
|
+
return api.check_image(path, config=_cfg())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@server.tool()
|
|
51
|
+
def flash(path: str, virtual: bool = False) -> dict:
|
|
52
|
+
"""Flash a firmware file (.elf) to the board. The flash pages are backed up first.
|
|
53
|
+
virtual=true uses a Renode virtual board instead of real hardware."""
|
|
54
|
+
return api.flash(path, config=_cfg(virtual))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@server.tool()
|
|
58
|
+
def run_tests(path: str, timeout_s: float = 10.0, virtual: bool = False) -> dict:
|
|
59
|
+
"""Flash test firmware built with the Siligent harness, run it, and return
|
|
60
|
+
pass/fail and CPU cycles per test, plus energy if a power meter is configured.
|
|
61
|
+
virtual=true runs it on a Renode virtual board (no hardware needed; cycle counts
|
|
62
|
+
are simulated, and there is no energy)."""
|
|
63
|
+
return api.run_tests(path, timeout_s=timeout_s, config=_cfg(virtual))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@server.tool()
|
|
67
|
+
def read_memory(address: int, length: int, virtual: bool = False) -> dict:
|
|
68
|
+
"""Read up to 4096 bytes of chip memory. Returns hex."""
|
|
69
|
+
return api.read_memory(address, length, config=_cfg(virtual))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@server.tool()
|
|
73
|
+
def write_memory(address: int, data_hex: str, virtual: bool = False) -> dict:
|
|
74
|
+
"""Write bytes to RAM or a peripheral register. Flash, option bytes and protected pins are refused."""
|
|
75
|
+
return api.write_memory(address, data_hex, config=_cfg(virtual))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@server.tool()
|
|
79
|
+
def action_log(n: int = 20) -> dict:
|
|
80
|
+
"""The most recent bench actions, including refused ones."""
|
|
81
|
+
return api.action_log(n, config=_cfg())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
server.run("stdio")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
siligent/meters/base.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The power-meter interface and the energy maths shared by every meter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class MeterReading:
|
|
11
|
+
meter: str
|
|
12
|
+
duration_s: float
|
|
13
|
+
samples: int
|
|
14
|
+
energy_uj: float
|
|
15
|
+
avg_current_a: float
|
|
16
|
+
min_current_a: float
|
|
17
|
+
max_current_a: float
|
|
18
|
+
avg_voltage_v: float
|
|
19
|
+
resolution_a: float # smallest current step the meter can see
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict:
|
|
22
|
+
d = asdict(self)
|
|
23
|
+
# Say plainly when the meter is too coarse for what it measured.
|
|
24
|
+
d["below_resolution"] = self.avg_current_a < 10 * self.resolution_a
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PowerMeter(Protocol):
|
|
29
|
+
name: str
|
|
30
|
+
resolution_a: float
|
|
31
|
+
|
|
32
|
+
def start(self) -> None:
|
|
33
|
+
"""Begin sampling in the background."""
|
|
34
|
+
|
|
35
|
+
def stop(self) -> MeterReading:
|
|
36
|
+
"""Stop sampling and return the totals for the window."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def integrate(meter: str, resolution_a: float, samples: list[tuple[float, float, float]]) -> MeterReading:
|
|
40
|
+
"""Totals from (time_s, current_a, voltage_v) samples, by the trapezoid rule."""
|
|
41
|
+
if not samples:
|
|
42
|
+
return MeterReading(meter, 0.0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, resolution_a)
|
|
43
|
+
energy_j = 0.0
|
|
44
|
+
for (t0, i0, v0), (t1, i1, v1) in zip(samples, samples[1:]):
|
|
45
|
+
energy_j += (i0 * v0 + i1 * v1) / 2 * (t1 - t0)
|
|
46
|
+
duration = samples[-1][0] - samples[0][0]
|
|
47
|
+
currents = [s[1] for s in samples]
|
|
48
|
+
return MeterReading(
|
|
49
|
+
meter=meter,
|
|
50
|
+
duration_s=duration,
|
|
51
|
+
samples=len(samples),
|
|
52
|
+
energy_uj=energy_j * 1e6,
|
|
53
|
+
avg_current_a=sum(currents) / len(currents),
|
|
54
|
+
min_current_a=min(currents),
|
|
55
|
+
max_current_a=max(currents),
|
|
56
|
+
avg_voltage_v=sum(s[2] for s in samples) / len(samples),
|
|
57
|
+
resolution_a=resolution_a,
|
|
58
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""TI INA219 current/voltage sensor over I2C.
|
|
2
|
+
|
|
3
|
+
Good for active current. With a 0.1 ohm shunt it reads in 100 uA steps, so it
|
|
4
|
+
cannot see microcontroller sleep currents (around 1 uA). Use a uA-capable
|
|
5
|
+
meter for those.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from .base import MeterReading, integrate
|
|
15
|
+
|
|
16
|
+
REG_CONFIG = 0x00
|
|
17
|
+
REG_SHUNT = 0x01
|
|
18
|
+
REG_BUS = 0x02
|
|
19
|
+
SHUNT_LSB_V = 10e-6
|
|
20
|
+
BUS_LSB_V = 4e-3
|
|
21
|
+
# 32 V range, PGA /8 (+-320 mV), 12-bit, shunt and bus continuous: the reset default.
|
|
22
|
+
CONFIG_DEFAULT = 0x399F
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class I2CBus(Protocol):
|
|
26
|
+
def read_word(self, address: int, register: int) -> int:
|
|
27
|
+
"""Read a 16-bit register, most significant byte first (as the INA219 sends it)."""
|
|
28
|
+
|
|
29
|
+
def write_word(self, address: int, register: int, value: int) -> None:
|
|
30
|
+
"""Write a 16-bit register, most significant byte first."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LinuxI2CBus:
|
|
34
|
+
"""A Linux /dev/i2c-N bus via smbus2 (pip install siligent[ina219])."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, bus: int) -> None:
|
|
37
|
+
from smbus2 import SMBus # imported here so the dependency stays optional
|
|
38
|
+
|
|
39
|
+
self._bus = SMBus(bus)
|
|
40
|
+
|
|
41
|
+
def read_word(self, address: int, register: int) -> int:
|
|
42
|
+
w = self._bus.read_word_data(address, register)
|
|
43
|
+
return ((w & 0xFF) << 8) | (w >> 8) # SMBus words arrive low byte first
|
|
44
|
+
|
|
45
|
+
def write_word(self, address: int, register: int, value: int) -> None:
|
|
46
|
+
self._bus.write_word_data(address, register, ((value & 0xFF) << 8) | (value >> 8))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class INA219:
|
|
50
|
+
name = "INA219"
|
|
51
|
+
|
|
52
|
+
def __init__(self, bus: I2CBus, address: int = 0x40, shunt_ohms: float = 0.1, sample_hz: float = 500) -> None:
|
|
53
|
+
self.bus = bus
|
|
54
|
+
self.address = address
|
|
55
|
+
self.shunt_ohms = shunt_ohms
|
|
56
|
+
self.period = 1.0 / sample_hz
|
|
57
|
+
self.resolution_a = SHUNT_LSB_V / shunt_ohms
|
|
58
|
+
self._samples: list[tuple[float, float, float]] = []
|
|
59
|
+
self._stop = threading.Event()
|
|
60
|
+
self._thread: threading.Thread | None = None
|
|
61
|
+
self.bus.write_word(self.address, REG_CONFIG, CONFIG_DEFAULT)
|
|
62
|
+
|
|
63
|
+
def read_once(self) -> tuple[float, float]:
|
|
64
|
+
"""One (current_a, bus_voltage_v) reading."""
|
|
65
|
+
raw = self.bus.read_word(self.address, REG_SHUNT)
|
|
66
|
+
if raw & 0x8000:
|
|
67
|
+
raw -= 0x10000
|
|
68
|
+
bus = self.bus.read_word(self.address, REG_BUS)
|
|
69
|
+
return raw * SHUNT_LSB_V / self.shunt_ohms, (bus >> 3) * BUS_LSB_V
|
|
70
|
+
|
|
71
|
+
def _run(self) -> None:
|
|
72
|
+
t0 = time.monotonic()
|
|
73
|
+
while not self._stop.is_set():
|
|
74
|
+
i, v = self.read_once()
|
|
75
|
+
self._samples.append((time.monotonic() - t0, i, v))
|
|
76
|
+
time.sleep(self.period)
|
|
77
|
+
|
|
78
|
+
def start(self) -> None:
|
|
79
|
+
self._samples = []
|
|
80
|
+
self._stop.clear()
|
|
81
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
82
|
+
self._thread.start()
|
|
83
|
+
|
|
84
|
+
def stop(self) -> MeterReading:
|
|
85
|
+
self._stop.set()
|
|
86
|
+
if self._thread:
|
|
87
|
+
self._thread.join()
|
|
88
|
+
return integrate(self.name, self.resolution_a, self._samples)
|
siligent/probes/base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The interface a probe plugin implements. Plugins never apply safety rules themselves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from ..image import Segment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProbeBackend(Protocol):
|
|
11
|
+
unique_id: str
|
|
12
|
+
description: str
|
|
13
|
+
|
|
14
|
+
def open(self) -> None: ...
|
|
15
|
+
|
|
16
|
+
def close(self) -> None: ...
|
|
17
|
+
|
|
18
|
+
def read32(self, address: int) -> int: ...
|
|
19
|
+
|
|
20
|
+
def read_memory(self, address: int, length: int) -> bytes: ...
|
|
21
|
+
|
|
22
|
+
def write_memory(self, address: int, data: bytes) -> None: ...
|
|
23
|
+
|
|
24
|
+
def program(self, segments: list[Segment]) -> None:
|
|
25
|
+
"""Halt the core with a reset, then erase the sectors these segments cover and program
|
|
26
|
+
them. Never a whole-chip erase."""
|
|
27
|
+
|
|
28
|
+
def reset(self, halt: bool = False) -> None: ...
|
|
29
|
+
|
|
30
|
+
def halt(self) -> None: ...
|
|
31
|
+
|
|
32
|
+
def resume(self) -> None: ...
|
|
33
|
+
|
|
34
|
+
def state(self) -> str:
|
|
35
|
+
"""'running', 'halted', 'sleeping', 'lockup' or 'reset'."""
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""GuardedProbe: the only way Siligent touches a probe.
|
|
2
|
+
|
|
3
|
+
Every write and flash passes the safety gate here, and every action (allowed
|
|
4
|
+
or refused) is written to the action log. The CLI, the Python API and the
|
|
5
|
+
MCP server all go through this class, so none of them can get around the rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..errors import SafetyRefusal
|
|
14
|
+
from ..image import Image, Segment
|
|
15
|
+
from ..log import ActionLog
|
|
16
|
+
from ..rules import BoardRules
|
|
17
|
+
from ..safety import SafetyGate
|
|
18
|
+
from .base import ProbeBackend
|
|
19
|
+
|
|
20
|
+
FLASH_PAGE = 2048
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GuardedProbe:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
backend: ProbeBackend,
|
|
27
|
+
rules: BoardRules,
|
|
28
|
+
gate: SafetyGate,
|
|
29
|
+
log: ActionLog,
|
|
30
|
+
snapshot_dir: str | Path,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.backend = backend
|
|
33
|
+
self.rules = rules
|
|
34
|
+
self.gate = gate
|
|
35
|
+
self.log = log
|
|
36
|
+
self.snapshot_dir = Path(snapshot_dir).expanduser()
|
|
37
|
+
|
|
38
|
+
# Reads are allowed anywhere. Some peripheral registers clear flags when read,
|
|
39
|
+
# so reads are still logged.
|
|
40
|
+
def read_memory(self, address: int, length: int) -> bytes:
|
|
41
|
+
data, _ = self.read_memory_checked(address, length)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
def read_memory_checked(self, address: int, length: int) -> tuple[bytes, bool]:
|
|
45
|
+
"""Read memory; returns (data, halted_to_read).
|
|
46
|
+
|
|
47
|
+
While the core sleeps, the ST-LINK returns zeros for RAM and flash with no
|
|
48
|
+
error. So a sleeping core is halted for the read, then resumed.
|
|
49
|
+
"""
|
|
50
|
+
halted = self.backend.state() == "sleeping"
|
|
51
|
+
if halted:
|
|
52
|
+
self.backend.halt()
|
|
53
|
+
try:
|
|
54
|
+
data = self.backend.read_memory(address, length)
|
|
55
|
+
finally:
|
|
56
|
+
if halted:
|
|
57
|
+
self.backend.resume()
|
|
58
|
+
self.log.record("read_memory", "ok", address=hex(address), length=length, halted_to_read=halted)
|
|
59
|
+
return data, halted
|
|
60
|
+
|
|
61
|
+
def read32(self, address: int) -> int:
|
|
62
|
+
return self.backend.read32(address)
|
|
63
|
+
|
|
64
|
+
def device_id(self) -> int:
|
|
65
|
+
return self.backend.read32(self.rules.idcode_address) & 0xFFF
|
|
66
|
+
|
|
67
|
+
def write_memory(self, address: int, data: bytes) -> None:
|
|
68
|
+
try:
|
|
69
|
+
self.gate.check_host_write(address, data)
|
|
70
|
+
except SafetyRefusal as e:
|
|
71
|
+
self.log.record("write_memory", "refused", rule=e.rule, address=hex(address), length=len(data))
|
|
72
|
+
raise
|
|
73
|
+
self.backend.write_memory(address, data)
|
|
74
|
+
self.log.record("write_memory", "ok", address=hex(address), length=len(data))
|
|
75
|
+
|
|
76
|
+
def flash(self, image: Image) -> dict:
|
|
77
|
+
"""Check, snapshot, then program an image. Returns what was done."""
|
|
78
|
+
halted = False
|
|
79
|
+
try:
|
|
80
|
+
self.gate.check_bench_for_flash()
|
|
81
|
+
self.gate.check_image(image)
|
|
82
|
+
# Halt first: while the core sleeps, register reads come back as zeros.
|
|
83
|
+
self.backend.halt()
|
|
84
|
+
halted = True
|
|
85
|
+
dev_id = self.device_id()
|
|
86
|
+
if dev_id != self.rules.dev_id:
|
|
87
|
+
raise SafetyRefusal(
|
|
88
|
+
"chip.mismatch",
|
|
89
|
+
f"The connected chip (device ID {hex(dev_id)}) is not a {self.rules.chip} "
|
|
90
|
+
f"({hex(self.rules.dev_id)}); these rules do not apply to it.",
|
|
91
|
+
)
|
|
92
|
+
self.gate.check_read_protection(self.backend.read32(self.rules.optr_address))
|
|
93
|
+
except SafetyRefusal as e:
|
|
94
|
+
if halted:
|
|
95
|
+
self.backend.resume() # a refusal leaves the board as it was
|
|
96
|
+
self.log.record("flash", "refused", rule=e.rule, image=image.path, sha256=image.sha256)
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
# A virtual board starts fresh each time, so there is nothing to back up.
|
|
100
|
+
snapshot = self._snapshot(image.segments) if getattr(self.backend, "persistent", True) else None
|
|
101
|
+
self.backend.program(image.segments)
|
|
102
|
+
result = {
|
|
103
|
+
"ok": True,
|
|
104
|
+
"image": image.path,
|
|
105
|
+
"sha256": image.sha256,
|
|
106
|
+
"bytes": image.size,
|
|
107
|
+
"snapshot": str(snapshot) if snapshot else None,
|
|
108
|
+
}
|
|
109
|
+
self.log.record("flash", "ok", **result)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def reset(self, halt: bool = False) -> None:
|
|
113
|
+
self.backend.reset(halt=halt)
|
|
114
|
+
self.log.record("reset", "ok", halt=halt)
|
|
115
|
+
|
|
116
|
+
def resume(self) -> None:
|
|
117
|
+
self.backend.resume()
|
|
118
|
+
|
|
119
|
+
def halt(self) -> None:
|
|
120
|
+
self.backend.halt()
|
|
121
|
+
|
|
122
|
+
def _snapshot(self, segments: list[Segment]) -> Path:
|
|
123
|
+
"""Save the flash pages about to be overwritten, so they can be put back.
|
|
124
|
+
|
|
125
|
+
The first flash on a device also saves the whole flash as a baseline.
|
|
126
|
+
"""
|
|
127
|
+
uid = self.backend.unique_id or "unknown"
|
|
128
|
+
folder = self.snapshot_dir / uid
|
|
129
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
baseline = folder / "baseline-full-flash.bin"
|
|
131
|
+
if not baseline.exists():
|
|
132
|
+
baseline.write_bytes(self.backend.read_memory(self.rules.flash.start, self.rules.flash.size))
|
|
133
|
+
self.log.record("snapshot", "ok", kind="baseline", path=str(baseline))
|
|
134
|
+
|
|
135
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
136
|
+
path = folder / f"pages-{stamp}.bin"
|
|
137
|
+
with path.open("wb") as f:
|
|
138
|
+
for seg in segments:
|
|
139
|
+
start = seg.address - (seg.address % FLASH_PAGE)
|
|
140
|
+
end = -(-seg.end // FLASH_PAGE) * FLASH_PAGE
|
|
141
|
+
f.write(start.to_bytes(4, "little") + (end - start).to_bytes(4, "little"))
|
|
142
|
+
f.write(self.backend.read_memory(start, end - start))
|
|
143
|
+
return path
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""ST-LINK (and other CMSIS-DAP style probes) through pyOCD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from ..errors import HardwareError
|
|
8
|
+
from ..image import Segment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PyOCDBackend:
|
|
12
|
+
def __init__(self, target: str, unique_id: str | None = None, frequency_hz: int = 4_000_000) -> None:
|
|
13
|
+
self.target_name = target
|
|
14
|
+
self.unique_id = unique_id or ""
|
|
15
|
+
self.description = ""
|
|
16
|
+
self.frequency_hz = frequency_hz
|
|
17
|
+
self._session = None
|
|
18
|
+
self._target = None
|
|
19
|
+
|
|
20
|
+
def open(self) -> None:
|
|
21
|
+
from pyocd.core.helpers import ConnectHelper
|
|
22
|
+
|
|
23
|
+
logging.getLogger("pyocd").setLevel(logging.WARNING)
|
|
24
|
+
session = ConnectHelper.session_with_chosen_probe(
|
|
25
|
+
blocking=False,
|
|
26
|
+
unique_id=self.unique_id or None,
|
|
27
|
+
options={
|
|
28
|
+
"target_override": self.target_name,
|
|
29
|
+
# Attach without halting, so reading never disturbs running firmware.
|
|
30
|
+
"connect_mode": "attach",
|
|
31
|
+
"frequency": self.frequency_hz,
|
|
32
|
+
"hide_programming_progress": True,
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
if session is None:
|
|
36
|
+
raise HardwareError("no debug probe found (is the board plugged in?)")
|
|
37
|
+
try:
|
|
38
|
+
session.open()
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise HardwareError(f"could not connect to the chip through the probe: {e}") from e
|
|
41
|
+
self._session = session
|
|
42
|
+
self._target = session.board.target
|
|
43
|
+
self.unique_id = session.probe.unique_id
|
|
44
|
+
self.description = session.probe.description
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
if self._session is not None:
|
|
48
|
+
self._session.close()
|
|
49
|
+
self._session = None
|
|
50
|
+
self._target = None
|
|
51
|
+
|
|
52
|
+
def _t(self):
|
|
53
|
+
if self._target is None:
|
|
54
|
+
raise HardwareError("probe is not open")
|
|
55
|
+
return self._target
|
|
56
|
+
|
|
57
|
+
def read32(self, address: int) -> int:
|
|
58
|
+
return self._t().read32(address)
|
|
59
|
+
|
|
60
|
+
def read_memory(self, address: int, length: int) -> bytes:
|
|
61
|
+
return bytes(self._t().read_memory_block8(address, length))
|
|
62
|
+
|
|
63
|
+
def write_memory(self, address: int, data: bytes) -> None:
|
|
64
|
+
self._t().write_memory_block8(address, list(data))
|
|
65
|
+
|
|
66
|
+
def program(self, segments: list[Segment]) -> None:
|
|
67
|
+
from pyocd.flash.loader import MemoryLoader
|
|
68
|
+
|
|
69
|
+
# Stop whatever firmware is running first. Erasing flash under running code
|
|
70
|
+
# locks the core up and makes the erase time out (seen on the real L476).
|
|
71
|
+
self._t().reset_and_halt()
|
|
72
|
+
# Sector erase only: a chip erase would wipe more than the image covers.
|
|
73
|
+
loader = MemoryLoader(self._session, chip_erase="sector", smart_flash=True, keep_unwritten=True)
|
|
74
|
+
for seg in segments:
|
|
75
|
+
loader.add_data(seg.address, seg.data)
|
|
76
|
+
loader.commit()
|
|
77
|
+
|
|
78
|
+
def reset(self, halt: bool = False) -> None:
|
|
79
|
+
if halt:
|
|
80
|
+
self._t().reset_and_halt()
|
|
81
|
+
else:
|
|
82
|
+
self._t().reset()
|
|
83
|
+
|
|
84
|
+
def halt(self) -> None:
|
|
85
|
+
self._t().halt()
|
|
86
|
+
|
|
87
|
+
def resume(self) -> None:
|
|
88
|
+
self._t().resume()
|
|
89
|
+
|
|
90
|
+
def state(self) -> str:
|
|
91
|
+
return self._t().get_state().name.lower()
|