Compare commits

..

15 Commits

Author SHA1 Message Date
7ca24225a9 Include cstddef in PacketParser.cpp to fix error about size_t
Some checks failed
RFPowerView CI / build (push) Failing after 10s
2024-04-13 17:42:44 +10:00
b2fef194ed Add build.yaml for Github 2024-04-13 17:42:39 +10:00
cbf188e554 Fixes to readme 2024-04-13 17:35:57 +10:00
f069022f5c Document protocol and added examples 2024-04-13 17:27:15 +10:00
a398a0b679 Add more parser tests 2024-04-13 17:26:52 +10:00
168c1414bd Improve failure message when a packet parser test fails 2024-04-13 17:25:49 +10:00
83f6e8fa52 Improve failure message when a CRC test fails 2024-04-13 17:25:33 +10:00
5b6d951cbc Simplify test setup using a helper to convert hex strings to byte arrays 2024-04-13 17:25:13 +10:00
88caab56a2 Commit generated .vscode/extensions.json 2024-04-10 21:14:04 +10:00
d3325b71cd Fixes to unit tests to work on native platform 2024-04-10 21:13:45 +10:00
3d315be27f Add a native environment to platformio.ini 2024-04-10 21:12:33 +10:00
71dbf1f4ca Rename runParseUnityTests to just runUnityTests 2024-04-10 21:11:12 +10:00
7f7e02a8b7 Avoid including Arduino.h where possible 2024-04-10 21:10:31 +10:00
746d0fad6f Create initial tests
Tests are run via a PlatformIO project
2024-04-09 00:31:46 +10:00
4460224202 Reformat library.json 2024-04-04 21:50:14 +11:00
13 changed files with 591 additions and 31 deletions

18
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: RFPowerView CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install PlatformIO Core
run: pip install --upgrade platformio
- name: Build PlatformIO Project
run: pio test --environment native

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

177
README.md
View File

