Compare commits

...

51 Commits

Author SHA1 Message Date
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
18 changed files with 1340 additions and 0 deletions

5
.gitignore vendored Normal file
View File

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

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

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,62 @@
#include "Configurator.h"
#include <cctype>
Configurator::Configurator() {
// TODO: Should be using MQTT directly here (and methods for handling methods should be private)
}
std::string Configurator::extractKey(std::string topic) {
int startIndex = topic.find("/") + 1;
int endIndex = topic.find("/", startIndex);
// TODO: need to verify that startIndex and endIndex are valid before substr()
return topic.substr(startIndex, endIndex - startIndex);
}
void Configurator::processIdMessage(std::string topic, std::string payload) {
auto key = extractKey(topic);
if (payload.length() != 4) {
// Ignore payloads that aren't exactly 4 characters
return;
}
uint16_t id = 0;
for (char c : payload) {
// Check if valid hex digit (0-9, A-F, a-f)
if (!std::isxdigit(c)) {
return; // Invalid character, not a hex digit
}
// Convert hex digit to numerical value (0-9, A-B=10-11, ...)
int digit = std::toupper(c) - (c <= '9' ? '0' : 'A' - 10);
id = (id << 4) | digit;
}
discoveredIds[key] = id;
tryBuild(key);
}
void Configurator::processFriendlyNameMessage(std::string topic, std::string friendlyName) {
auto key = extractKey(topic);
discoveredFriendlyNames[key] = friendlyName;
tryBuild(key);
}
void Configurator::addShadeConfiguredCallback(std::function<void (Shade)> callback) {
shadeConfiguredCallbacks.push_back(callback);
}
bool Configurator::tryBuild(std::string key) {
if (auto id = discoveredIds.find(key); id != discoveredIds.end()) {
if (auto friendlyName = discoveredFriendlyNames.find(key); friendlyName != discoveredFriendlyNames.end()) {
auto shade = Shade{id->second, key, friendlyName->second, -1, -1, 0, 0, nullptr};
for (size_t i = 0; i < shadeConfiguredCallbacks.size(); i++) {
shadeConfiguredCallbacks[i](shade);
}
return true;
}
}
return false;
}

View File

@@ -0,0 +1,30 @@
#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;
std::map<std::string, uint16_t> discoveredIds;
std::map<std::string, std::string> discoveredFriendlyNames;
std::string extractKey(std::string topic);
bool tryBuild(std::string topic);
public:
Configurator();
void addShadeConfiguredCallback(std::function<void (Shade)> callback);
void processIdMessage(std::string topic, std::string id);
void processFriendlyNameMessage(std::string topic, std::string friendlyName);
};
#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

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

@@ -0,0 +1,18 @@
#ifndef SHADE_H
#define SHADE_H
#include <stdint.h>
#include <string>
struct Shade {
uint16_t ID;
std::string key;
std::string friendlyName;
int8_t lastTargetPosition;
int8_t lastPosition;
uint8_t samePositionCount;
uint8_t positionFetchCount;
void* timer;
};
#endif // SHADE_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

49
platformio.ini Normal file
View File

@@ -0,0 +1,49 @@
; 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
lib_deps =
${env.lib_deps}
nrf24/RF24@^1.4.8
plapointe6/EspMQTTClient@^1.13.3
contrem/arduino-timer@^3.0.1
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 =
ArduinoJson=https://github.com/bblanchon/ArduinoJson#v7.0.0

1
src/.gitignore vendored Normal file
View File

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

400
src/main.cpp Normal file
View File

