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

Reply via email to