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/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