Compare commits

...

59 Commits

Author SHA1 Message Date
32e0462a69 Move packet building and sending logic out of main.cpp 2025-05-24 14:36:32 +10:00
b50edfa4aa Move configuration to json 2025-02-16 15:35:18 +11:00
41af7b508f Replace arduino-timer with PositionWatcher 2025-02-09 22:45:14 +11:00
680b6b3b6a Ignore .vscode/settings.json 2025-02-09 22:41:22 +11:00
049037a0a1 Update ArduinoJson to 7.3.0 2025-01-20 22:53:54 +11:00
ba6cca8992 Update RF24 to 1.4.11 2025-01-20 22:53:54 +11:00
f90fa34a07 Move RF_ID to platformio.ini 2024-05-04 23:44:38 +10:00
a4c49bd3a5 Update ArduinoJson to v7.0.4 2024-05-04 23:13:16 +10:00
82fdc29f2a Modify helpers for publishing to accept a std::string 2024-05-04 23:11:29 +10:00
e8003772ef Configure shades via MQTT messages 2024-05-04 23:08:21 +10:00
0458c70b47 Make topic prefix configurable 2024-05-04 23:07:47 +10:00
7c24d6b411 Use a randomly generated suffix in MQTT client ID 2024-05-04 22:56:30 +10:00
244af38c2b Continue setup if setting up RFPowerView fails 2024-05-04 22:56:30 +10:00
a71eeb1dec Setup environments to allow running tests 2024-05-04 22:56:30 +10:00
e60fa052df Log a warning when setting max packet size fails 2024-05-04 22:56:27 +10:00
59b6ed281c Extract Home Assistant discovery logic 2024-05-04 22:56:11 +10:00
a9dccae331 Extract Shade struct and create ShadeRepository 2024-05-04 20:40:07 +10:00
eee6cbef46 Update RFPowerView to v0.0.3 2024-04-04 21:22:07 +11:00
0a9e3c640c Use specific version of RFPowerView 2024-04-04 21:13:33 +11:00
39b61997d2 Add log message when a packet is received 2024-04-04 21:13:33 +11:00
7adbff6890 Add button to trigger a refresh in Home Assistant 2024-04-04 21:13:33 +11:00
97d450ab3e Add battery sensor in Home Assistant 2024-04-04 21:13:33 +11:00
55d114afd6 Add support for a refresh command 2024-04-04 21:13:32 +11:00
bbc54947b1 Changed command topic from /set to /command 2024-04-04 21:13:32 +11:00
01f2e267a9 Split discovery topic logic into smaller functions 2024-04-04 21:13:32 +11:00
42713c1bb1 Publish battery level to MQTT 2024-04-04 21:13:32 +11:00
0eba1a6f1b Create device in Home Assistant 2024-04-04 21:13:32 +11:00
be9de4a2f6 Add log when attempting to send a packet 2024-04-04 21:13:32 +11:00
a5c5a7ac19 Allow Home Assistant to discover the blinds 2024-04-04 21:13:32 +11:00
fcd4f81798 Add ArduinoJson library 2024-04-04 21:13:32 +11:00
3c190897ee Improve logic for fetching position while blind is moving or stopped 2024-04-04 21:13:32 +11:00
dbed260fd1 Publish a state topic for each shade 2024-04-04 21:13:31 +11:00
e02add87ff Fetch position every 2 seconds after sending a command 2024-04-04 21:13:31 +11:00
ccdbea4283 Allow controlling any blind in the house 2024-04-04 21:13:31 +11:00
8b835b6af0 Fetch position after stopping blind 2024-04-04 21:13:31 +11:00
62474c3911 Use timer to fetch position periodically after setting position 2024-04-04 21:13:31 +11:00
a1cd3656d4 Add arduino-timer library 2024-04-04 21:13:31 +11:00
9fb9c19a27 Added helper for sending a Packet 2024-04-04 21:13:31 +11:00
1838de5b35 Remove old comment 2024-04-04 21:13:31 +11:00
abd850399a Fetch the current position of the blind every 10 seconds 2024-04-04 21:13:31 +11:00
4d0af9e98d Add helper methods for sending various packets 2024-04-04 21:13:30 +11:00
c2c55d6764 Tracking rolling codes received from real hub 2024-04-04 21:13:30 +11:00
2bc7835dce Migrate Hotdog to RFPowerView 2024-04-04 21:13:30 +11:00
bdebcf83ff Shorten delay after sending command 2024-04-04 21:13:30 +11:00
194d1e96f3 Fetch position after sending a stop packet 2024-04-04 21:13:30 +11:00
18639220b8 Support building fetch position packets 2024-04-04 21:13:30 +11:00
f41e0edf56 Improve naming in PacketParser 2024-04-04 21:13:30 +11:00
cffae2bfe5 Publish blind position to MQTT when packet is received 2024-04-04 21:13:30 +11:00
80ea823fae Fix packetReceiver callback in Hotdog 2024-04-04 21:13:29 +11:00
0ec231cb0d Allow sending up, down, and stop packets from the hub address 2024-04-04 21:13:29 +11:00
7dfe523c52 Enable setting position from MQTT 2024-04-04 21:13:29 +11:00
f80cc9a6b9 Convert Hotdog sketch to PlatformIO 2024-04-04 21:13:29 +11:00
981c28f72e Add rolling codes for remote and hub 2024-04-04 21:13:29 +11:00
3051246cb2 Don't duplicate MQTT messages 2024-04-04 21:13:29 +11:00
d6a72c5811 Avoid initialising buffer twice 2024-04-04 21:13:29 +11:00
041f09896a Use PacketCRC helper 2024-04-04 21:13:29 +11:00
33551d7e01 Move print helpers to Hotdog.ino 2024-04-04 21:13:29 +11:00
207ffe9cc9 Update packet building logic to work with header byte 2024-04-04 21:13:29 +11:00
df13de502d Create Hotdog script to interface between MQTT and the radio 2024-04-04 21:13:28 +11:00
27 changed files with 1675 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
.vscode/settings.json

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"
]
}

9
config.example.json Normal file
View File

@@ -0,0 +1,9 @@
{
"shades": [
{
"radioId": "13AC",
"mqttId": "example_blind",
"friendly_name": "Example Blind"
}
]
}

