hector-cli 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.
- hector/__init__.py +4 -0
- hector/__main__.py +7 -0
- hector/commands/__init__.py +35 -0
- hector/commands/base.py +111 -0
- hector/commands/export.py +26 -0
- hector/commands/init.py +15 -0
- hector/commands/run.py +27 -0
- hector/commands/test.py +52 -0
- hector/commands/validate.py +15 -0
- hector/connections.py +130 -0
- hector/core.py +259 -0
- hector/dependencies.py +41 -0
- hector/docker.py +191 -0
- hector/generator.py +292 -0
- hector/hubs.py +196 -0
- hector/mappings.py +167 -0
- hector/modules.py +201 -0
- hector/peripherals.py +168 -0
- hector/pipeline.py +693 -0
- hector/reporters.py +111 -0
- hector/runners.py +380 -0
- hector/scaffold.py +75 -0
- hector/validator.py +414 -0
- hector_cli-0.1.0.dist-info/METADATA +1401 -0
- hector_cli-0.1.0.dist-info/RECORD +29 -0
- hector_cli-0.1.0.dist-info/WHEEL +5 -0
- hector_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hector_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
- hector_cli-0.1.0.dist-info/top_level.txt +1 -0
hector/generator.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Node descriptor, .repl generation, and .resc generation.
|
|
3
|
+
|
|
4
|
+
Each upstream step (peripherals, connections) is a pure data producer that populates
|
|
5
|
+
a NodeDescriptor. This module is the single place that turns those descriptors into
|
|
6
|
+
files on disk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import glob
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from .core import ARTIFACTS_DIR
|
|
17
|
+
from .hubs import HUBS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ==========================================================================
|
|
21
|
+
# DATA STRUCTURES
|
|
22
|
+
# ==========================================================================
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PeripheralDef:
|
|
26
|
+
name: str
|
|
27
|
+
ptype: str
|
|
28
|
+
at: str | None
|
|
29
|
+
args: dict
|
|
30
|
+
signals: list # IRQ/GPIO connection lines inlined on this peripheral
|
|
31
|
+
init: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class NodeDescriptor:
|
|
36
|
+
name: str
|
|
37
|
+
peripheral_defs: list = field(default_factory=list) # PeripheralDef items, declaration order
|
|
38
|
+
updating_entries: list = field(default_factory=list) # (periph_name, wire_line) from connections
|
|
39
|
+
so_commands: list = field(default_factory=list) # Verilator SimulationFilePathLinux commands
|
|
40
|
+
csharp_dlls: set = field(default_factory=set)
|
|
41
|
+
init: str | None = None # sysbus init block (node-level init:)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ==========================================================================
|
|
45
|
+
# REPL VALUE FORMATTING
|
|
46
|
+
# ==========================================================================
|
|
47
|
+
|
|
48
|
+
_IDENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_.]*$')
|
|
49
|
+
_HEX_RE = re.compile(r'^0[xX][0-9a-fA-F]+$')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def repl_value(v):
|
|
53
|
+
if isinstance(v, bool):
|
|
54
|
+
return "true" if v else "false"
|
|
55
|
+
if isinstance(v, list):
|
|
56
|
+
return "[" + ", ".join(repl_value(i) for i in v) + "]"
|
|
57
|
+
if isinstance(v, str):
|
|
58
|
+
# Identifiers (peripheral refs) and hex literals are emitted unquoted.
|
|
59
|
+
if _IDENT_RE.match(v) or _HEX_RE.match(v):
|
|
60
|
+
return v
|
|
61
|
+
return f'"{v}"'
|
|
62
|
+
return str(v)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_repl_fragment(name, ptype, args=None, at=None, irqs=None, init=None):
|
|
66
|
+
"""One repl entry: `name: Type [@ registration]` plus args, IRQ connections, and init commands."""
|
|
67
|
+
head = f"{name}: {ptype}" + (f" @ {at}" if at else "")
|
|
68
|
+
lines = [head]
|
|
69
|
+
lines += [f" {k}: {repl_value(v)}" for k, v in (args or {}).items()]
|
|
70
|
+
lines += [f" {irq}" for irq in (irqs or [])]
|
|
71
|
+
if init:
|
|
72
|
+
lines.append(" init:")
|
|
73
|
+
for cmd in init.splitlines():
|
|
74
|
+
if cmd.strip():
|
|
75
|
+
lines.append(f" {cmd.strip()}")
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def queue_command(node_config, command):
|
|
80
|
+
node_config.setdefault("_gen_commands", []).append(command)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ==========================================================================
|
|
84
|
+
# .REPL GENERATION
|
|
85
|
+
# ==========================================================================
|
|
86
|
+
|
|
87
|
+
def generate_node_repl(descriptor, job_index):
|
|
88
|
+
"""Write a .repl for one node from its NodeDescriptor.
|
|
89
|
+
|
|
90
|
+
Layout:
|
|
91
|
+
1. Peripheral declarations (with inline signals and per-peripheral init blocks)
|
|
92
|
+
2. Updating entries from connections (one per intra-machine wire)
|
|
93
|
+
3. sysbus: init: block (node-level init commands)
|
|
94
|
+
|
|
95
|
+
Returns the host path written, or None if there was nothing to emit.
|
|
96
|
+
"""
|
|
97
|
+
parts = []
|
|
98
|
+
|
|
99
|
+
for p in descriptor.peripheral_defs:
|
|
100
|
+
parts.append(build_repl_fragment(p.name, p.ptype, p.args, p.at,
|
|
101
|
+
irqs=p.signals, init=p.init))
|
|
102
|
+
|
|
103
|
+
for periph_name, wire in descriptor.updating_entries:
|
|
104
|
+
parts.append(f"{periph_name}:\n {wire}")
|
|
105
|
+
|
|
106
|
+
if not parts and not descriptor.init:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
repl_root = os.path.join(ARTIFACTS_DIR, "repl")
|
|
110
|
+
os.makedirs(repl_root, exist_ok=True)
|
|
111
|
+
repl_host = os.path.join(repl_root, f"job_{job_index}_{descriptor.name}.gen.repl")
|
|
112
|
+
|
|
113
|
+
with open(repl_host, "w") as f:
|
|
114
|
+
if parts:
|
|
115
|
+
f.write("\n\n".join(parts) + "\n")
|
|
116
|
+
if descriptor.init:
|
|
117
|
+
indented = "\n".join(f" {l}" for l in descriptor.init.splitlines())
|
|
118
|
+
f.write(f"\nsysbus:\n init:\n{indented}\n")
|
|
119
|
+
|
|
120
|
+
return repl_host
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ==========================================================================
|
|
124
|
+
# NODE COMMAND FINALIZATION
|
|
125
|
+
# ==========================================================================
|
|
126
|
+
|
|
127
|
+
def finalize_commands(concrete_nodes):
|
|
128
|
+
"""Prepend generated commands (instance attaches, then host mappings) before the
|
|
129
|
+
user's own commands: order is [attach] [map] [user]."""
|
|
130
|
+
for node_config in concrete_nodes.values():
|
|
131
|
+
gen = node_config.get("_gen_commands")
|
|
132
|
+
if not gen:
|
|
133
|
+
continue
|
|
134
|
+
block = "\n".join(gen)
|
|
135
|
+
existing = node_config.get("commands")
|
|
136
|
+
if not existing:
|
|
137
|
+
node_config["commands"] = block
|
|
138
|
+
elif isinstance(existing, list):
|
|
139
|
+
node_config["commands"] = [block] + existing
|
|
140
|
+
else:
|
|
141
|
+
node_config["commands"] = block + "\n" + existing.rstrip()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ==========================================================================
|
|
145
|
+
# .RESC GENERATION
|
|
146
|
+
# ==========================================================================
|
|
147
|
+
|
|
148
|
+
def generate_emulation_resc(renode_nodes, hubs_registry, cross_connections,
|
|
149
|
+
quantum, debug_config, output_filename, emulation_setup=None):
|
|
150
|
+
"""One resc per job: every node becomes a named machine in one emulation; hubs and
|
|
151
|
+
cross-machine connectors wire them; one shared `start`.
|
|
152
|
+
|
|
153
|
+
renode_nodes : list of (node_name, node_config)
|
|
154
|
+
hubs_registry : {hub_name: hub_type}
|
|
155
|
+
debug_config : {node_name: gdb_port}
|
|
156
|
+
emulation_setup : list of emulation-level lines to emit before machine blocks
|
|
157
|
+
(e.g. CreateServerSocketTerminal, CreateUartPtyTerminal)
|
|
158
|
+
"""
|
|
159
|
+
lines = []
|
|
160
|
+
|
|
161
|
+
# --- emulation-level setup (terminals, etc.) – must exist before machines connect ---
|
|
162
|
+
if emulation_setup:
|
|
163
|
+
lines.append("# === emulation-level setup ===")
|
|
164
|
+
lines.extend(emulation_setup)
|
|
165
|
+
|
|
166
|
+
# --- machines ---
|
|
167
|
+
for node_name, node_config in renode_nodes:
|
|
168
|
+
lines.append(f"\n# === machine: {node_name} ===")
|
|
169
|
+
lines.append(f'mach create "{node_name}"')
|
|
170
|
+
for repl in node_config.get("platform", []):
|
|
171
|
+
lines.append(f"machine LoadPlatformDescription @{repl}")
|
|
172
|
+
fw = node_config.get("firmware")
|
|
173
|
+
if fw and fw != "none":
|
|
174
|
+
lines.append(f"sysbus LoadELF @{fw}")
|
|
175
|
+
cmds = node_config.get("commands", "")
|
|
176
|
+
if cmds:
|
|
177
|
+
lines.extend(cmds if isinstance(cmds, list) else [cmds.strip()])
|
|
178
|
+
if node_name in debug_config:
|
|
179
|
+
port = debug_config[node_name]
|
|
180
|
+
lines.append(f"machine StartGdbServer {port}")
|
|
181
|
+
lines.append("cpu IsHalted true")
|
|
182
|
+
lines.append(f'echo "GDB server ready for {node_name} on port {port}"')
|
|
183
|
+
|
|
184
|
+
# --- emulation-global quantum ---
|
|
185
|
+
if quantum:
|
|
186
|
+
lines.append(f'\nemulation SetGlobalQuantum "{quantum}"')
|
|
187
|
+
|
|
188
|
+
# --- hubs (each hub class emits its own create command) ---
|
|
189
|
+
if hubs_registry:
|
|
190
|
+
lines.append("\n# === hubs ===")
|
|
191
|
+
for hub_name, hub_type in hubs_registry.items():
|
|
192
|
+
lines.extend(HUBS.get(hub_type).emit_create(hub_name))
|
|
193
|
+
|
|
194
|
+
# --- cross-machine connections (each hub class emits its own connect lines) ---
|
|
195
|
+
if cross_connections:
|
|
196
|
+
lines.append("\n# === cross-machine connections ===")
|
|
197
|
+
for spec in cross_connections:
|
|
198
|
+
lines.extend(HUBS.get(hubs_registry[spec["hub"]]).emit_connect(spec))
|
|
199
|
+
|
|
200
|
+
lines.append("\nstart")
|
|
201
|
+
content = "\n".join(lines)
|
|
202
|
+
with open(output_filename, "w") as f:
|
|
203
|
+
f.write(content)
|
|
204
|
+
return content
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def snapshot_resc(snapshot_container_path, debug_config=None):
|
|
208
|
+
"""Build resc text that loads a snapshot and resumes.
|
|
209
|
+
|
|
210
|
+
When debug_config ({machine_name: gdb_port}) is given, each named machine gets a
|
|
211
|
+
GDB server opened and its CPU halted before the resume — so a loaded snapshot is
|
|
212
|
+
debuggable just like a fresh boot. (A GDB server is a live connection, never part
|
|
213
|
+
of saved snapshot state, so it must be (re)started after every Load.)
|
|
214
|
+
"""
|
|
215
|
+
lines = [f"Load @{snapshot_container_path}"]
|
|
216
|
+
for node_name, port in (debug_config or {}).items():
|
|
217
|
+
lines += [f'mach set "{node_name}"',
|
|
218
|
+
f"machine StartGdbServer {port}",
|
|
219
|
+
"cpu IsHalted true",
|
|
220
|
+
f'echo "GDB server ready for {node_name} on port {port}"']
|
|
221
|
+
lines.append("start")
|
|
222
|
+
return "\n".join(lines) + "\n"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ==========================================================================
|
|
226
|
+
# ARTIFACT COLLECTION
|
|
227
|
+
# ==========================================================================
|
|
228
|
+
|
|
229
|
+
def collect_artifacts(patterns, output_dir, job_index=None):
|
|
230
|
+
"""Sweep the global artifact glob patterns and copy matches into
|
|
231
|
+
<output_dir>/artifacts/ so a single directory can be uploaded by CI / read by a
|
|
232
|
+
dashboard.
|
|
233
|
+
|
|
234
|
+
patterns : list of glob strings (top-level 'artifacts:' or --artifacts override)
|
|
235
|
+
output_dir : the run output directory (--output)
|
|
236
|
+
job_index : 1-based job number; prefixes copied filenames to avoid cross-job clashes
|
|
237
|
+
|
|
238
|
+
Returns the list of collected destination paths (relative to cwd).
|
|
239
|
+
"""
|
|
240
|
+
if not patterns:
|
|
241
|
+
return []
|
|
242
|
+
dest_root = os.path.realpath(os.path.join(output_dir, "artifacts"))
|
|
243
|
+
# Never collect from the framework's own tree (the Renode/integration clones and
|
|
244
|
+
# generated files live here) or from our own previous copies.
|
|
245
|
+
prune = (dest_root, os.path.realpath(ARTIFACTS_DIR))
|
|
246
|
+
job_sub = f"job_{job_index}" if job_index else ""
|
|
247
|
+
collected, seen = [], set()
|
|
248
|
+
header_shown = False
|
|
249
|
+
for pattern in patterns:
|
|
250
|
+
for src in sorted(_match_artifacts(pattern, dest_root)):
|
|
251
|
+
real = os.path.realpath(src)
|
|
252
|
+
if real in seen or not os.path.isfile(real):
|
|
253
|
+
continue
|
|
254
|
+
if any(real == p or real.startswith(p + os.sep) for p in prune):
|
|
255
|
+
continue
|
|
256
|
+
seen.add(real)
|
|
257
|
+
# Mirror the source path under artifacts/job_N/ so files with the same
|
|
258
|
+
# basename in different directories don't clobber each other.
|
|
259
|
+
rel_src = os.path.relpath(real)
|
|
260
|
+
if rel_src.startswith(".."): # outside cwd → flatten to basename
|
|
261
|
+
rel_src = os.path.basename(real)
|
|
262
|
+
dest = os.path.join(dest_root, job_sub, rel_src)
|
|
263
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
264
|
+
shutil.copy2(real, dest)
|
|
265
|
+
rel = os.path.relpath(dest)
|
|
266
|
+
collected.append(rel)
|
|
267
|
+
if not header_shown:
|
|
268
|
+
label = f" (job {job_index})" if job_index else ""
|
|
269
|
+
print(f"\n[ARTIFACTS]{label}:")
|
|
270
|
+
header_shown = True
|
|
271
|
+
print(f" -> {os.path.relpath(real)} → {rel}")
|
|
272
|
+
return collected
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _match_artifacts(pattern, dest_root):
|
|
276
|
+
"""Resolve one artifact glob pattern to a list of host paths.
|
|
277
|
+
|
|
278
|
+
A bare filename pattern (no '/', e.g. '*.xml') searches the whole project tree
|
|
279
|
+
recursively, pruning hidden dirs (.hector, .git, …) and the artifacts output dir —
|
|
280
|
+
so 'results/junit.xml' is found without sweeping the bundled Renode clone.
|
|
281
|
+
A pattern with a path component ('results/*.bin', 'logs/**/*.log') is passed to
|
|
282
|
+
glob as-is, so explicit directories and '**' behave normally.
|
|
283
|
+
"""
|
|
284
|
+
if "/" in pattern or os.sep in pattern:
|
|
285
|
+
return glob.glob(pattern, recursive=True)
|
|
286
|
+
matches = []
|
|
287
|
+
for root, dirs, files in os.walk("."):
|
|
288
|
+
dirs[:] = [d for d in dirs
|
|
289
|
+
if not d.startswith(".")
|
|
290
|
+
and os.path.realpath(os.path.join(root, d)) != dest_root]
|
|
291
|
+
matches.extend(os.path.join(root, name) for name in fnmatch.filter(files, pattern))
|
|
292
|
+
return matches
|
hector/hubs.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
HUBS - cross-machine connection media (emulation-level objects).
|
|
4
|
+
|
|
5
|
+
==========================================================================
|
|
6
|
+
HOW TO ADD A NEW HUB TYPE
|
|
7
|
+
==========================================================================
|
|
8
|
+
1. Subclass `Hub`.
|
|
9
|
+
2. Decorate it with `@HUBS.register("<yaml-type-name>")`.
|
|
10
|
+
3. Set `create_command` (the `emulation <X> "name"` verb) and `symmetric`.
|
|
11
|
+
4. If it is asymmetric (needs source/dest roles + pins), override `build_spec`
|
|
12
|
+
and `emit_connect` (see GpioConnector below for the template).
|
|
13
|
+
|
|
14
|
+
That is the whole change. `connections.py` and `engine.py` are generic over this
|
|
15
|
+
registry, so nothing else needs editing. Example — adding a SPI connector:
|
|
16
|
+
|
|
17
|
+
@HUBS.register("spi")
|
|
18
|
+
class SpiConnector(Hub):
|
|
19
|
+
create_command = "CreateSPIConnector" # whatever Renode calls it
|
|
20
|
+
symmetric = False
|
|
21
|
+
# ... override build_spec/emit_connect if it needs pins/roles ...
|
|
22
|
+
|
|
23
|
+
The YAML then immediately accepts: hubs:\n mylink: { type: spi }
|
|
24
|
+
==========================================================================
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .core import Registry, RuntimeOptions
|
|
28
|
+
|
|
29
|
+
HUBS = Registry("hub type")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Hub:
|
|
33
|
+
"""Base class for a cross-machine connection medium.
|
|
34
|
+
|
|
35
|
+
A symmetric hub (uart/can/ethernet) is wired with '<->' and every endpoint is
|
|
36
|
+
equal. An asymmetric connector (gpio) is wired with '->' and each endpoint has a
|
|
37
|
+
role (source/destination) plus a pin.
|
|
38
|
+
"""
|
|
39
|
+
create_command = None # the `emulation <create_command> "name"` verb
|
|
40
|
+
symmetric = True # True -> '<->'; False -> directional '->'
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def emit_create(cls, name):
|
|
44
|
+
"""resc line(s) that instantiate the hub."""
|
|
45
|
+
return [f'emulation {cls.create_command} "{name}"']
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def build_spec(cls, node, periph, hub_name, ep, hub_on_left, line):
|
|
49
|
+
"""Validate one connection endpoint and return a spec dict for the resc.
|
|
50
|
+
Default: symmetric medium, no role/pin."""
|
|
51
|
+
return {"node": node, "periph": periph, "hub": hub_name}
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def emit_connect(cls, spec):
|
|
55
|
+
"""resc lines connecting one endpoint (one spec) to this hub."""
|
|
56
|
+
return [f'mach set "{spec["node"]}"',
|
|
57
|
+
f'connector Connect sysbus.{spec["periph"]} {spec["hub"]}']
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def runtime_options(cls, spec):
|
|
61
|
+
"""Execution-environment needs this connection imposes on the Renode run
|
|
62
|
+
(docker caps/devices/root). Default: none. Override for a medium that needs,
|
|
63
|
+
e.g., a host device or NET_ADMIN."""
|
|
64
|
+
return RuntimeOptions()
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def describe(cls, spec):
|
|
68
|
+
return f'{spec["node"]}.{spec["periph"]} <-> {spec["hub"]}'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -------------------- symmetric media --------------------
|
|
72
|
+
@HUBS.register("uart")
|
|
73
|
+
class UartHub(Hub):
|
|
74
|
+
create_command = "CreateUARTHub"
|
|
75
|
+
symmetric = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@HUBS.register("can")
|
|
79
|
+
class CanHub(Hub):
|
|
80
|
+
create_command = "CreateCANHub"
|
|
81
|
+
symmetric = True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@HUBS.register("ethernet")
|
|
85
|
+
class EthernetSwitch(Hub):
|
|
86
|
+
create_command = "CreateSwitch"
|
|
87
|
+
symmetric = True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# -------------------- asymmetric connectors --------------------
|
|
91
|
+
@HUBS.register("gpio")
|
|
92
|
+
class GpioConnector(Hub):
|
|
93
|
+
create_command = "CreateGPIOConnector"
|
|
94
|
+
symmetric = False
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def build_spec(cls, node, periph, hub_name, ep, hub_on_left, line):
|
|
98
|
+
if ep["kind"] != "pin":
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Connection '{line}': gpio connections need a pin, e.g. "
|
|
101
|
+
f"{node}.{periph}@<n>."
|
|
102
|
+
)
|
|
103
|
+
# arrow direction relative to the hub picks the role:
|
|
104
|
+
# periph -> hub => this port is the SOURCE
|
|
105
|
+
# hub -> periph => this port is the DESTINATION
|
|
106
|
+
role = "dest" if hub_on_left else "source"
|
|
107
|
+
return {"node": node, "periph": periph, "hub": hub_name,
|
|
108
|
+
"role": role, "pin": ep["pin"]}
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def emit_connect(cls, spec):
|
|
112
|
+
sel = "SelectSourcePin" if spec["role"] == "source" else "SelectDestinationPin"
|
|
113
|
+
return [f'mach set "{spec["node"]}"',
|
|
114
|
+
f'connector Connect sysbus.{spec["periph"]} {spec["hub"]}',
|
|
115
|
+
f'{spec["hub"]} {sel} sysbus.{spec["periph"]} {spec["pin"]}']
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def describe(cls, spec):
|
|
119
|
+
return f'{spec["node"]}.{spec["periph"]}@{spec["pin"]} ({spec["role"]}) -> {spec["hub"]}'
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# -------------------- USB connector --------------------
|
|
123
|
+
@HUBS.register("usb")
|
|
124
|
+
class UsbConnector(Hub):
|
|
125
|
+
"""Asymmetric USB connector. The device side uses `connector Connect`;
|
|
126
|
+
the controller side uses `RegisterInController`.
|
|
127
|
+
|
|
128
|
+
In connections:
|
|
129
|
+
mcu.usb -> usblink # mcu is the USB DEVICE (periph->hub)
|
|
130
|
+
usblink -> host.usb # host is the USB CONTROLLER (hub->periph)
|
|
131
|
+
|
|
132
|
+
Docs: https://renode.readthedocs.io/en/latest/tutorials/usbip.html
|
|
133
|
+
"""
|
|
134
|
+
create_command = "CreateUSBConnector"
|
|
135
|
+
symmetric = False
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def build_spec(cls, node, periph, hub_name, ep, hub_on_left, line):
|
|
139
|
+
# periph -> hub => device; hub -> periph => controller
|
|
140
|
+
role = "controller" if hub_on_left else "device"
|
|
141
|
+
return {"node": node, "periph": periph, "hub": hub_name, "role": role}
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def emit_connect(cls, spec):
|
|
145
|
+
if spec["role"] == "device":
|
|
146
|
+
return [f'mach set "{spec["node"]}"',
|
|
147
|
+
f'connector Connect sysbus.{spec["periph"]} {spec["hub"]}']
|
|
148
|
+
else: # controller
|
|
149
|
+
return [f'mach set "{spec["node"]}"',
|
|
150
|
+
f'{spec["hub"]} RegisterInController sysbus.{spec["periph"]}']
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def describe(cls, spec):
|
|
154
|
+
return f'{spec["node"]}.{spec["periph"]} ({spec["role"]}) <-> {spec["hub"]}'
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@HUBS.register("wireless")
|
|
158
|
+
class WirelessMedium(Hub):
|
|
159
|
+
"""IEEE 802.15.4 wireless medium (ZigBee / Thread / 6LoWPAN / Matter).
|
|
160
|
+
|
|
161
|
+
Connects radio interfaces across machines. All connected interfaces receive all
|
|
162
|
+
packets by default; use SetMediumFunction to model range / packet loss.
|
|
163
|
+
|
|
164
|
+
Docs: https://renode.readthedocs.io/en/latest/networking/wireless.html
|
|
165
|
+
"""
|
|
166
|
+
create_command = "CreateIEEE802_15_4Medium"
|
|
167
|
+
symmetric = True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@HUBS.register("ble")
|
|
171
|
+
class BLEMedium(Hub):
|
|
172
|
+
"""Bluetooth Low Energy medium.
|
|
173
|
+
|
|
174
|
+
Connects BLE radio interfaces across machines. Same positioning and range/loss
|
|
175
|
+
model as the IEEE 802.15.4 medium — see the wireless hub for details.
|
|
176
|
+
|
|
177
|
+
Docs: https://renode.readthedocs.io/en/latest/networking/wireless.html
|
|
178
|
+
https://renode.readthedocs.io/en/latest/tutorials/ble-simulation.html
|
|
179
|
+
"""
|
|
180
|
+
create_command = "CreateBLEMedium"
|
|
181
|
+
symmetric = True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@HUBS.register("wisun")
|
|
185
|
+
class WiSUNMedium(Hub):
|
|
186
|
+
"""Wi-SUN (Wireless Smart Utility Network) medium — IEEE 802.11ah-based mesh.
|
|
187
|
+
|
|
188
|
+
Used for large-scale IoT mesh networks (smart meters, grid infrastructure).
|
|
189
|
+
All connected radio interfaces receive all packets by default.
|
|
190
|
+
Supports the same SetPosition / SetMediumFunction range-loss model as the
|
|
191
|
+
IEEE 802.15.4 and BLE mediums.
|
|
192
|
+
|
|
193
|
+
Docs: https://renode.readthedocs.io/en/latest/networking/wireless.html
|
|
194
|
+
"""
|
|
195
|
+
create_command = "CreateWiSUNMedium"
|
|
196
|
+
symmetric = True
|
hector/mappings.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
MAPPING_BACKENDS - bind an emulated peripheral to a host resource.
|
|
4
|
+
|
|
5
|
+
PTY and TCP backends follow a two-step emulation model (identical to hubs):
|
|
6
|
+
1. An emulation-level object is created before any machine blocks in the resc.
|
|
7
|
+
2. The machine connects its peripheral to that object via `connector Connect`.
|
|
8
|
+
|
|
9
|
+
The `file` backend is peripheral-level only (no emulation object needed).
|
|
10
|
+
|
|
11
|
+
==========================================================================
|
|
12
|
+
HOW TO ADD A NEW MAPPING BACKEND
|
|
13
|
+
==========================================================================
|
|
14
|
+
Register a function: (node_name, peripheral, param, node_config) -> MappingResult
|
|
15
|
+
|
|
16
|
+
@MAPPING_BACKENDS.register("tap")
|
|
17
|
+
def tap_backend(node_name, peripheral, param, node_config):
|
|
18
|
+
iface = param or "tap0"
|
|
19
|
+
term = f"ss_{node_name}_{peripheral}"
|
|
20
|
+
return MappingResult(
|
|
21
|
+
machine_cmd = f"connector Connect sysbus.{peripheral} {term}",
|
|
22
|
+
emulation_lines = [f'emulation CreateTap "{term}" "{iface}"'],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
`emulation_lines` are emitted at the top of the resc before any `mach create` blocks
|
|
26
|
+
so the object already exists when the machine tries to connect to it. `machine_cmd` is
|
|
27
|
+
queued on the node as an ordinary monitor command.
|
|
28
|
+
|
|
29
|
+
YAML then accepts: mappings: |\n boardA.eth0 -> tap:tap0
|
|
30
|
+
==========================================================================
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import List
|
|
35
|
+
|
|
36
|
+
from .core import Registry, RuntimeOptions, parse_endpoint, to_container_path
|
|
37
|
+
from .generator import queue_command
|
|
38
|
+
|
|
39
|
+
MAPPING_BACKENDS = Registry("mapping backend")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class MappingResult:
|
|
44
|
+
"""What a mapping backend produces for one mapping line."""
|
|
45
|
+
machine_cmd: str # monitor command queued on the node
|
|
46
|
+
emulation_lines: List[str] = field(default_factory=list) # emulation-level setup
|
|
47
|
+
runtime: RuntimeOptions = field(default_factory=RuntimeOptions) # execution-env needs
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# -------------------- backends --------------------
|
|
51
|
+
@MAPPING_BACKENDS.register("file")
|
|
52
|
+
def file_backend(_node_name, peripheral, param, _node_config):
|
|
53
|
+
"""Redirect UART output to a file. uart4 -> file:results/uart.log
|
|
54
|
+
Subsequent calls append with a numeric suffix rather than overwriting."""
|
|
55
|
+
if not param:
|
|
56
|
+
raise ValueError("'file' backend requires a path (file:<path>).")
|
|
57
|
+
return MappingResult(
|
|
58
|
+
machine_cmd=f"{peripheral} CreateFileBackend @{to_container_path(param)} true",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@MAPPING_BACKENDS.register("tcp")
|
|
63
|
+
def tcp_backend(node_name, peripheral, param, _node_config):
|
|
64
|
+
"""Expose a UART as a TCP socket terminal. uart4 -> tcp:4567
|
|
65
|
+
Append ':raw' to suppress IAC telnet bytes: uart4 -> tcp:4567:raw
|
|
66
|
+
"""
|
|
67
|
+
if not param:
|
|
68
|
+
raise ValueError("'tcp' backend requires a port number (tcp:<port>).")
|
|
69
|
+
|
|
70
|
+
# Optional ':raw' suffix suppresses IAC bytes
|
|
71
|
+
raw = False
|
|
72
|
+
if ":" in param:
|
|
73
|
+
port_str, _, flag = param.partition(":")
|
|
74
|
+
raw = flag.strip().lower() == "raw"
|
|
75
|
+
else:
|
|
76
|
+
port_str = param
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
port = int(port_str)
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise ValueError(f"'tcp' backend: '{port_str}' is not a valid port number.")
|
|
82
|
+
if not 1 <= port <= 65535:
|
|
83
|
+
raise ValueError(f"'tcp' backend: port {port} is out of range (1-65535).")
|
|
84
|
+
|
|
85
|
+
term_name = f"ss_{node_name}_{peripheral}"
|
|
86
|
+
create_cmd = (f'emulation CreateServerSocketTerminal {port} "{term_name}" false'
|
|
87
|
+
if raw else
|
|
88
|
+
f'emulation CreateServerSocketTerminal {port} "{term_name}"')
|
|
89
|
+
return MappingResult(
|
|
90
|
+
machine_cmd=f"connector Connect sysbus.{peripheral} {term_name}",
|
|
91
|
+
emulation_lines=[create_cmd],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@MAPPING_BACKENDS.register("pty")
|
|
96
|
+
def pty_backend(node_name, peripheral, param, _node_config):
|
|
97
|
+
"""Expose a UART as a PTY symlink inside the container. uart4 -> pty:/tmp/uart4
|
|
98
|
+
Note: PTY is not accessible from the host when running in Docker; use tcp: instead.
|
|
99
|
+
"""
|
|
100
|
+
if not param:
|
|
101
|
+
raise ValueError("'pty' backend requires a path (pty:/tmp/uart0).")
|
|
102
|
+
|
|
103
|
+
term_name = f"ss_{node_name}_{peripheral}"
|
|
104
|
+
return MappingResult(
|
|
105
|
+
machine_cmd=f"connector Connect sysbus.{peripheral} {term_name}",
|
|
106
|
+
emulation_lines=[f'emulation CreateUartPtyTerminal "{term_name}" "{param}"'],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@MAPPING_BACKENDS.register("tap")
|
|
111
|
+
def tap_backend(node_name, peripheral, param, _node_config):
|
|
112
|
+
"""Bridge an Ethernet peripheral to a host TAP device. eth0 -> tap:tap0
|
|
113
|
+
|
|
114
|
+
Host interface name defaults to 'tap0'. Renode wires the emulated NIC and the host
|
|
115
|
+
TAP through a switch (the NIC can't connect to the TAP directly), so this creates a
|
|
116
|
+
per-mapping switch, a TAP (exposed as `host.<name>`), and joins both to the switch.
|
|
117
|
+
Creating a TAP needs NET_ADMIN and /dev/net/tun on the container (host networking
|
|
118
|
+
alone does NOT grant these).
|
|
119
|
+
"""
|
|
120
|
+
iface = param or "tap0"
|
|
121
|
+
sw = f"sw_{node_name}_{peripheral}" # emulation switch
|
|
122
|
+
tap = f"tap_{node_name}_{peripheral}" # Renode TAP element -> host.<tap>
|
|
123
|
+
return MappingResult(
|
|
124
|
+
machine_cmd=f"connector Connect sysbus.{peripheral} {sw}",
|
|
125
|
+
emulation_lines=[
|
|
126
|
+
f'emulation CreateSwitch "{sw}"',
|
|
127
|
+
# CreateTap(hostInterfaceName, name): host device first, Renode name second.
|
|
128
|
+
f'emulation CreateTap "{iface}" "{tap}"',
|
|
129
|
+
f'connector Connect host.{tap} {sw}',
|
|
130
|
+
],
|
|
131
|
+
# Opening a real host TAP needs the TUN device, NET_ADMIN, and root.
|
|
132
|
+
runtime=RuntimeOptions(capabilities={"NET_ADMIN"},
|
|
133
|
+
devices={"/dev/net/tun"}, root=True),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# -------------------- resolver --------------------
|
|
138
|
+
def resolve_mappings(mappings, concrete_nodes):
|
|
139
|
+
"""Process each mapping line; queue machine commands on nodes.
|
|
140
|
+
|
|
141
|
+
Returns (emulation_lines, runtime): the emulation-level setup lines to emit before
|
|
142
|
+
machine blocks, and the merged RuntimeOptions any backend declared (e.g. a tap needs
|
|
143
|
+
TUN/TAP access) so the caller can grant them without re-scanning the generated resc."""
|
|
144
|
+
emulation_lines = []
|
|
145
|
+
runtime = RuntimeOptions()
|
|
146
|
+
for line in mappings:
|
|
147
|
+
if "->" not in line:
|
|
148
|
+
raise ValueError(f"Mapping '{line}' has no '->' operator")
|
|
149
|
+
lhs, rhs = line.split("->", 1)
|
|
150
|
+
ep = parse_endpoint(lhs)
|
|
151
|
+
backend, _, param = rhs.strip().partition(":")
|
|
152
|
+
backend, param = backend.strip(), (param.strip() or None)
|
|
153
|
+
|
|
154
|
+
node = ep["node"]
|
|
155
|
+
if node not in concrete_nodes:
|
|
156
|
+
raise ValueError(f"Mapping '{line}': unknown node '{node}'.")
|
|
157
|
+
if concrete_nodes[node].get("backend", "renode") != "renode":
|
|
158
|
+
raise ValueError(f"Mapping '{line}': left side must be on a renode node.")
|
|
159
|
+
|
|
160
|
+
handler = MAPPING_BACKENDS.get(backend)
|
|
161
|
+
result = handler(node, ep["name"], param, concrete_nodes[node])
|
|
162
|
+
queue_command(concrete_nodes[node], result.machine_cmd)
|
|
163
|
+
emulation_lines.extend(result.emulation_lines)
|
|
164
|
+
runtime.merge(result.runtime)
|
|
165
|
+
print(f"[MAP] {node}.{ep['name']} -> {backend}:{param or ''}")
|
|
166
|
+
|
|
167
|
+
return emulation_lines, runtime
|