@@ -1,4 +1,179 @@
# RFPowerView # RFPowerView
A library for receiving and sending PowerView packets via an nRF24L01 module Library for sending and receiving PowerView packets so that blinds can be controlled programatically. PowerView is a brand of remote controlled blinds developed by Hunter Douglas (also known as Luxaflex in some regions). This library is intended to be used on a microcontroller like an ESP8266 in conjunction with a nRF24L01 transceiver module. The implementation has been entirely reverse engineered by sniffing packets sent from a PowerView Hub or Pebble remote.
## Supported commands
- Open
- Close
- Stop
- Open slowly
- Close slowly
- Move to saved position
- Move to position
- Query position
- Query battery level
## Protocol
A PowerView packet consists of a header (which contains a variable length address section), a variable length payload, and a two byte checksum at the end. Each packet is limited to being at most 32 bytes due to limitations of the nRF24L01 module.
### Header
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0 | 8 | ID Byte | Identifies the start of a new packet. | `0xCO` |
| 1 | 8 | Length | Specifies how many bytes of header and data come after this byte (excludes checksum bytes). | `0x0A` - `0x1D` |
| 2 | 8 | Unknown | Could indicate the type of sender | `0x00` (from hub or remote), `0x01` (from blind) |
| 3 | 8 | Fixed | This byte is always the same. | `0x05` |
| 4 | 8 | Rolling Code 1 | This byte is incremented by 0x01 each time a packet is sent. | `0x00`-`0xFF` |
| 5 | 16 | Fixed | These two bytes are always the same. | `0xFFFF` |
| 7 | 16 | Physical Source ID | The ID of device that actually sent the packet (can be different to Logical Source ID when a repeater is being used) | `0x0000`-`0xFFFF` |
| 9 | 8 | Fixed | This byte is always the same | `0x86` |
| 10 | 8 | Address Type | Indicates which type of address this packet is using. | `0x04` (Broadcast), `0x05` (Unicast), `0x06` (Groups) |
| 11 | 8 | Rolling Code 2 | This byte is incremented by 0x01 each time a packet is sent. | `0x00`-`0xFF` |
| 12 | Varies | Address | This section is a variable length. | Broadcast, Unicast, or Groups section (see next section) |
#### Broadcast address
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0 | 16 | Logical Source ID | The ID of device where the packet originated (hubs seem to always be 0x0000) | `0x0000`-`0xFFFF` |
Note: this type of address seems to be used when a hub is activating a scene (and multiple blinds could be included in the scene).
#### Unicast address
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0 | 16 | Destination ID | The ID of device that should process the packet | `0x0000`-`0xFFFF` |
| 2 | 16 | Logical Source ID | The ID of device where the packet originated (hubs seem to always be 0x0000) | `0x0000`-`0xFFFF` |
Note: this type of address is used when a hub wants to communicate with a specific blind or when a blind is communicating with a hub.
#### Groups address
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0-5 | 8 | Group Number | Devices assigned to the specified group numbers should process the packet. | `0x01`-`0x06` |
| n | 8 | Terminator | Marker to stop processing group numbers | `0x00` |
| n+1 | 16 | Logical Source ID | The ID of device where the packet originated | `0x0000`-`0xFFFF` |
Note: this type of address is used by pebble remotes. The Group Number corresponds to the the numbered buttons on the pebble remote. When multiple group numbers are included, generally they are in ascending order.
### Payload
This section begins at the next byte following the variable length address section. I've observed two types of payloads: simple and "fields".
### Simple payload
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0 | 8 | Packet Type | Indicates the payload is a simple command | `0x52` |
| 1 | 8 | Command | What should the blind do | `0x55` (Open), `0x44` (Close), `0x53` (Stop), `0x52` (Open Slowly), `0x4C` (Close Slowly), `0x48` (Move to saved position) |
| 2 | 8 | End | Marker to indicate end of data | `0x00` |
Note: this type of payload is used by both the hub and pebble remotes. Used for commands like "Open", "Close", or "Stop"
#### Fields payload
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0 | 8 | Field Mode | Indicates the payloads contains "fields" | `0x3F` (fields without values), `0x21` (fields with values) |
| 1 | 8 | Fixed | This byte is always the same. | `0x5A` |
| 2 | varies | Field data | This section is a variable length depending on the number of fields the payload contains | Field data (see next section) |
Note: this type of payload is used by the hub. If sent to a blind without values, it will prompt the blind to respond with the value of those fields. If sent to a blind with values, it will prompt the blind to "apply" the value to the fields (eg. by moving to a position). A single payload can contain multiple fields.
#### Field data
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0 | 8 | Field Length | Indicates how many bytes are in this field | `0x02`-`0x04` |
| 1 | 8 | Field Mode | Seems to always be the same as the first Field Mode | `0x3F` (fields without values), `0x21` (fields with values) |
| 2 | 8 | Field ID | The field ID | `0x00`-`0xFF`, eg. `0x50` is Position, `0x42` is Battery level |
| 3 (if length=3) | 8 | Field Value | The value of the field | `0x00`-`0xFF` |
| 3 (if length=4) | 16 | Field Value | The value of the field | `0x0000`-`0xFFFF` |
### Checksum
| Byte Offset | Size (Bits) | Name | Description | Valid Values |
|---|---|---|---|---|
| 0 | 16 | Checksum | A CRC16 checksum of the packet data | `0x0000`-`0xFFFF` |
## Examples
### Open blinds in group 4
`C01100056CFFFF369E86063C0400369E525500B988`
| Name | Value | Notes |
|---|---|---|
| Length | `0x11` | |
| Rolling Code 1 | `0x6C` | |
| Rolling Code 2 | `0x3C` | |
| Physical Source ID | `0x369E` | ID of pebble remote |
| Address type | `0x06` | Using Groups address type |
| Logical Source ID | `0x369E` | ID of pebble remote (same as Physical Source ID) |
| Groups | 4 | Only a single group specified |
| Checksum | `0xB988` | |
### Hub requesting values from a blind
`C019000592FFFF72CB85054E4EF100003F5A023F50023F4D023F54C9F3`
| Name | Value | Notes |
|---|---|---|
| Length | `0x19` | |
| Rolling Code 1 | `0x92` | |
| Rolling Code 2 | `0x4E` | |
| Physical Source ID | `0x72CB` | ID of repeater |
| Address type | `0x05` | Using Unicast address type |
| Destination ID | `0x4EF1` | ID of blind |
| Logical Source ID | `0x0000` | ID of hub (different than Physical Source ID) |
| Field 1 | `023F50` | Field of 2 bytes, ID = `0x50` (position) |
| Field 2 | `023F4D` | Field of 2 bytes, ID = `0x4D` (unknown) |
| Field 3 | `023F54` | Field of 2 bytes, ID = `0x54` (unknown) |
| Checksum | `0xC9F3` | |
### Blind reporting position value to hub
`C0151005E0FFFF4EF186051A00004EF1215A04215040016670`
| Name | Value | Notes |
|---|---|---|
| Length | `0x15` | |
| Rolling Code 1 | `0xE0` | |
| Rolling Code 2 | `0x1A` | |
| Physical Source ID | `0x4EF1` | ID of blind |
| Address type | `0x05` | Using Unicast address type |
| Destination ID | `0x0000` | ID of hub |
| Logical Source ID | `0x4EF1` | ID of blind |
| Field 1 | `0421504001` | Field of 4 bytes, ID = `0x50` (position), value = `0x4001` |
| Checksum | `0x6670` | |
### Blind reporting battery level value to hub
`C014100558FFFF4EF18605C100004EF1215A0321429DEC23`
| Name | Value | Notes |
|---|---|---|
| Length | `0x14` | |
| Rolling Code 1 | `0x58` | |
| Rolling Code 2 | `0xC1` | |
| Physical Source ID | `0x4EF1` | ID of blind |
| Address type | `0x05` | Using Unicast address type |
| Destination ID | `0x0000` | ID of hub |
| Logical Source ID | `0x4EF1` | ID of blind |
| Field 1 | `0321429D` | Field of 3 bytes, ID = `0x42` (battery level), value = `0x9D` |
| Checksum | `0xEC23` | |
### Hub activating a scene
`C00F0005A1FFFF00008604FF000053471B446B`
| Name | Value | Notes |
|---|---|---|
| Length | `0x0F` | |
| Rolling Code 1 | `0xA1` | |
| Rolling Code 2 | `0xFF` | |
| Physical Source ID | `0x0000` | ID of hub |
| Address type | `0x04` | Using Broadcast address type |
| Logical Source ID | `0x0000` | ID of hub |
| Scene ID | `1B` | |
| Checksum | `0x446B` | |
Note: activating scenes is not supported by the library (yet!)

