This is a copypaste from https://juskihackery.wordpress.com/ in case if this article will get lost.

In my previous post I started telling the sorry tale of how I came to using cheap wireless DMX boards with my lightshow, and how I became motivated to figuring out how they work.

In this post I will reveal my findings, explain exactly what it all means – and I’ll ultimately even give some example Arduino code to let YOU make your own compatible wireless DMX transmitter/receiver.

I captured a wireless DMX board’s SPI communications from powering up, to acquiring a ‘lock’ on a DMX transmitter. Here’s what Pulseview showed me was going on:

10657954-10658020 nRF24L01(+): Commands: Cmd W_REGISTER: CONFIG = "3F"  0x00111111 RX mode, Power Up, EN_CRC, 2 byte CRC
10659814-10659880 nRF24L01(+): Commands: Cmd W_REGISTER: EN_AA = "00" No auto Ack
10661674-10661740 nRF24L01(+): Commands: Cmd W_REGISTER: EN_RXADDR = "01" Enable data pipe 1
10663534-10663600 nRF24L01(+): Commands: Cmd W_REGISTER: SETUP_AW = "03" Use 5 byte addresses
10665394-10665460 nRF24L01(+): Commands: Cmd W_REGISTER: SETUP_RETR = "00" no auto-retransmit delay
10667254-10667318 nRF24L01(+): Commands: Cmd W_REGISTER: RF_CH = "00"
10669120-10669184 nRF24L01(+): Commands: Cmd W_REGISTER: RF_SETUP = "26" 0x00100110  250kbps, 0dBm
10670979-10671045 nRF24L01(+): Commands: Cmd W_REGISTER: STATUS = "7E"
10672839-10672905 nRF24L01(+): Commands: Cmd W_REGISTER: RX_PW_P0 = "20" 32 byte payload
10676251-10676496 nRF24L01(+): Commands: Cmd W_REGISTER: RX_ADDR_P0 = "01FEFF0100"
10676670-10676916 nRF24L01(+): Commands: Cmd W_REGISTER: TX_ADDR = "01FEFF0100"
11055714-11055780 nRF24L01(+): Commands: Cmd W_REGISTER: STATUS = "7E"
11056686-11056706 nRF24L01(+): Commands: Cmd FLUSH_RX
11057929-11057995 nRF24L01(+): Commands: Cmd W_REGISTER: RF_CH = "2A"
11061353-11061598 nRF24L01(+): Commands: Cmd W_REGISTER: RX_ADDR_P0 = "2BFED5012A"
11061772-11062018 nRF24L01(+): Commands: Cmd W_REGISTER: TX_ADDR = "2BFED5012A"
11455904-11455968 nRF24L01(+): Commands: Cmd W_REGISTER: STATUS = "7E"
11456875-11456897 nRF24L01(+): Commands: Cmd FLUSH_RX
11458116-11458182 nRF24L01(+): Commands: Cmd W_REGISTER: RF_CH = "54"
11461540-11461785 nRF24L01(+): Commands: Cmd W_REGISTER: RX_ADDR_P0 = "55FEAB0154"
11461959-11462205 nRF24L01(+): Commands: Cmd W_REGISTER: TX_ADDR = "55FEAB0154"
11856095-11856161 nRF24L01(+): Commands: Cmd W_REGISTER: STATUS = "7E"
11857067-11857089 nRF24L01(+): Commands: Cmd FLUSH_RX

So there’s the preamble which tells us the transmission parameters – no CRC, using data pipe 1, 5 byte addresses, no retransmit attempting, no auto ACK.. oh and the data rate is 256kbps & payloads are 32 bytes long.

After the first few lines it can be seen that the board is setting the RF channel to a value, then setting the RX & TX 5 byte addresses to SOMETHING, then reading the status register & flushing the RX buffer. What is this SOMETHING that it’s setting the address to? I captured a few scan attempts from the board acting as a receiver, using different unit IDs to see if I could spot any pattern in the 5 byte addresses the micro was putting into the NRF radio chip. Note: a wireless DMX board like this (and the ones made by Donner too, presumably) can be set to one of 7 IDs. According to many sellers’ product descriptions this is ‘to avoid interference’. Ha!

