All I wanted was to dim my key light from a script
Reverse-engineering the Nanlite FS-300B's Bluetooth Mesh + Feasycom TEA stack so I could control it from Linux without the NANLINK app.
I have a Nanlite FS-300B LED light on my desk. It's lovely, except that the only sanctioned way to control it is the NANLINK app on a phone. I wanted to control it from a script on my laptop.
How hard could it be? It's just Bluetooth.
It is just Bluetooth, in roughly the same way that a 1972 wiring harness is just copper. There are layers.
What's actually in the box
The FS-300B uses a Feasycom Bluetooth Mesh SoC. That matters, because Nanlite ships several different radio modules across their product line, and they are not interoperable. There are four protocols I'm aware of:
| Protocol | Radio | Devices | Encryption |
|---|---|---|---|
| RF V1.0 | nRF24L0 2.4 GHz | PavoTubeII6C, FC-60B/120B | None |
| RF V2.0 | Unknown | Newer firmware | Unknown |
| BLE "app protocol" | USR IOT WH-BLE 102 | PavoTubeII6C | None |
| BLE Mesh (Feasycom) | Feasycom BLE Mesh SoC | FS-300B | AES-CCM + TEA |
The first three are documented in vmedea's excellent gist, which I leaned on heavily. The fourth is what I had in front of me, and what I ended up writing up in a separate doc. The good news: all four protocols share the same application layer. Brightness is 0x01. CCT is 0x03. Hue is 0x05. Saturation is 0x0C. So once you can get a byte through, you already know what bytes to send.
The bad news: the FS-300B requires you to get the bytes through about five layers of envelope first.
Pulling the keys out of the APK
Standard Bluetooth Mesh is almost enough to talk to the light. It is not quite enough, because Feasycom layered their own TEA-encrypted authentication challenge on top of the standard, on a proprietary GATT service (0xFFF0). If you don't pass that handshake, the device silently drops every vendor-model message you send. It will accept your provisioning, configure your app key, bind it to the model — and then quietly throw your "set brightness to 50" packet on the floor.
The TEA key lives inside libencrypted.so in the NANLINK Android app. Getting it out went like this:
-
Download the NANLINK APK from APKMirror. It's a "split" APK — the actual native libraries live in a per-architecture split (e.g.
split_config.arm64_v8a.apk), not the base. -
Unzip the split. Locate
libencrypted.so. -
The 16-byte key is stored as raw bytes (not ASCII hex) immediately after the string
getRandomNumber\0. One line of Python:data = open('libencrypted.so', 'rb').read() idx = data.find(b'getRandomNumber\x00') print(data[idx+16:idx+32].hex()) -
Save the resulting 32 hex characters as
tea_key.hex. That's it.
I'm not republishing the key here, because it's Feasycom's. You extract it from the app you presumably already own. The act of extracting it is what makes the rest of this work.
The protocol stack
Every command I send to the light traverses the following stack. I am not exaggerating.
-
PB-GATT provisioning (Mesh Profile 5.4), one time per device. This is the standard Bluetooth Mesh handshake: ECDH on
secp256r1, AES-CMAC for confirmation values, AES-CCM for the encrypted provisioning data containingnet_key,iv_index, and the assignedunicast_address. The provisioning data PDU is 25 bytes plaintext, 33 bytes ciphertext + MIC. -
Feasycom TEA authentication on the
0xFFF0GATT service, every connection. Generate 4 random bytes, pad with 4 zero bytes, TEA-encrypt the 8-byte block, send"AUTH" + ciphertext + ciphertextto characteristic0xFFF2. The device responds on0xFFF1. If it doesn't respond within 2 seconds, retry with fewer TEA rounds (32 → 2 → 1) — this accommodates older Feasycom firmware that ships fewer rounds. -
Config AppKey Add (opcode
0x00) and Config Model App Bind (opcode0x803D), encrypted withdev_key. Without these, the device drops vendor-model messages even after you authenticate. -
Vendor model
0x1111/0x1111carrying an 8-byte Feasycom "fast command":0x00 uint8 rollCode Anti-replay counter, wraps at 0xFF 0x01 uint8 functionCode bit5: SET=1/QUERY=0; bit0: needReturn 0x02 uint8 typeCode Always 0x01 for light control 0x03 uint8 optionCode Parameter (0x01 = brightness, 0x03 = CCT, ...) 0x04 uint16_be value Parameter value 0x06 uint16_be channel 0x0000 for unicastSET brightness to 50isXX 20 01 01 00 32 00 00, whereXXis the rolling counter. Eleven bytes total, fits cleanly in a single unsegmented access message. -
Mesh access layer AES-CCM, encrypted with the application key.
-
Mesh transport + network layer, with another round of AES-CCM and an AES-ECB-based privacy obfuscation pass over the 6-byte network header.
-
Proxy PDU framing — segmentation/reassembly, message type
0x00(Network PDU), wrapped onto GATT characteristic0x2ADDof the Mesh Proxy Service (0x1828).
Six layers of envelope to make the lamp dimmer.
The Bluetooth Mesh sample app key, shipped in production
This is my favorite detail.
The Bluetooth Mesh Profile v1.0 specification, §8, contains an example application key for use in test vectors:
63964771734fbd76e3b40519d1d94a48
Feasycom shipped this exact key in their firmware. Every Feasycom mesh device uses it. So do, presumably, every MeshManagerApi.java derivative in the wild. It's hardcoded. You can verify it yourself in the spec.
I want to be clear that this is fine — the network key (which is per-device and randomly generated at provisioning) is what actually authenticates the network. The app key is a separate scope. But there's something delightfully on-brand about an entire IoT product line keying off a value the spec authors used as a placeholder.
The 24-bit sequence number trap
Bluetooth Mesh requires monotonically increasing 24-bit sequence numbers per source. The device rejects anything it has already seen. If your provisioner crashes and reloads mesh_keys.json with a stale seq value, the device starts ignoring you, and the only way to recover is to re-provision.
The implementation cheats:
time_seq = (unix_time - 1735689600) // 10 # 10-second intervals since 2025-01-01
seq = max(saved_seq, time_seq) + 32 # +32 buffer per connection
The current time_seq value is around 3.5 million, which is far above any realistic stale saved_seq, so the sequence is monotonically increasing even if the key file is wiped. The 24-bit space (16,777,215 values) runs out around 2030, at which point an IV Index update — re-provisioning — is required1. I'll be sad to do it, but I have until then.
What vmedea did for free
The reason this project worked at all is that I wasn't starting from scratch on the application layer. vmedea's gist documents the parameter codes for the older nRF24 and USR IOT BLE protocols, and the FS-300B's underlying light-control MCU uses the same numbering scheme — 0x01 brightness, 0x03 CCT, 0x04 green/magenta shift, 0x05 hue, 0x0C saturation. Once I could get an 8-byte payload through, the meaning of those bytes was already documented by someone else.
Reverse engineering is mostly nobody handed you the key, but it is occasionally "the previous person left half the answer in plain sight." That second case is much nicer.
What this is for
You'd be forgiven for asking: who has the patience for this?
But the layers are not arbitrary. AES-CCM with separate network and application keys is what lets a mesh light bulb relay packets for a neighbor without being able to read them. The secp256r1 ECDH exchange is what lets a fresh device share keys with a provisioner over an untrusted radio. The privacy obfuscation pass is what keeps a passive sniffer from correlating a source address across messages. Even the rolling counter in the Feasycom fast command is a real anti-replay mechanism. This stack is overkill for a dimmer slider, but the same stack is also what runs in a hospital, a bridge sensor network, and a building's HVAC. It's nice to see it from the inside.
Also, I can finally type python cli.py brightness 50 and the lamp dims. Worth it.
- 24 bits, divided into 10-second intervals, gets you about 5.3 years from the 2025-01-01 epoch. After that the IV Index has to update, which is itself a Mesh-spec'd procedure — you don't actually have to re-provision in the strict sense, but it's easier to do.↩