View File

@@ -1,7 +1,7 @@
#ifndef BUFFERFILLER_H #ifndef BUFFERFILLER_H
#define BUFFERFILLER_H #define BUFFERFILLER_H
#include <Arduino.h> #include <stdint.h>
#include "PacketCRC.h" #include "PacketCRC.h"
#include "PacketTypes.h" #include "PacketTypes.h"

View File

@@ -1,7 +1,7 @@
#ifndef PACKET_PARSER_H #ifndef PACKET_PARSER_H
#define PACKET_PARSER_H #define PACKET_PARSER_H
#include <Arduino.h> #include <vector>
#include "PacketTypes.h" #include "PacketTypes.h"
class PacketParser { class PacketParser {

View File

@@ -1,7 +1,7 @@
#ifndef PACKET_TYPES_H #ifndef PACKET_TYPES_H
#define PACKET_TYPES_H #define PACKET_TYPES_H
#include <Arduino.h> #include <stdint.h>
#include <variant> #include <variant>
// Define packet types // Define packet types

View File

@@ -1,7 +1,7 @@
#ifndef RFPOWERVIEW_H #ifndef RFPOWERVIEW_H
#define RFPOWERVIEW_H #define RFPOWERVIEW_H
#include <Arduino.h> #include <stdint.h>
#include <RF24.h> #include <RF24.h>
#include "PacketReceiver.h" #include "PacketReceiver.h"
#include "PacketParser.h" #include "PacketParser.h"

View File

@@ -7,8 +7,7 @@
"type": "git", "type": "git",
"url": "https://github.com/mattyway/RFPowerView.git" "url": "https://github.com/mattyway/RFPowerView.git"
}, },
"authors": "authors": [
[
{ {
"name": "Matt Way", "name": "Matt Way",
"email": "mattyway@gmail.com" "email": "mattyway@gmail.com"
@@ -21,7 +20,5 @@
"rlogiacco/CircularBuffer": "1.3.3" "rlogiacco/CircularBuffer": "1.3.3"
}, },
"frameworks": "arduino", "frameworks": "arduino",
"platforms": [ "platforms": ["espressif8266"]
"espressif8266"
]
} }

33
platformio.ini Normal file
View File

@@ -0,0 +1,33 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:nodemcuv2]
platform = espressif8266
board = nodemcuv2
framework = arduino
[env]
monitor_speed = 115200
test_build_src = true
test_framework = unity
lib_deps =
robtillaart/CRC @ 1.0.2
nrf24/RF24 @ 1.4.8
rlogiacco/CircularBuffer @ 1.3.3
[env:native]
platform = native
lib_deps =
${env.lib_deps}
ArduinoFake
build_src_filter =
+<**/*.cpp>
-<**/RFPowerView.cpp>
-<**/PacketReceiver.cpp>

View File

