Skip to content

Instantly share code, notes, and snippets.

@vmedea
Last active April 13, 2024 18:32
Show Gist options
  • Save vmedea/434694c11092261fcac401b7a4b9a741 to your computer and use it in GitHub Desktop.
Save vmedea/434694c11092261fcac401b7a4b9a741 to your computer and use it in GitHub Desktop.
Nanlite RF and BLE protocol

(this document can be found here: https://gist.github.com/vmedea/434694c11092261fcac401b7a4b9a741)

Contents:

  • Nanlite RF protocol (V1.0)
  • Nanlite RF protocol (V2.0) -- TODO
  • Bluetooth LE ("app protocol")

Nanlite RF protocol (V1.0)

This document describes the RF remote control protocol used by at least the Nanlite PavoTubeII6C RGBWW photography lights, and probably more lighting equipment by the same vendor (I do not have access to any other so cannot verify this).

The lights use a Nordic nRF24L0 single chip 2.4 GHz transceiver module to receive updates of the current light mode and settings.

  • The nRF channel used (RF_CH) is always 0x73.

  • All packets are four bytes.

  • The 5-byte address is generated from the configured channel as 0x00 0x00 0x00 0xAA 0xBB, where 0xAABB is the channel number between 1 and 511.

  • RF_SETUP is 0x07 (2 Mbps, RF output power 0 dBm, setup LNA gain).

  • CONFIG is 0x0f (Enable CRC, 2-byte CRC, POWER UP, PRX).

Four known kinds of packets exist:

  • Type 1: Set intensity (independent of mode).

  • Type 2: Set CCT color temperature + intensity.

  • Type 3: Set HSI hue, saturation and intensity.

  • Type 4: Set CCT color temperature, intensity and G/M (Green/Magenta).

These are described in the individual sections below. The numbering is arbitrary. Although all four bytes, the packet layouts are completely different and they are recognized by matching certain requirements on how the byte values relate.

The RC-1 remote control can only set color temperature and intensity, not hue/saturation/intensity. The other packets are likely used by the WiFi control box.

Type 1

Set intensity of the light independent of mode.

pkt[0] = I   (0..100)
pkt[1] = 0x19

With the following requirements:

pkt[2] == pkt[3]

pkt[2] and pkt[3], besides having to match, can have any value, with no known effect.

Type 2

Switch the light to CCT mode, and set color temperature + intensity.

pkt[0] = I   (0..100)
pkt[1] = CT  (0..100, linearly maps to 2700K..7500K)

With the following requirements:

(pkt[0] + pkt[1]) == pkt[2]
(pkt[2] ^ 0xff) == pkt[3]

Type 3

Switch the light to HSI mode, and set hue, saturation and intensity.

pkt[0] = 0xf0 | Hmsb
pkt[1] = I  (0..100)
pkt[2] = Hlsb
pkt[3] = S  (0..100)

With the following requirements:

pkt[0] == 0xf0 || pkt[0] == 0xf1

The hue H is computed as ((pkt[0] << 8) | pkt[2]) & 0xfff, and must be in the range 0..360. The the upper 4 bits of pkt[0] must be set, so it can only ever be 0xf0 or 0xf1.

Type 4

Switch the light to CCT mode, and set color temperature + intensity, as well as G/M.

pkt[0] = I   (0..100)
pkt[1] = CT  (0..100, linearly maps to 2700K..7500K)
pkt[2] = G/M (0..100, linearly maps to -100..100)

With the following requirements:

(pkt[0] + pkt[1]) == pkt[3]
(pkt[2] ^ 0xff) != pkt[3]

Nanlite RF protocol (V2.0)

Newer devices (and firmwares) have a setting for a V2 radio protocol. Its specifics are unknown to me.

Bluetooth LE ("app protocol")

Newer firmware versions also introduced a protocol that communicates over Bluetooth LE, which is used to communicate with the vendor app. It's also referred to as the "app protocol" in places like change logs.

Note that Bluetooth has higher latency, is less reliable (at least in the form used here), has shorter range, and is much more complicated than the 2.4Ghz radio protocol. The reason one would use it is that BLE hardware is very common in mobile devices, and offers some functionality not possible with the radio protocol (for example: setting effect parameters and reading back values).

Data types

The following types are used throughout the document:

  • uint8 an unsigned byte.
  • uint16_be an unsigned 16-bit word (big-endian).
  • int16_be a signed 16-bit word (two's complement, big-endian).
  • uint32_be an unsigned 32-bit word (big-endian).
  • int32_be a signed 32-bit word ((two's complement, big-endian).
  • X[Y] an array of type X of size Y. Size can be left out if it is variable and implied by the context.

All integer fields in the protocol appear to be big-endian.

GATT protocol

The Bluetooth GATT stack is provided by a USR IOT WH-BLE 102 module, which provides a serial UART to the device, using the following characteristics:

Service UUID: 0003cdd0-0000-1000-8000-00805f9b0131
    UART_TX   0003cdd1-0000-1000-8000-00805f9b0131
    UART_RX   0003cdd2-0000-1000-8000-00805f9b0131

To send to the device, send up to 20 bytes at a time to UART_RX. To receive from the device, subscribe to notifications from UART_TX using its notification handle (UUID 00002902-0000-1000-8000-00805f9b34fb, which will have the characteristic handle after UART_TX). See the example session below.

Larger command packets can span multiple 20-byte BLE packets but the timing seems to be important: the packet is only processed if everything arrives at once, not if there is significant delay between fragments. Conversely, if two packets arrive in too quick a succession they are seen as one packet and the second packet will be ignored.

What seems to achieve most stability in my experiments is to send the first fragments of a packet as write_command (will have no response), then the last fragment with write_request, then wait for the write_response before sending the next packet. A fixed delay (if long enough) will also work. If you want to be more sure that a packet was processed, send it multiple times. The sequence number will make sure it is only processed at most once.

I've also noticed that responses larger than 20 bytes seem to be truncated. The device will never send back anything larger. I'm not sure if this is a problem with the BLE module, or the device firmware, or my own mistake.

Serial protocol

From the perspective of the serial line, the protocol looks like:

fa55001101 0320010100320004 01bd0d0a

Application-level packets are enclosed in this envelope with a five-byte header, and a four byte trailer. The header consists of:

(byte offset, type, name, description)
0x00 uint8      magic1      Always 0xfa.
0x01 uint8      magic2      Always 0x55.
0x02 uint16_be  pkt_size    Size of packet in bytes (including header and trailer).
0x04 uint8      class       Packet class. Always 0x01 for packets described here.

The trailer consists of:

(byte offset, type, name, description)
0x00 uint16_be  checksum    Checksum of packet. The sum of all the bytes in the packets (including
                            header, but not trailer) plus 0x01.
0x02 uint8      magic3      Always 0x0d.
0x03 uint8      magic4      Always 0x0a.

Magic values inside packets are not escaped inside this envelope, as one would normally expect for line synchronization in SLIP-like protocols. Reply packets will have the same format.

Besides these packets, the application has been seen to send 0x01 periodically, probably a keep-alive. The device occasionally sends +++ spuriously (may be a failed AT break — the BLE module uses a AT command set for setup).

Application packet protocol

This section describes the application level protocol, inside the serial protocol wrapping.

An example of the simplest packet would be: 03 20 01 01 00 32 00 04. This sets the brightness parameter to 50.

An example of an extended packet is 23 24 00 04 01 01 01 08 00 0b 03 01 00 00 64. This switches the device to HSI lighting mode.

Header

Packets are prefixed by a header:

(byte offset, type, name, description)
0x00 uint8      seq         Increasing sequence number. this is used to ignore duplicated packets, as well as reproduced in replies.
0x01 uint8      flags       Bit field specifying packet layout and requested parameter operations:

    00000001    read_req    Request parameter read.
    00000010    read_resp   Response to parameter read.
    00000100    extpkt      Extended packet.
    00100000    write_req   Request parameter write.

Packets can have two known formats:

  • Short packets which are always 8 bytes.
  • Extended packets which can be any size. These are marked by the extpkt flag.

Unknown packets, commands or parameter accesses are ignored.

Response packets will only be sent if requested with req_read. These will generally have the same format as the requesting command packet.

Short packets

Short packets have a fixed format, specifying:

(byte offset, type, name, description)
0x00 uint8      seq         (see "Header" section)
0x01 uint8      flags       (see "Header" section)
0x02 uint8      pgroup      Parameter group.
0x03 uint8      param       Parameter number within group.
0x04 uint8[4]   payload     Parameter-specific payload. Generally one of:
    
    uint8 | padding[3]
    uint16_be | padding[2]
    uint16_be[2]
    uint32_be
  • To write a parameter, set write_req in flags (0x20). The parameter-specific payload is written to the specified parameter. The value of padding bytes seems to be ignored. But the app consistently uses 0x00 0x04.

    • There will be no reply unless read_req is set as well (0x21), in which case the parameter is written and a (confirmation?) response is sent, with the write_req flag set but not read_resp (0x20).
  • To read a parameter, set read_req in flags (0x01). The payload is ignored. The reply will have read_resp flag set.

Extended packets

Extended packets have the following header:

(byte offset, type, name, description)
0x00 uint8      seq         (see "Header" section)
0x01 uint8      flags       (see "Header" section)
0x02 uint16_be  unk1        Always 0x0004?
0x04 uint16_be  unk2        Always 0x0101?
0x05 uint8      opcode      One of:

    0x01    set_mode        Set lighting mode.
    0x04    animate         Animate?

0x06 uint8[]    payload     Command-dependent variable payload.

To send a command, set the write_req and extpkt bits in flags (0x24). See the "Commands" section below for specifics on the payload.

Parameters

There are tons of different parameters that can be set, it's surprising how a device like this has so many, though, it's bound to be a more general protocol adapted to different devices and not all may apply to this one.

Group 0x01

General light parameters.

(param, payload type, description)
0x01   uint16_be            Brightness (any mode). Range 0..100.
0x02   uint16_be[2]         Animation parameters?
0x03   uint16_be            Color temperature (CCT). Range 2700..7500.
0x04   int16_be             Green/Magenta (CCT). Range -100..100.
0x05   uint16_be            Hue (HSI). Range 0..359.
0x0c   uint8                Saturation (HSI). Range 0..100.
0x64   uint32_be            Set ??
0x65   N/A                  Disable ??

Probing has shown that parameter IDs in this group run up to 0x65 (101). Most of these will be effect parameters. I've not looked into these modes as the built-in effects aren't relevant to our use case. Please let me know if you figure any out, though.

Other groups

Probing up to group 0x0c using parameter reads has shown that the following other groups have values that can be read:

  • 0x03 range 0x01..0x06
  • 0x05 range 0x00..0xff
  • 0x09 range 0x00..0xff
  • 0x0a range 0x00..0xff

I've seen parameter 0x0c:0x00 being read in captures, but without response. I'm not sure what any of these are.

Commands

Some extended packets do more than parameter setting. This section describes known application command packets and their payload.

0x01 set_mode Set lighting mode

This command sets the device mode, and full initial parameters for that mode.

(byte offset, type, name, description)
0x00 uint8      ssize       Size of payload in bytes (including this byte)
0x01 uint16_be  brightness  Brightness value 0..100 (mode independent).
0x03 uint8      mode        Lighting mode.
0x04 uint8      submode     Lighting submode (values depend on mode).
0x05 uint8[]    params      Concatenated parameters, depending on mode and submode.

The following sections describe mode:submode combinations observed.

Mode 02:02 and 02:03 CCT

Both of these switch to CCT (Correlated Color Temperature) mode. I've seen no functional difference between either. Params (6 bytes) are:

(byte offset, type, name, description)
0x00 uint8      unk1        Always 0x01? No visible effect.
0x01 uint16_be  color_temp  Color temperature.
0x03 uint16_be  green_mag   Green/Magenta.
0x05 uint8      unk2        Always 0x01? No visible effect.

Mode 03:01 HSI

Switch to HSI (Hue Saturation Intensity) mode. Params (3 bytes) are:

(byte offset, type, name, description)
0x00 uint16_be  hue         Hue.
0x02 uint8      sat         Saturation.

Mode 07:xx Miscellaneous effects

This sets effects like "hue loop", "lightning", "police car", etc. The submode specifies the specific effect. The parameters also depend on the specific effect.

I've not investigated specifics, but here are some example packets setting different modes:

(mode:submode, params)
02:02 010e10000001 (CCT)
03:01 012b58 (HSI)
07:04 000001686408020103e8322832
07:05 0c8015e0000008020103e8322832
07:06 00280115e0000000006408020103e8322832
07:08 0115e0000000006428
07:09 0115e00206030601
07:0a 040232
07:0c 15e0000032
07:0e 070903320000001e003c005a0078009664646464646464
07:10 070903323215e0000002580000001e003c005a0078009664646464646464

0x04 animate?

(byte offset, type, name, description)
0x00 uint8      pgroup      Parameter group.
0x01 uint8      param       Parameter number within group.
0x02 uint8[4]   payload     Parameter-specific payload.

I'm not actually sure what this does I've only seen 010200000002, which animates the brightness (see below). The parameter group and number seems to match with the parameters used in short packets, but it definitely only works for a subset.

01:02 Animate brightness

(byte offset, type, name, description)
0x00 uint8      pgroup      0x01
0x01 uint8      param       0x02
0x02 uint16_be  brightness  Target brightness value
0x04 uint16_be  time        Time in seconds to reach that value

Example session

You can follow these steps on a typical Linux system with Bluetooth 4.1+ stack and the necessary tools installed:

  • Find the device advertisements. Nanlite devices will start with ..NL. The other numbers probably specifies the model and configured channel number:
$ hciconfig hci0 up
$ hcitool lescan
9C:A5:xx:xx:xx:xx ..NL 004 00000A
  • Connect using gatttool:
$ gatttool -i hci0 -b 9C:A5:xx:xx:xx:xx -I
[9C:A5:xx:xx:xx:xx][LE]> connect
Attempting to connect to 9C:A5:xx:xx:xx:xx
Connection successful

[9C:A5:xx:xx:xx:xx][LE]> primary
attr handle: 0x0001, end grp handle: 0x0004 uuid: 00001801-0000-1000-8000-00805f9b34fb
attr handle: 0x0005, end grp handle: 0x000b uuid: 00001800-0000-1000-8000-00805f9b34fb
attr handle: 0x000c, end grp handle: 0x0011 uuid: 0003cdd0-0000-1000-8000-00805f9b0131

[9C:A5:xx:xx:xx:xx][LE]> char-desc
handle: 0x0001, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0002, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0003, uuid: 00002a05-0000-1000-8000-00805f9b34fb
handle: 0x0004, uuid: 00002902-0000-1000-8000-00805f9b34fb
handle: 0x0005, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0006, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0007, uuid: 00002a00-0000-1000-8000-00805f9b34fb
handle: 0x0008, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0009, uuid: 00002a01-0000-1000-8000-00805f9b34fb
handle: 0x000a, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x000b, uuid: 00002a04-0000-1000-8000-00805f9b34fb
handle: 0x000c, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x000d, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x000e, uuid: 0003cdd1-0000-1000-8000-00805f9b0131 tx
handle: 0x000f, uuid: 00002902-0000-1000-8000-00805f9b34fb subscribe for read
handle: 0x0010, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0011, uuid: 0003cdd2-0000-1000-8000-00805f9b0131 rx
  • Subscribe to notifications:
[9C:A5:xx:xx:xx:xx][LE]> char-write-req 0x0f 0100
Characteristic value was written successfully
  • Set the brightness to 50%:
> char-write-req 0x11 fa55001101032001010032000401bd0d0a
Characteristic value was written successfully
  • Set the brightness to 25%:
> char-write-req 0x11 fa55001101082001010019000401a90d0a
Characteristic value was written successfully
  • Read the current brightness:
> char-write-req 0x11 fa550011010601010100000000016b0d0a
Characteristic value was written successfully
Notification handle = 0x000e value: fa 55 00 11 01 06 02 01 01 00 32 00 00 01 9e 0d 0a

Notes

  • It looks like the Bluetooth module is on when the device is off and charging. It doesn't respond to (nor likely, process) any application-level commands in this case, but will appear in advertisements, and respond to Bluetooth queries.

Credits and disclaimers

SPDX-License-Identifier: CC-BY-4.0

I have provided this information in the hope it is useful, and have tried to be as careful as possible, but I cannot be held responsible if use of this information causes damage to your device.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment