at-os3 is a small event-driven AT modem firmware for CH32V003 boards driving
SX1278/E32-style or SX1262/E22-style LoRa modules.
The firmware exposes a UART AT command interface and keeps the radio control path deterministic:
interrupt -> event queue -> event loop -> FSM / handler
The firmware is built for one radio backend at a time with RADIO=SX1278 or
RADIO=SX1262. It is used as a raw LoRa modem by a Linux host.
Early CH32V003 + Ebyte-module prototype wired to a quarter-wave ground plane antenna:
Development setup used with a Linux host and window-mounted antenna:
at-os3 was built to act as the small RF modem in a split TinyGS-style ground
station.
The companion project is PTGS:
https://github.com/netmonk/ptgs
PTGS runs on a Linux host. It connects to the TinyGS MQTT backend, receives backend modem commands, computes Doppler correction from TLE/TLX data, and publishes received packets back as TinyGS telemetry.
at-os3 stays next to the radio hardware. It only handles the deterministic
modem side: UART AT commands, radio driver calls, LoRa RX/TX, interrupt
handling, and packet reports with RSSI/SNR/frequency error where the selected
radio backend supports it.
This split keeps TLS, MQTT credentials, logs, and satellite scheduling on Linux, while the CH32V003 remains a small replaceable LoRa front end close to the antenna.
at-os3 is built on the OS3 kernel model used in this repository: a small
deterministic event kernel, not a general-purpose RTOS.
OS3 is organized around a few rules:
- all progress starts from explicit events;
- interrupts only acknowledge hardware, capture minimal data, and enqueue one event;
- protocol behavior runs later from the event loop;
- stateful behavior lives in table-driven FSMs;
- hardware access stays in drivers;
- services and handlers must not create hidden background progression.
For the LoRa modem path, the intended execution shape is:
UART byte / radio IRQ
-> event_enqueue
-> event_loop
-> AT parser / LoRa FSM
-> bounded action
This is why the code is split into:
| Directory | Role |
|---|---|
core/ |
event queue, event loop, FSM engine, timer, console service |
drivers/ch32v003/ |
CH32V003 peripherals, UART, SPI, EXTI, radio drivers and radio backends |
subfsm/ |
table-driven domain FSMs, including the AT parser and LoRa FSM |
handlers/ |
stateless event reactions |
The project constitution is in CONSTITUTION.md.
- UART AT command interface at 115200 8N1
- Raw LoRa RX/TX
- Compile-time radio backend selection:
SX1278orSX1262 - Frequency, SF, bandwidth, coding rate, preamble, sync word, IQ inversion, CRC, LDRO, and implicit payload length configuration
- RX packet reports with RSSI and SNR; SX1278 builds also report frequency error estimate
- Event-driven interrupt handling
- Table-driven LoRa FSM
See doc/AT_COMMANDS.md for the command reference.
Current firmware targets:
- MCU: official CH32V003 development board
- Radio backends: SX1278/E32-style modules and SX1262/E22-style modules
- Valid build selections:
RADIO=SX1278andRADIO=SX1262 - Host link: CH32V003 USART1 connected to a USB-UART bridge or host UART
- Default UART: 115200 baud, 8 data bits, no parity, 1 stop bit
Example CH32V003 development board:
https://fr.aliexpress.com/item/1005005269690018.html
Example Ebyte SX1278/E32-style LoRa module:
https://fr.aliexpress.com/item/1005004447877680.html
The SX1262 backend has been validated with a CH32V003 wired to an Ebyte E22/SX1262-style module and tested against an ESP32 LoRa node.
Equivalent CH32V003 boards can be used if the pins required by doc/PINOUT.md are available.
Important pins:
- Radio SPI/control:
PC0..PC7 - Host UART:
PD5TX,PD6RX - LEDs:
PD4heartbeat,PD2radio activity
See doc/PINOUT.md for the complete pinout.
The build script expects a RISC-V embedded toolchain in PATH:
riscv32-unknown-elf-asriscv32-unknown-elf-ldriscv32-unknown-elf-objcopyriscv32-unknown-elf-sizeriscv32-unknown-elf-nm
The firmware is assembled as RV32EC with the zicsr extension and the
ilp32e ABI.
From the repository root:
RADIO=SX1278 ./run.shThis builds the SX1278 radio variant with the HSE clock variant. RADIO is
mandatory; there is no default radio target. CLOCK defaults to HSE.
Build output is written to:
build/ch32v003/kernel.elf
build/ch32v003/kernel-sx1278-hse.bin
Valid radio selections are:
RADIO=SX1278 SX1278 / E32-style modules
RADIO=SX1262 SX1262 / E22-style modules
Valid clock selections are:
CLOCK=HSE 24 MHz external crystal, default
CLOCK=HSI CH32V003 internal 24 MHz RC oscillator
Common build commands:
RADIO=SX1278 ./run.sh -> build/ch32v003/kernel-sx1278-hse.bin
CLOCK=HSI RADIO=SX1278 ./run.sh -> build/ch32v003/kernel-sx1278-hsi.bin
RADIO=SX1262 ./run.sh -> build/ch32v003/kernel-sx1262-hse.bin
CLOCK=HSI RADIO=SX1262 ./run.sh -> build/ch32v003/kernel-sx1262-hsi.bin
The SX1262/E22 backend has been validated on E22-900M30S hardware with bidirectional loopback tests against an ESP32 LoRa node.
The script cleans and reuses build/ch32v003 for each build. Object files and
kernel.elf keep the same names; only the generated binary image is named by
radio and clock variant. The script also prints section sizes and
.kernel_init entries.
Flash the generated binary with your CH32V003 programming tool. For a local
minichlink setup, the command shape is:
minichlink -w build/ch32v003/kernel-sx1278-hse.bin flashUse the binary matching the build command, for example
build/ch32v003/kernel-sx1262-hse.bin after RADIO=SX1262 ./run.sh.
minichlink is part of the ch32fun project:
https://github.com/cnlohr/ch32fun
Use the exact command required by your installed programmer and target board.
After flashing, connect the host UART at 115200 8N1 and send:
AT
Expected response:
+OK
Read the firmware version:
AT+VER?
Expected response:
+VER=at-os3-0.1.0
+OK
The repository includes tools/test_radio.py, a small
host-side serial test script for RX/TX checks. It requires Python 3 and
pyserial:
python3 -m pip install pyserialProbe the modem and radio SPI link:
python3 tools/test_radio.py /dev/ttyACM0 --probeListen on a raw LoRa profile:
python3 tools/test_radio.py /dev/ttyACM0 \
--freq 436995000 --sf 8 --bw 62.5 --cr 7 --sw 0x12 \
--rx-seconds 120Transmit text on the same profile:
python3 tools/test_radio.py /dev/ttyACM0 \
--freq 436995000 --sf 8 --bw 62.5 --cr 7 --sw 0x12 \
--send-text pingTransmit a hexadecimal payload:
python3 tools/test_radio.py /dev/ttyACM0 \
--freq 436995000 --sf 8 --bw 62.5 --cr 7 --sw 0x12 \
--send 70696E67tools/test_loopback.py runs bidirectional profile checks between two serial LoRa modems. It configures both sides, sends packets in both directions, and verifies the received payloads across the profile matrix defined in the script.
Example with two USB serial ports:
python3 tools/test_loopback.py /dev/ttyACM0 /dev/ttyACM1The profile matrix is expressed relative to a default base frequency of
433175000 Hz. Use --base-freq to run the same SF/BW/CR/preamble/sync/IQ/CRC
matrix in another band:
python3 tools/test_loopback.py /dev/ttyACM0 /dev/ttyACM1 --base-freq 868000000
python3 tools/test_loopback.py /dev/ttyACM0 /dev/ttyACM1 --base-freq 915000000Use --verbose to print the AT exchange and received packet details:
python3 tools/test_loopback.py /dev/ttyACM0 /dev/ttyACM1 --verboseThe sx1262 branch has been validated with an ESP32 LoRa node on one side and
a CH32V003 RADIO=SX1262 firmware on the other side, with the current loopback
suite passing 56/56 profile-direction checks.
Example raw LoRa RX profile:
AT+MODE=1
AT+BAND=436995000
AT+PARAMETER=8,6,3,8
AT+PKT=1,0,0
AT+SYNCWORD=18
AT+IQI=0
AT+MODE=0
Received packets are emitted as:
+RCV=<addr>,<len>,<hex_payload>,<rssi_dbm>,<snr_db>,<freq_err_hz>
PHY CRC failures are emitted as:
+ERR=1
core/ hardware-agnostic event/FSM/kernel services
drivers/ch32v003/ CH32V003 hardware drivers and radio backends
subfsm/ table-driven domain FSMs
handlers/ stateless event handlers
link/ linker scripts
doc/ AT command and hardware documentation
run.sh build script
Copyright (C) 2026 Dominique CARREL (netmonk) netmonk@netmonk.org.
Firmware source code, build scripts, and hardware documentation are licensed under GPL-3.0-or-later.
The OS3 constitution text in CONSTITUTION.md is licensed separately under CC BY-ND 4.0, because the constitution is the project's canonical design contract and must remain attributable and non-mutated.
See LICENSE and LICENSE-CONSTITUTION.md.
- The firmware is a raw LoRa modem, not a network stack.
AT+ADDRESSandAT+NETWORKIDare stored for host-side compatibility, but raw LoRa RX/TX payloads are not framed with a network header.- Ebyte module RF performance outside its specified operating band depends on the module RF front end, not only on the transceiver synthesizer range.

