Skip to content

Commit 8bbdf9b

Browse files
committed
zephyr-cp: async_spi
Transfers use spi_transceive_cb() where the driver has an async path, and otherwise run on the system work queue. Zephyr cannot stop a transfer, so cancel waits for it. Enabled on boards with an SPI bus; tested on native_sim through the loopback device.
1 parent 4f18be7 commit 8bbdf9b

6 files changed

Lines changed: 365 additions & 0 deletions

File tree

‎ports/zephyr-cp/boards/native/native_sim/autogen_board_info.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ aesio = true
1414
alarm = false
1515
analogbufio = false
1616
analogio = true # Zephyr board has analogio
17+
async_spi = true # Zephyr board has async_spi
1718
atexit = false
1819
audiobusio = true # Zephyr board has audiobusio
1920
audiocore = true # Zephyr board has audiobusio
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
#include "shared-bindings/async_spi/SPI.h"
8+
9+
#include <string.h>
10+
11+
#include "py/runtime.h"
12+
#include "supervisor/port.h"
13+
#include "bindings/zephyr_kernel/__init__.h"
14+
15+
#include <errno.h>
16+
#include <iobroker/iobroker.h>
17+
18+
// Settings take the other config slot: drivers see a change by the pointer.
19+
static void apply_config(async_spi_spi_obj_t *self, uint32_t frequency, uint16_t operation) {
20+
struct spi_config *current = &self->config[self->active_config];
21+
if (current->frequency == frequency && current->operation == operation) {
22+
return;
23+
}
24+
self->active_config = 1 - self->active_config;
25+
self->config[self->active_config].frequency = frequency;
26+
self->config[self->active_config].operation = operation;
27+
}
28+
29+
static void transfer_done(const struct device *dev, int result, void *userdata) {
30+
ARG_UNUSED(dev);
31+
ARG_UNUSED(result);
32+
async_spi_spi_obj_t *self = userdata;
33+
CIRCUITPY_ASYNC_FLAG_SET(self->done);
34+
// A configure() that waited for this transfer.
35+
if (self->idle_done != NULL) {
36+
apply_config(self, self->pending_frequency, self->pending_operation);
37+
CIRCUITPY_ASYNC_FLAG_SET(self->idle_done);
38+
self->idle_done = NULL;
39+
}
40+
port_wake_main_task_from_isr();
41+
}
42+
43+
// Zephyr cannot stop a transfer, so this waits for it; deinit() waits too. Does not allocate.
44+
static void wait_done(async_spi_spi_obj_t *self) {
45+
if (self->done == NULL) {
46+
return;
47+
}
48+
while (!CIRCUITPY_ASYNC_FLAG_IS_SET(self->done)) {
49+
RUN_BACKGROUND_TASKS;
50+
}
51+
self->done = NULL;
52+
}
53+
54+
void common_hal_async_spi_spi_construct(async_spi_spi_obj_t *self, const mcu_pin_obj_t *clock,
55+
const mcu_pin_obj_t *mosi, const mcu_pin_obj_t *miso) {
56+
const struct device *dev = NULL;
57+
int ret = iobroker_spi_allocate(clock->package_pin,
58+
mosi != NULL ? mosi->package_pin : IOBROKER_NO_PIN,
59+
miso != NULL ? miso->package_pin : IOBROKER_NO_PIN, &dev);
60+
if (ret < 0) {
61+
if (ret == -ENODEV) {
62+
mp_raise_ValueError(MP_ERROR_TEXT("All SPI peripherals are in use"));
63+
}
64+
if (ret == -EBUSY) {
65+
mp_raise_ValueError(MP_ERROR_TEXT("Internal resource(s) in use"));
66+
}
67+
mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("Use device tree to define %q devices"), MP_QSTR_SPI);
68+
}
69+
70+
self->spi_device = dev;
71+
self->clock = clock;
72+
self->mosi = mosi;
73+
self->miso = miso;
74+
self->active_config = 0;
75+
for (size_t i = 0; i < 2; i++) {
76+
self->config[i].frequency = 100000;
77+
self->config[i].operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(8) | SPI_LINES_SINGLE;
78+
}
79+
self->done = NULL;
80+
self->fill = NULL;
81+
self->idle_done = NULL;
82+
83+
// A routed device is initialized now; a fixed one already was (-EALREADY).
84+
int init_ret = device_init(dev);
85+
if (init_ret < 0 && init_ret != -EALREADY) {
86+
common_hal_async_spi_spi_deinit(self);
87+
raise_zephyr_error(init_ret);
88+
}
89+
}
90+
91+
bool common_hal_async_spi_spi_deinited(async_spi_spi_obj_t *self) {
92+
return self->spi_device == NULL;
93+
}
94+
95+
void common_hal_async_spi_spi_deinit(async_spi_spi_obj_t *self) {
96+
if (common_hal_async_spi_spi_deinited(self)) {
97+
return;
98+
}
99+
wait_done(self);
100+
(void)iobroker_release(self->spi_device);
101+
self->spi_device = NULL;
102+
self->clock = NULL;
103+
self->mosi = NULL;
104+
self->miso = NULL;
105+
}
106+
107+
static uint16_t operation_of(uint8_t polarity, uint8_t phase, uint8_t bits) {
108+
uint16_t operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(bits) | SPI_LINES_SINGLE;
109+
if (polarity) {
110+
operation |= SPI_MODE_CPOL;
111+
}
112+
if (phase) {
113+
operation |= SPI_MODE_CPHA;
114+
}
115+
return operation;
116+
}
117+
118+
void common_hal_async_spi_spi_configure_start(async_spi_spi_obj_t *self, uint32_t baudrate,
119+
uint8_t polarity, uint8_t phase, uint8_t bits, circuitpy_async_flag_t *done) {
120+
CIRCUITPY_ASYNC_FLAG_INIT(done);
121+
uint16_t operation = operation_of(polarity, phase, bits);
122+
if (self->done == NULL || CIRCUITPY_ASYNC_FLAG_IS_SET(self->done)) {
123+
apply_config(self, baudrate, operation);
124+
CIRCUITPY_ASYNC_FLAG_SET(done);
125+
return;
126+
}
127+
// A later configure() replaces one still waiting.
128+
if (self->idle_done != NULL) {
129+
CIRCUITPY_ASYNC_FLAG_SET(self->idle_done);
130+
}
131+
self->pending_frequency = baudrate;
132+
self->pending_operation = operation;
133+
self->idle_done = done;
134+
}
135+
136+
mp_obj_t common_hal_async_spi_spi_configure_end(void *context, circuitpy_async_flag_t *done) {
137+
return mp_const_none;
138+
}
139+
140+
void common_hal_async_spi_spi_configure_cancel(void *context, circuitpy_async_flag_t *done) {
141+
async_spi_spi_obj_t *self = context;
142+
if (self->idle_done == done) {
143+
self->idle_done = NULL;
144+
}
145+
}
146+
147+
uint32_t common_hal_async_spi_spi_get_frequency(async_spi_spi_obj_t *self) {
148+
return self->config[self->active_config].frequency;
149+
}
150+
151+
// Start a transfer; done is set from the driver's callback, or at once for an empty one.
152+
static void start(async_spi_spi_obj_t *self, const uint8_t *data_out, uint8_t *data_in,
153+
size_t len, circuitpy_async_flag_t *done) {
154+
wait_done(self);
155+
CIRCUITPY_ASYNC_FLAG_INIT(done);
156+
self->done = done;
157+
if (len == 0) {
158+
CIRCUITPY_ASYNC_FLAG_SET(done);
159+
return;
160+
}
161+
self->tx_buf.buf = (void *)data_out;
162+
self->tx_buf.len = len;
163+
self->tx.buffers = &self->tx_buf;
164+
self->tx.count = data_out != NULL ? 1 : 0;
165+
self->rx_buf.buf = data_in;
166+
self->rx_buf.len = len;
167+
self->rx.buffers = &self->rx_buf;
168+
self->rx.count = data_in != NULL ? 1 : 0;
169+
170+
int ret = -ENOTSUP;
171+
if (DEVICE_API_GET(spi, self->spi_device)->transceive_async != NULL) {
172+
ret = spi_transceive_cb(self->spi_device, &self->config[self->active_config],
173+
self->tx.count ? &self->tx : NULL, self->rx.count ? &self->rx : NULL,
174+
transfer_done, self);
175+
}
176+
if (ret < 0) {
177+
CIRCUITPY_ASYNC_FLAG_SET(done);
178+
self->done = NULL;
179+
raise_zephyr_error(ret);
180+
}
181+
}
182+
183+
void common_hal_async_spi_spi_write_start(async_spi_spi_obj_t *self, const uint8_t *data,
184+
size_t len, circuitpy_async_flag_t *done) {
185+
start(self, data, NULL, len, done);
186+
}
187+
188+
void common_hal_async_spi_spi_readinto_start(async_spi_spi_obj_t *self, uint8_t *data,
189+
size_t len, uint8_t write_value, circuitpy_async_flag_t *done) {
190+
wait_done(self);
191+
// Without a TX buffer Zephyr sends zeros; another value needs a buffer.
192+
uint8_t *fill = NULL;
193+
if (write_value != 0) {
194+
fill = m_malloc(len);
195+
memset(fill, write_value, len);
196+
}
197+
self->fill = fill;
198+
start(self, fill, data, len, done);
199+
}
200+
201+
void common_hal_async_spi_spi_write_readinto_start(async_spi_spi_obj_t *self,
202+
const uint8_t *data_out, uint8_t *data_in, size_t len, circuitpy_async_flag_t *done) {
203+
start(self, data_out, data_in, len, done);
204+
}
205+
206+
mp_obj_t common_hal_async_spi_spi_transfer_end(void *context, circuitpy_async_flag_t *done) {
207+
async_spi_spi_obj_t *self = context;
208+
if (self->done == done) {
209+
wait_done(self);
210+
}
211+
return mp_const_none;
212+
}
213+
214+
void common_hal_async_spi_spi_transfer_cancel(void *context, circuitpy_async_flag_t *done) {
215+
common_hal_async_spi_spi_transfer_end(context, done);
216+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
#pragma once
8+
9+
#include "py/obj.h"
10+
#include "common-hal/microcontroller/Pin.h"
11+
#include "supervisor/shared/async_flag.h"
12+
13+
#include <zephyr/device.h>
14+
#include <zephyr/drivers/spi.h>
15+
#include <zephyr/kernel.h>
16+
17+
typedef struct {
18+
mp_obj_base_t base;
19+
const struct device *spi_device; // NULL once deinited
20+
struct spi_config config[2]; // two slots: a driver sees a change by the pointer
21+
uint8_t active_config;
22+
const mcu_pin_obj_t *clock;
23+
const mcu_pin_obj_t *mosi;
24+
const mcu_pin_obj_t *miso;
25+
circuitpy_async_flag_t *done; // of the running transfer, NULL when none
26+
struct spi_buf tx_buf; // the running transfer's buffers
27+
struct spi_buf rx_buf;
28+
struct spi_buf_set tx;
29+
struct spi_buf_set rx;
30+
uint8_t *fill; // the bytes a read sends, when not zero
31+
circuitpy_async_flag_t *idle_done; // a configure() waiting for the transfer to finish
32+
uint32_t pending_frequency;
33+
uint16_t pending_operation;
34+
} async_spi_spi_obj_t;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
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+
// No async_spi module functions.

‎ports/zephyr-cp/cptools/zephyr2cp.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,8 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig
925925
elif driver in BUSIO_CLASSES:
926926
# busio driver (i2c, spi, uart)
927927
board_info["busio"] = True
928+
if driver == "spi":
929+
board_info["async_spi"] = True
928930
logger.info(f"Supported busio driver: {driver}")
929931
if driver not in active_zephyr_devices:
930932
active_zephyr_devices[driver] = []
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# SPDX-FileCopyrightText: 2026 Vladimir Smitka
2+
# SPDX-License-Identifier: MIT
3+
4+
"""async_spi.SPI on native_sim, through the SPI loopback device."""
5+
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
# The asyncio library is not frozen on native_sim, so the test puts it on the drive.
11+
FROZEN = Path(__file__).resolve().parents[3] / "frozen"
12+
ASYNCIO_LIB = {
13+
f"lib/asyncio/{path.name}": path.read_text()
14+
for path in (FROZEN / "Adafruit_CircuitPython_asyncio" / "asyncio").glob("*.py")
15+
}
16+
ASYNCIO_LIB["lib/adafruit_ticks.py"] = (
17+
FROZEN / "Adafruit_CircuitPython_Ticks" / "adafruit_ticks.py"
18+
).read_text()
19+
20+
LOOPBACK_CODE = """\
21+
import asyncio
22+
import async_spi
23+
import microcontroller
24+
25+
26+
async def ticker(count):
27+
while True:
28+
count[0] += 1
29+
await asyncio.sleep(0)
30+
31+
32+
async def main():
33+
spi = async_spi.SPI(microcontroller.pin.P_02, MOSI=microcontroller.pin.P_03, MISO=microcontroller.pin.P_00)
34+
await spi.configure(baudrate=1_000_000)
35+
print("frequency", spi.frequency)
36+
bad = 0
37+
for n in (1, 32, 4096):
38+
out = bytes(i * 7 & 0xFF for i in range(n))
39+
inb = bytearray(n)
40+
await spi.write_readinto(out, inb)
41+
if inb != out:
42+
bad += 1
43+
print("write_readinto", n, "differs")
44+
await spi.readinto(inb, write_value=0xA5)
45+
if inb != b"\\xa5" * n:
46+
bad += 1
47+
print("readinto", n, "differs")
48+
await spi.write(out)
49+
50+
# Another task keeps running around the transfers.
51+
count = [0]
52+
task = asyncio.create_task(ticker(count))
53+
for _ in range(10):
54+
await spi.write_readinto(out, inb)
55+
await asyncio.sleep(0)
56+
task.cancel()
57+
print("ticker ran", count[0], "times")
58+
59+
# A cancelled transfer leaves the bus usable.
60+
task = asyncio.create_task(spi.write(out))
61+
await asyncio.sleep(0)
62+
task.cancel()
63+
try:
64+
await task
65+
except asyncio.CancelledError:
66+
pass
67+
await spi.write_readinto(out, inb)
68+
if inb != out:
69+
bad += 1
70+
print("after cancel differs")
71+
72+
try:
73+
await spi.write(123)
74+
except TypeError:
75+
print("bad argument raised")
76+
spi.deinit()
77+
try:
78+
await spi.write(out)
79+
except ValueError:
80+
print("deinit raised")
81+
with async_spi.SPI(microcontroller.pin.P_02, MOSI=microcontroller.pin.P_03, MISO=microcontroller.pin.P_00) as spi2:
82+
await spi2.readinto(inb, end=8, write_value=0x5A)
83+
if inb[:8] != b"\\x5a" * 8:
84+
bad += 1
85+
print("second bus differs")
86+
print("bad", bad)
87+
print("done")
88+
89+
90+
asyncio.run(main())
91+
"""
92+
93+
94+
@pytest.mark.circuitpy_drive({"code.py": LOOPBACK_CODE, **ASYNCIO_LIB})
95+
def test_async_spi_loopback(circuitpython):
96+
"""Awaited transfers come back through the loopback, with cancel and deinit."""
97+
circuitpython.wait_until_done()
98+
99+
output = circuitpython.serial.all_output
100+
assert "differs" not in output
101+
assert "ticker ran" in output
102+
assert "bad argument raised" in output
103+
assert "deinit raised" in output
104+
assert "bad 0" in output
105+
assert "done" in output

0 commit comments

Comments
 (0)