Skip to content

Commit 996c671

Browse files
committed
zephyr-cp: SPI loopback device and bus construction from pins on native_sim
A circuitpython,spi-loopback emulator on spi0 reads back every byte sent. On native_sim, iobroker now gives any pins the emulated SPI controller, so a bus can be constructed from pins, as on real boards. tests/test_spi.py checks busio.SPI through the loopback.
1 parent 9cd45fc commit 996c671

7 files changed

Lines changed: 168 additions & 1 deletion

File tree

‎ports/zephyr-cp/CMakeLists.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ target_sources(app PRIVATE zephyr_main.c)
1919

2020
# Add I2C emulator control for native_sim testing
2121
if(CONFIG_BOARD_NATIVE_SIM)
22-
target_sources(app PRIVATE native_sim_i2c_emul_control.c)
22+
target_sources(app PRIVATE native_sim_i2c_emul_control.c native_sim_spi_loopback_emul.c)
2323
endif()
2424

2525
if(CONFIG_TRACING_PERFETTO)

‎ports/zephyr-cp/boards/native/native_sim/board.overlay‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,13 @@
3636
};
3737
};
3838

39+
/* An SPI loopback for testing: MOSI wired to MISO. */
40+
&spi0 {
41+
loopback: loopback@0 {
42+
compatible = "circuitpython,spi-loopback";
43+
reg = <0>;
44+
spi-max-frequency = <50000000>;
45+
};
46+
};
47+
3948
#include "../../../app.overlay"

‎ports/zephyr-cp/boards/native/native_sim_lfs/board.overlay‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,12 @@
6868
};
6969

