AN: Connecting a ChirpStack Gateway to ThinkLink (via the lns-bridge)
Applies to: ChirpStack v4 + ThinkLink (AS MQTT) + the lns-bridge forwarder. Scenario: take a ManThink LoRaWAN device/gateway that natively talks to ThinkOne NS, switch it to a self-hosted ChirpStack as the NS, and bridge the data bidirectionally into ThinkLink. Credentials/keys/addresses are placeholders — replace at deploy time.
1. Architecture
Device ─up─▶ Gateway ─▶ ChirpStack(NS) ─▶ application up(MQTT/JSON)
└─ lns-chirpstack-up ─▶ ThinkLink AS /v32/{tenant}/as/up/data/{eui} ─▶ thing-model parser ─▶ telemetry
Down: ThinkLink RPC ─▶ AS /v32/{tenant}/as/dn/data/{eui} ─▶ lns-chirpstack-down ─▶ ChirpStack command/down ─▶ Gateway ─▶ DeviceThree brokers are involved: ChirpStack mosquitto (external), ThinkLink AS (internal), and the bridge moving messages between them. Hard rule: a device must NOT be active on two NS at once — disable the device profile on the source NS (ThinkOne) before migrating, otherwise both NS answer Joins / track frame counters / send downlinks → data corruption.
2. Prerequisites
- A Linux + Docker host (≥2 vCPU / 4 GB; add swap if memory is tight).
- Device join parameters (DevEUI, band, Class, OTAA AppKey or ABP DevAddr/NwkSKey/AppSKey); on ThinkLink these come from
mtSdevPfMacsFindPage. - SSH access to the gateway, whose packet forwarder supports Semtech UDP.
- A ThinkLink account; all platform calls go through
tkl-api-caller.
3. Deploy ChirpStack v4
git clone https://github.com/chirpstack/chirpstack-docker && cd chirpstack-docker && docker compose up -d- In mainland China, configure a Docker registry mirror first (
registry-mirrorsin/etc/docker/daemon.json). - On host port conflicts, remap the UI (e.g.
8070:8080). - CN470 band: in
configuration/chirpstack/chirpstack.toml, add theenabled_regionssub-band that matches the device's channels (device uplink 470.3–471.7 MHz →cn470_0; per-sub-band frequencies are inregion_cn470_*.toml), thendocker compose restart chirpstack.
4. Provision ChirpStack (gRPC)
Login is only exposed over gRPC (the REST gateway has no internal/login), use grpcurl:
JWT=$(grpcurl -plaintext -d '{"email":"<admin>","password":"<pw>"}' <HOST>:<GRPC_PORT> api.InternalService/Login | jq -r .jwt)Create in order: Application (note applicationId) → Device Profile (region=CN470, regionConfigId=cn470_0, macVersion per device, Class per the device's real capability — battery sensors are usually Class A) → Device (link app+profile, skipFcntCheck=true) → ABP Activate (Activate with devAddr+appSKey+nwkSEncKey/sNwkSIntKey/fNwkSIntKey; for 1.0.x all three = NwkSKey) → Gateway (gatewayId = gateway EUI).
Use ABP if you only have session keys and want to avoid a re-join; OTAA needs JoinEUI+AppKey and the device will re-join.
5. gateway-bridge region prefix
The gateway-bridge publishes to <prefix>/gateway/{eui}/event/*, where <prefix> must equal an enabled region_id. chirpstack-docker defaults to eu868 (in docker-compose.yml, the INTEGRATION__MQTT__*_TOPIC_TEMPLATE env vars); for CN470 change it to the sub-band:
INTEGRATION__MQTT__EVENT_TOPIC_TEMPLATE=cn470_0/gateway/{{ .GatewayID }}/event/{{ .EventType }} # change STATE/COMMAND tooThen docker compose up -d chirpstack-gateway-bridge.
6. Switch the ManThink gateway to Semtech UDP
Data path: SX130x → pfd (localhost:1661) → gwbrgmt → NS. Change the uplink target in gwgen_conf.json (read by gwbrgmt), NOT pfd's global_conf: nsNms.nsProtocol="pkt_fwd", nsAddress="<CHIRPSTACK_HOST>", nsPort_up=nsPort_down=1700; then restart gwbrgmt (gwm_mtc is a maintenance daemon unrelated to the forwarder protocol — leave it; the gwgdn watchdog guards gwbrgmt). The gateway radio channels must match the ChirpStack sub-band.
⚠️ A leading space in
nsAddressmakes gwbrgmt fail to resolve the uplink socket and silently send nothing — strip it.
7. ThinkLink forwarder bridge
- Broker connections (
forwarderEmqxBatchSaveUpdate): AS broker (type=AS, address auto-filled, butoptionsmust carry the tenant MQTT credentials); ChirpStack broker (type=Customize, host/port 1883). - Script forwarders (
forwarderScriptBatchSaveUpdate): up (from=ChirpStack, topicapplication/+/device/+/event/up, to=AS); down (from=AS, topic/v32/{tenant}/as/dn/data/+, to=ChirpStack). The script mustreturn {topic,msg,option}at top level — the engine wraps it asfunction forwarderRunner(){<script>} forwarderRunner()withtopic/msg/org_paramsinjected as scope variables; if your source script is infunction forwardScript(){}form, appendreturn forwardScript({topic,msg,org_params}). - org_params (
tenantBatchSaveUpdate, read-modify-write):lns.tenant,lns.chirpstack.appId(= the applicationId, needed by the down bridge).
8. Device migration (one device, one NS)
Before the device actually uplinks via ChirpStack, disable its ThinkOne profile: mtSdevPfMacsBatchSaveUpdate with enable=false (a tkoResult=true response means it synced to the NS).
9. Verification
- Uplink: the gateway-bridge log shows
event=up topic=cn470_0/gateway/{eui}/..., thenendDeviceDataRecordFindPageshows new telemetry. - Downlink:
deviceDispatchRpcsends a command; subscribe to ChirpStackapplication/+/device/+/command/downto see the payload, and observe the physical effect.
10. Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| No gateway UDP reaches ChirpStack | Security group allows TCP only, not UDP 1700 | Allow UDP 1700 |
| Gateway sends but packets rejected (malformed/1-byte) | Not Semtech UDP / a proprietary protocol | gwbrgmt nsProtocol=pkt_fwd |
| gwbrgmt receives pfd packets but forwards nothing | Leading space in nsAddress | Strip space, restart gwbrgmt |
| Gateway connects but frames dropped | gateway-bridge prefix (region) doesn't match the device band | Set *_TOPIC_TEMPLATE to the correct sub-band |
| ChirpStack publishes app events but ThinkLink has no telemetry | The bridge forwarder's MQTT subscription went stale after a NS/bridge restart | Re-save the forwarder to force re-subscription |
| Erratic uplinks / frequent retransmits | Profile wrongly set to Class C + ADR + DevStatusReq, sending needless downlinks to a battery Class A device | Set profile to Class A, disable ADR/DevStatusReq |
| AS data layer empty but telemetry fine | Raw-message storage is off | Enable the device's real_time_storage if needed |
Source: tkl-opt real-device commissioning (ManThink KS61 + gateway, 2026-06-15). Generic procedure; credentials/keys/addresses are placeholders.