39
include/README Normal file
View File

@@ -0,0 +1,39 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the usual convention is to give header files names that end with `.h'.
It is most portable to use only letters, digits, dashes, and underscores in
header file names, and at most one dot.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

46
lib/README Normal file
View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into executable file.
The source code of each library should be placed in a an own separate directory
("lib/your_library_name/[here are source files]").
For example, see a structure of the following two libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
and a contents of `src/main.c`:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
PlatformIO Library Dependency Finder will find automatically dependent
libraries scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

View File

@@ -0,0 +1,68 @@
#include "Configurator.h"
#include <cctype>
#include <ArduinoJson.h>
Configurator::Configurator() {
// TODO: Should be using MQTT directly here (and methods for handling methods should be private)
}
uint16_t Configurator::parseRadioId(const char* rawRadioId) {
if (!rawRadioId) {
return 0; // Or another appropriate error value/exception
}
uint16_t id = 0;
std::string radioId = std::string(rawRadioId);
if (radioId.size() != 4) {
return 0; // Invalid length
}
for (char c : radioId) {
if (!std::isxdigit(c)) {
return 0; // Invalid character, not a hex digit
}
int digit = std::toupper(c) - (c <= '9' ? '0' : 'A' - 10);
id = (id << 4) | digit;
}
return id;
}
void Configurator::processJson(std::string json) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json);
if (error) {
return;
}
JsonArray shades = doc["shades"];
for (JsonObject shade : shades) {
const char* rawRadioId = shade["radioId"];
const char* mqttId = shade["mqttId"];
const char* friendlyName = shade["friendly_name"];
if (rawRadioId && mqttId && friendlyName && std::char_traits<char>::length(mqttId) > 0) {
uint16_t id = parseRadioId(rawRadioId);
if (id != 0) {
Shade s{id, mqttId, friendlyName, "stopped", -1, -1};
for (const auto& callback : shadeConfiguredCallbacks) {
callback(s);
}
}
}
else
{
// Invalid JSON - missing radioId, mqttId, or friendly_name"
}
}
}
void Configurator::addShadeConfiguredCallback(std::function<void (Shade)> callback) {
shadeConfiguredCallbacks.push_back(callback);
}

View File

@@ -0,0 +1,26 @@
#ifndef CONFIGURATOR_H
#define CONFIGURATOR_H
#include <vector>
#include <map>
#include <functional>
#include "Shade.h"
// TODO: Allow shades to be removed
class Configurator
{
private:
std::vector<std::function<void (Shade)>> shadeConfiguredCallbacks;
uint16_t parseRadioId(const char* rawRadioId);
public:
Configurator();
void addShadeConfiguredCallback(std::function<void (Shade)> callback);
void processJson(std::string json);
};
#endif // CONFIGURATOR_H

View File

@@ -0,0 +1,101 @@
#include "HADiscovery.h"
#include <sstream>
HADiscovery::HADiscovery(const char *topic_prefix, std::function<void(const char*, const char*)> publish_callback) : topic_prefix(topic_prefix), publish_callback(publish_callback)
{
}
void HADiscovery::publish(const Shade &shade)
{
publishCoverDiscoveryTopic(shade);
publishBatteryDiscoveryTopic(shade);
publishRefreshButtonDiscoveryTopic(shade);
}
void HADiscovery::addDeviceObject(JsonDocument &doc, const Shade &shade)
{
std::stringstream ss;
ss << "hotdog-" << std::hex << shade.ID;
std::string deviceID = ss.str();
JsonObject device = doc["device"].to<JsonObject>();
device["name"] = shade.friendlyName;
JsonArray identifiers = device["identifiers"].to<JsonArray>();
identifiers.add(deviceID);
device["manufacturer"] = "Hunter Douglas";
// TODO: Add fields like sw_version and model
}
void HADiscovery::publishCoverDiscoveryTopic(const Shade &shade)
{
std::stringstream ss;
ss << std::hex << shade.ID;
std::string objectID = ss.str();
std::string entityID = "cover-" + objectID;
JsonDocument doc;
doc["name"] = nullptr;
doc["unique_id"] = entityID;
doc["availability_topic"] = std::string(topic_prefix) + "availability";
doc["state_topic"] = std::string(topic_prefix) + shade.key + "/state";
doc["command_topic"] = std::string(topic_prefix) + shade.key + "/command";
doc["position_topic"] = std::string(topic_prefix) + shade.key + "/position";
doc["set_position_topic"] = std::string(topic_prefix) + shade.key + "/set_position";
doc["position_open"] = 100;
doc["position_closed"] = 0;
doc["optimistic"] = false;
addDeviceObject(doc, shade);
serializeJson(doc, jsonBuffer);
publish_callback(("homeassistant/cover/" + objectID + "/config").c_str(), jsonBuffer);
}
void HADiscovery::publishBatteryDiscoveryTopic(const Shade &shade)
{
std::stringstream ss;
ss << std::hex << shade.ID;
std::string objectID = ss.str();
std::string entityID = "battery-" + objectID;
JsonDocument doc;
doc["name"] = "Battery";
doc["unique_id"] = entityID;
doc["availability_topic"] = std::string(topic_prefix) + "availability";
doc["device_class"] = "battery";
doc["state_topic"] = std::string(topic_prefix) + shade.key + "/battery";
addDeviceObject(doc, shade);
serializeJson(doc, jsonBuffer);
publish_callback(("homeassistant/sensor/" + objectID + "/config").c_str(), jsonBuffer);
}
void HADiscovery::publishRefreshButtonDiscoveryTopic(const Shade &shade)
{
std::stringstream ss;
ss << std::hex << shade.ID;
std::string objectID = ss.str();
std::string entityID = "refresh-button-" + objectID;
JsonDocument doc;
doc["name"] = "Refresh";
doc["unique_id"] = entityID;
doc["availability_topic"] = std::string(topic_prefix) + "availability";
doc["command_topic"] = std::string(topic_prefix) + shade.key + "/command";
doc["payload_press"] = "REFRESH";
addDeviceObject(doc, shade);
serializeJson(doc, jsonBuffer);
publish_callback(("homeassistant/button/" + objectID + "/config").c_str(), jsonBuffer);
}

View File

@@ -0,0 +1,27 @@
#ifndef HA_DISCOVERY_H
#define HA_DISCOVERY_H
#include "Shade.h"
#include <ArduinoJson.h>
#include <functional>
class HADiscovery
{
private:
char jsonBuffer[1024];
const char *topic_prefix;
const std::function<void(const char*, const char*)> publish_callback;
void addDeviceObject(JsonDocument &doc, const Shade &shade);
void publishCoverDiscoveryTopic(const Shade &shade);
void publishBatteryDiscoveryTopic(const Shade &shade);
void publishRefreshButtonDiscoveryTopic(const Shade &shade);
public:
HADiscovery(const char *topic_prefix, std::function<void(const char* topic, const char* message)> publish_callback);
void publish(const Shade &shade);
};
#endif // HA_DISCOVERY_H

View File

@@ -0,0 +1,84 @@
#include "PacketBuilder.h"
PacketBuilder::PacketBuilder()
{
}
Packet PacketBuilder::buildOpenPacket(uint16_t destination)
{
Packet packet;
auto header = UnicastHeader{};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::OPEN;
return packet;
}
Packet PacketBuilder::buildClosePacket(uint16_t destination)
{
Packet packet;
auto header = UnicastHeader{};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::CLOSE;
return packet;
}
Packet PacketBuilder::buildStopPacket(uint16_t destination)
{
Packet packet;
auto header = UnicastHeader{};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::STOP;
return packet;
}
Packet PacketBuilder::buildSetPositionPacket(uint16_t destination, float percentage)
{
Packet packet;
auto header = UnicastHeader{};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::FIELD_COMMAND;
std::vector<Field> fields;
uint8_t identifier = 0x50; // position
FieldType type = FieldType::SET;
uint16_t position = (uint16_t)(0xFFFF * percentage);
fields.push_back(Field{identifier, type, true, position});
packet.parameters = FieldsParameters{fields};
return packet;
}
Packet PacketBuilder::buildFetchPositionPacket(uint16_t destination)
{
Packet packet;
auto header = UnicastHeader{};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::FIELD_COMMAND;
std::vector<Field> fields;
// position
fields.push_back(Field{0x50, FieldType::FETCH, false, std::monostate{}});
// battery
fields.push_back(Field{0x42, FieldType::FETCH, false, std::monostate{}});
packet.parameters = FieldsParameters{fields};
return packet;
}

View File

@@ -0,0 +1,17 @@
#ifndef PACKET_BUILDER_H
#define PACKET_BUILDER_H
#include <PacketTypes.h>
class PacketBuilder {
public:
PacketBuilder();
Packet buildOpenPacket(uint16_t destination);
Packet buildClosePacket(uint16_t destination);
Packet buildStopPacket(uint16_t destination);
Packet buildSetPositionPacket(uint16_t destination, float percentage);
Packet buildFetchPositionPacket(uint16_t destination);
};
#endif // PACKET_BUILDER_H

View File

@@ -0,0 +1,27 @@
#include "PacketSender.h"
PacketSender::PacketSender(RFPowerView& powerView) : powerView(powerView)
{
}
bool PacketSender::send(Packet packet)
{
Serial.println("Attempting to send a packet");
packet.rollingCode1 = lastRollingCode1 + 1;
packet.rollingCode2 = lastRollingCode2 + 1;
bool didSend = powerView.sendPacket(&packet);
if (!didSend) {
Serial.println("Failed to send");
return false;
} else {
lastRollingCode1++;
lastRollingCode2++;
return true;
}
}
void PacketSender::setLastRollingCodes(uint8_t code1, uint8_t code2) {
lastRollingCode1 = code1;
lastRollingCode2 = code2;
}

View File

@@ -0,0 +1,20 @@
#ifndef PACKET_SENDER_H
#define PACKET_SENDER_H
#include <RFPowerView.h>
#include <PacketTypes.h>
class PacketSender {
public:
PacketSender(RFPowerView& powerView);
bool send(Packet packet);
void setLastRollingCodes(uint8_t code1, uint8_t code2);
private:
RFPowerView& powerView;
uint8_t lastRollingCode1 = 0x3D;
uint8_t lastRollingCode2 = 0x96;
};
#endif // PACKET_SENDER_H

View File

@@ -0,0 +1,53 @@
#include "PositionWatcher.h"
#include <Arduino.h>
void PositionWatcher::start(int8_t newTargetValue)
{
targetValue = newTargetValue;
lastPollTime = 0;
pollingInterval = 2000;
maxAttempts = 3;
maxConsecutiveDuplicates = 2;
consecutiveDuplicates = 0;
currentAttempt = 0;
isActive = true;
}
bool PositionWatcher::shouldFetch()
{
if (isWatching() && millis() - lastPollTime > pollingInterval) {
if (currentAttempt >= maxAttempts) {
isActive = false;
return false;
}
return true;
}
return false;
}
bool PositionWatcher::isWatching()
{
return isActive;
}
void PositionWatcher::fetchQueued()
{
lastPollTime = millis();
currentAttempt++;
}
void PositionWatcher::fetchReceived(int8_t value)
{
currentAttempt = 0;
if (value == targetValue) {
isActive = false;
} else if (lastValue == value) {
consecutiveDuplicates++;
if (consecutiveDuplicates >= maxConsecutiveDuplicates) {
isActive = false;
}
} else {
consecutiveDuplicates = 1;
}
lastValue = value;
}

View File

@@ -0,0 +1,31 @@
#ifndef POSITION_WATCHER_H
#define POSITION_WATCHER_H
#include <stdint.h>
class PositionWatcher
{
private:
bool isActive;
int8_t targetValue;
int8_t lastValue;
int8_t consecutiveDuplicates;
uint32_t pollingInterval;
int8_t maxAttempts;
int8_t maxConsecutiveDuplicates;
int8_t currentAttempt;
uint32_t lastPollTime;
public:
void start(int8_t newTargetValue);
bool shouldFetch();
bool isWatching();
void fetchQueued();
void fetchReceived(int8_t value);
};
#endif // POSITION_WATCHER_H

20
lib/shade/Shade.h Normal file
View File

@@ -0,0 +1,20 @@
#ifndef SHADE_H
#define SHADE_H
#include <stdint.h>
#include <string>
struct Shade {
uint16_t ID;
std::string key;
std::string friendlyName;
std::string state;
int8_t lastPosition;
int8_t lastBattery;
bool modified;
};
#endif // SHADE_H

View File

@@ -0,0 +1,33 @@
#ifndef SHADE_COMMAND_H
#define SHADE_COMMAND_H
#include <variant>
#include <stdint.h>
#include <typeindex>
struct OpenCommand {
uint16_t shadeID;
};
struct CloseCommand {
uint16_t shadeID;
};
struct StopCommand {
uint16_t shadeID;
};
struct SetPositionCommand {
uint16_t shadeID;
float percentage;
};
struct RefreshCommand {
uint16_t shadeID;
};
using ShadeCommand = std::variant<OpenCommand, CloseCommand, StopCommand, SetPositionCommand, RefreshCommand>;
#endif // SHADE_COMMAND_H

View File

@@ -0,0 +1,66 @@
#include "ShadeRepository.h"
#include "Shade.h"
void ShadeRepository::upsert(Shade shade)
{
for (Shade &existing_shade : shades)
{
if (existing_shade.key == shade.key)
{
existing_shade.friendlyName = shade.friendlyName;
existing_shade.ID = shade.ID;
for (auto &callback : shadeChangedCallbacks) {
callback(shade);
}
return;
}
}
// Didn't find it in the repository, add it
shades.push_back(shade);
for (auto &callback : shadeAddedCallbacks) {
callback(shade);
}
}
Shade* ShadeRepository::findById(const uint16_t id)
{
for (Shade &shade : shades)
{
if (shade.ID == id)
{
return &shade;
}
}
return nullptr;
}
Shade* ShadeRepository::findByKey(const std::string &key)
{
for (Shade &shade : shades)
{
if (shade.key == key)
{
return &shade;
}
}
return nullptr;
}
std::vector<Shade>::iterator ShadeRepository::begin()
{
return shades.begin();
}
std::vector<Shade>::iterator ShadeRepository::end()
{
return shades.end();
}
void ShadeRepository::addShadeAddedCallback(std::function<void(Shade&)> callback) {
shadeAddedCallbacks.push_back(callback);
}
void ShadeRepository::addShadeChangedCallback(std::function<void(Shade&)> callback) {
shadeChangedCallbacks.push_back(callback);
}

View File

@@ -0,0 +1,25 @@
#ifndef SHADE_REPOSITORY_H
#define SHADE_REPOSITORY_H
#include <vector>
#include <functional>
#include "Shade.h"
class ShadeRepository {
private:
std::vector<Shade> shades;
std::vector<std::function<void(Shade&)>> shadeAddedCallbacks;
std::vector<std::function<void(Shade&)>> shadeChangedCallbacks;
public:
void upsert(Shade shade);
std::vector<Shade>::iterator begin();
std::vector<Shade>::iterator end();
Shade* findById(const uint16_t id);
Shade* findByKey(const std::string& key);
void addShadeAddedCallback(std::function<void(Shade&)> callback);
void addShadeChangedCallback(std::function<void(Shade&)> callback);
};
#endif // SHADE_REPOSITORY_H

48
platformio.ini Normal file
View File

@@ -0,0 +1,48 @@
; 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:release]
platform = espressif8266
board = nodemcuv2
framework = arduino
build_flags =
-D RF_CE_PIN=5
-D RF_CS_PIN=15
-D RF_IRQ_PIN=4
-D RF_ID=0x2EC8 ; Copied from PowerView Hub userdata API, eg: http://POWERVIEW_HUB_IP_ADDRESS/api/userdata/ and find the field labeled "rfID"
lib_deps =
${env.lib_deps}
nrf24/RF24@^1.4.11
plapointe6/EspMQTTClient@^1.13.3
RFPowerView=https://git.mattway.com.au/matt/RFPowerView.git#v0.0.3
[env:test_embedded]
platform = espressif8266
board = nodemcuv2
framework = arduino
build_type = test
lib_deps =
${env.lib_deps}
[env:test_desktop]
platform = native
build_type = test
lib_deps =
${env.lib_deps}
ArduinoFake
[env]
monitor_speed = 115200
test_framework = unity
lib_deps =
bblanchon/ArduinoJson@^7.3.0

2
src/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
secrets.h
config.h

352
src/main.cpp Normal file
View File

@@ -0,0 +1,352 @@
#include <Arduino.h>
#include <EspMqttClient.h>
#include <ArduinoJson.h>
#include <RFPowerView.h>
#include <random>
#include <chrono>
#include "config.h"
#include "Shade.h"
#include "Configurator.h"
#include "ShadeRepository.h"
#include "HADiscovery.h"
#include "secrets.h"
#include "ShadeCommand.h"
#include "PositionWatcher.h"
#include "PacketBuilder.h"
#include "PacketSender.h"
#include <queue>
#define SER_BAUDRATE (115200)
RFPowerView powerView(RF_CE_PIN, RF_CS_PIN, RF_IRQ_PIN, RF_ID);
static std::mt19937 generator(std::chrono::system_clock::now().time_since_epoch().count());
std::string generate_client_id_suffix(int length) {
std::uniform_int_distribution<char> distribution('0', '9');
std::string random_string;
for (int i = 0; i < length; ++i) {
random_string += distribution(generator);
}
return random_string;
}
std::string clientID = "hotdog_" + generate_client_id_suffix(6);
std::string topic_prefix = "hotdog/";
std::string last_will_topic = std::string(topic_prefix) + "availability";
EspMQTTClient client(
SECRET_WIFI_SSID, // Wifi SSID
SECRET_WIFI_PASSWORD, // Wifi Password
SECRET_MQTT_SERVER_IP, // MQTT Broker server IP
SECRET_MQTT_USERNAME, // MQTT Username
SECRET_MQTT_PASSWORD, // MQTT Password
clientID.c_str(), // MQTT Client ID
1883
);
Configurator configurator = Configurator();
ShadeRepository shadeRepository = ShadeRepository();
using Command = std::variant<OpenCommand, CloseCommand, StopCommand, SetPositionCommand, RefreshCommand>;
std::queue<Command> commands;
std::map<uint16_t, PositionWatcher> positionWatchers;
HADiscovery haDiscovery(topic_prefix.c_str(), [] (const char* topic, const char* message) {
client.publish(topic, message);
});
PacketBuilder packetBuilder;
PacketSender packetSender(powerView);
void processPacket(const Packet*);
void processCommandMessage(const String& topic, const String &payload);
void processSetPositionMessage(const String& topic, const String &payload);
void publishPosition(const std::string& shadeKey, const uint8_t position);
void publishState(const std::string& shadeKey, const String& state);
void publishBattery(const std::string& shadeKey, const uint8_t battery);
void startPositionWatcher(uint16_t shadeID, uint8_t targetPosition);
void publishDiscoveryTopics();
void setup() {
Serial.begin(SER_BAUDRATE);
Serial.println("Starting up");
powerView.setPacketReceivedCallback(processPacket);
if (!powerView.begin()) {
Serial.println("Failed to start RFPowerView");
}
client.setKeepAlive(10);
client.enableLastWillMessage(last_will_topic.c_str(), "offline", true);
client.enableDebuggingMessages();
if (!client.setMaxPacketSize(1024)) {
Serial.println("Failed to set max packet size");
}
configurator.addShadeConfiguredCallback([] (Shade shade) {
shadeRepository.upsert(shade);
});
shadeRepository.addShadeAddedCallback([] (Shade& shade) {
// Only add shade if client is already connected
if (client.isConnected()) {
client.subscribe((topic_prefix + shade.key + "/command").c_str(), processCommandMessage);
client.subscribe((topic_prefix + shade.key + "/set_position").c_str(), processSetPositionMessage);
}
publishDiscoveryTopics();
});
shadeRepository.addShadeChangedCallback([] (Shade& shade) {
publishDiscoveryTopics();
});
configurator.processJson(config_json);
delay(100);
Serial.println("Ready");
}
void loop() {
powerView.loop();
client.loop();
if (client.isConnected()) {
if (!commands.empty()) {
auto command = commands.front();
commands.pop();
if (std::holds_alternative<OpenCommand>(command)) {
auto openCommand = std::get<OpenCommand>(command);
Serial.printf("Open command received for shade 0x%04x\n", openCommand.shadeID);
if (packetSender.send(packetBuilder.buildOpenPacket(openCommand.shadeID))) {
auto shade = shadeRepository.findById(openCommand.shadeID);
if (shade != nullptr) {
shade->state = "opening";
shade->modified = true;
}
startPositionWatcher(openCommand.shadeID, 100);
}
} else if (std::holds_alternative<CloseCommand>(command)) {
auto closeCommand = std::get<CloseCommand>(command);
Serial.printf("Close command received for shade 0x%04x\n", closeCommand.shadeID);
if (packetSender.send(packetBuilder.buildClosePacket(closeCommand.shadeID))) {
auto shade = shadeRepository.findById(closeCommand.shadeID);
if (shade != nullptr) {
shade->state = "closing";
shade->modified = true;
}
startPositionWatcher(closeCommand.shadeID, 0);
}
} else if (std::holds_alternative<StopCommand>(command)) {
auto stopCommand = std::get<StopCommand>(command);
Serial.printf("Stop command received for shade 0x%04x\n", stopCommand.shadeID);
packetSender.send(packetBuilder.buildStopPacket(stopCommand.shadeID));
startPositionWatcher(stopCommand.shadeID, -1);
} else if (std::holds_alternative<SetPositionCommand>(command)) {
auto setPositionCommand = std::get<SetPositionCommand>(command);
Serial.printf("Set Position command received for shade 0x%04x (%.2f)\n", setPositionCommand.shadeID, setPositionCommand.percentage);
if (packetSender.send(packetBuilder.buildSetPositionPacket(setPositionCommand.shadeID, setPositionCommand.percentage))) {
auto shade = shadeRepository.findById(setPositionCommand.shadeID);
if (shade != nullptr) {
if (setPositionCommand.percentage > shade->lastPosition / 100.0f) {
shade->state = "opening";
shade->modified = true;
} else {
shade->state = "closing";
shade->modified = true;
}
}
startPositionWatcher(setPositionCommand.shadeID, setPositionCommand.percentage * 100);
}
} else if (std::holds_alternative<RefreshCommand>(command)) {
auto refreshCommand = std::get<RefreshCommand>(command);
Serial.printf("Refresh command received for shade 0x%04x\n", refreshCommand.shadeID);
startPositionWatcher(refreshCommand.shadeID, -1);
}
}
for (auto& [shadeID, watcher] : positionWatchers) {
if (watcher.shouldFetch()) {
if (packetSender.send(packetBuilder.buildFetchPositionPacket(shadeID))) {
watcher.fetchQueued();
}
} else if (!watcher.isWatching()) {
positionWatchers.erase(shadeID);
auto shade = shadeRepository.findById(shadeID);
if (shade != nullptr) {
if (shade->lastPosition == 0) {
shade->state = "closed";
shade->modified = true;
} else {
shade->state = "open";
shade->modified = true;
}
}
}
}
for (auto shade = shadeRepository.begin(); shade != shadeRepository.end(); shade++) {
if (shade->modified) {
if (shade->lastPosition != -1) {
publishPosition(shade->key, shade->lastPosition);
}
if (shade->lastBattery != -1) {
publishBattery(shade->key, shade->lastBattery);
}
publishState(shade->key, shade->state.c_str());
shade->modified = false;
}
}
}
}
void processPacket(const Packet *packet) {
Serial.println("Got a packet");
uint16_t source = -1;
if (std::holds_alternative<BroadcastHeader>(packet->header)) {
auto header = std::get<BroadcastHeader>(packet->header);
source = header.source;
} else if (std::holds_alternative<UnicastHeader>(packet->header)) {
auto header = std::get<UnicastHeader>(packet->header);
source = header.source;
} else if (std::holds_alternative<GroupsHeader>(packet->header)) {
auto header = std::get<GroupsHeader>(packet->header);
source = header.source;
} else {
Serial.print("Cannot process packet, unknown header");
return;
}
// Update last rolling codes each time a packet from a real hub is detected
if (source == 0x0000) {
Serial.println("Updating rolling codes");
packetSender.setLastRollingCodes(packet->rollingCode1, packet->rollingCode2);
}
if (packet->type == PacketType::FIELDS) {
auto shade = shadeRepository.findById(source);
if (shade != nullptr) {
FieldsParameters parameters = std::get<FieldsParameters>(packet->parameters);
for (size_t i = 0; i < parameters.fields.size(); i++) {
Field field = parameters.fields[i];
if (field.identifier == 0x50) {
uint16_t value = std::get<uint16_t>(field.value);
uint8_t position = (uint8_t)std::round(((float)value / 0xFFFF) * 100);
shade->lastPosition = position;
shade->modified = true;
auto it = positionWatchers.find(shade->ID);
if (it != positionWatchers.end()) {
auto& watcher = it->second;
watcher.fetchReceived(position);
}
// TODO: set updated flag?
} else if (field.identifier == 0x42) {
uint8_t value = std::get<uint8_t>(field.value);
uint8_t battery = uint8_t(((float)value / 200) * 100);
shade->lastBattery = battery;
shade->modified = true;
// TODO: set updated flag?
}
}
}
}
}
void onConnectionEstablished() {
Serial.println("Connection established");
for (auto shade = shadeRepository.begin(); shade != shadeRepository.end(); shade++) {
client.subscribe((topic_prefix + shade->key + "/command").c_str(), processCommandMessage);
client.subscribe((topic_prefix + shade->key + "/set_position").c_str(), processSetPositionMessage);
}
client.publish((topic_prefix + "availability").c_str(), "online", true);
publishDiscoveryTopics();
client.subscribe("homeassistant/status", [] (const String& topic, const String& message) {
if (message == "online") {
publishDiscoveryTopics();
}
});
}
void startPositionWatcher(uint16_t shadeID, uint8_t targetValue) {
auto& watcher = positionWatchers.try_emplace(shadeID).first->second;
watcher.start(targetValue);
}
void processCommandMessage(const String &topic, const String &payload) {
int startIndex = topic.indexOf("/") + 1;
int endIndex = topic.indexOf("/", startIndex);
String key = topic.substring(startIndex, endIndex);
Serial.printf("Received command '%s' for shade '%s'\n", payload.c_str(), key.c_str());
auto shade = shadeRepository.findByKey(key.c_str());
if (shade != nullptr) {
if (payload == "OPEN") {
commands.push(OpenCommand {shade->ID});
} else if (payload == "CLOSE") {
commands.push(CloseCommand {shade->ID});
} else if (payload == "STOP") {
commands.push(StopCommand {shade->ID});
} else if (payload == "REFRESH") {
commands.push(RefreshCommand {shade->ID});
}
} else {
Serial.println("Failed to find shade");
}
}
void processSetPositionMessage(const String& topic, const String &payload) {
int startIndex = topic.indexOf("/") + 1;
int endIndex = topic.indexOf("/", startIndex);
String key = topic.substring(startIndex, endIndex);
Serial.printf("Received set position command for shade '%s' (%s)\n", key.c_str(), payload.c_str());
auto shade = shadeRepository.findByKey(key.c_str());
if (shade != nullptr) {
float percentage = payload.toInt() / 100.0f;
commands.push(SetPositionCommand {shade->ID, percentage});
}
}
void publishState(const std::string& shadeKey, const String& state) {
client.publish((topic_prefix + shadeKey + "/state").c_str(), state, true);
}
void publishPosition(const std::string& shadeKey, const uint8_t position) {
client.publish((topic_prefix + shadeKey + "/position").c_str(), String(position), true);
}
void publishBattery(const std::string& shadeKey, const uint8_t battery) {
client.publish((topic_prefix + shadeKey + "/battery").c_str(), String(battery), true);
}
void publishDiscoveryTopics() {
for (auto shade = shadeRepository.begin(); shade != shadeRepository.end(); shade++) {
haDiscovery.publish(*shade);
}
}

11
test/README Normal file
View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html

View File

@@ -0,0 +1,105 @@
#include <unity.h>
#include <Arduino.h>
#include "Configurator.h"
void setUp()
{
}
void tearDown()
{
}
std::string singleBlindJson = R"({"shades": [{"radioId": "ABCD","mqttId": "test_shade","friendly_name": "Test Shade"}]})";
std::string doubleBlindJson = R"({"shades": [{"radioId": "ABCD","mqttId": "test_shade","friendly_name": "Test Shade"}, {"radioId": "1234","mqttId": "test_shade2","friendly_name": "Test Shade 2"}]})";
std::string invalidRadioIdBlindJson = R"({"shades": [{"radioId": "XYZ9","mqttId": "test_shade","friendly_name": "Test Shade"}, {"mqttId": "test_shade","friendly_name": "Test Shade"}, {"radioId": "","mqttId": "test_shade","friendly_name": "Test Shade"}, {"radioId": "ABCDE","mqttId": "test_shade","friendly_name": "Test Shade"}]})";
std::string invalidMqttIdBlindJson = R"({"shades": [{"radioId": "ABCD","mqttId": "","friendly_name": "Test Shade"}, {"radioId": "ABCD","friendly_name": "Test Shade"}]})";
void test_shade_is_configured_with_id_and_friendly_name()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
TEST_ASSERT_EQUAL_STRING("Test Shade", shade.friendlyName.c_str());
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade.ID);
TEST_ASSERT_EQUAL_STRING("test_shade", shade.key.c_str());
});
configurator.processJson(singleBlindJson);
TEST_ASSERT_EQUAL_INT(1, callbackInvokedCount);
}
void test_multiple_shades_are_configured()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
});
configurator.processJson(doubleBlindJson);
TEST_ASSERT_EQUAL_INT(2, callbackInvokedCount);
}
void test_invalid_radio_id_is_ignored()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
});
configurator.processJson(invalidRadioIdBlindJson);
TEST_ASSERT_EQUAL_INT(0, callbackInvokedCount);
}
void test_invalid_mqtt_id_is_ignored()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
});
configurator.processJson(invalidMqttIdBlindJson);
TEST_ASSERT_EQUAL_INT(0, callbackInvokedCount);
}
int runUnityTests(void)
{
UNITY_BEGIN();
RUN_TEST(test_shade_is_configured_with_id_and_friendly_name);
RUN_TEST(test_multiple_shades_are_configured);
RUN_TEST(test_invalid_radio_id_is_ignored);
RUN_TEST(test_invalid_mqtt_id_is_ignored);
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,130 @@
#include <unity.h>
#include <Arduino.h>
#include "HADiscovery.h"
void setUp()
{
}
void tearDown()
{
}
void test_publish_message_count()
{
int callbackInvokedCount = 0;
auto callback = [&](const char *topic, const char *message)
{
callbackInvokedCount++;
// TEST_ASSERT_EQUAL_STRING("my_prefix/shade_key", topic);
// TEST_ASSERT_EQUAL_STRING("{}", message);
};
HADiscovery haDiscovery = HADiscovery("my_prefix/", callback);
haDiscovery.publish(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
TEST_ASSERT_EQUAL_INT(3, callbackInvokedCount);
}
void test_publish_cover_message()
{
int messageAssertedCount = 0;
auto callback = [&](const char *topic, const char *message)
{
if (strstr(topic, "homeassistant/cover") != nullptr)
{
messageAssertedCount++;
TEST_ASSERT_EQUAL_STRING("homeassistant/cover/abcd/config", topic);
auto expected_message = "{\"name\":null,\"unique_id\":\"cover-abcd\",\"availability_topic\":\"my_prefix/availability\",\"state_topic\":\"my_prefix/test_shade/state\",\"command_topic\":\"my_prefix/test_shade/command\",\"position_topic\":\"my_prefix/test_shade/position\",\"set_position_topic\":\"my_prefix/test_shade/set_position\",\"position_open\":100,\"position_closed\":0,\"optimistic\":false,\"device\":{\"name\":\"Test Shade\",\"identifiers\":[\"hotdog-abcd\"],\"manufacturer\":\"Hunter Douglas\"}}";
TEST_ASSERT_EQUAL_STRING(expected_message, message);
}
};
HADiscovery haDiscovery = HADiscovery("my_prefix/", callback);
haDiscovery.publish(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
TEST_ASSERT_EQUAL_INT(1, messageAssertedCount);
}
void test_publish_battery_message()
{
int messageAssertedCount = 0;
auto callback = [&](const char *topic, const char *message)
{
if (strstr(topic, "homeassistant/sensor") != nullptr)
{
messageAssertedCount++;
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/abcd/config", topic);
auto expected_message = "{\"name\":\"Battery\",\"unique_id\":\"battery-abcd\",\"availability_topic\":\"my_prefix/availability\",\"device_class\":\"battery\",\"state_topic\":\"my_prefix/test_shade/battery\",\"device\":{\"name\":\"Test Shade\",\"identifiers\":[\"hotdog-abcd\"],\"manufacturer\":\"Hunter Douglas\"}}";
TEST_ASSERT_EQUAL_STRING(expected_message, message);
}
};
HADiscovery haDiscovery = HADiscovery("my_prefix/", callback);
haDiscovery.publish(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
TEST_ASSERT_EQUAL_INT(1, messageAssertedCount);
}
void test_publish_button_message()
{
int messageAssertedCount = 0;
auto callback = [&](const char *topic, const char *message)
{
if (strstr(topic, "homeassistant/button") != nullptr)
{
messageAssertedCount++;
TEST_ASSERT_EQUAL_STRING("homeassistant/button/abcd/config", topic);
auto expected_message = "{\"name\":\"Refresh\",\"unique_id\":\"refresh-button-abcd\",\"availability_topic\":\"my_prefix/availability\",\"command_topic\":\"my_prefix/test_shade/command\",\"payload_press\":\"REFRESH\",\"device\":{\"name\":\"Test Shade\",\"identifiers\":[\"hotdog-abcd\"],\"manufacturer\":\"Hunter Douglas\"}}";
TEST_ASSERT_EQUAL_STRING(expected_message, message);
}
};
HADiscovery haDiscovery = HADiscovery("my_prefix/", callback);
haDiscovery.publish(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
TEST_ASSERT_EQUAL_INT(1, messageAssertedCount);
}
int runUnityTests(void)
{
UNITY_BEGIN();
RUN_TEST(test_publish_message_count);
RUN_TEST(test_publish_cover_message);
RUN_TEST(test_publish_battery_message);
RUN_TEST(test_publish_button_message);
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,161 @@
#include <unity.h>
#include <Arduino.h>
#include <ArduinoFake.h>
#include "PositionWatcher.h"
using namespace fakeit;
void setUp()
{
}
void tearDown()
{
}
void test_position_watcher_should_not_fetch_initially()
{
PositionWatcher positionWatcher = PositionWatcher();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
}
void test_position_watcher_should_fetch_after_start()
{
When(Method(ArduinoFake(), millis)).Return(10000);
PositionWatcher positionWatcher = PositionWatcher();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
positionWatcher.start(75);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
}
void test_position_watcher_should_not_fetch_before_polling_interval()
{
When(Method(ArduinoFake(), millis)).AlwaysReturn(10000);
PositionWatcher positionWatcher = PositionWatcher();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
positionWatcher.start(75);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
positionWatcher.fetchQueued();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
}
void test_position_watcher_should_fetch_after_polling_interval()
{
When(Method(ArduinoFake(), millis)).AlwaysReturn(10000);
PositionWatcher positionWatcher = PositionWatcher();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
positionWatcher.start(75);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
positionWatcher.fetchQueued();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
When(Method(ArduinoFake(), millis)).AlwaysReturn(20000);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
}
void test_position_watcher_should_not_fetch_after_max_attempts()
{
When(Method(ArduinoFake(), millis)).AlwaysReturn(10000);
PositionWatcher positionWatcher = PositionWatcher();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
positionWatcher.start(75);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
positionWatcher.fetchQueued();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
When(Method(ArduinoFake(), millis)).AlwaysReturn(20000);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
positionWatcher.fetchQueued();
When(Method(ArduinoFake(), millis)).AlwaysReturn(30000);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
positionWatcher.fetchQueued();
When(Method(ArduinoFake(), millis)).AlwaysReturn(40000);
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
}
void test_position_watcher_should_stop_after_enough_duplicates()
{
When(Method(ArduinoFake(), millis)).AlwaysReturn(10000);
PositionWatcher positionWatcher = PositionWatcher();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
positionWatcher.start(75);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
positionWatcher.fetchQueued();
positionWatcher.fetchReceived(50);
TEST_ASSERT_TRUE(positionWatcher.isWatching());
positionWatcher.fetchQueued();
positionWatcher.fetchReceived(60);
TEST_ASSERT_TRUE(positionWatcher.isWatching());
positionWatcher.fetchQueued();
positionWatcher.fetchReceived(60);
TEST_ASSERT_FALSE(positionWatcher.isWatching());
}
void test_position_watcher_should_stop_after_target_value()
{
When(Method(ArduinoFake(), millis)).AlwaysReturn(10000);
PositionWatcher positionWatcher = PositionWatcher();
TEST_ASSERT_FALSE(positionWatcher.shouldFetch());
positionWatcher.start(75);
TEST_ASSERT_TRUE(positionWatcher.shouldFetch());
positionWatcher.fetchQueued();
positionWatcher.fetchReceived(50);
TEST_ASSERT_TRUE(positionWatcher.isWatching());
positionWatcher.fetchQueued();
positionWatcher.fetchReceived(60);
TEST_ASSERT_TRUE(positionWatcher.isWatching());
positionWatcher.fetchQueued();
positionWatcher.fetchReceived(75);
TEST_ASSERT_FALSE(positionWatcher.isWatching());
}
int runUnityTests(void)
{
UNITY_BEGIN();
RUN_TEST(test_position_watcher_should_not_fetch_initially);
RUN_TEST(test_position_watcher_should_fetch_after_start);
RUN_TEST(test_position_watcher_should_not_fetch_before_polling_interval);
RUN_TEST(test_position_watcher_should_fetch_after_polling_interval);
RUN_TEST(test_position_watcher_should_not_fetch_after_max_attempts);
RUN_TEST(test_position_watcher_should_stop_after_enough_duplicates);
RUN_TEST(test_position_watcher_should_stop_after_target_value);
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,158 @@
#include <unity.h>
#include <Arduino.h>
#include "ShadeRepository.h"
void setUp()
{
}
void tearDown()
{
}
void test_shade_is_found_with_id()
{
ShadeRepository shadeRepository = ShadeRepository();
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
auto shade = shadeRepository.findById(0xABCD);
TEST_ASSERT_NOT_NULL_MESSAGE(shade, "shade should not be null");
TEST_ASSERT_EQUAL_STRING("Test Shade", shade->friendlyName.c_str());
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade->ID);
TEST_ASSERT_EQUAL_STRING("test_shade", shade->key.c_str());
}
void test_shade_is_found_with_key()
{
ShadeRepository shadeRepository = ShadeRepository();
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
auto shade = shadeRepository.findByKey("test_shade");
TEST_ASSERT_NOT_NULL_MESSAGE(shade, "shade should not be null");
TEST_ASSERT_EQUAL_STRING("Test Shade", shade->friendlyName.c_str());
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade->ID);
TEST_ASSERT_EQUAL_STRING("test_shade", shade->key.c_str());
}
void test_adding_shade_twice_only_added_once()
{
ShadeRepository shadeRepository = ShadeRepository();
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
int count = 0;
for (auto iter = shadeRepository.begin(); iter != shadeRepository.end(); iter++) {
count++;
}
TEST_ASSERT_EQUAL_INT(1, count);
}
void test_updating_shade_id()
{
ShadeRepository shadeRepository = ShadeRepository();
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
auto shade1 = shadeRepository.findById(0xABCD);
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade1->ID);
shadeRepository.upsert(Shade{0x1234, "test_shade", "Test Shade", "stopped", -1, -1});
auto shade2 = shadeRepository.findById(0x1234);
auto shade3 = shadeRepository.findById(0xABCD);
TEST_ASSERT_EQUAL_HEX16(0x1234, shade2->ID);
TEST_ASSERT_EQUAL_HEX16(0x1234, shade1->ID);
TEST_ASSERT_NULL(shade3);
}
void test_updating_shade_friendly_name()
{
ShadeRepository shadeRepository = ShadeRepository();
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
auto shade1 = shadeRepository.findByKey("test_shade");
TEST_ASSERT_EQUAL_STRING("Test Shade", shade1->friendlyName.c_str());
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Updated Test Shade", "stopped", -1, -1});
auto shade2 = shadeRepository.findByKey("test_shade");
TEST_ASSERT_EQUAL_STRING("Updated Test Shade", shade2->friendlyName.c_str());
TEST_ASSERT_EQUAL_STRING("Updated Test Shade", shade1->friendlyName.c_str());
}
void test_shade_added_callback()
{
int callbackInvokedCount = 0;
ShadeRepository shadeRepository = ShadeRepository();
shadeRepository.addShadeAddedCallback([&] (Shade& shade) {
callbackInvokedCount++;
TEST_ASSERT_EQUAL_STRING("Test Shade", shade.friendlyName.c_str());
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade.ID);
TEST_ASSERT_EQUAL_STRING("test_shade", shade.key.c_str());
});
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
TEST_ASSERT_EQUAL_INT(1, callbackInvokedCount);
}
void test_shade_changed_callback()
{
int callbackInvokedCount = 0;
ShadeRepository shadeRepository = ShadeRepository();
shadeRepository.addShadeChangedCallback([&] (Shade& shade) {
callbackInvokedCount++;
TEST_ASSERT_EQUAL_STRING("Updated Test Shade", shade.friendlyName.c_str());
TEST_ASSERT_EQUAL_HEX16(0x1234, shade.ID);
TEST_ASSERT_EQUAL_STRING("test_shade", shade.key.c_str());
});
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", "stopped", -1, -1});
shadeRepository.upsert(Shade{0x1234, "test_shade", "Updated Test Shade", "stopped", -1, -1});
TEST_ASSERT_EQUAL_INT(1, callbackInvokedCount);
}
int runUnityTests(void)
{
UNITY_BEGIN();
RUN_TEST(test_shade_is_found_with_id);
RUN_TEST(test_shade_is_found_with_key);
RUN_TEST(test_adding_shade_twice_only_added_once);
RUN_TEST(test_updating_shade_id);
RUN_TEST(test_updating_shade_friendly_name);
RUN_TEST(test_shade_added_callback);
RUN_TEST(test_shade_changed_callback);
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();
}