7070
#include "../../../app.overlay"
71+
72+
/* An SPI loopback for testing: MOSI wired to MISO. */
73+
&spi0 {
74+
loopback: loopback@0 {
75+
compatible = "circuitpython,spi-loopback";
76+
reg = <0>;
77+
spi-max-frequency = <50000000>;
78+
};
79+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# SPDX-FileCopyrightText: 2026 Vladimir Smitka
2+
# SPDX-License-Identifier: MIT
3+
4+
description: SPI loopback for tests, MOSI wired to MISO
5+
6+
compatible: "circuitpython,spi-loopback"
7+
8+
include: spi-device.yaml

‎ports/zephyr-cp/internal-modules/iobroker/src/iobroker.c‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
#include <errno.h>
1515
#include <stddef.h>
1616

17+
#include <zephyr/device.h>
18+
#include <zephyr/devicetree.h>
1719
#include <zephyr/logging/log.h>
1820
#include <zephyr/sys/util.h>
1921

@@ -87,13 +89,29 @@ int iobroker_i2c_allocate(package_pin_t sda, package_pin_t scl,
8789
return -ENOSYS;
8890
}
8991

92+
#if defined(CONFIG_BOARD_NATIVE_SIM) && DT_HAS_COMPAT_STATUS_OKAY(zephyr_spi_emul_controller)
93+
// native_sim has no pins to route. Any pins get the emulated SPI controller, so that tests can
94+
// construct an SPI bus from pins.
95+
#define NATIVE_SIM_SPI DEVICE_DT_GET_ONE(zephyr_spi_emul_controller)
96+
static bool native_sim_spi_in_use;
97+
#endif
98+
9099
int iobroker_spi_allocate(package_pin_t clock, package_pin_t mosi,
91100
package_pin_t miso, const struct device **dev_out) {
92101
(void)clock;
93102
(void)mosi;
94103
(void)miso;
104+
#ifdef NATIVE_SIM_SPI
105+
if (native_sim_spi_in_use) {
106+
return -ENODEV;
107+
}
108+
native_sim_spi_in_use = true;
109+
*dev_out = NATIVE_SIM_SPI;
110+
return 0;
111+
#else
95112
(void)dev_out;
96113
return -ENOSYS;
114+
#endif
97115
}
98116

99117
int iobroker_uart_allocate(package_pin_t tx, package_pin_t rx,
@@ -107,6 +125,11 @@ int iobroker_uart_allocate(package_pin_t tx, package_pin_t rx,
107125
}
108126

109127
bool iobroker_release(const struct device *dev) {
128+
#ifdef NATIVE_SIM_SPI
129+
if (dev == NATIVE_SIM_SPI) {
130+
native_sim_spi_in_use = false;
131+
}
132+
#endif
110133
(void)dev;
111134
LOG_DBG("release: no routing support on this SoC, nothing to release");
112135
return false;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// This file is part of the CircuitPython project: https://circuitpython.org
2+
//
3+
// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka
4+
//
5+
// SPDX-License-Identifier: MIT
6+
7+
// An SPI loopback device for native_sim: MOSI is wired to MISO, so every byte sent is read back.
8+
// With nothing to send the line idles low. Tests use it through busio.SPI and async_spi.SPI.
9+
10+
#define DT_DRV_COMPAT circuitpython_spi_loopback
11+
12+
#include <zephyr/device.h>
13+
#include <zephyr/drivers/emul.h>
14+
#include <zephyr/drivers/spi.h>
15+
#include <zephyr/drivers/spi_emul.h>
16+
17+
static int loopback_io(const struct emul *target, const struct spi_config *config,
18+
const struct spi_buf_set *tx_bufs, const struct spi_buf_set *rx_bufs) {
19+
ARG_UNUSED(target);
20+
ARG_UNUSED(config);
21+
if (rx_bufs == NULL) {
22+
return 0;
23+
}
24+
// A buffer with no data stands for that many idle or dropped bytes, as in Zephyr's drivers.
25+
size_t tx_i = 0;
26+
size_t tx_pos = 0;
27+
for (size_t i = 0; i < rx_bufs->count; i++) {
28+
uint8_t *rx = rx_bufs->buffers[i].buf;
29+
for (size_t j = 0; j < rx_bufs->buffers[i].len; j++) {
30+
uint8_t value = 0;
31+
while (tx_bufs != NULL && tx_i < tx_bufs->count && tx_pos >= tx_bufs->buffers[tx_i].len) {
32+
tx_i++;
33+
tx_pos = 0;
34+
}
35+
if (tx_bufs != NULL && tx_i < tx_bufs->count) {
36+
const uint8_t *tx = tx_bufs->buffers[tx_i].buf;
37+
if (tx != NULL) {
38+
value = tx[tx_pos];
39+
}
40+
tx_pos++;
41+
}
42+
if (rx != NULL) {
43+
rx[j] = value;
44+
}
45+
}
46+
}
47+
return 0;
48+
}
49+
50+
static int loopback_init(const struct emul *target, const struct device *parent) {
51+
ARG_UNUSED(target);
52+
ARG_UNUSED(parent);
53+
return 0;
54+
}
55+
56+
static const struct spi_emul_api loopback_api = {
57+
.io = loopback_io,
58+
};
59+
60+
#define LOOPBACK_DEFINE(n) \
61+
DEVICE_DT_INST_DEFINE(n, NULL, NULL, NULL, NULL, POST_KERNEL, CONFIG_SPI_INIT_PRIORITY, NULL); \
62+
EMUL_DT_INST_DEFINE(n, loopback_init, NULL, NULL, &loopback_api, NULL);
63+
64+
DT_INST_FOREACH_STATUS_OKAY(LOOPBACK_DEFINE)

‎ports/zephyr-cp/tests/test_spi.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# SPDX-FileCopyrightText: 2026 Vladimir Smitka
2+
# SPDX-License-Identifier: MIT
3+
4+
"""busio.SPI on native_sim, through the SPI loopback device."""
5+
6+
import pytest
7+
8+
LOOPBACK_CODE = """\
9+
import board
10+
11+
spi = board.SPI()
12+
while not spi.try_lock():
13+
pass
14+
spi.configure(baudrate=1_000_000)
15+
bad = 0
16+
for n in (1, 31, 32, 33, 4096):
17+
out = bytes(i * 7 & 0xFF for i in range(n))
18+
inb = bytearray(n)
19+
spi.write_readinto(out, inb)
20+
if inb != out:
21+
bad += 1
22+
print("write_readinto", n, "differs")
23+
spi.readinto(inb, write_value=0xA5)
24+
if inb != b"\\xa5" * n:
25+
bad += 1
26+
print("readinto 0xA5", n, "differs")
27+
spi.readinto(inb)
28+
if inb != bytes(n):
29+
bad += 1
30+
print("readinto 0", n, "differs")
31+
spi.write(out)
32+
out = bytes(range(64))
33+
inb = bytearray(64)
34+
spi.write_readinto(out, inb, out_start=8, out_end=40, in_start=8, in_end=40)
35+
if inb[8:40] != out[8:40] or any(inb[:8]) or any(inb[40:]):
36+
bad += 1
37+
print("slices differ")
38+
spi.unlock()
39+
spi.deinit()
40+
print("frequency", spi.frequency if False else "ok")
41+
print("bad", bad)
42+
print("done")
43+
"""
44+
45+
46+
@pytest.mark.circuitpy_drive({"code.py": LOOPBACK_CODE})
47+
def test_spi_loopback(circuitpython):
48+
"""Blocking transfers come back through the loopback, including slices and reads."""
49+
circuitpython.wait_until_done()
50+
51+
output = circuitpython.serial.all_output
52+
assert "differs" not in output
53+
assert "bad 0" in output
54+
assert "done" in output

0 commit comments

Comments
 (0)