The first two articles covered the GD6 as a product and its hardware design. This one gets into what developers care about most: how to use the code.
The GD6 firmware and web configuration system are open-source on GitHub (github.com/ManThink/gd6-open). This article walks through the repository structure, development environment setup, firmware compilation, multi-region builds, flashing, web interface customization, REST API usage, and the remote MQTT configuration channel.
| Item | Details |
|---|---|
| Repository | https://github.com/ManThink/gd6-open |
| Description | LoRaWAN gateway based on ESP32 |
| Primary language | C (ESP-IDF framework) |
| Build system | PlatformIO + ESP-IDF |
| License | See LICENSE file in repo |
| Current version | 1.0.2 (see version.txt) |
| Initial release | July 23, 2026 |
gd6-open/
├── src/ # Main application code
│ ├── main.c # Entry point, 6-phase boot
│ ├── ethernet_w5500.c # W5500 Ethernet driver
│ ├── cellular_4g.c # 4G PPP dial-up
│ ├── net_route_mgr.c # Triple-link failover manager
│ ├── wifi_ap.c # Wi-Fi AP + STA
│ ├── web_server.c # HTTP REST API + static files
│ ├── config_rpc.c # Config core (parse → validate → persist → apply)
│ ├── auth.c # Login password + Token auth
│ ├── mqtt_bridge.c # UDP↔MQTT bridge
│ ├── remote_config.c # Remote MQTT config channel
│ ├── ota_update.c # A/B dual-partition OTA
│ ├── sys_log.c # In-memory ring buffer log
│ ├── uplink_driver.c # pktfwd / station driver selection
│ ├── periph_power.c # Peripheral power management
│ ├── status_led.c # Status LED + watchdog
│ └── factory_reset.c # Factory reset
├── components/ # Third-party components
│ ├── packet_forwarder/ # Semtech lora_pkt_fwd
│ ├── sx1302_hal/ # SX1302 HAL (Semtech v2.1.0 port)
│ ├── basicstation/ # Semtech BasicStation 2.0.6
│ └── esp_modem/ # 4G PPP modem component
├── frontend/ # Web UI source (Vite build)
├── data/ # SPIFFS filesystem image
│ ├── web/ # Web UI static files (build output)
│ ├── global_conf.json # Active radio + gateway config
│ ├── regions/ # LoRaWAN regional config templates
│ ├── eth_config.json # Ethernet config
│ ├── wifi_ap.json # Wi-Fi AP/STA config
│ ├── cellular_config.json # 4G config
│ ├── mqtt_bridge_config.json # MQTT bridge config
│ └── remote_config.json # Remote config channel settings
├── scripts/ # Build scripts
│ ├── build_regions.py # Per-region batch build
│ └── embed_defaults_copy.py # Default config sync
├── tools/ # Flasher + crypto scripts
├── hardware/ # Hardware project files
├── partitions.csv # Dual-OTA partition table
├── platformio.ini # PlatformIO configuration
├── sdkconfig.defaults # SDK configuration
└── CMakeLists.txt # Top-level CMake
Key directories:
src/ contains the main application code. Each .c file corresponds to a functional module. Code is compiled with strict -Wall warnings.components/ holds third-party components. The SX1302 HAL and Packet Forwarder are ported from Semtech's official source; the original reference code is preserved in .upstream/ for diffing before modifications.frontend/ contains the Vue/Vite source for the web interface. Build output goes to data/web/.data/ is the SPIFFS filesystem image — flashed to the gateway's configuration storage partition.ManThink has other open-source repositories on GitHub:
| Repository | Purpose |
|---|---|
| ThinkLink | ThinkLink IoT platform |
| TKL-EB-SDK | EdgeBus SDK for edge device development |
| ThinkLink-docker | ThinkLink platform Docker deployment |
The GD6 repository is the gateway hardware open-source project. ThinkLink is the platform open-source project. They can be used together, but GD6 also works independently with ChirpStack, TTN, and other third-party network servers.
The GD6 build stack is PlatformIO + ESP-IDF. You do not need to install ESP-IDF separately — PlatformIO manages the framework version automatically.
You need:
frontend/).platformio.ini explicitly locks the platform version:
platform = espressif32 @ 6.13.0
This version corresponds to ESP-IDF 5.5.3. The comments specifically warn: do not use bare espressif32, as it will float to the latest version (7.0.x / IDF 6.0). IDF 6.0 removed the built-in mqtt component, which will cause compilation failures.
This is the first pitfall: if you update PlatformIO and compilation breaks, check whether the espressif32 version has changed.
git clone https://github.com/ManThink/gd6-open.git
cd gd6-open
pio run # Build firmware
If compilation succeeds and generates firmware.bin, your environment is ready. The first build downloads the ESP-IDF framework and dependencies, so it takes some time.
pio run # Build firmware
pio run --target upload # Flash firmware (via USB)
pio run --target uploadfs # Flash SPIFFS image
pio device monitor # Serial monitor (115200 baud)
pio run --target menuconfig # SX1302 pin / SPI configuration
pio run --target clean # Clean build/
Serial baud rate is locked to 115200. The platformio.ini comments explain: ESP-IDF console defaults to 115200; without this setting, pio run -t monitor would use 9600 and show garbled output.
The GD6 application code is frequency-agnostic — regional differences are entirely in global_conf.json. Regional templates are in data/regions/ (AS923 / EU868 / US915 / AU915 / CN470 / KR920 / IN865).
The scripts/build_regions.py script provides one-command builds:
# List available regions and center frequencies
python scripts/build_regions.py --list
# Set a region as development default (overwrites data/global_conf.json)
python scripts/build_regions.py --activate EU868
pio run && pio run -t uploadfs
# Build firmware + SPIFFS for all regions (output in dist/<REGION>/)
python scripts/build_regions.py
# Build specific regions only
python scripts/build_regions.py EU868 US915 AS923
# Rebuild SPIFFS only (faster when only config changed)
python scripts/build_regions.py --fs-only AS923
Build artifact naming (version from version.txt):
| File | Description | Flash Offset |
|---|---|---|
GD6-<ver>-<REGION>-full.bin |
Complete 8 MiB Flash image | 0x0 |
GD6-<ver>-<REGION>-firmware.bin |
App firmware | 0x20000 |
GD6-<ver>-<REGION>-spiffs.bin |
SPIFFS image | 0x5A0000 |
GD6-<ver>-<REGION>-global_conf.json |
Config copy used at build time | — |
build_info.json |
Build metadata and offset table | — |
Note: RF front-end hardware must match the target band. 868 / 915 / 470 modules are not interchangeable — this is a physical constraint, not a software limitation (explained in detail in Article 2).
For initial flashing or development debugging:
# Regular flash (firmware + SPIFFS)
pio run --target upload
pio run --target uploadfs
# Erase entire flash and write full image (initial flash or recovery)
python -m esptool --chip esp32s3 erase_flash
python -m esptool --chip esp32s3 write_flash 0x0 GD6-1.0.2-EU868-full.bin
Full erase overwrites all partitions and device data (including Gateway EUI). Back up any data you need to preserve first.
For online upgrades of deployed gateways:
.bin firmware matching the gateway model, hardware version, and target regionOTA uses the A/B dual-partition mechanism: new firmware is written to the inactive partition, verified, then the boot partition is switched. Do not refresh the page, disconnect the network, or power off during the upgrade.
The repository's tools/flasher/ directory contains a browser-based flasher (built on ESP Web Serial API) that supports flashing firmware directly via USB in Chrome/Edge. This tool also integrates ESP32-S3 external watchdog (TPL5010) feed control, plus abort and exception reset protection.
The manual explicitly warns: do not upload a full Flash manufacturing image (-full.bin) as a Web OTA firmware. Web OTA only accepts app firmware (-firmware.bin), because OTA writes to the inactive partition, not the entire Flash.
GD6 uses a custom partition table (partitions.csv). The 8 MB Flash is divided as follows:
| Partition | Type | Offset | Size | Description |
|---|---|---|---|---|
| nvs | data/nvs | 0x9000 | 24 KB | Non-volatile storage (passwords, etc.) |
| otadata | data/ota | 0xF000 | 8 KB | OTA boot slot record |
| phy_init | data/phy | 0x11000 | 4 KB | RF PHY initialization |
| ota_0 | app | 0x20000 | 2.75 MB | Firmware slot A |
| ota_1 | app | 0x2E0000 | 2.75 MB | Firmware slot B |
| storage | data/spiffs | 0x5A0000 | 2.375 MB | Web UI + config files |
The table fills exactly 8 MB. Two OTA partitions are 2.75 MB each; the SPIFFS partition is 2.375 MB. If firmware exceeds 2.75 MB, the partition table needs to be adjusted.
The web interface source is in frontend/, built with Vite:
cd frontend && npm install # First-time dependency install
cd frontend && npm run build # Build to data/web/
pio run --target uploadfs # Flash to SPIFFS
cd frontend && npm run dev # Local dev server
Build output goes to data/web/ (index.html, app.js, style.css), then is flashed as part of the SPIFFS image.
The README explicitly warns: do not modify data/web/* directly — they are build artifacts. The correct approach is to modify the source in frontend/, then run npm run build.
If you need to change web interface text, styles, or layout:
frontend/npm run build to generate new data/web/ filespio run --target uploadfs to flash to the gatewaynpm run dev starts a Vite local dev server. /api/* requests are proxied to the target gateway via Vite proxy. This lets you modify frontend code and preview changes in real time without flashing.
Overwriting web/ files online may render the console inaccessible, and factory reset does not restore damaged web files. If you need to update the web interface online, ensure you have serial access as a fallback recovery method.
The GD6 REST API uses Token authentication:
POST /api/login with the password to obtain a TokenDefault login password: wifi!0804 (change immediately after first login).
| Method | Path | Description |
|---|---|---|
| GET | /api/status |
System/network/LoRa/SPIFFS status |
| POST | /api/login · /api/logout |
Login / logout |
| PUT | /api/password |
Change password |
| GET | /api/auth_state |
Auth state query |
| GET/PUT | /api/ethernet |
Ethernet config |
| POST | /api/ethernet/power |
Ethernet power control |
| GET/PUT | /api/wifi |
Wi-Fi config |
| GET | /api/wifi/scan |
Wi-Fi scan |
| GET/PUT | /api/lora |
LoRa gateway config |
| GET/PUT | /api/cellular |
4G config |
| GET | /api/cellular/status |
4G real-time status |
| GET/PUT | /api/mqtt_bridge |
MQTT bridge config |
| GET | /api/mqtt_bridge/status |
MQTT bridge status |
| POST/DELETE | /api/mqtt_bridge/cert |
MQTT bridge certificates |
| GET/PUT | /api/remote_config |
Remote config channel settings |
| GET | /api/remote_config/status |
Remote config status |
| POST/DELETE | /api/remote_config/cert |
Remote config certificates |
| GET | /api/station |
BasicStation status |
| GET | /api/logs |
Logs (incremental) |
| DELETE | /api/logs |
Clear logs |
| GET | /api/logs/download |
Download logs |
| GET | /api/fs |
File listing |
| POST | /api/fs/upload |
Upload file |
| GET | /api/firmware |
Firmware info |
| POST | /api/firmware/upload |
OTA upload |
| POST | /api/restart |
Restart LoRa service or system |
Get system status:
# Login to get Token
TOKEN=$(curl -s -X POST http://192.168.4.1/api/login \
-H "Content-Type: application/json" \
-d '{"password":"wifi!0804"}' | jq -r '.token')
# Query system status
curl -s http://192.168.4.1/api/status \
-H "Authorization: Bearer $TOKEN" | jq
Set Ethernet to static IP:
curl -s -X PUT http://192.168.4.1/api/ethernet \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"mode": "static",
"ip": "192.168.1.200",
"netmask": "255.255.255.0",
"gateway": "192.168.1.1",
"dns1": "8.8.8.8"
}'
Batch management script for multiple gateways:
#!/bin/bash
# Batch query gateway status
GATEWAYS=("192.168.1.101" "192.168.1.102" "192.168.1.103")
PASSWORD="your_password"
for GW in "${GATEWAYS[@]}"; do
TOKEN=$(curl -s -X POST "http://$GW/api/login" \
-H "Content-Type: application/json" \
-d "{\"password\":\"$PASSWORD\"}" | jq -r '.token')
echo "=== $GW ==="
curl -s "http://$GW/api/status" \
-H "Authorization: Bearer $TOKEN" | jq '{firmware: .firmware_ver, uptime: .uptime, lora: .lora_status}'
done
The GD6 has two independent MQTT connections — do not confuse them:
| MQTT Bridge | Remote MQTT Config Channel | |
|---|---|---|
| Purpose | LoRaWAN data forwarding | Remote device configuration read/write |
| Config file | mqtt_bridge_config.json |
remote_config.json |
| Code module | src/mqtt_bridge.c |
src/remote_config.c |
| Connection | Independent TLS connection | Independent TLS connection |
| Auth | Broker credentials | Device login password (RPC auth) |
The two connections are fully isolated — do not mix Topics or credentials.
The remote channel has a critical security design: the remote channel cannot modify its own Broker settings.
This prevents remote misconfiguration from causing device disconnection. If the remote channel could modify its own Broker address or credentials, a single erroneous configuration could permanently sever the device's remote management capability.
Configure via the web console → "Remote Config" page:
Once configured, you can remotely read and write allowed device configuration items via MQTT messages and trigger reboots.
| File | Description |
|---|---|
/spiffs/global_conf.json |
SX1302 radio params + full gateway config (includes uplink_driver selection) |
/spiffs/eth_config.json |
Ethernet DHCP / static IP |
/spiffs/wifi_ap.json |
Wi-Fi AP/STA config |
/spiffs/cellular_config.json |
4G APN / auth / priority |
/spiffs/mqtt_bridge_config.json |
MQTT bridge broker / account / TLS / topic |
/spiffs/remote_config.json |
Remote config channel broker / account / TLS / topic |
These JSON files also serve as factory reset targets — they are embedded into the firmware. Factory reset extracts the default values from the firmware and overwrites the SPIFFS copies.
Configuration JSON changes require a gateway reboot to take effect. Once SPIFFS writes to Flash, the data is persistent and does not depend on RAM — it survives power loss, but requires a reboot to load the new configuration.
The web console's "File System" page provides direct management of /spiffs files, supporting viewing and editing configuration JSON. This opens a path for deep customization — you can modify configuration fields directly, beyond the options exposed in the web interface.
The uplink_driver field in global_conf.json selects which driver occupies the SX1302. The two are mutually exclusive:
| Driver | Value | Description |
|---|---|---|
| Packet Forwarder | pktfwd |
Semtech UDP packet forwarder (default) |
| Basic Station | station |
Semtech BasicStation 2.0.6, wss/TLS to LNS |
Switching drivers requires a gateway reboot. In Basic Station mode, the channel plan is provisioned by the LNS, and credentials/config are stored in separate SPIFFS files (tc.* / cups.*).
Understanding the boot process helps with troubleshooting. app_main() (src/main.c) boots in 6 phases:
| Phase | Action |
|---|---|
| Phase 0 | NVS init + event loop (status LED and factory reset button ready early) |
| Phase 1 | Mount SPIFFS — mount failure disables web server and packet forwarder |
| Phase 2 | Start Wi-Fi Soft-AP (async, non-blocking) |
| Phase 3 | Route manager starts W5500 Ethernet → 4G PPP, primary/backup failover |
| Phase 4 | Start HTTPD (REST API + static files) + remote MQTT config channel |
| Phase 5 | SX1302 self-test → start packet forwarder or BasicStation per uplink_driver |
Design principle: later phases tolerate non-fatal failures in earlier phases, ensuring the device can always enter recovery mode via Wi-Fi AP. Even if SPIFFS mount fails or SX1302 self-test fails, the web console remains accessible.
If you need non-standard channel configuration (e.g., custom center frequencies), modify data/global_conf.json directly:
python scripts/build_regions.py --activate EU868
# Then edit radio_0 / radio_1 center frequencies in data/global_conf.json
pio run --target uploadfs
Use the REST API to integrate GD6 gateways into your own operations system:
/api/status periodically to collect gateway runtime status/api/logs to fetch logs/api/remote_config to batch-push configuration changes/api/restart for remote restartsModify the Vue source in frontend/ to add custom pages or modify existing interfaces, then npm run build + pio run --target uploadfs.
Q: Compilation fails with mqtt component not found?
A: Check that platform = espressif32 @ 6.13.0 in platformio.ini has not changed. IDF 6.0 removed the built-in mqtt component — you must lock to 6.13.0 (IDF 5.5.3).
Q: Garbled serial output after flashing?
A: Confirm the baud rate is 115200. platformio.ini sets monitor_speed = 115200.
Q: Web OTA firmware upload fails to boot?
A: Check whether you uploaded app firmware (-firmware.bin) or full image (-full.bin). Web OTA only accepts app firmware. If a full image was uploaded, wait for automatic restart. If still inaccessible, use Wi-Fi AP or controlled serial recovery — do not repeatedly power-cycle.
Q: Web console won't open after factory reset?
A: Factory reset does not restore the web/ directory. If web/ files were previously overwritten and damaged, reflash the SPIFFS image via serial (pio run --target uploadfs).
Q: Modified data/ config but changes not taking effect?
A: After modifying files in data/, you must run pio run --target uploadfs to flash to SPIFFS. After modifying SPIFFS files online, a gateway reboot is required for changes to take effect.
The GD6 open-source repository is not a shell — it contains the complete firmware source, web interface source, build toolchain, and hardware project files. From cloning the code to compiling usable firmware, the entire process can be completed in under 30 minutes.
Key takeaways:
build_regions.py for multi-region builds — do not manually edit global_conf.json region fieldsfrontend/, not data/web/ — the latter are build artifactsThe next article moves into deployment — from unboxing to connecting ThinkLink / ChirpStack / TTN, walking through the entire deployment process step by step.
ManThink — Making IoT development more efficient.