@@ -0,0 +1,400 @@
#include <Arduino.h>
#include <EspMqttClient.h>
#include <arduino-timer.h>
#include <ArduinoJson.h>
#include <RFPowerView.h>
#include <random>
#include <chrono>
#include "Shade.h"
#include "Configurator.h"
#include "ShadeRepository.h"
#include "HADiscovery.h"
#include "secrets.h"
#define SER_BAUDRATE (115200)
// Copied from Powerview Hub userdata API
// eg: http://POWERVIEW_HUB_IP_ADDRESS/api/userdata/ and find the field labeled "rfID"
#define RF_ID (0x2EC8)
RFPowerView powerView(RF_CE_PIN, RF_CS_PIN, RF_IRQ_PIN, RF_ID);
uint8_t lastRollingCode1 = 0x3D;
uint8_t lastRollingCode2 = 0x96;
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();
HADiscovery haDiscovery(topic_prefix.c_str(), [] (const char* topic, const char* message) {
client.publish(topic, message);
});
#define MAX_FETCH_COUNT (20)
auto timer = Timer<10, millis, uint16_t>();
void processPacket(const Packet*);
void processCommandMessage(const String& topic, const String &payload);
void processSetPositionMessage(const String& topic, const String &payload);
bool sendOpenPacket(uint16_t destination);
bool sendClosePacket(uint16_t destination);
bool sendStopPacket(uint16_t destination);
bool sendSetPosition(uint16_t destination, float percentage);
bool sendFetchPosition(uint16_t destination);
bool sendPacket(Packet *packet);
bool checkPosition(uint16_t shadeID);
void startFetchingPosition(uint16_t shadeID, int8_t targetPosition);
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 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();
});
delay(100);
Serial.println("Ready");
}
void loop() {
powerView.loop();
client.loop();
timer.tick();
}
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");
lastRollingCode1 = packet->rollingCode1;
lastRollingCode2 = packet->rollingCode2;
}
if (packet->type == PacketType::FIELDS) {
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) {
auto shade = shadeRepository.findById(source);
if (shade != nullptr) {
uint16_t value = std::get<uint16_t>(field.value);
uint8_t position = (uint8_t)std::round(((float)value / 0xFFFF) * 100);
if (shade->lastPosition == position) {
shade->samePositionCount++;
} else {
shade->samePositionCount = 1;
}
shade->lastPosition = position;
publishPosition(shade->key, position);
}
} else if (field.identifier == 0x42) {
auto shade = shadeRepository.findById(source);
if (shade != nullptr) {
uint8_t value = std::get<uint8_t>(field.value);
uint8_t battery = uint8_t(((float)value / 200) * 100);
publishBattery(shade->key, battery);
}
}
}
}
}
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();
}
});
}
bool sendOpenPacket(uint16_t destination) {
Packet packet;
auto header = UnicastHeader {};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::OPEN;
return sendPacket(&packet);
}
bool sendClosePacket(uint16_t destination) {
Packet packet;
auto header = UnicastHeader {};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::CLOSE;
return sendPacket(&packet);
}
bool sendStopPacket(uint16_t destination) {
Packet packet;
auto header = UnicastHeader {};
header.destination = destination;
header.source = 0x0000;
packet.header = header;
packet.type = PacketType::STOP;
return sendPacket(&packet);
}
bool sendSetPosition(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 sendPacket(&packet);
}
bool sendFetchPosition(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 sendPacket(&packet);
}
bool sendPacket(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 processCommandMessage(const String &topic, const String &payload) {
int startIndex = topic.indexOf("/") + 1;
int endIndex = topic.indexOf("/", startIndex);
auto key = topic.substring(startIndex, endIndex).c_str();
auto shade = shadeRepository.findByKey(key);
if (shade != nullptr) {
if (payload == "OPEN") {
sendOpenPacket(shade->ID);
startFetchingPosition(shade->ID, 100);
publishState(shade->key, "opening");
} else if (payload == "CLOSE") {
sendClosePacket(shade->ID);
startFetchingPosition(shade->ID, 0);
publishState(shade->key, "closing");
} else if (payload == "STOP") {
sendStopPacket(shade->ID);
startFetchingPosition(shade->ID, -1);
timer.in(100, sendFetchPosition, shade->ID);
} else if (payload == "REFRESH") {
startFetchingPosition(shade->ID, -1);
}
}
}
void processSetPositionMessage(const String& topic, const String &payload) {
int startIndex = topic.indexOf("/") + 1;
int endIndex = topic.indexOf("/", startIndex);
auto key = topic.substring(startIndex, endIndex).c_str();
auto shade = shadeRepository.findByKey(key);
if (shade != nullptr) {
float percentage = payload.toInt() / 100.0f;
sendSetPosition(shade->ID, percentage);
if (payload.toInt() > shade->lastPosition) {
publishState(shade->key, "opening");
} else if (payload.toInt() < shade->lastPosition) {
publishState(shade->key, "closing");
}
startFetchingPosition(shade->ID, payload.toInt());
}
}
bool checkPosition(uint16_t shadeID) {
auto shade = shadeRepository.findById(shadeID);
if (shade != nullptr) {
// Keep fetching position if:
// - the last reported position doesn't match the target position
// - a position hasn't been reported yet
// - there is no target position
if (shade->lastTargetPosition != shade->lastPosition || shade->lastPosition == -1 || shade->lastTargetPosition == -1) {
// Keep fetching position if the count limits have not been reached
if (shade->positionFetchCount < MAX_FETCH_COUNT && shade->samePositionCount < 2) {
shade->positionFetchCount++;
sendFetchPosition(shadeID);
return true;
}
}
publishState(shade->key, shade->lastPosition > 0 ? "open" : "closed");
return false;
}
return false;
}
void startFetchingPosition(uint16_t shadeID, int8_t targetPosition) {
auto shade = shadeRepository.findById(shadeID);
if (shade != nullptr) {
// Cancel any existing timer
if (shade->timer != nullptr) {
timer.cancel(shade->timer);
shade->timer = nullptr;
}
shade->lastTargetPosition = targetPosition;
shade->positionFetchCount = 0;
shade->samePositionCount = 1;
shade->timer = timer.every(2000, checkPosition, shade->ID);
}
}
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,162 @@
#include <unity.h>
#include <Arduino.h>
#include "Configurator.h"
void setUp()
{
}
void tearDown()
{
}
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.processIdMessage("hotdog/test_shade/id", "ABCD");
configurator.processFriendlyNameMessage("hotdog/test_shade/friendly_name", "Test Shade");
TEST_ASSERT_EQUAL_INT(1, callbackInvokedCount);
}
void test_shade_is_configured_again_with_new_friendly_name()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
if (callbackInvokedCount == 1) {
TEST_ASSERT_EQUAL_STRING("Test Shade", shade.friendlyName.c_str());
} else if (callbackInvokedCount == 2) {
TEST_ASSERT_EQUAL_STRING("Updated Test Shade", shade.friendlyName.c_str());
} else {
TEST_ABORT();
}
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade.ID);
TEST_ASSERT_EQUAL_STRING("test_shade", shade.key.c_str());
});
configurator.processIdMessage("hotdog/test_shade/id", "ABCD");
configurator.processFriendlyNameMessage("hotdog/test_shade/friendly_name", "Test Shade");
TEST_ASSERT_EQUAL_INT(1, callbackInvokedCount);
configurator.processFriendlyNameMessage("hotdog/test_shade/friendly_name", "Updated Test Shade");
TEST_ASSERT_EQUAL_INT(2, callbackInvokedCount);
}
void test_shade_is_configured_again_with_new_id()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
TEST_ASSERT_EQUAL_STRING("Test Shade", shade.friendlyName.c_str());
if (callbackInvokedCount == 1) {
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade.ID);
} else if (callbackInvokedCount == 2) {
TEST_ASSERT_EQUAL_HEX16(0x9876, shade.ID);
} else {
TEST_ABORT();
}
TEST_ASSERT_EQUAL_STRING("test_shade", shade.key.c_str());
});
configurator.processIdMessage("hotdog/test_shade/id", "ABCD");
configurator.processFriendlyNameMessage("hotdog/test_shade/friendly_name", "Test Shade");
TEST_ASSERT_EQUAL_INT(1, callbackInvokedCount);
configurator.processIdMessage("hotdog/test_shade/id", "9876");
TEST_ASSERT_EQUAL_INT(2, callbackInvokedCount);
}
void test_shade_is_not_configured_without_friendly_name()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
});
configurator.processIdMessage("hotdog/test_shade/id", "ABCD");
TEST_ASSERT_EQUAL_INT(0, callbackInvokedCount);
}
void test_shade_is_not_configured_without_id()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
});
configurator.processFriendlyNameMessage("hotdog/test_shade/friendly_name", "Test Shade");
TEST_ASSERT_EQUAL_INT(0, callbackInvokedCount);
}
void test_shade_is_not_configured_with_invalid_id()
{
int callbackInvokedCount = 0;
Configurator configurator = Configurator();
configurator.addShadeConfiguredCallback([&] (Shade shade) {
callbackInvokedCount++;
});
configurator.processIdMessage("hotdog/test_shade/id", "ZZZZ");
configurator.processFriendlyNameMessage("hotdog/test_shade/friendly_name", "Test Shade");
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_shade_is_configured_again_with_new_friendly_name);
RUN_TEST(test_shade_is_configured_again_with_new_id);
RUN_TEST(test_shade_is_not_configured_without_friendly_name);
RUN_TEST(test_shade_is_not_configured_without_id);
RUN_TEST(test_shade_is_not_configured_with_invalid_id);
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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
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,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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
shadeRepository.upsert(Shade{0xABCD, "test_shade", "Test Shade", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
auto shade1 = shadeRepository.findById(0xABCD);
TEST_ASSERT_EQUAL_HEX16(0xABCD, shade1->ID);
shadeRepository.upsert(Shade{0x1234, "test_shade", "Test Shade", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
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", -1, -1, 0, 0, nullptr});
shadeRepository.upsert(Shade{0x1234, "test_shade", "Updated Test Shade", -1, -1, 0, 0, nullptr});
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();
}