@@ -1,4 +1,5 @@
#include "PacketParser.h" #include "PacketParser.h"
#include <cstddef>
PacketParser::PacketParser() PacketParser::PacketParser()
{ {

29
test/hex_helper.h Normal file
View File

@@ -0,0 +1,29 @@
#ifndef HEX_HELPER_H
#define HEX_HELPER_H
#include <cstring> // for strlen
uint8_t* hex_string_to_array(const char* str) {
size_t len = strlen(str);
if (len % 2 != 0) {
// Cannot handle odd string length
return nullptr;
}
size_t data_len = len / 2;
uint8_t* data = new uint8_t[data_len];
// Convert each hexadecimal character pair to a byte
for (size_t i = 0; i < data_len; ++i) {
uint8_t high_nibble = str[i * 2] >= 'A' ? (str[i * 2] - 'A' + 10) : (str[i * 2] - '0');
uint8_t low_nibble = str[i * 2 + 1] >= 'A' ? (str[i * 2 + 1] - 'A' + 10) : (str[i * 2 + 1] - '0');
// Combine the nibbles into a byte
data[i] = (high_nibble << 4) | low_nibble;
}
// Return the pointer to the allocated data array
return data;
}
#endif // HEX_HELPER_H

111
test/test_crc/test_crc.cpp Normal file
View File

@@ -0,0 +1,111 @@
#include <unity.h>
#include <Arduino.h>
#include "../hex_helper.h"
#include "PacketCRC.h"
void setUp()
{
}
void tearDown()
{
}
void run_calculate_test(const uint8_t *packet_data, const uint16_t expected_checksum)
{
PacketCRC packetCRC;
uint16_t actual_checksum = packetCRC.calculate(packet_data);
TEST_ASSERT_EQUAL_HEX16(expected_checksum, actual_checksum);
}
void test_calculate_withDataLengthOf15()
{
const uint8_t* packet_data = hex_string_to_array("C00F0005A1FFFF00008604FF000053471B");
const uint16_t expected_checksum = 0x446B;
run_calculate_test(packet_data, expected_checksum);
delete[] packet_data;
}
void test_calculate_withDataLengthOf17()
{
const uint8_t* packet_data = hex_string_to_array("C0110005D7FFFF369E8606B30400369E525300");
const uint16_t expected_checksum = 0xE04C;
run_calculate_test(packet_data, expected_checksum);
delete[] packet_data;
}
void test_calculate_withDataLengthOf19()
{
const uint8_t* packet_data = hex_string_to_array("C0130005DDFFFF00008605364EF100003F5A023F42");
const uint16_t expected_checksum = 0x9BD3;
run_calculate_test(packet_data, expected_checksum);
delete[] packet_data;
}
void test_calculate_withDataLengthOf20()
{
const uint8_t* packet_data = hex_string_to_array("C014100559FFFF4EF18605C200004EF1215A0321429C");
const uint16_t expected_checksum = 0x4A8B;
run_calculate_test(packet_data, expected_checksum);
delete[] packet_data;
}
void test_calculate_withDataLengthOf21()
{
const uint8_t* packet_data = hex_string_to_array("C0151005EAFFFF4EF186052400004EF121464E98070801");
const uint16_t expected_checksum = 0x9887;
run_calculate_test(packet_data, expected_checksum);
delete[] packet_data;
}
void test_calculate_withDataLengthOf25()
{
const uint8_t* packet_data = hex_string_to_array("C019000547FFFF000086053D4EF100003F5A023F50023F4D023F54");
const uint16_t expected_checksum = 0x8318;
run_calculate_test(packet_data, expected_checksum);
delete[] packet_data;
}
int runUnityTests(void)
{
UNITY_BEGIN();
RUN_TEST(test_calculate_withDataLengthOf15);
RUN_TEST(test_calculate_withDataLengthOf17);
RUN_TEST(test_calculate_withDataLengthOf19);
RUN_TEST(test_calculate_withDataLengthOf20);
RUN_TEST(test_calculate_withDataLengthOf21);
RUN_TEST(test_calculate_withDataLengthOf25);
return UNITY_END();
}
/**
* For Arduino framework
*/
void setup()
{
// Wait ~2 seconds before the Unity test runner
// establishes connection with a board Serial interface
delay(2000);
runUnityTests();
}
void loop() {}
int main(void)
{
return runUnityTests();
}

View File

@@ -0,0 +1,186 @@
#include <unity.h>
#include <Arduino.h>
#include "../hex_helper.h"
#include "PacketParser.h"
void setUp()
{
}
void tearDown()
{
}
void run_parse_test(const uint8_t *packet_data, Packet &packet)
{
PacketParser packetParser;
bool result = packetParser.parsePacket(packet_data, packet);
TEST_ASSERT_TRUE_MESSAGE(result, "Unable to parse packet_data");
}
void test_parse_stop()
{
const uint8_t* packet_data = hex_string_to_array("C01100056CFFFF369E86063C0400369E525300");
Packet packet;
run_parse_test(packet_data, packet);
TEST_ASSERT_TRUE(packet.type == PacketType::STOP);
delete[] packet_data;
}
void test_parse_close()
{
const uint8_t* packet_data = hex_string_to_array("C01100056CFFFF369E86063C0400369E524400");
Packet packet;
run_parse_test(packet_data, packet);
TEST_ASSERT_TRUE(packet.type == PacketType::CLOSE);
delete[] packet_data;
}
void test_parse_open()
{
const uint8_t* packet_data = hex_string_to_array("C01100056CFFFF369E86063C0400369E525500");
Packet packet;
run_parse_test(packet_data, packet);
TEST_ASSERT_TRUE(packet.type == PacketType::OPEN);
delete[] packet_data;
}
void test_parse_rolling_codes()
{
const uint8_t* packet_data = hex_string_to_array("C01100056CFFFF369E86063C0400369E525300");
Packet packet;
run_parse_test(packet_data, packet);
TEST_ASSERT_EQUAL_HEX8_MESSAGE(0x6C, packet.rollingCode1, "Rolling Code 1");
TEST_ASSERT_EQUAL_HEX8_MESSAGE(0x3C, packet.rollingCode2, "Rolling Code 2");
delete[] packet_data;
}
void test_broadcast_source_address()
{
const uint8_t* packet_data = hex_string_to_array("C01100056CFFFF369E86063C0400369E525300");
Packet packet;
run_parse_test(packet_data, packet);
auto header = std::get<GroupsHeader>(packet.header);
TEST_ASSERT_EQUAL_HEX16(0x369E, header.source);
delete[] packet_data;
}
void test_broadcast_single_group()
{
const uint8_t* packet_data = hex_string_to_array("C01100056CFFFF369E86063C0400369E525300");
Packet packet;
run_parse_test(packet_data, packet);
auto header = std::get<GroupsHeader>(packet.header);
TEST_ASSERT_EQUAL_MESSAGE(1, header.groups.size(), "Group count");
TEST_ASSERT_EQUAL_INT8_MESSAGE(4, header.groups[0], "Group at offset 0");
delete[] packet_data;
}
void test_broadcast_multiple_groups()
{
const uint8_t* packet_data = hex_string_to_array("C01100056CFFFF369E86063C03040500369E525300");
Packet packet;
run_parse_test(packet_data, packet);
auto header = std::get<GroupsHeader>(packet.header);
TEST_ASSERT_EQUAL_MESSAGE(3, header.groups.size(), "Group count");
TEST_ASSERT_EQUAL_INT8_MESSAGE(3, header.groups[0], "Group at offset 0");
TEST_ASSERT_EQUAL_INT8_MESSAGE(4, header.groups[1], "Group at offset 1");
TEST_ASSERT_EQUAL_INT8_MESSAGE(5, header.groups[2], "Group at offset 2");
delete[] packet_data;
}
void test_unicast_source_address()
{
const uint8_t* packet_data = hex_string_to_array("C019000592FFFF72CB85054E4EF100003F5A023F50023F4D023F54");
Packet packet;
run_parse_test(packet_data, packet);
auto header = std::get<UnicastHeader>(packet.header);
TEST_ASSERT_EQUAL_HEX16(0x0000, header.source);
delete[] packet_data;
}
void test_unicast_destination_address()
{
const uint8_t* packet_data = hex_string_to_array("C019000592FFFF72CB85054E4EF100003F5A023F50023F4D023F54");
Packet packet;
run_parse_test(packet_data, packet);
auto header = std::get<UnicastHeader>(packet.header);
TEST_ASSERT_EQUAL_HEX16(0x4EF1, header.destination);
delete[] packet_data;
}
int runUnityTests(void)
{
UNITY_BEGIN();
RUN_TEST(test_parse_stop);
RUN_TEST(test_parse_close);
RUN_TEST(test_parse_open);
RUN_TEST(test_parse_rolling_codes);
RUN_TEST(test_broadcast_source_address);
RUN_TEST(test_broadcast_single_group);
RUN_TEST(test_broadcast_multiple_groups);
RUN_TEST(test_unicast_source_address);
RUN_TEST(test_unicast_destination_address);
return UNITY_END();
}
/**
* For Arduino framework
*/
void setup()
{
// Wait ~2 seconds before the Unity test runner
// establishes connection with a board Serial interface
delay(2000);
runUnityTests();
}
void loop() {}
int main(void)
{
return runUnityTests();
}