With the stock Bluetooth application confirmed to be scan/advertise/connect-locked (see the previous chapter), the next step was building a custom Bluetooth LE Network Co-Processor image and proving it could both run reliably on this specific board and be genuinely useful — in this case, as a passive reader for Govee H5075 BLE thermo-hygrometers.
Prior startup failures and the HFXO erratum
Earlier generated radio projects (Zigbee and Thread images built before this BLE work) had appeared to hang during clock initialization. SWD tracing showed execution stuck around Silicon Labs’ HFXO initialization path, and exposed the EFR32MG21 HFXO_E301 erratum: changing HFXO.DISONDEMAND from 0 to 1 while HFXO is enabled can hang the EMU/CMU handshake, requiring a physical pin reset to recover. This affects silicon revisions A through D with no documented silicon-level fix.
The stock SDK’s CMU_HFXOInit() crystal-mode sequence does exactly this forbidden transition. The durable workaround, tools/patch-sdk-hfxo-errata.py, removes the DISONDEMAND toggle and everything depending on the resulting FSM-override state. Earlier experiments that instead tried to relax HFXO wait conditions more broadly were reverted after skipping a FSMLOCK wait produced a real fault on a subsequent register write — proving that FSM state was meaningful and that bypassing synchronization more broadly was unsafe. The narrow HFXO_E301 patch was kept; the broad wait-loop bypass was not.
Earlier Zigbee/Thread projects also carried trustzone_unaware components and application constructors that rewrote SMU->PPUSATD* access masks or disabled SMU_SECURE_IRQn, because peripheral access to CMU, HFXO0, USART0, and radio-adjacent blocks could trap under some generated configurations. The final BLE build deliberately omits all of that: its app.c is the ordinary NCP application skeleton with no custom SMU constructor, and this is the configuration that produced clean, verified BGAPI responses.
UART pin mapping settled by the working build
Earlier phases of this broader investigation had recorded conflicting PA5/PA6 interpretations, partly because host TX/RX and MG21 TX/RX labels were being discussed from opposite viewpoints, and partly because some intermediate firmware genuinely used the wrong mapping. For the final BLE NCP, the generated configuration and live, bidirectional BGAPI traffic settle it unambiguously:
MG21 USART0 TX = PA5
MG21 USART0 RX = PA6tools/build-ble-ncp.sh writes these exact pins into sl_iostream_usart_vcom_config.h and disables hardware flow control (the W1700K connection exposes only TX/RX, no CTS/RTS).
Baud-rate diagnosis
The MG21 firmware runs at a nominal 115200 baud. The W1700K host, however, needed to request 230400 to get clean framing, due to this snapshot kernel’s AN7581 UART driver behaving as if every requested rate needs to be doubled. Requesting 460800 (double the intended 230400, i.e. quadruple the firmware’s real 115200) produced framing errors and garbage. The final, verified working combination:
MG21 firmware setting: 115200
W1700K stty/request: 230400
format: 8N1, raw, no CTS/RTS, no XON/XOFFA BOOT-pin test that had not fully released the pin also produced misleading bytes during diagnosis and was ruled out as a red herring before the baud-rate explanation was confirmed.
Flashing without WSTK
The final BLE application/S37 was first flashed over SWD at application origin 0x4000, with no mass erase and the Gecko bootloader preserved. The identical GBL was then separately tested through the W1700K’s own UART DFU path (the tooling built in the reverse-engineering chapter): with the H5075 reader stopped, GPIO537/536 entered the factory Gecko bootloader (a0 04 00 00 02 00 01 02), and with MG21_BAUD=230400, the router transferred all 254,868 bytes as 1,004 acknowledged chunks. DFU_FLASH_UPLOAD_FINISH returned 20 02 00 03 00 00, and reset produced the full BLE application boot event:
a0 12 01 00 0b 00 01 00 00 00 00 00 02 00 01 02 01 01 35 44 cd 01This proves the BLE image can be installed and used entirely through the W1700K, with no SWD required for routine reflashing once the case is closed — the flashing uploader’s own comparison logic printed a spurious MISMATCH only because it compared this current boot payload against a hard-coded older stock-firmware payload; the actual upload and boot succeeded.
BGAPI command/response verification
Three commands proved the NCP was parsing requests correctly, not just emitting stray startup bytes:
system_hello:
request: 20 00 01 00
response: 20 02 01 00 00 00
system_get_version:
request: 20 00 01 1b
response: 20 12 01 1b 00 00 0b 00 01 00 00 00 00 00 02 00 01 02 35 44 cd 01
system_get_identity_address:
response: 20 09 01 15 00 00 f1 88 0c 89 c0 1c 00After starting active observation scanning, the NCP returned tens of kilobytes of valid legacy advertising-report events from multiple nearby devices (including a Govee_H6168_3D5E), confirming the scanner was not narrowly tailored to any one sensor.
The Govee H5075 and a generic reader
Among the scanned devices was a powered-on Govee H5075 temperature/humidity sensor:
| Field | Observed value |
|---|---|
| Local name | GVH5075_52B3 |
| BLE address | A4:C1:38:37:52:B3 |
| Manufacturer/company ID | 0xEC88 |
The H5075 broadcasts its reading in manufacturer-specific advertising data, requiring no BLE connection. After the little-endian company ID, the body has the form 00 TT TT TT BB 00, where TT TT TT is a 24-bit big-endian packed value and BB is battery percentage:
packed = big_endian(TT TT TT)
temperature = floor(packed / 1000) / 10
humidity = (packed % 1000) / 10
battery = BBA captured test vector, 04 28 43, decodes to 27.2 °C and 45.1% RH, with 64 giving 100% battery.
A generic host utility, host/h5075-reader/main.go, was built to consume this: it configures /dev/ttyS1 for 230400/8N1/raw with no flow control, sets active scanning (100 ms interval/window) and 1M-PHY observation mode, resynchronizes and parses self-delimiting BGAPI frames, and decodes any advertiser whose name begins with GVH5075 and whose company ID decodes to 0xEC88 — dynamically, without hard-coding a MAC address, suffix, or serial number. It supports -json for machine-readable output and -once for a single-reading smoke test, and attempts a clean scan-stop on exit or SIGINT/SIGTERM. It was built reproducibly with CGO_ENABLED=0 GOOS=linux GOARCH=arm64.
Deployed as /usr/local/bin/h5075-reader on the router and run under start-stop-daemon (the OpenWrt image lacks nohup), a live log entry read:
2026-09-12T23:13:01Z GVH5075_52B3 A4:C1:38:37:52:B3 27.3 C 44.3 %RH 100 % battery RSSI -26 dBmBecause only one process may own /dev/ttyS1 at a time (BLE, Zigbee, and Thread all share the same MG21 UART), the reader must be stopped before running any other BGAPI client, serial bridge, or Zigbee/Thread process:
start-stop-daemon -K -p /tmp/h5075-reader.pidVerification and open items
Confirmed: SWD and UART DFU flashing both work without mass erase; the TX=PA5/RX=PA6, no-flow-control USART0 mapping is bidirectional on this board; system_hello/get_version/get_identity_address all return complete responses; active scanning returns valid events from multiple devices; the H5075 decoder produces stable, plausible readings; and the reader’s unit tests pass.
Not yet verified: behavior with two or more H5075 sensors present simultaneously (the decoder is identity-independent by design but this was never tested with multiple units); reboot persistence (the current process is a test invocation under start-stop-daemon, not an OpenWrt init/procd service); long-duration UART/BGAPI stability across MG21 or host resets; and any BLE-to-MQTT/Home Assistant integration, which was deliberately deferred.