It took me a while to work it out but each of the 5 address bytes corresponds to the RF channel and the unit’s ID.

Byte 0: RF_CH + UnitID
Byte 1: 255 - UnitID
Byte 2: 255 - RF_CH
Byte 3: UnitID
Byte 4: RF_CH

Without knowing this address it wouldn’t exactly be child’s play to sniff the protocol blind (apparently though it is, by setting the NRF radio to use only 1 byte addressing & dumping the rest out as part of a data payload LOL).

In order to find out how each payload is constructed, and how they relate to actual DMX channels I set some easy to spot values in my DMX program on certain channels. If you’ve never encountered DEADBEEF let me tell you it’s a really handy little hex string you can use whenever you need something to stand out.

By setting various groups of channels to certain values & looking at the received payloads in Pulseview I was able to glean that each payload works like this:

0x80 is always the first byte. For a 512-channel set of payloads the next two bytes are always 0xFF 0x01 (0x1FF == 511 – or 0-511 == 512 channels). The next 28 bytes of the payload are DMX channel data.

80 00 FF 01 DE AD BE EF 11 22 33 44 55 66 77 88 99 aa bb cc dd ee ..
80 01 FF 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ..
thru
80 12 FF 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ..

Payload 0 carries DMX channels 1-28. Payload 1 carries channels 29-57 .. and in the last payload (0x12 or 18 decimal) rather than waste the last 8 bytes or whatever, channels 1 to 8 are sent again:

80 12 FF 01 00 00 00 00 00 00 00 00 00 DE AD BE EF 11 22 33 44 ..

Great! So now we can code a receiver for an Arduino board? Why yes, we can!

Now, before we get too far ahead of ourselves, me posting the following code is based on the assumption that YOU ARE A GROWN-UP AND ARE CAPABLE OF CONNECTING AN NRF24L01 module to an Arduino without blowing it or yourself to smithereens. The SPI connections on the NRF24 module go to SPI connections on the Arduino simply enough but the two you can most easily get wrong are CE & CSN. In my code CE is connected to D1 (GPIO5) and CSN is hooked up to D8 (GPIO15). Get either of these wrong, you’ll see “ERROR: failed to start radio” when the Arduino starts up. The code should build just fine – just make sure you have the required libraries installed. NB – I’m not helping you with that, or with any whim you might have to make the NRF module talk to the International Space Station via BLE or whatever crazy-arse plan you might have!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198

// Sample Arduino code to use an NRF24L01 module as a receiver/transmitter
// which is compatible with many cheap & cheerful wireless DMX products
// Feel free to base your own code on these findings
//

#include
#include
#include
#include
//#include “ESP8266WiFi.h”
//#define VERBOSE

// UnitID 1 – 7
// 5 byte address string
// Byte 0 – Unit ID (0x01 – 0x07) + RF Channel
// Byte 1 – 255 – UnitID
// Byte 2 – 255 – RF Channel
// Byte 3 – UnitID
// Byte 4 – RF Channel
//
// CONFIG = “3F” 0x00111111 RX mode, Power Up, EN_CRC
// EN_AA = “00” No CRC, No auto Ack
// EN_RXADDR = “01” Enable data pipe 1
// SETUP_AW = “03” Use 5 byte addresses
// SETUP_RETR = “00” no auto-retransmit delay
// RF_CH = “00”
// RF_SETUP = “26” 0x00100110 256kbps, 0dBm
// STATUS = “7E”
// RX_PW_P0 = “20” 32 byte payload
//
// example payload DMX ch 1-
// byte DE AD BE EF 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// 80 00 FF 01 DE AD BE EF 05 06 07 08 09 0A 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1A 1B 1C
// 80 payloadID FF 01 then 28 DMX channels
// thru
// 80 12 FF 01 XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX 01FF = 511
//

// Receiver code goes as follows
// 1. setup the radio gear
// 2. set receive address.
// 3. listen on a channel
// 4. if signal found, start taking payloads into DMX buffer
// 5. if no signal found, loop back to 2
//
int D7 = 7;
int D8 = 8;

