uPyRTKBase
MicroPython RTK base station on W5500-EVB-Pico2. RTCM3 from a UM980 streams to an NTRIP caster while a status server and telemetry share the W5500's sockets.
uPyRTKBase — A Centimetre-Accurate RTK Base Station, Written Entirely in MicroPython
#MicroPython #RTK #GNSS #NTRIP #RTCM3 #W5500 #RP2350 #UM980 #DualCore #OpenSource
📚 Context: Open-source firmware (BSD-3-Clause) by Philippe Bourcier. Repository created 2025-10-04, last updated 2026-04-27. Seventeen MicroPython modules, roughly 110 KB of source. ✅ Verification status: The README carries a complete component table and GPIO pinout; every claim below was read from the repository source.
01 — What is this project?
An RTK base station is the fixed half of a centimetre-accuracy positioning system. It sits on a surveyed point, watches the same satellites as the rover, and streams correction data — RTCM3 messages — so the rover can cancel out atmospheric and orbital error. Commercially these are sealed boxes costing thousands, running vendor firmware nobody can read.
uPyRTKBase is a working base station whose entire firmware is MicroPython, running on a WIZnet W5500-EVB-Pico2 and driving a Unicore UM980 tri-band GNSS receiver. It self-surveys its position, encodes RTCM3, and pushes corrections to a Centipede NTRIP caster over wired Ethernet — while simultaneously serving a live status page and reporting telemetry to a cloud endpoint.
The interesting part is not that it works. It is that a scripting language on a $30 board is holding three concurrent network conversations, on two CPU cores, under a watchdog, for a workload that normally demands C.
UM980 GNSS ──UART1──▶ RP2350 core 1 ──▶ W5500 ──▶ NTRIP caster (RTCM3 stream) │ │ └────────UART0 (control) ├──▶ HTTP status server :80 └──▶ Telemetry POST (every 5 min)UM980 GNSS ──UART1──▶ RP2350 core 1 ──▶ W5500 ──▶ NTRIP caster (RTCM3 stream)
│ │
└────────UART0 (control) ├──▶ HTTP status server :80
└──▶ Telemetry POST (every 5 min)02 — Why MicroPython for a real-time correction stream?
🔷 The workload is I/O-bound, not compute-bound
An RTK base does not do heavy maths. The UM980 computes its own position and emits framed RTCM3 on a serial port. The firmware's job is to move bytes from a UART to a TCP socket without dropping frames, and to keep that socket alive for months. That is an I/O supervision problem, and MicroPython handles it with far less code than C — ntrip_caster.py implements the entire NTRIP/2.0 client in 8.6 KB.
🔷 Both cores are used, deliberately
The RP2350 is dual-core, and the project splits along the natural seam:
- Core 1 runs the NTRIP thread (
_thread), doing nothing but shovelling RTCM3 from UART1 to the caster socket. - Core 0 runs the main loop: sensor polling, LED state machine, the HTTP status server, and telemetry.
A stalled web request therefore cannot interrupt the correction stream. That separation is what makes a scripted base station credible.
🔷 Field-serviceability
Every module is a readable .py file on the device's flash. Changing the RTCM message set, the survey duration, or the telemetry format is a text edit, not a toolchain. For hardware installed on a rooftop, that matters more than a few microseconds of latency.
03 — System architecture
┌──────────────────────────────────────────────────────────────────┐│ Unicore UM980 — tri-band RTK GNSS receiver ││ COM1 ──▶ UART0 (GPIO 0/1) control, configuration ││ COM2 ──▶ UART1 (GPIO 8/9) RTCM3 correction stream │└───────────────────────────┬──────────────────────────────────────┘ │┌───────────────────────────▼──────────────────────────────────────┐│ W5500-EVB-Pico2 — RP2350 (dual Cortex-M33) + WIZnet W5500 ││ ││ CORE 1 │ CORE 0 ││ ntrip_caster.py │ base.py main loop ││ · NTRIP/2.0 POST │ · IMU check ~1–2 s ││ · RTCM3 passthrough │ · SHT40 + AGC 60 s ││ · 409 retry @ 60 s │ · telemetry POST 5 min ││ · generic retry @ 7 s │ · HTTP status server :80 │└───────────────────────────┬──────────────────────────────────────┘ │ SPI — MISO 16 · CS 17 · SCK 18 │ MOSI 19 · RST 20 · INT 21┌───────────────────────────▼──────────────────────────────────────┐│ WIZnet W5500 network.WIZNET6K() · DHCP or static ││ Socket A → NTRIP caster (crtk.net:2101, persistent TCP) ││ Socket B → HTTP status server, port 80 ││ Socket C → telemetry POST, fire-and-forget │└──────────────────────────────────────────────────────────────────┘I²C bus (SDA 12 / SCL 13): LSM6DSV16X @ 0x6B · SHT40 @ 0x44┌──────────────────────────────────────────────────────────────────┐
│ Unicore UM980 — tri-band RTK GNSS receiver │
│ COM1 ──▶ UART0 (GPIO 0/1) control, configuration │
│ COM2 ──▶ UART1 (GPIO 8/9) RTCM3 correction stream │
└───────────────────────────┬──────────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────────┐
│ W5500-EVB-Pico2 — RP2350 (dual Cortex-M33) + WIZnet W5500 │
│ │
│ CORE 1 │ CORE 0 │
│ ntrip_caster.py │ base.py main loop │
│ · NTRIP/2.0 POST │ · IMU check ~1–2 s │
│ · RTCM3 passthrough │ · SHT40 + AGC 60 s │
│ · 409 retry @ 60 s │ · telemetry POST 5 min │
│ · generic retry @ 7 s │ · HTTP status server :80 │
└───────────────────────────┬──────────────────────────────────────┘
│ SPI — MISO 16 · CS 17 · SCK 18
│ MOSI 19 · RST 20 · INT 21
┌───────────────────────────▼──────────────────────────────────────┐
│ WIZnet W5500 network.WIZNET6K() · DHCP or static │
│ Socket A → NTRIP caster (crtk.net:2101, persistent TCP) │
│ Socket B → HTTP status server, port 80 │
│ Socket C → telemetry POST, fire-and-forget │
└──────────────────────────────────────────────────────────────────┘
I²C bus (SDA 12 / SCL 13): LSM6DSV16X @ 0x6B · SHT40 @ 0x44Boot sequence, as documented in the README:
1. Load local config → determines DHCP vs static2. Initialize Ethernet → W5500 via SPI3. Download remote config → merges into local, saves only if changed4. Initialize UM980 → detect, check config, reconfigure if needed5. Initialize sensors → LSM6DSV16X (0x6B) + SHT40 (0x44)6. Start NTRIP thread → core 1, streams COM2 RTCM to caster7. Main loop1. Load local config → determines DHCP vs static
2. Initialize Ethernet → W5500 via SPI
3. Download remote config → merges into local, saves only if changed
4. Initialize UM980 → detect, check config, reconfigure if needed
5. Initialize sensors → LSM6DSV16X (0x6B) + SHT40 (0x44)
6. Start NTRIP thread → core 1, streams COM2 RTCM to caster
7. Main loopStep 3 is a nice touch: the device fetches config.json from a URL at every boot and writes flash only if a value actually changed, so a fleet can be reconfigured centrally without wearing out flash on unchanged devices.
04 — Why WIZnet W5500? ⭐
🔷 The RP2350 has no Ethernet MAC
The Raspberry Pi RP2350 gives you two Cortex-M33 cores, PIO, and no networking whatsoever. Every wired option therefore means an external controller on SPI. The W5500-EVB-Pico2 packages the RP2350 and the W5500 on one board, so the project's bill of materials for networking is a single line.
🔷 Three concurrent sockets, and that is the whole point
This is where the choice stops being incidental. The firmware needs, at the same time:
| Role | Nature | Runs on |
|---|---|---|
| NTRIP caster client | long-lived outbound TCP, must never drop | core 1 |
| HTTP status server, port 80 | inbound, request-response, non-blocking | core 0 |
| Telemetry POST every 5 min | short outbound HTTP, fire-and-forget | core 0 |
Three independent conversations, two of them outbound and one inbound, driven from two different cores. The W5500 provides eight independent hardware sockets with their own buffers, so each of these gets its own socket and none of them starves the others. A single-socket controller would have forced the project to multiplex by hand — which, for a correction stream that must not stutter, would have been the end of the design.
The .env and config.json schema make the concurrency explicit: ntrip_server / ntrip_port / ntrip_mountpoint for the persistent stream, TELEMETRY_URL and CONFIG_URL for the HTTP traffic, all live simultaneously.
🔷 Wired Ethernet is the right medium for a base station
A base station is, by definition, never moving. It sits on a mast or a rooftop and must hold a TCP session to the caster for weeks. Wi-Fi credentials rotate, access points reboot, and 2.4 GHz is crowded exactly where antennas live. The project defaults to DHCP but exposes full static configuration (ip / subnet / gateway / dns), which is what a permanently installed instrument actually wants.
🔷 One driver, the whole EVB family
wiznet_init.py is the clearest expression of the design. Its board table covers the entire WIZnet evaluation range:
_DEFAULTS = { "w5100s-evb-pico": {}, "w5100s-evb-pico2": {}, "w5500-evb-pico": {}, "w5500-evb-pico2": {}, "w6100-evb-pico": {}, "w6100-evb-pico2": {},}_QSPI = {"w6300-evb-pico", "w6300-evb-pico2"}
def wiznet(board="w5500-evb-pico2", dhcp=True, **kw): ... nic = network.WIZNET6K()_DEFAULTS = {
"w5100s-evb-pico": {}, "w5100s-evb-pico2": {},
"w5500-evb-pico": {}, "w5500-evb-pico2": {},
"w6100-evb-pico": {}, "w6100-evb-pico2": {},
}
_QSPI = {"w6300-evb-pico", "w6300-evb-pico2"}
def wiznet(board="w5500-evb-pico2", dhcp=True, **kw):
...
nic = network.WIZNET6K()The project targets w5500-evb-pico2, but moving to W6100 for IPv6 or W6300 for QSPI throughput is a one-string change. The _QSPI set is already declared, waiting.
That board table is not arbitrary — it mirrors the EVB line-up being proposed to MicroPython upstream. The network.WIZNET6K class this firmware calls comes from WIZnet's MicroPython work for the EVB family, currently open for review as micropython/micropython PR #18035: 65 files, +3,256 lines, adding (~1,197 lines), the lib/wiznet6k ioLibrary submodule, PIO-based SPI for W55RP20, and QSPI single/dual/quad support for W6300. uPyRTKBase is a working downstream user of that interface before it has even landed.
🔷 Verified evidence ✅
- ✅ Complete GPIO pinout in the README: W5500
MISO 16 · CS 17 · SCK 18 · MOSI 19 · RST 20 · INT 21 - ✅ Component table names the board as WIZnet W5500-EVB-Pico2 (RP2350 + W5500)
- ✅
wiznet_init.pycallsnetwork.WIZNET6K(), prints the MAC vianic.config("mac"), and supports bothifconfig("dhcp")and a static(ip, sn, gw, dns)tuple - ✅
network_init.pyimplements a 120-second connection timeout with reconnect andgc.collect()between attempts — real deployment hardening, not sample code - ✅
ntrip_caster.pybuilds a genuine NTRIP/2.0 source request (POST /{mountpoint},Ntrip-Version: Ntrip/2.0, HTTP Basic auth) and distinguishes a 409 Conflict (60 s backoff, mountpoint still held by a stale session) from a generic failure (7 s) - ✅ BSD-3-Clause, 17 modules,
base.py23.5 KB andum980_config.py21.8 KB — a real codebase, not a snippet
05 — Key components
🌐 WIZnet W5500 — on a W5500-EVB-Pico2, driven by network.WIZNET6K()
10/100 Mbps hardwired TCP/IP Ethernet controller with eight independent hardware sockets and a 32 KB packet buffer. Here it carries the NTRIP correction stream, the HTTP status server, and the telemetry channel concurrently. SPI on GPIO 16–19 with reset on 20 and interrupt on 21; DHCP by default, full static configuration available.
🧠 RP2350 — dual Cortex-M33
The Pico2-class MCU on the same EVB. Core 1 is dedicated to the NTRIP thread via _thread; core 0 runs sensors, LEDs, HTTP and telemetry. A shared watchdog (wdt.py, feed_wdt()) is fed from both.
📡 Unicore UM980 — tri-band RTK GNSS receiver
Two serial links: UART0 for control and configuration, UART1 for the RTCM3 data stream. Signal group 2 enables GPS + GLONASS + Galileo + BeiDou, with optional SBAS. The base position comes either from a timed self-survey (base_mode: "time", with base_duration and a base_pdop quality gate) or from fixed coordinates.
🧭 LSM6DSV16X IMU + SHT40 — antenna health
Not decoration. An RTK base is only as good as its antenna's stability, so the IMU (I²C 0x6B) watches for vibration and tilt and raises an LED alarm, while the SHT40 (0x44) logs enclosure temperature and humidity. Per-band AGC from the UM980 is sampled on the same 60-second cadence to catch interference and antenna degradation.
💡 Dual RGB status LEDs
LED1 encodes the network side (orange booting, cyan Ethernet up, green NTRIP connected, yellow remote config failed, red init failure); LED2 encodes GNSS and antenna health. Both are common-anode and active LOW. Holding BTN_USER on GPIO 26 for over three seconds reboots and forces a config re-download, with both LEDs flashing white as confirmation.
06 — Application scenarios
01. Community and cooperative RTK networks
Networks like Centipede are built from base stations contributed by individuals, farms, and municipalities. A readable, low-cost, wired base lowers the barrier to contributing a station — and the remote-config mechanism lets a network operator retune RTCM intervals across a whole fleet without site visits.
02. Precision agriculture
A farm running RTK-guided machinery needs its own base when the nearest public station is too far. Wired Ethernet into the farm office switch is more dependable than Wi-Fi across a yard, and the antenna-health telemetry warns of a drifting or vibrating mast before the tractor starts wandering.
03. Construction and survey site bases
A temporary site base can be configured centrally, dropped on a known point, and monitored through its own status page on port 80. The base_mode: "fixed" path takes surveyed coordinates directly.
04. Structural and geodetic monitoring
The same hardware, in fixed mode with the IMU active, becomes a monitoring node: any tilt or vibration on a structure shows up in both the RTK solution and the IMU alarm, cross-validated by two independent sensors.
Conclusion
uPyRTKBase shows that the practical limit on scripted embedded firmware is rarely the language — it is whether the network hardware can keep several conversations alive at once. Eight hardware sockets is what makes MicroPython enough here.
- ✅ A complete RTK base station — self-survey, RTCM3 encoding, NTRIP casting, telemetry — written entirely in MicroPython
- ✅ Runs on a WIZnet W5500-EVB-Pico2, with the W5500 carrying three concurrent network roles across two CPU cores
- ✅ Full GPIO pinout, component table, config schema, and boot sequence documented in the repository
- ✅ Production hardening throughout: 120-second link timeout, NTRIP 409-versus-generic retry policy, shared watchdog, flash-write-only-on-change remote config
- ✅
wiznet_init.pyabstracts the entire WIZnet EVB family — W5100S, W5500, W6100 on Pico and Pico2, with W6300 QSPI already declared - ✅ Antenna health monitored by an independent IMU and environmental sensor, not just by the GNSS solution
- ✅ BSD-3-Clause, 17 modules, actively developed through April 2026
- ✅ Demonstrates a WIZnet chip in a role our ecosystem rarely shows — concurrent multi-socket service under a scripting runtime, rather than a single fixed protocol link
07 — Similar Projects on WIZnet Makers
- MaixPy-v1_scripts is related because it also drives a WIZnet controller from MicroPython through the
networkmodule rather than from C. The difference is that MaixPy's scripts are vendor demonstrations of single-socket connectivity, whereas uPyRTKBase runs three concurrent sockets from two cores as a production workload. - OpenJBOD is the closest architectural sibling on the platform: an RP2040 plus W5500 device whose firmware is MicroPython and which exposes a web interface for remote control. The difference is that OpenJBOD manages disk enclosure power and monitoring, while uPyRTKBase sustains a long-lived outbound correction stream where a dropped socket immediately degrades positioning accuracy for every rover downstream.
- Migration guide to Zephyr v4.4.0 is relevant as a contrast rather than a similarity: it covers the same WIZnet silicon reached through a compiled RTOS. Reading the two together shows the same chip serving both ends of the abstraction spectrum — a C RTOS driver stack on one side, a
.pyfile editable on the device's own flash on the other.
Q&A
Q. Why does an RTK base station need wired Ethernet rather than Wi-Fi? Because it must hold a single outbound TCP session to the NTRIP caster continuously, often for months, from a mast or rooftop where 2.4 GHz is congested and access points are far away. A dropped session means every rover relying on that mountpoint loses centimetre accuracy until it reconnects. The firmware defaults to DHCP but exposes full static addressing, which is what permanently installed instruments normally use.
Q. What does the W5500 actually do that a simpler controller could not? It sustains three independent sockets simultaneously — the NTRIP stream from core 1, an HTTP status server on port 80 from core 0, and a periodic telemetry POST — each with its own hardware buffer among the chip's eight sockets. With a single-socket part, the project would have had to multiplex these in software and risk stalling the correction stream behind a web request.
Q. How is the GNSS receiver connected? Over two separate UARTs. UART0 on GPIO 0/1 talks to the UM980's COM1 for control and configuration; UART1 on GPIO 8/9 receives the RTCM3 correction stream from COM2. Separating control from data means a configuration query can never interleave with correction frames.
Q. Can this run on other WIZnet boards? Yes. wiznet_init.py already tabulates W5100S, W5500, and W6100 on both Pico and Pico2 form factors, and declares a separate QSPI set for W6300-EVB-Pico and W6300-EVB-Pico2. Retargeting is a single string argument, so W6100 for IPv6 or W6300 for higher SPI throughput are open paths.
Q. Where does network.WIZNET6K come from? From WIZnet's MicroPython support for the EVB board family, proposed upstream in micropython/micropython PR #18035. The pull request is still open at the time of writing. It migrates the existing W5100S-EVB-Pico and W5500-EVB-Pico boards from wiznet5k to wiznet6k and adds seven more — W5100S/W5500-EVB-Pico2, W55RP20-EVB-Pico, W6100-EVB-Pico and Pico2, and W6300-EVB-Pico and Pico2 — together with a PIO-based SPI driver and QSPI single/dual/quad modes for the W6300. Anyone wanting to reproduce this base station today needs a MicroPython build that carries that driver.
Q. What happens when the NTRIP connection fails? The client distinguishes two cases. A 409 Conflict means the caster still holds a stale session on that mountpoint, so it waits 60 seconds before retrying; any other failure retries after 7 seconds. Every retry closes and releases the old socket first. LED1 turns red if reconnection ultimately fails, and the watchdog is fed from both cores throughout.
Q. How is the device managed once installed? It downloads config.json from a configured URL at every boot and merges it into local settings, writing flash only when a value has actually changed. Holding the user button on GPIO 26 for more than three seconds forces a reboot and a fresh config download. A status page on port 80 refreshes every 45 seconds with sensor readings, NTRIP state, and antenna health.
Original Link: https://github.com/philippebourcier/uPyRTKBase
Upstream driver: micropython/micropython PR #18035 — ports/rp2/boards: Add WIZnet-EVB-boards and wiznet6k submodule (open for review)
