This is an automated email from the ASF dual-hosted git repository.
acassis pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nuttx-ntfc.git
The following commit(s) were added to refs/heads/main by this push:
new dc8488f device: add Renode emulator support
dc8488f is described below
commit dc8488f62de1fc0b96b71ea6d4cc232013d2c672
Author: raiden00pl <[email protected]>
AuthorDate: Wed Sep 16 20:12:11 2026 +0200
device: add Renode emulator support
add Renode emulator support
Signed-off-by: raiden00pl <[email protected]>
---
CHANGES.md | 4 +
Documentation/api.rst | 1 +
Documentation/config-yaml.rst | 26 ++-
Documentation/config.yaml | 8 +-
Documentation/debug/gdb.rst | 2 +-
README.md | 2 +-
config/nuttx-renode-nrf52840-dk.yaml | 16 ++
config/nuttx-renode-nucleo-h743zi.yaml | 18 ++
config/nuttx-renode-stm32f4discovery.yaml | 17 ++
config/nuttx-renode-stm32f746g-disco.yaml | 16 ++
config/renode/nrf52840-dk.repl | 6 +
config/renode/nrf52840-dk.resc | 17 ++
config/renode/nucleo-h743zi.resc | 17 ++
config/renode/stm32f4discovery.resc | 19 ++
config/renode/stm32f746g-disco.resc | 17 ++
src/ntfc/device/getdev.py | 2 +
src/ntfc/device/host.py | 9 +
src/ntfc/device/renode.py | 210 ++++++++++++++++++++
tests/device/test_host.py | 30 +++
tests/device/test_renode.py | 311 ++++++++++++++++++++++++++++++
20 files changed, 740 insertions(+), 8 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index ea545cb..408554e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,9 @@
# Change Log
+## Unreleased
+
+- Add Renode device support.
+
## 0.0.3
- Add GDB and coredump debugging tools.
diff --git a/Documentation/api.rst b/Documentation/api.rst
index 6f369ee..b680641 100644
--- a/Documentation/api.rst
+++ b/Documentation/api.rst
@@ -36,6 +36,7 @@ Device Interface
ntfc.device.host
ntfc.device.nuttx
ntfc.device.qemu
+ ntfc.device.renode
ntfc.device.serial
ntfc.device.sim
diff --git a/Documentation/config-yaml.rst b/Documentation/config-yaml.rst
index 57f4f1f..06d663b 100644
--- a/Documentation/config-yaml.rst
+++ b/Documentation/config-yaml.rst
@@ -16,12 +16,12 @@ This file defines device-under-test (DUT) setup and global
configuration.
cores: # List of product cores
core0: # Core0 entry
name: 'core0-name'
- device: 'sim|qemu|serial'
+ device: 'sim|qemu|renode|serial'
# Device-specific configuration
core1: # Core1 entry
name: 'core1-name'
- device: 'sim|qemu|serial'
+ device: 'sim|qemu|renode|serial'
# Device-specific configuration
@@ -37,7 +37,7 @@ This file defines device-under-test (DUT) setup and global
configuration.
cores:
core0:
name: 'core-name'
- device: 'sim|qemu|serial'
+ device: 'sim|qemu|renode|serial'
# Device-specific configuration
product1:
@@ -45,7 +45,7 @@ This file defines device-under-test (DUT) setup and global
configuration.
cores:
core0:
name: 'core-name'
- device: 'sim|qemu|serial'
+ device: 'sim|qemu|renode|serial'
# Device-specific configuration
@@ -228,6 +228,24 @@ At default NTFC automatically add the ``-kernel
path_to_elf_image`` option
to ``exec_args``. You can also add your custom boot parameter with
``$IMAGE_ELF``, where ``$IMAGE_ELF`` will be replaced with the path to the ELF.
+Renode
+------
+
+This device type is implemented in :class:`ntfc.device.renode.DeviceRenode`.
+
+.. code-block:: yaml
+
+ cores:
+ core0:
+ name: 'main'
+ device: 'renode'
+ exec_path: 'renode'
+ exec_args: '-e "include @config/renode/board.resc"'
+
+NTFC sets ``$bin`` to the ELF and creates a socket terminal named ``ntfc``
+before ``exec_args`` runs. The script must load ``$bin`` and connect its
+console UART with ``connector Connect <uart> ntfc``. See ``config/renode/``.
+
Serial Device
-------------
diff --git a/Documentation/config.yaml b/Documentation/config.yaml
index a0d6f90..f1a8d5e 100644
--- a/Documentation/config.yaml
+++ b/Documentation/config.yaml
@@ -63,14 +63,18 @@ product: # many products can be
supported in tests (pro
cores: # Cores configuration
core0: # Main core (core 0)
name: '' # Core name
- device: '' # Choose a device: sim, qemu
+ device: '' # Choose a device: sim, qemu, renode, serial
exec_path: '' # Path of emulator execution for QEMU
targets eg: `qemu-system-x86_64`
+ # Renode launcher for renode targets, eg:
`renode`
# Path of serial port for serial targets,
eg: `/dev/ttyACM0`
# Empty for simulator.
exec_args: '' # Args for emulator execution for QEMU
tragets.
+ # Renode args for renode targets, eg '-e
"include @board.resc"'.
+ # The script loads $bin and connects the
console UART to
+ # the `ntfc` terminal, see config/renode/.
# Serial port configuration for serial
targets, eg '9600,n,8,1'
# Empty for simulator.
- exec_cwd: '' # (optional) working directory for the
spawned sim/qemu process.
+ exec_cwd: '' # (optional) working directory for the
spawned sim/qemu/renode process.
# Kernel-mode hostfs mounts resolve relative
to this directory.
# Defaults to the core build directory for
kernel-mode builds.
boot_timeout: 5 # (optional) seconds to wait for the first
shell prompt. Defaults to 5
diff --git a/Documentation/debug/gdb.rst b/Documentation/debug/gdb.rst
index c567b13..13e2162 100644
--- a/Documentation/debug/gdb.rst
+++ b/Documentation/debug/gdb.rst
@@ -20,7 +20,7 @@ and connects to it one of two ways:
* **Remote target** (``debug.gdb.target``) - for QEMU or real hardware
with a debug probe. Set it to the ``host:port`` (or Unix socket path)
of a GDB stub. For QEMU, add ``-s`` to ``exec_args`` to get a stub on
- ``localhost:1234``.
+ ``localhost:1234``. For Renode, add ``-e "machine StartGdbServer 3333"``.
* **Simulator PID-attach** (``debug.gdb.attach: true``) - for the
host-based NuttX simulator, where there's no remote stub, just a
diff --git a/README.md b/README.md
index 7c999a7..08aa984 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ versions of Python.
## Features
-- NuttX simulator, QEMU and devices with serial port are supported
+- NuttX simulator, QEMU, Renode and devices with serial port are supported
- Run the command from NSH and compare with the expected output
diff --git a/config/nuttx-renode-nrf52840-dk.yaml
b/config/nuttx-renode-nrf52840-dk.yaml
new file mode 100644
index 0000000..e8a59e1
--- /dev/null
+++ b/config/nuttx-renode-nrf52840-dk.yaml
@@ -0,0 +1,16 @@
+config:
+ cwd: './external'
+ build_dir: './build'
+
+product:
+
+ name: "ntfc-renode-nrf52840-dk"
+ cores:
+ core0:
+ name: 'main'
+ device: 'renode'
+ exec_path: 'renode'
+ exec_args: '-e "include @config/renode/nrf52840-dk.resc"'
+ defconfig: 'boards/arm/nrf52/nrf52840-dk/configs/ntfc'
+ boot_timeout: 30
+ flash: ''
diff --git a/config/nuttx-renode-nucleo-h743zi.yaml
b/config/nuttx-renode-nucleo-h743zi.yaml
new file mode 100644
index 0000000..26764f4
--- /dev/null
+++ b/config/nuttx-renode-nucleo-h743zi.yaml
@@ -0,0 +1,18 @@
+config:
+ cwd: './external'
+ build_dir: './build'
+
+product:
+
+ name: "ntfc-renode-nucleo-h743zi"
+ cores:
+ core0:
+ name: 'main'
+ device: 'renode'
+ exec_path: 'renode'
+ exec_args: '-e "include @config/renode/nucleo-h743zi.resc"'
+ defconfig: 'boards/arm/stm32h7/nucleo-h743zi/configs/ntfc'
+ boot_timeout: 30
+ flash: ''
+ kv:
+ CONFIG_STM32_PWR_IGNORE_ACTVOSRDY: "y"
diff --git a/config/nuttx-renode-stm32f4discovery.yaml
b/config/nuttx-renode-stm32f4discovery.yaml
new file mode 100644
index 0000000..ccf0f83
--- /dev/null
+++ b/config/nuttx-renode-stm32f4discovery.yaml
@@ -0,0 +1,17 @@
+# No CCM in Renode: single 112 KiB heap.
+config:
+ cwd: './external'
+ build_dir: './build'
+
+product:
+
+ name: "ntfc-renode-stm32f4discovery"
+ cores:
+ core0:
+ name: 'main'
+ device: 'renode'
+ exec_path: 'renode'
+ exec_args: '-e "include @config/renode/stm32f4discovery.resc"'
+ defconfig: 'boards/arm/stm32f4/stm32f4discovery/configs/ntfc'
+ boot_timeout: 30
+ flash: ''
diff --git a/config/nuttx-renode-stm32f746g-disco.yaml
b/config/nuttx-renode-stm32f746g-disco.yaml
new file mode 100644
index 0000000..6646d81
--- /dev/null
+++ b/config/nuttx-renode-stm32f746g-disco.yaml
@@ -0,0 +1,16 @@
+config:
+ cwd: './external'
+ build_dir: './build'
+
+product:
+
+ name: "ntfc-renode-stm32f746g-disco"
+ cores:
+ core0:
+ name: 'main'
+ device: 'renode'
+ exec_path: 'renode'
+ exec_args: '-e "include @config/renode/stm32f746g-disco.resc"'
+ defconfig: 'boards/arm/stm32f7/stm32f746g-disco/configs/ntfc'
+ boot_timeout: 30
+ flash: ''
diff --git a/config/renode/nrf52840-dk.repl b/config/renode/nrf52840-dk.repl
new file mode 100644
index 0000000..72d486a
--- /dev/null
+++ b/config/renode/nrf52840-dk.repl
@@ -0,0 +1,6 @@
+// nrf52840-dk for NuttX: the NuttX nRF52 serial driver uses the legacy
+// (non-EasyDMA) UART registers, while Renode defaults to UARTE.
+using "platforms/cpus/nrf52840.repl"
+
+uart0:
+ easyDMA: false
diff --git a/config/renode/nrf52840-dk.resc b/config/renode/nrf52840-dk.resc
new file mode 100644
index 0000000..a3d2af6
--- /dev/null
+++ b/config/renode/nrf52840-dk.resc
@@ -0,0 +1,17 @@
+:name: nrf52840-dk
+:description: NuttX on the nrf52840-dk. Console on sysbus.uart0, connected to
the NTFC terminal.
+
+using sysbus
+mach create "nrf52840-dk"
+machine LoadPlatformDescription $ORIGIN/nrf52840-dk.repl
+
+$bin?=@nuttx
+
+macro reset
+"""
+ sysbus LoadELF $bin
+"""
+
+runMacro $reset
+
+connector Connect sysbus.uart0 ntfc
diff --git a/config/renode/nucleo-h743zi.resc b/config/renode/nucleo-h743zi.resc
new file mode 100644
index 0000000..fa1e062
--- /dev/null
+++ b/config/renode/nucleo-h743zi.resc
@@ -0,0 +1,17 @@
+:name: nucleo-h743zi
+:description: NuttX on the nucleo-h743zi. Console on sysbus.usart3, connected
to the NTFC terminal.
+
+using sysbus
+mach create "nucleo-h743zi"
+machine LoadPlatformDescription @platforms/boards/nucleo_h753zi.repl
+
+$bin?=@nuttx
+
+macro reset
+"""
+ sysbus LoadELF $bin
+"""
+
+runMacro $reset
+
+connector Connect sysbus.usart3 ntfc
diff --git a/config/renode/stm32f4discovery.resc
b/config/renode/stm32f4discovery.resc
new file mode 100644
index 0000000..630de5d
--- /dev/null
+++ b/config/renode/stm32f4discovery.resc
@@ -0,0 +1,19 @@
+:name: stm32f4discovery
+:description: NuttX on the stm32f4discovery. Console on sysbus.usart2,
connected to the NTFC terminal.
+
+using sysbus
+mach create "stm32f4discovery"
+machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.repl
+
+cpu PerformanceInMips 125
+
+$bin?=@nuttx
+
+macro reset
+"""
+ sysbus LoadELF $bin
+"""
+
+runMacro $reset
+
+connector Connect sysbus.usart2 ntfc
diff --git a/config/renode/stm32f746g-disco.resc
b/config/renode/stm32f746g-disco.resc
new file mode 100644
index 0000000..f6c1d02
--- /dev/null
+++ b/config/renode/stm32f746g-disco.resc
@@ -0,0 +1,17 @@
+:name: stm32f746g-disco
+:description: NuttX on the stm32f746g-disco. Console on sysbus.usart1,
connected to the NTFC terminal.
+
+using sysbus
+mach create "stm32f746g-disco"
+machine LoadPlatformDescription @platforms/boards/stm32f7_discovery-bb.repl
+
+$bin?=@nuttx
+
+macro reset
+"""
+ sysbus LoadELF $bin
+"""
+
+runMacro $reset
+
+connector Connect sysbus.usart1 ntfc
diff --git a/src/ntfc/device/getdev.py b/src/ntfc/device/getdev.py
index 5a75ee8..7156274 100644
--- a/src/ntfc/device/getdev.py
+++ b/src/ntfc/device/getdev.py
@@ -23,6 +23,7 @@
from typing import TYPE_CHECKING, Callable, Dict
from .qemu import DeviceQemu
+from .renode import DeviceRenode
from .serial import DeviceSerial
from .sim import DeviceSim
@@ -36,6 +37,7 @@ _DEVICE_FACTORIES: Dict[str, Callable[["CoreConfig"],
"DeviceCommon"]] = {
"sim": DeviceSim,
"qemu": DeviceQemu,
"serial": DeviceSerial,
+ "renode": DeviceRenode,
}
###############################################################################
diff --git a/src/ntfc/device/host.py b/src/ntfc/device/host.py
index e188e65..2aed060 100644
--- a/src/ntfc/device/host.py
+++ b/src/ntfc/device/host.py
@@ -181,12 +181,21 @@ class DeviceHost(DeviceCommon):
time.sleep(uptime)
+ self._post_spawn()
+
ret = self._wait_for_boot(self._conf.boot_timeout)
if ret is False: # pragma: no cover
raise TimeoutError("device boot timeout")
return self._child
+ def _post_spawn(self) -> None:
+ """Run after the child is spawned and before the boot wait.
+
+ Transports that do not talk to the child's pty (e.g. a socket
+ opened by the emulator) attach here. Runs on every reopen.
+ """
+
def _stop_impl(self) -> None:
"""Stop host device and kill the underlying process."""
if not self._child:
diff --git a/src/ntfc/device/renode.py b/src/ntfc/device/renode.py
new file mode 100644
index 0000000..98d6a6b
--- /dev/null
+++ b/src/ntfc/device/renode.py
@@ -0,0 +1,210 @@
+############################################################################
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership. The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+############################################################################
+
+"""Renode device: a spawned emulator with the console on a TCP socket."""
+
+import os
+import select
+import shlex
+import socket
+import time
+from typing import TYPE_CHECKING, Optional
+
+import pexpect # type: ignore
+
+from ntfc.log.logger import logger
+
+from .host import DeviceHost
+
+if TYPE_CHECKING:
+ from ntfc.coreconfig import CoreConfig
+
+###############################################################################
+# Class: DeviceRenode
+###############################################################################
+
+
+class DeviceRenode(DeviceHost):
+ """Host-based Renode emulator.
+
+ Renode runs headless with its monitor on the child pty. NTFC creates a
+ raw TCP socket terminal named ``ntfc`` that the target script connects
+ its console UART to. Process ownership comes from :class:`DeviceHost`.
+ """
+
+ DEFAULT_EXEC = "renode"
+ RENODE_FLAGS = ("--disable-gui", "--console", "--plain")
+ TERMINAL_NAME = "ntfc"
+ CONNECT_POLL = 0.1
+ SOCK_TIMEOUT = 5.0
+ QUIT_TIMEOUT = 3.0
+
+ def __init__(self, conf: "CoreConfig"):
+ """Initialize Renode device.
+
+ :param conf: configuration handler
+ """
+ DeviceHost.__init__(self, conf)
+ self._sock: Optional[socket.socket] = None
+ self._port = 0
+
+ @property
+ def name(self) -> str:
+ """Get device name."""
+ return "renode"
+
+ @staticmethod
+ def _free_port() -> int:
+ """Draw an ephemeral localhost port for the socket terminal."""
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+ def _start_impl(self) -> None:
+ """Start Renode.
+
+ NTFC sets ``$bin`` to the ELF and creates the socket terminal
+ ``ntfc`` before the user arguments run; the script given there is
+ expected to load ``$bin`` and connect its console UART to ``ntfc``.
+ """
+ elf = os.path.abspath(self._image_path())
+ exec_args = self._conf.exec_args
+ if not exec_args:
+ raise KeyError("no exec_args in configuration file!")
+
+ self._port = self._free_port()
+ terminal = (
+ f'CreateServerSocketTerminal {self._port} "{self.TERMINAL_NAME}"'
+ )
+
+ cmd = [self._conf.exec_path or self.DEFAULT_EXEC]
+ cmd.append(" " + " ".join(self.RENODE_FLAGS))
+ cmd.append(" -e " + shlex.quote(f"$bin=@{elf}"))
+ cmd.append(" -e " + shlex.quote(f"emulation {terminal} false"))
+ cmd.append(" " + exec_args)
+ cmd.append(" -e start")
+
+ self.host_open(cmd, self._conf.uptime)
+
+ def _dev_reopen(self) -> pexpect.spawn:
+ """Restart Renode on a fresh socket terminal port."""
+ self._stop_impl()
+ self._start_impl()
+ assert self._child
+ return self._child
+
+ def _drain_child(self) -> None:
+ """Forward Renode's own monitor/log output to the debug log."""
+ if not self._child:
+ return
+ try:
+ while True:
+ out = self._child.read_nonblocking(size=5120, timeout=0)
+ logger.debug(f"renode: {out!r}")
+ except (pexpect.TIMEOUT, pexpect.EOF):
+ pass
+
+ def _post_spawn(self) -> None:
+ """Connect to the socket terminal once Renode listens on it."""
+ deadline = time.monotonic() + self._conf.boot_timeout
+ while True:
+ try:
+ sock = socket.create_connection(
+ ("127.0.0.1", self._port), timeout=self.SOCK_TIMEOUT
+ )
+ break
+ except OSError:
+ self._drain_child()
+ if not self._child or not self._child.isalive():
+ raise IOError("renode exited before opening console")
+ if time.monotonic() >= deadline:
+ raise TimeoutError("renode console connect timeout")
+ time.sleep(self.CONNECT_POLL)
+
+ sock.settimeout(self.SOCK_TIMEOUT)
+ self._sock = sock
+ logger.info(f"renode console connected on port {self._port}")
+
+ def _close_sock(self) -> None:
+ """Close the console socket."""
+ if self._sock:
+ self._sock.close()
+ self._sock = None
+
+ def _dev_is_health_priv(self) -> bool:
+ """Check if Renode runs and the console is connected."""
+ return DeviceHost._dev_is_health_priv(self) and self._sock is not None
+
+ def _read(self) -> bytes:
+ """Read console data from the socket terminal."""
+ self._drain_child()
+ if not self.dev_is_health():
+ return b""
+
+ assert self._sock
+ ready, _, _ = select.select([self._sock], [], [], 0)
+ if not ready:
+ return b""
+
+ try:
+ data = self._sock.recv(5120)
+ except OSError:
+ data = b""
+
+ if not data:
+ # peer closed the console
+ self._close_sock()
+ return data
+
+ def _write(self, data: bytes) -> None:
+ """Write to the console. One write per command: TCP is a stream."""
+ if not self.dev_is_health():
+ return
+
+ if data[-1] != ord("\n"):
+ data += self.NEWLINE_PAD
+
+ assert self._sock
+ self._sock.sendall(data)
+
+ def _write_ctrl(self, c: str) -> None:
+ """Write a control character to the console."""
+ if not self.dev_is_health():
+ return
+
+ assert self._sock
+ self._sock.sendall(bytes([ord(c.upper()) & 0x1F]))
+
+ def _stop_impl(self) -> None:
+ """Close the console, ask the monitor to quit, then kill if needed."""
+ self._close_sock()
+
+ child = self._child
+ if child and child.isalive():
+ child.sendline("quit")
+ deadline = time.monotonic() + self.QUIT_TIMEOUT
+ while child.isalive() and time.monotonic() < deadline:
+ time.sleep(self.CONNECT_POLL)
+
+ if child and child.isalive():
+ self._kill_process_group(child)
+
+ self._child = None
+ logger.info("renode device closed")
diff --git a/tests/device/test_host.py b/tests/device/test_host.py
index 1d1f57c..65aeb4e 100644
--- a/tests/device/test_host.py
+++ b/tests/device/test_host.py
@@ -259,3 +259,33 @@ def test_device_host_exec_cwd_boot_timeout(tmp_path,
monkeypatch):
# - test for timeout
# - test for very long output
# - test for
+
+
+def test_device_host_post_spawn_hook(envconfig_dummy, monkeypatch):
+ """host_open() calls _post_spawn() once the child exists, before the
+ boot wait, and again on every reopen."""
+
+ conf = envconfig_dummy.product[0].cfg_core(0)
+ path = "./tests/resources/nuttx/sim/nuttx"
+ dev = DeviceHost2(conf)
+
+ order = []
+
+ def post_spawn():
+ assert dev._child is not None
+ order.append("post_spawn")
+
+ def wait_for_boot(timeout):
+ order.append("boot")
+ return True
+
+ monkeypatch.setattr(dev, "_post_spawn", post_spawn)
+ monkeypatch.setattr(dev, "_wait_for_boot", wait_for_boot)
+
+ dev.host_open([path])
+ assert order == ["post_spawn", "boot"]
+
+ dev._dev_reopen()
+ assert order == ["post_spawn", "boot", "post_spawn", "boot"]
+
+ dev.stop()
diff --git a/tests/device/test_renode.py b/tests/device/test_renode.py
new file mode 100644
index 0000000..f579bbf
--- /dev/null
+++ b/tests/device/test_renode.py
@@ -0,0 +1,311 @@
+############################################################################
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership. The
+# ASF licenses this file to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance with the
+# License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+############################################################################
+
+import os
+import shlex
+import socket
+import threading
+
+import pytest
+
+from ntfc.coreconfig import CoreConfig
+from ntfc.device.getdev import get_device
+from ntfc.device.renode import DeviceRenode
+
+
+def _conf(**extra):
+ cfg = {
+ "name": "t",
+ "device": "renode",
+ "exec_args": '-e "include @config/renode/board.resc"',
+ "boot_timeout": 2,
+ "uptime": 0,
+ }
+ cfg.update(extra)
+ conf = CoreConfig(cfg)
+ conf._config["elf_path"] = "some/nuttx"
+ return conf
+
+
+class FakeChild:
+ def __init__(self, alive=True, output=(), quits=True):
+ self.quits = quits
+ self.alive = alive
+ self.output = list(output)
+ self.sent = []
+ self.pid = 4242
+
+ def isalive(self):
+ return self.alive
+
+ def read_nonblocking(self, size=0, timeout=0):
+ import pexpect
+
+ if not self.output:
+ raise pexpect.TIMEOUT("no data")
+ return self.output.pop(0)
+
+ def sendline(self, data):
+ self.sent.append(data)
+ if data == "quit" and self.quits:
+ self.alive = False
+
+
+class Listener:
+ """Minimal TCP peer standing in for Renode's socket terminal."""
+
+ def __init__(self):
+ self.srv = socket.socket()
+ self.srv.bind(("127.0.0.1", 0))
+ self.srv.listen(1)
+ self.port = self.srv.getsockname()[1]
+ self.conn = None
+ self.thread = threading.Thread(target=self._accept, daemon=True)
+ self.thread.start()
+
+ def _accept(self):
+ self.conn, _ = self.srv.accept()
+
+ def wait(self):
+ self.thread.join(timeout=5)
+ assert self.conn is not None
+ return self.conn
+
+ def close(self):
+ self.conn.close()
+ self.srv.close()
+
+
+def test_device_renode_factory(envconfig_dummy):
+ envconfig_dummy.product_get(0)["cores"]["core0"]["device"] = "renode"
+ dev = get_device(envconfig_dummy.product[0].cfg_core(0))
+ assert isinstance(dev, DeviceRenode)
+ assert dev.name == "renode"
+
+
+def test_device_renode_start_command():
+ dev = DeviceRenode(_conf())
+ cmds = []
+ dev.host_open = lambda cmd, uptime: cmds.append((cmd, uptime))
+ dev.start()
+
+ cmd, uptime = cmds[0]
+ assert uptime == 0
+ argv = shlex.split("".join(cmd))
+
+ assert argv[0] == "renode"
+ assert argv[1:4] == ["--disable-gui", "--console", "--plain"]
+
+ execs = [argv[i + 1] for i, a in enumerate(argv) if a == "-e"]
+ assert execs[0] == "$bin=@" + os.path.abspath("some/nuttx")
+ assert execs[1] == (
+ f'emulation CreateServerSocketTerminal {dev._port} "ntfc" false'
+ )
+ # user arguments run after the terminal exists, before start
+ assert execs[2] == "include @config/renode/board.resc"
+ assert execs[3] == "start"
+ assert dev._port > 0
+
+
+def test_device_renode_start_custom_exec_path():
+ dev = DeviceRenode(_conf(exec_path="/opt/renode/renode"))
+ cmds = []
+ dev.host_open = lambda cmd, uptime: cmds.append(cmd)
+ dev.start()
+ assert shlex.split("".join(cmds[0]))[0] == "/opt/renode/renode"
+
+
+def test_device_renode_start_requires_exec_args():
+ dev = DeviceRenode(_conf(exec_args=""))
+ with pytest.raises(KeyError):
+ dev.start()
+
+
+def test_device_renode_post_spawn_connects():
+ listener = Listener()
+ dev = DeviceRenode(_conf())
+ dev._child = FakeChild()
+ dev._port = listener.port
+
+ assert dev._dev_is_health_priv() is False
+ dev._post_spawn()
+ listener.wait()
+ assert dev._dev_is_health_priv() is True
+
+ dev._stop_impl = lambda: None # keep FakeChild, only exercise socket
+ dev._close_sock()
+ assert dev._dev_is_health_priv() is False
+ listener.close()
+
+
+def test_device_renode_post_spawn_timeout():
+ # bound but not listening: connect fails until the deadline
+ dev = DeviceRenode(_conf(boot_timeout=0))
+ dev._child = FakeChild()
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ dev._port = s.getsockname()[1]
+ with pytest.raises(TimeoutError):
+ dev._post_spawn()
+
+
+def test_device_renode_post_spawn_retries_until_listening():
+ dev = DeviceRenode(_conf(boot_timeout=5))
+ dev._child = FakeChild()
+ srv = socket.socket()
+ srv.bind(("127.0.0.1", 0))
+ dev._port = srv.getsockname()[1]
+
+ # not listening yet: the first connects are refused
+ timer = threading.Timer(0.3, srv.listen, args=(1,))
+ timer.start()
+ dev._post_spawn()
+ conn, _ = srv.accept()
+ assert dev._dev_is_health_priv() is True
+
+ dev._close_sock()
+ conn.close()
+ srv.close()
+
+
+def test_device_renode_read_without_child():
+ dev = DeviceRenode(_conf())
+ assert dev._read() == b""
+
+
+def test_device_renode_read_connection_reset():
+ import struct
+
+ listener = Listener()
+ dev = DeviceRenode(_conf())
+ dev._child = FakeChild()
+ dev._port = listener.port
+ dev._post_spawn()
+ peer = listener.wait()
+
+ # RST instead of FIN: recv raises and the console is dropped
+ peer.setsockopt(
+ socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)
+ )
+ peer.close()
+ import time
+
+ time.sleep(0.05)
+ assert dev._read() == b""
+ assert dev._dev_is_health_priv() is False
+ listener.srv.close()
+
+
+def test_device_renode_post_spawn_child_died():
+ dev = DeviceRenode(_conf(boot_timeout=5))
+ dev._child = FakeChild(alive=False, output=[b"Renode error log"])
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ dev._port = s.getsockname()[1]
+ with pytest.raises(IOError):
+ dev._post_spawn()
+
+
+def test_device_renode_read_write():
+ listener = Listener()
+ dev = DeviceRenode(_conf())
+ child = FakeChild(output=[b"renode log line"])
+ dev._child = child
+ dev._port = listener.port
+ dev._post_spawn()
+ peer = listener.wait()
+
+ # nothing pending
+ assert dev._read() == b""
+ # renode's own output was drained from the child
+ assert child.output == []
+
+ peer.sendall(b"nsh> ")
+ import time
+
+ time.sleep(0.05)
+ assert dev._read() == b"nsh> "
+
+ dev._write(b"help")
+ assert peer.recv(100) == b"help\n"
+ dev._write(b"ls\n")
+ assert peer.recv(100) == b"ls\n"
+
+ dev._write_ctrl("c")
+ assert peer.recv(100) == b"\x03"
+
+ # peer closes: read returns nothing and the device turns unhealthy
+ peer.close()
+ time.sleep(0.05)
+ assert dev._read() == b""
+ assert dev._dev_is_health_priv() is False
+ assert dev._read() == b""
+ dev._write(b"ignored")
+ dev._write_ctrl("c")
+ listener.close()
+
+
+def test_device_renode_stop_quits_monitor_and_kills():
+ listener = Listener()
+ dev = DeviceRenode(_conf())
+ child = FakeChild()
+ dev._child = child
+ dev._port = listener.port
+ dev._post_spawn()
+ listener.wait()
+
+ killed = []
+ dev._kill_process_group = lambda proc: killed.append(proc)
+
+ dev._stop_impl()
+ assert child.sent == ["quit"]
+ assert killed == []
+ assert dev._child is None
+ assert dev._sock is None
+ listener.close()
+
+ # a monitor that ignores quit is killed
+ dev.QUIT_TIMEOUT = 0.2
+ dev._child = FakeChild(quits=False)
+ dev._stop_impl()
+ assert len(killed) == 1
+ assert dev._child is None
+
+ # stop on a stopped device is a no-op
+ dev._stop_impl()
+
+
+def test_device_renode_reopen_picks_new_port():
+ dev = DeviceRenode(_conf())
+ ports = []
+
+ def fake_host_open(cmd, uptime=0):
+ ports.append(dev._port)
+ dev._child = FakeChild()
+ return dev._child
+
+ dev.host_open = fake_host_open
+ dev.start()
+ dev._dev_reopen()
+ assert len(ports) == 2
+ assert all(p > 0 for p in ports)
+ # the old process is gone before the new one starts, so a fresh
+ # ephemeral port is drawn each time rather than reusing the command
+ assert dev._child is not None