uint8_t dmxBuf[512]; // initialise DMX buffer
uint8_t myAddress[5]; // initialise address
unsigned long flashTimer, receiveTimer, lastPayloadTime, chaseTimer;

int step = 1;
bool txparamsSet;
bool gotLock;
RF24 radio(D7,D8); // CE, CSN

bool transmitter = false; // = true;
bool configMode = false;

int rfCH = 70; //temporarily set rfCH
int unitID = 1; // set initial unit ID.
const int dmxPerPayload = 28; //
const int numPayloads = 19; // 0x00 – 0x12 == 19 payloads

int counter;
void setup() {
DMXSerial.init(DMXController);

clearDMX(); // set dmxBuf to all zeros

#ifdef VERBOSE
Serial.begin(115200);
#endif

#ifdef VERBOSE
//if (WiFi.mode(WIFI_OFF)){
// Serial.println(“wifi radio disabled”);
// }
#endif

if (!radio.begin()){
#ifdef VERBOSE
Serial.println(“ERROR: failed to start radio”);
#endif
}
delay(100);
#ifdef VERBOSE
Serial.println(“Starting up!”);
#endif
radio.setDataRate(RF24_250KBPS);
radio.setCRCLength(RF24_CRC_16);
radio.setPALevel(RF24_PA_MAX);
radio.setAutoAck(false);
radio.setPayloadSize(32);
radio.setChannel(0);
}

void getAddress(int ID, int channel) {
myAddress[4] = ID + channel;
myAddress[3] = 255 – ID;
myAddress[2] = 255 – channel;
myAddress[1] = ID;
myAddress[0] = channel;
}

void clearDMX() {
for (int i = 0; i<512; i++) { dmxBuf[i] = 0; } } void doScan() { //Clear all dmx signals while scanning for (int i = 0; i<512; i++) { DMXSerial.write(i,0); } for ( int rfCH = 0; rfCH < 126; rfCH++ ) { getAddress(unitID, rfCH); //delay(1); radio.flush_rx(); radio.openReadingPipe(0,myAddress); radio.startListening(); radio.setChannel(rfCH); #ifdef VERBOSE Serial.print("Trying channel "); Serial.println(radio.getChannel()); #endif unsigned long started_waiting_at = micros(); // timeout setup bool timeout = false; while ( ! radio.available() ){ // While nothing is received if (micros() - started_waiting_at > 10000 ){ // If waited longer than 10ms, indicate timeout and exit while loop
timeout = true;
break;
}
}
if (!timeout ){
uint8_t buf[32];
radio.read(buf, sizeof(buf));
if (buf[0] == 0x80) { gotLock = true; }
}
if (gotLock) {
#ifdef VERBOSE
Serial.print(“Found a transmitter on channel “);
Serial.println(rfCH);
Serial.print(“And unit ID “);
Serial.println(unitID);
#endif
break; }
}
}

