nRF (Nordic) + Zephyr as a MoonLight target — feasibility analysis¶
Forward-looking research document — exception to CLAUDE.md present-tense rule. Feasibility study only, no implementation. Assesses whether the Nordic nRF52/53/54 BLE SoC families, running Zephyr (nRF Connect SDK), can join the supported-target list the way Teensy does — a constrained secondary target behind the existing
src/platform/seam. Researched 2026-07; sources cited at the bottom.
Verdict¶
As a big-LED controller: poor fit. As a portable-core + BLE-UI showcase: a genuinely interesting spike, gated on one load-bearing question (BLE-UI feasibility), which the research below finds to be clean, not hacky. Strongest yes: Zephyr is a first-class C++20 + BSD-sockets + LittleFS RTOS whose primitives map onto almost every function in platform.h cleanly and MoonLight's core is deliberately domain-neutral, so "MoonLight's runtime runs on a tiny BLE chip" is a real, achievable, marketable claim — the port is mechanical, not architectural. Strongest no: the chips have no WiFi, no PSRAM, and no wide parallel LED DMA peripheral, so nRF loses badly at the large-installation job MoonLight's light domain is tuned for.
The reframe from the product owner (see § Reframing below) changes the weighing: the question is not "can nRF drive 16 K LEDs" (it can't) but "is MoonLight's core a compelling showcase on a small BLE device, and can its UI reach that device over BLE with no WiFi/HTTP?" On the first, the core is ~90% portable as-is. On the second — the crux — a BLE GATT channel comfortably carries our JSON control/state model (BLE 5 sustains ~100–700 kB/s, orders of magnitude above what the UI needs), and config-over-Web-Bluetooth is well-trodden prior art. The one honest caveat is client reach: Web Bluetooth is Chrome/Edge/Android/desktop only — never iOS Safari — so a no-install browser UI works everywhere except iPhone, where a companion app or a third-party BLE browser is required. That caveat shapes the spike; it does not sink it. Recommendation moves from "defer" to "a scoped showcase spike is justified regardless of the LED limits, if the iOS-client gap is acceptable for a demo."
The nRF hardware¶
| Chip | Core(s) | Clock | RAM | Flash | Radio | Notes |
|---|---|---|---|---|---|---|
| nRF52832 | Cortex-M4F | 64 MHz | 64 KB | 512 KB | BLE 5 | Too small; excluded below |
| nRF52840 | Cortex-M4F | 64 MHz | 256 KB | 1 MB | BLE 5 + 802.15.4 | USB device; QSPI (flash XIP, not a RAM heap) |
| nRF5340 | 2× Cortex-M33 | app 128 MHz + net 64 MHz | 512 KB (app) | 1 MB | BLE 5.4 + 802.15.4 | The second core is the network core, reserved for the radio stack — not a general app core |
| nRF54L15 | Cortex-M33 + RISC-V VPR | 128 MHz | 256 KB | 1.5 MB RRAM | BLE 6 + 802.15.4 | The VPR is a peripheral coprocessor, not an app core |
| nRF54H20 | multi-M33 + RISC-V VPRs | app up to 320 MHz | 1 MB | 2 MB MRAM | BLE 5.4 + 802.15.4 | The family flagship; app + radio cores, like the 5340 pattern scaled up |
No PSRAM, anywhere in the family. There is no external-RAM interface comparable to ESP32's PSRAM (the nRF52840 QSPI does execute-in-place flash, not a mappable data heap). The RAM ceiling is the table above minus the BLE controller/stack (~30–60 KB), Zephyr kernel, and network buffers. Rough usable-for-buffers estimate: ~150 KB on a 256 KB part, ~350 KB on the 5340, ~700–800 KB on the 54H20. Against MoonLight's memory-scaling table (16 K lights proven on the ESP32's ~320 KB internal; Teensy's 1 MB rated "comfortable headroom"), the 52840/54L15 sit below the no-PSRAM ESP32, the 5340 slightly above it, and only the 54H20 reaches Teensy class.
WS2812 output — the DMA story¶
Nordic has no RMT, no LCD_CAM/i80, no Parlio, no FlexIO — no one-shot wide-parallel DMA peripheral at all. Three documented single-lane routes exist, each abusing a serial peripheral + EasyDMA:
- PWM + EasyDMA — each WS2812 bit becomes one 16-bit PWM compare value (≈ 0.32/0.64 µs duty at 800 kHz), DMA-fed from a RAM sequence. Cost: 2 bytes per bit = 48 bytes per RGB LED per lane. This is what Adafruit's nRF52 NeoPixel support and FastLED's
clockless_arm_nrf52use. The nRF52840 has 4 PWM instances (52832 has 3); one WS2812 stream per instance → up to ~4 PWM lanes, each with its own 48 B/LED encode buffer. - I2S + EasyDMA — the classic Nordic-blog trick: run I2S SDOUT at 3.2 MHz and encode each WS2812 bit as a 4-bit symbol (
0xE/0x8). Cost: 4 bytes per color byte = 12 bytes per RGB LED; one EasyDMA transfer carries up to 16 KB ≈ 1365 LEDs. One I2S peripheral, one SDOUT pin → 1 lane. This is the closest analogue to thei80Ws2812*whole-frame-DMA shape. - SPI(M) MOSI — Zephyr's default
worldsemi,ws2812-spidriver; ~3–4 SPI bits per WS2812 bit. One lane per SPIM instance; the nRF52840's SPIM3 runs 32 MHz with large EasyDMA, the lesser SPIMs have small DMA lengths.
Realistic total: ~4–6 heterogeneous lanes (4× PWM + 1× I2S + 1× SPIM3), each behind a different peripheral driver — versus 8–16 uniform lanes on one peripheral on ESP32-S3/P4, or 40+ on Teensy via FlexIO. Combined with the RAM ceiling (a 4-lane PWM setup burns 48 B/LED/lane just for encode buffers: 4 × 500 LEDs ≈ 96 KB of the ~150 KB usable), the honest capability estimate for a 256 KB part is ~1–2 K LEDs total, ~4 K on the 5340, maybe 8–10 K on the 54H20. One more hazard FastLED's issue tracker documents: the BLE controller runs at interrupt priorities application code can't mask, so any non-DMA (bit-banged) output glitches under radio load — DMA routes are mandatory, which the three above all are.
Zephyr fit with the platform.h seam¶
Zephyr fundamentals check out: C++20 is a supported standard (CONFIG_STD_CPP20, full GNU libstdc++ available), with the usual embedded restrictions — no std::thread/std::mutex (Zephyr k_thread/k_mutex instead — irrelevant to us, the seam already hides threading behind spawnPinnedTask), no static-object destructors, exceptions/RTTI opt-in. BSD sockets (subset), LittleFS, and a devicetree-driven driver model are all first-class.
platform.h seam group |
Zephyr provides | Assessment |
|---|---|---|
Time (millis/micros), yield/delay, reboot |
k_uptime_get, k_cycle_get_32, k_yield, k_msleep, k_busy_wait, sys_reboot |
Easy — direct 1:1 |
Heap (alloc/free, introspection) |
k_malloc / sys_heap + runtime stats |
Easy; hasPsram = false, one heap — the maxAllocBlock-style diagnostics need sys_heap stats plumbing |
allocExec/writeExec (MoonLive JIT) |
Cortex-M executes from RAM; no W^X obstacle | Easy mechanically, but pointless today: MoonLive has Xtensa/RISC-V/arm64 backends, no ARM Thumb-2 emitter — scripted modules fail cleanly (run dark) until a Thumb-2 backend exists, exactly the per-ISA cost the MoonLive IR-seam design anticipated |
RTOS introspection (taskSnapshot), spawnPinnedTask/notifyTask |
k_thread_foreach, k_thread_create + k_event/k_sem |
Easy — but every viable nRF is single application core (the 5340/54H20 second core belongs to the radio stack), so the multicore Step 2 render/encode split has no core to pin to. The seam already handles this: spawnPinnedTask returns false → the caller runs inline, the documented allocate-and-degrade fallback. Same stance as Teensy ("desktop is OS-threaded, Teensy single-core" — measured on the P4, see performance.md) |
| GPIO capability/live state | Zephyr gpio API + hand-written per-pin caps table |
With effort — same per-chip table work every target needs |
LED DMA seams (rmtWs2812*, i80Ws2812*, parlioWs2812*) |
None of these peripherals exist | New seam required: an nrfPwm/i2s WS2812 function pair in the same init/buffer/transmit/wait shape. The capability-flag pattern absorbs this cleanly (rmtTxChannels = 0, lcdLanes = 0, parlioLanes = 0, a new lane constant) — the existing drivers go inert exactly as they do on desktop, and a new driver guards on the new flag. Loopback self-test possible via GPIOTE+timer capture, but bespoke |
UdpSocket/TcpConnection/TcpServer |
BSD sockets subset, fd-based | Easy port — IF an IP link exists at all (the crux, next section) |
WiFi (wifiSta*, wifiAp*) |
Only via the nRF7002 companion IC (QSPI/SPI-attached, Zephyr WiFi mgmt API) | With effort + extra chip — the same shape as the P4's hasWifiCoprocessor C6 story, so the seam precedent exists |
Ethernet (ethInit etc.) |
Zephyr ships a W5500 SPI MAC driver | With effort — mirrors the S3's hasEthW5500 path exactly |
mDNS, hostname, httpRequest |
Zephyr mDNS responder, sockets | With effort / easy respectively, given IP |
OTA (http_fetch_to_ota, otaBegin, otaWrite, otaEnd) |
MCUboot + mcumgr/SMP (BLE or serial DFU), or hand-rolled HTTP-to-slot given IP | With effort — a different but standard mechanism; the seam's status-buffer contract is implementable |
| Improv provisioning (UART) | USB CDC-ACM on 52840; Improv-BLE is the natural transport on this family | With effort — note Improv's BLE variant fits nRF better than the serial one, but that's a new transport in our Improv module, not just a platform stub |
Audio in (audioMicInit), FFT |
nRF PDM peripheral (built for MEMS mics) or I2S RX; CMSIS-DSP arm_rfft |
Easy-to-moderate — arguably nicer than ESP32 (PDM is purpose-built); audioFft maps to the textbook CMSIS kernel |
i2cScan, filesystem (fs*) |
Zephyr i2c, LittleFS |
Easy — LittleFS is the same fs ESP32 uses |
IR (irRead) |
No RMT-RX equivalent; GPIOTE + timer capture | Bespoke effort — low priority, can stub false like desktop |
Summary: the seam itself is no obstacle — Zephyr is the most MoonLight-shaped RTOS outside ESP-IDF, and the platform_config.h flag pattern (everything inert-by-if constexpr when a capability constant is 0/false) absorbs the missing peripherals exactly as it does for desktop. The three structural gaps are: single app core (multicore split degrades to inline — acceptable, Teensy has the same), no PSRAM (hard memory ceiling, the 256 KB parts sit below the no-PSRAM ESP32), and no native IP networking (below).
The architectural question: is nRF an IP-networked LED controller?¶
Honestly: no, not natively. MoonLight's product surface is IP end to end — the HTTP config server + WebSocket UI, ArtNet/DDP/WLED-sync over UDP, mDNS discovery, OTA over HTTP, Improv provisioning of WiFi credentials. An nRF SoC has BLE + 802.15.4 and nothing else:
- BLE carries no IP for our purposes (IPSP is dead in practice). A BLE-only nRF node cannot run NetworkModule, HttpServerModule, ArtnetDriver, or WLED sync at all — the entire networked surface would be replaced by a new BLE GATT control plane that doesn't exist in MoonLight. That is not a port; it is a second product.
- Thread/802.15.4 does give real UDP/IPv6 (OpenThread), so
UdpSockettechnically works — but at a 250 kbps radio, pixel streaming (one 170-LED DMX universe at 44 Hz ≈ 190 kbps) saturates the mesh. Thread is a control-plane transport, not a pixel-data one. - WiFi via nRF7002 (companion IC over QSPI, Wi-Fi 6, the nRF7002-DK pairs it with an nRF5340) or Ethernet via W5500 SPI restores the whole MoonLight stack unchanged — sockets, HTTP, ArtNet, everything. Both have Zephyr drivers and both match existing seam precedents (the P4's WiFi co-processor, the S3's SPI Ethernet). But then the BOM is a two-chip radio board with fewer LED lanes, less RAM, and a slower CPU than a single ESP32-S3 — the nRF's low-power/BLE strengths are dead weight in a mains-powered LED installation.
So the honest framing: as an IP node it fits our architecture but loses to ESP32 on every axis we care about; as a BLE node it doesn't fit our architecture. The genuinely nRF-shaped use case — a battery-powered, BLE- or Matter-controlled wearable/small-strip product, phone-app-controlled, sub-500 LEDs — is real, but it is not the product MoonLight is (large installations, web UI, network protocols, no-reboot live reconfig of a mains-powered controller).
Precedent¶
- FastLED supports nRF52 (Adafruit nRF52 boards): the
clockless_arm_nrf52path drives WS281x via the PWM peripheral + EasyDMA, effectively one strip per PWM instance, with documented friction (BLE interrupt priority vs timing, per-board pin/compile issues — GitHub issues #595, #747). It proves single-to-few-lane WS2812 works; nobody uses it at installation scale. - WLED is ESP-only (ESP8266/ESP32 families); no nRF port exists or is planned — a data point that the networked-LED-controller product category has not found nRF worth the port either.
- Zephyr itself ships three single-lane
led_stripWS2812 drivers (SPI default, I2S, GPIO-bitbang) with nRF DK sample overlays — good bring-up references for a platform-layer driver, all small-N demos. - Nordic's own I2S-WS2812 blog (2016, still the canonical reference) demonstrates the 4-bit-symbol I2S encode; community repos (electronut, cheehieu, edwios) replicate it at 12–240 LEDs.
Reframing: core-as-library and the BLE-UI question¶
The sections above answer "is nRF a good LED-installation controller" (no). But MoonLight is more than its light domain — its src/core/ is a domain-neutral runtime that the architecture explicitly holds apart from lights (see architecture.md § Core and light domain: "Core: MoonModule base, controls, scheduling, persistence, platform abstraction... Domain-neutral. Knows nothing about lights."). Offered as a library, that core is a compelling thing in its own right, and the interesting fit question becomes: does MoonLight's runtime — not its LED pipeline — run cleanly on a tiny BLE device, and can its UI reach it? This reframes nRF from "loses the big-installation race" to "a showcase of how far MoonLight's core stretches."
1. How much of the core is portable as-is?¶
Nearly all of it. Walking src/core/ against the domain line:
- Portable, domain-neutral, zero LED/network dependency: the MoonModule base + lifecycle (
setup/tick/release, child propagation, the enabled-gate), the Scheduler, ModuleFactory, the Control system (bind-to-member, defaults, conditionalhidden), the persistence system (FilesystemModule → LittleFS flat POD image), the state/JSON model, and the generic UI-from-modules contract (the UI renders whatever a module declares; there is no per-module UI code). This is the bulk of the core, and it needs nothing nRF lacks — Zephyr gives it a heap, a filesystem, and a clock, which is all the runtime asks. - Portable but inert-by-flag: the network/service modules (HttpServerModule, NetworkModule, the ArtNet/DDP/WLED-sync drivers, mDNS, OTA) are core-resident but gate on capability flags already (
hasNetwork,hasWiFi,hasOta). On ahasNetwork = falsebuild they compile to nothing viaif constexpr— exactly the desktop-stub pattern. So they don't block the port; they simply aren't present, and the UI transport that would normally ride HTTP/WS has to come from somewhere else (§ 3). - MoonLive (the live-script engine) is portable in structure but needs an ARM Thumb-2 backend to actually emit code (it has Xtensa/RISC-V/arm64 today); until then scripted modules fail cleanly and run dark. Not a blocker for a core showcase — every non-scripted module works.
- Entangled with the light domain (stays behind, correctly): layouts, layers, mapping/blending, effects, modifiers, the LED drivers. None of this is core; it's
src/light/, and a core-as-library showcase simply doesn't ship most of it (it ships one small LED driver as the visible output, § 2).
The honest figure: the domain-neutral core ports with essentially no architectural work — it's the same code the desktop build already runs headless-of-LEDs. The port cost is the platform seam (§ Zephyr fit above) plus one new thing the desktop never needed: a UI transport that isn't HTTP (§ 3). That single item is the whole ballgame.
2. A few LEDs is a feature, not only a shortfall¶
The ~1–2 K-LED ceiling on a 256 KB nRF part reads as a shortfall against 16 K-on-ESP32 — but for the device class nRF actually is (battery-powered, BLE-remote, wearable, retail/prop lighting), a handful to a few hundred WS2812 is the whole point, and MoonLight's pipeline runs identically at that scale (the architecture's "effects must run at every grid size" rule guarantees a 16-LED strip or an 8×8 matrix is a first-class configuration, not a degraded one). The single-lane I2S-EasyDMA route (≈ 1365 LEDs, § WS2812 output) is more than enough for this niche, and the PWM route gives a few independent short strips. So the small-device story isn't "MoonLight crippled" — it's "the same full pipeline, small, battery-friendly, phone-controlled," which is a coherent product the ESP32 line doesn't naturally serve (ESP32 + WiFi is mains-power-shaped). Re-weighed: the LED limit is a positioning, not a defect, for this specific niche.
3. THE crux — can MoonLight's UI run over BLE (no WiFi/HTTP)?¶
MoonLight's UI ↔ device link is, at its heart, a JSON control/state channel: the browser sends control writes, the device notifies state back (today over a WebSocket). Nothing about that model is HTTP-specific — it maps directly onto a BLE GATT custom service: one write (or write-without-response) characteristic for control-set, one notify characteristic for state-push. This is the exact shape of well-established prior art, and the research says the path is clean, not hacky:
- Throughput is a non-issue. BLE 5 with a 247-byte ATT MTU and DLE sustains a theoretical ~97 kB/s (≈ 780 kbps) of notifications, and real-world BLE-throughput guides put practical rates at ~100–700 kbps depending on connection interval and MTU. MoonLight's state sync is a few hundred bytes to low-KB of JSON per push at ~1 Hz for full state, with small deltas on control changes — three-plus orders of magnitude under the ceiling. Chunking a larger JSON blob across successive notifications (each ≤ MTU−3 bytes) is the standard technique and is trivial here. BLE bandwidth is not the limiting factor for our UI.
- The channel maps to our existing seams naturally. State-notify ↔ the WS state push (produced already, just framed onto a characteristic); control-write ↔ the
/api/controlmutation path (the same apply-core + per-control validators the HTTP API and the ImprovAPPLY_OP"REST over serial" path already reuse). In other words MoonLight already has a transport-agnostic control/state core with two transports (HTTP/WS and Improv-serial); BLE GATT is a third transport onto the same core, which is precisely the extensibility the "Improv = REST over serial" design demonstrated. That makes it an extend, not a bespoke rewrite. - Prior art is abundant and specifically config-UI-over-BLE. Zephyr + Web Bluetooth config UIs, Moddable's JSON-defined GATT services, Nordic's custom-BLE-service tutorials, and the entire ESP32-WiFi-provisioning-over-BLE ecosystem (Espressif's own BLE provisioning, plus community Web-BLE config portals like UriShX's
esp32_web-ble_wifi_config) all do exactly "browser/app ↔ BLE GATT ↔ device settings." MoonLight's Improv-BLE-style provisioning would slot in beside it. - The one real caveat — client reach. Web Bluetooth (browser → GATT with no app install) is Chromium-only: Chrome/Edge on desktop + Android, never Firefox, and crucially never Safari on iOS/iPadOS (Apple's WebKit rule means every iOS browser lacks it, Chrome-on-iOS included). Global support is ~76% (April 2026). So the frictionless "open a web page, connect, configure" experience works on Android + desktop but not on iPhone, where the fallbacks are a native companion app, a third-party BLE browser (Bluefy/WebBLE), or Nordic's nRF Connect app. This is the single honest wart on the BLE-UI story: the device side is clean, but the no-install browser client has an iOS-shaped hole.
Verdict on the crux: the BLE-UI path is clean at the protocol/device level and a well-trodden pattern — it is a weekend-to-two-weeks spike, not a research project, for the device firmware + an Android/desktop Web Bluetooth client. It becomes a larger undertaking only if the showcase must include a first-class iOS client (then: a small companion app, a separate effort). Framed as a demo/advertisement, the Android/desktop Web Bluetooth client is enough to make the claim land.
The advertising value¶
"MoonLight's full runtime — the same MoonModule lifecycle, controls, persistence, and generic UI you run on a 16 K-LED ESP32 installation — also runs on a coin-cell BLE chip, configured live from your browser over Bluetooth" is a strong, concrete credibility claim. It demonstrates the domain-neutral-core thesis better than any doc: the same core, top to bottom, from a battery BLE wearable to a mains ESP32 wall of light. That is a real reason to do the spike independent of whether nRF ever becomes a shipping LED target.
Recommendation¶
Do a scoped showcase spike; do not commit to nRF as a shipping LED target. The two questions have opposite answers and must not be conflated:
- nRF as a big-LED installation controller: decline / defer. Every such configuration underperforms an ESP32 (no PSRAM, ~4–6 heterogeneous lanes, companion chip needed for IP). The multicore split, PSRAM-scale buffers, and 8–16-lane output on the roadmap all regress here. Teensy remains the right next hardware LED target (1 MB RAM, built-in Ethernet, 40+ DMA lanes).
- MoonLight's portable core + BLE-UI as a showcase: pursue as a time-boxed spike. The core ports with essentially no architectural work, the small-LED story is a coherent niche rather than a crippling, and — the decisive finding — the BLE-UI channel is clean and well-precedented, bandwidth-comfortable, and reuses the transport-agnostic control/state core we already have. The showcase/advertising value is real and lands independent of LED scale. Gate: acceptable only if the iOS-client gap (Web Bluetooth ≠ Safari) is fine for a demo — which for an advertisement/proof-of-concept it is (Android + desktop Chrome/Edge reach the device; an iOS companion app is a later, separate call).
Concrete first step if pursued (sized as a spike, ~1–2 weeks):
1. A src/platform/nrf/ skeleton under Zephyr on an nRF52840-DK: platform_config.h with all LED-lane constants 0 except one new PWM/I2S lane flag, hasNetwork = false, hasWiFi/hasEthernet/hasOta = false; implement the time / heap / fs (LittleFS) / GPIO seam groups. This alone proves the domain-neutral core boots and runs its Scheduler + persistence on Zephyr.
2. One I2S-EasyDMA WS2812 driver (the whole-frame-DMA shape, closest to i80Ws2812*) on a single lane driving a small strip/matrix — the visible output that makes the demo tangible.
3. The BLE-UI transport: a GATT custom service with a control-write and a state-notify characteristic, framing the existing JSON control/state onto BLE (chunked to MTU), plus a minimal Web Bluetooth client page (Android/desktop Chrome) that renders the generic module UI from the notified state and writes control changes back. This is the load-bearing deliverable — it's what turns "the core runs on nRF" into "and you can use it from your browser over Bluetooth."
That spike answers the three real unknowns — how much RAM survives Zephyr + the BLE stack (does the core fit a 256 KB part?), BLE-radio-vs-DMA timing under load, and end-to-end BLE control/state round-trip latency in the actual UI — for about two weeks, and produces a genuinely marketable artifact whether or not nRF ever ships as an LED target.
What would additionally have to be true to promote it from showcase to a shipping target: a concrete product pull toward BLE/Matter-controlled small-strip devices (wearables, retail, battery props); a decision on the iOS client (companion app vs. Android/desktop-only); and, for scripting on-device, the ARM Thumb-2 MoonLive backend (already the named cost for Teensy, so nRF rides along once Teensy lands).
Sources¶
- Nordic product pages: nRF52840, nRF5340, nRF54L15, nRF54H20, nRF7002 Wi-Fi 6 companion IC
- Family comparisons: nRF52840 vs nRF54L15 (BLEFYI), nRF5340 vs nRF54L15 (BLEFYI), nRF54H20 announcement (CNX)
- WS2812 on nRF: Nordic DevZone blog — Driving WS2812B via I2S (the 16 KB EasyDMA ≈ 1365-LED figure), Electronut Labs write-up, nRF52 PWM peripheral guide
- Zephyr: C++ language support (C++98–C++20, restrictions), BSD sockets, WS2812 led_strip sample + SPI/I2S/GPIO drivers, worldsemi,ws2812-i2s binding, nRF7002 DK board doc, nRF54H20 DK board doc
- FastLED nRF52: issue #595 — NRF52 status, issue #747 — nRF52840 support
- WLED supported platforms (ESP8266/ESP32 only): WLED docs
- BLE-UI throughput: Punch Through — Maximizing BLE Throughput, Memfault Interrupt — A Practical Guide to BLE Throughput, Nordic GATT Throughput Service (the ~97 kB/s @ 251-byte PDU figure)
- Web Bluetooth support: caniuse — Web Bluetooth, MDN — Web Bluetooth API, WebBluetoothCG implementation status, Chromium issue — Web Bluetooth on iOS (unimplemented)
- Config-over-BLE / Web Bluetooth prior art: Zephyr + Web Bluetooth getting started, Moddable JSON-defined GATT services, Nordic custom BLE service tutorial, ESP32 WiFi provisioning over BLE (Espressif/community), UriShX esp32_web-ble_wifi_config (Web-BLE config portal)