void loop() {
chaseTimer=millis();
uint8_t buf[32];

if (!transmitter) { // Receive Mode
if (!gotLock) { doScan(); } // if we haven’t seen any payloads
else { // receive code starts here
while (radio.available()) {
lastPayloadTime = millis();
radio.read(buf, sizeof(buf));
int numChansA = buf[2]; // Byte 3 of payload is MSB of payload count
int numChansB = buf[3]; // Byte 4 of payload is LSB of payload count
int numChans = (numChansA+1)*2 + numChansB;
int numPayloads = (numChans / 28) + 1; // 28 channels per payload, minimum one payload
int payloadID = buf[1];
int channelPlace = payloadID * 28;

//Serial.print(“Test value: “);
//Serial.println(channelPlace);

for (int place = 4; place < 32; place++) { // put payload into dmx buffer //dmxBuf[channelPlace + place-4 && 512] = buf[place]; //Here we can get a dmx value of any channel int channel = channelPlace + place-4; #ifdef VERBOSE Serial.print("Chan: "); Serial.println(channel+1); Serial.print("Val: "); Serial.println(buf[place]); #endif DMXSerial.write(channel+1, buf[place]); } delayMicroseconds (100); } } //Check if there were no data received for 1 second if (millis() - lastPayloadTime > 1000) {
gotLock = false; //Serial.println (“Not received a payload for > 1s”);
lastPayloadTime = millis();
}
//}// receive code ends here
}
else { // Transmit code starts here

getAddress(unitID,rfCH);
if (!txparamsSet) {
radio.setChannel(rfCH);
radio.openWritingPipe(myAddress);
radio.stopListening();
txparamsSet = true;
}

for (int payloadID = 0; payloadID < numPayloads; payloadID++) { uint8_t payloadTx[32]; payloadTx[0] = 0x80; payloadTx[1] = payloadID; payloadTx[2] = 0xff; // Assume 512 channels of payloads payloadTx[3] = 0x01; // Assume 512 channels of payloads int payloadOffset = payloadID * 28; for (int ch = 0; ch < 28; ch++) { payloadTx[ch + 4] = dmxBuf[(payloadOffset + ch)&511]; // clip dmxBuf index } // now send the payload radio.write(payloadTx,32); } } //Transmit code ends here } //end of main loop

That should be enough to get on with for now. I eventually turned my attention to making my old blue wireless board be a transmitter, and sniffed the SPI comms the same way. The result of that didn’t surprise me much – a transmitter briefly scans a few channels looking for a carrier & quickly selects one to broadcast on, then starts stuffing payloads into the NRF chip using the scheme I’ve already described above.

Further notes & observations

The first byte (byte 0) of every payload always seems to be 0x80 (128 decimal). Right now I have absolutely no idea whether this is significant, or if it bears any relation to other transmission or reception modes that might be possible.

Bytes 1 & 2 of the payload are always 0xFF & 0x01 when a wireless DMX board is given a full 512 channels of DMX frame. I experimented further by changing the number of channels QLC+ outputs to 256 (and later 128) & found that a board in transmit mode seems to count the number of DMX channels in each frame and acts accordingly. A full set of 19 payloads takes 19 * 1.5ms to send, making the update rate approximately 35Hz. Pro tip: Send less than a full universe to get a better update rate across the wireless link 😉 How many mobile DJs / small scale stage lighting techs actually need 512 channels anyway?

Some online sellers use phrases like ‘2.4ghz wifi’ or ‘uses FHSS’. This is of course utter rubbish! WiFi is definitely NOT what these boards use to chat to each other. As for FHSS – frequency hopping spread spectrum – NOPE! The ONLY time a transmitter or receiver changes its operating frequency is when it’s scanning for a transmitter or a clear frequency to transmit on. During operation the RF channel is NEVER changed by the micro & besides that there doesn’t seem to be a mechanism in place for a transmitter to tell a receiver which channel to change to.

Then there’s the legality of these things. Are they operating within the laws of the country/state where you live? Most of the 2.4GHz ISM band is designated for unlicenced use. That is to say, things can transmit within the band without needing any licence with certain limitations & caveats – namely that the nature of the transmissions have to be within certain parameters. Power level limits are specified depending on the type of transmission used & could potentially be enforced against anybody found to be exceeding them.

In theory, a genuine NRF24L01 chip can output a maximum power output of 0dB – 1mW. However the presence of the PA/LNA IC can increase this by up to 22dB, pushing the power output level to 100mW. Is this legal in the UK? Yes but only just! Add an antenna with any kind of positive gain & a simple wireless DMX transmitter could possibly break the law, as well as upset other users of the 2.4GHz ISM band (bluetooth, WiFi, remote controls, wireless computer accessories etc etc etc). The vast majority of wireless DMX transmitter solutions out there offer end users no means to change the RF output power level. In ideal, line-of-sight conditions in free space, over 700 metres of range is possible with these things. Do most of us really need that?

Finally, where on Earth did this quite simple & resilient method of broadcasting DMX wirelessly come from? A few companies are known to make & sell products which are very similar in operation to the generic wireless DMX boards for sale on eBay & AliExpress – and most are said to be 100% compatible – which leaves me pondering whose was first?