Files
Hotdog/src/main.cpp

394 lines
12 KiB
C++

#include <Arduino.h>
#include <EspMQTTClient.h>
#include <arduino-timer.h>
#include <ArduinoJson.h>
#include <RFPowerView.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;
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
"hotdog", // MQTT Client ID
1883
);
#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 String& shadeName, const uint8_t position);
void publishState(const String& shadeName, const String& state);
void publishBattery(const String& shadeName, const uint8_t battery);
void publishDiscoveryTopics();
struct Shade {
uint16_t ID;
String name;
String friendlyName;
int8_t lastTargetPosition;
int8_t lastPosition;
uint8_t samePositionCount;
uint8_t positionFetchCount;
void* timer;
};
std::vector<Shade> shades;
void setup() {
Serial.begin(SER_BAUDRATE);
Serial.println("Starting up");
shades.push_back(Shade{0x4EF1, "study_blind", "Study Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0xA51F, "studio_blockout_left_blind", "Studio Blockout Left Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0xDAEF, "studio_blockout_right_blind", "Studio Blockout Right Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x7687, "studio_left_blind", "Studio Left Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0xB0DE, "studio_right_blind", "Studio Right Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0xB451, "bedroom_door_blockout_blind", "Bedroom Door Blockout Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x48A6, "bedroom_window_blockout_blind", "Bedroom Window Blockout Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x9E14, "bedroom_door_blind", "Bedroom Door Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x061C, "bedroom_window_blind", "Bedroom Window Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x2959, "bathroom_blind", "Bathroom Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x6FAD, "kitchen_door_blind", "Kitchen Door Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0xFB21, "kitchen_window_blind", "Kitchen Window Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x8B10, "living_room_big_window_blind", "Living Room Big Window Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x3EB8, "living_room_door_blind", "Living Room Door Blind", -1, -1, 0, 0, nullptr});
shades.push_back(Shade{0x5463, "living_room_window_blind", "Living Room Window Blind", -1, -1, 0, 0, nullptr});
powerView.setPacketCallback(processPacket);
if (!powerView.begin()) {
Serial.println("Failed to start RFPowerView");
return;
}
client.setKeepAlive(10);
client.enableLastWillMessage("hotdog/availability", "offline", true);
client.enableDebuggingMessages();
client.setMaxPacketSize(2048);
delay(100);
Serial.println("Ready");
}
void loop() {
powerView.loop();
client.loop();
timer.tick();
}
void processPacket(const Packet *packet) {
// Update last rolling codes each time a packet from a real hub is detected
if (packet->source == 0x0000) {
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) {
for (size_t i = 0; i < shades.size(); i++) {
if (packet->source == shades[i].ID) {
uint16_t value = std::get<uint16_t>(field.value);
uint8_t position = (uint8_t)std::round(((float)value / 0xFFFF) * 100);
if (shades[i].lastPosition == position) {
shades[i].samePositionCount++;
} else {
shades[i].samePositionCount = 1;
}
shades[i].lastPosition = position;
publishPosition(shades[i].name, position);
}
}
} else if (field.identifier == 0x42) {
for (size_t i = 0; i < shades.size(); i++) {
if (packet->source == shades[i].ID) {
uint8_t value = std::get<uint8_t>(field.value);
uint8_t battery = uint8_t(((float)value / 200) * 100);
publishBattery(shades[i].name, battery);
}
}
}
}
}
}
void onConnectionEstablished() {
Serial.println("Connection established");
for (size_t i = 0; i < shades.size(); i++) {
client.subscribe("hotdog/" + shades[i].name + "/command", processCommandMessage);
client.subscribe("hotdog/" + shades[i].name + "/set_position", processSetPositionMessage);
}
client.publish("hotdog/availability", "online", true);
publishDiscoveryTopics();
client.subscribe("homeassistant/status", [] (const String& topic, const String& message) {
if (message == "online") {
publishDiscoveryTopics();
}
});
}
bool sendOpenPacket(uint16_t destination) {
Packet packet;
packet.destination = destination;
packet.source = 0x0000;
packet.type = PacketType::OPEN;
return sendPacket(&packet);
}
bool sendClosePacket(uint16_t destination) {
Packet packet;
packet.destination = destination;
packet.source = 0x0000;
packet.type = PacketType::CLOSE;
return sendPacket(&packet);
}
bool sendStopPacket(uint16_t destination) {
Packet packet;
packet.destination = destination;
packet.source = 0x0000;
packet.type = PacketType::STOP;
return sendPacket(&packet);
}
bool sendSetPosition(uint16_t destination, float percentage) {
Packet packet;
packet.destination = destination;
packet.source = 0x0000;
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;
packet.destination = destination;
packet.source = 0x0000;
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);
String shadeName = topic.substring(startIndex, endIndex);
for (size_t i = 0; i < shades.size(); i++) {
if (shades[i].name == shadeName) {
if (payload == "OPEN") {
sendOpenPacket(shades[i].ID);
startFetchingPosition(shades[i].ID, 100);
publishState(shades[i].name, "opening");
} else if (payload == "CLOSE") {
sendClosePacket(shades[i].ID);
startFetchingPosition(shades[i].ID, 0);
publishState(shades[i].name, "closing");
} else if (payload == "STOP") {
sendStopPacket(shades[i].ID);
startFetchingPosition(shades[i].ID, -1);
timer.in(100, sendFetchPosition, shades[i].ID);
}
}
}
}
void processSetPositionMessage(const String& topic, const String &payload) {
int startIndex = topic.indexOf("/") + 1;
int endIndex = topic.indexOf("/", startIndex);
String shadeName = topic.substring(startIndex, endIndex);
for (size_t i = 0; i < shades.size(); i++) {
if (shades[i].name == shadeName) {
float percentage = payload.toInt() / 100.0f;
sendSetPosition(shades[i].ID, percentage);
if (payload.toInt() > shades[i].lastPosition) {
publishState(shades[i].name, "opening");
} else if (payload.toInt() < shades[i].lastPosition) {
publishState(shades[i].name, "closing");
}
startFetchingPosition(shades[i].ID, payload.toInt());
}
}
}
bool checkPosition(uint16_t shadeID) {
for (size_t i = 0; i < shades.size(); i++) {
if (shades[i].ID == shadeID) {
// 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 (shades[i].lastTargetPosition != shades[i].lastPosition || shades[i].lastPosition == -1 || shades[i].lastTargetPosition == -1) {
// Keep fetching position if the count limits have not been reached
if (shades[i].positionFetchCount < MAX_FETCH_COUNT && shades[i].samePositionCount < 2) {
shades[i].positionFetchCount++;
sendFetchPosition(shadeID);
return true;
}
}
publishState(shades[i].name, shades[i].lastPosition > 0 ? "open" : "closed");
return false;
}
}
return false;
}
void startFetchingPosition(uint16_t shadeID, int8_t targetPosition) {
for (size_t i = 0; i < shades.size(); i++) {
if (shades[i].ID == shadeID) {
// Cancel any existing timer
if (shades[i].timer != nullptr) {
timer.cancel(shades[i].timer);
shades[i].timer = nullptr;
}
shades[i].lastTargetPosition = targetPosition;
shades[i].positionFetchCount = 0;
shades[i].samePositionCount = 1;
shades[i].timer = timer.every(2000, checkPosition, shades[i].ID);
}
}
}
void publishState(const String& shadeName, const String& state) {
client.publish("hotdog/" + shadeName + "/state", state, true);
}
void publishPosition(const String& shadeName, const uint8_t position) {
client.publish("hotdog/" + shadeName + "/position", String(position), true);
}
void publishBattery(const String& shadeName, const uint8_t battery) {
client.publish("hotdog/" + shadeName + "/battery", String(battery), true);
}
char jsonBuffer[1024];
void addDeviceObject(JsonDocument &doc, const Shade& shade) {
String deviceID = "hotdog-" + String(shade.ID, HEX);
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 publishCoverDiscoveryTopic(const Shade& shade) {
String objectID = String(shade.ID, HEX);
String entityID = "cover-" + objectID;
JsonDocument doc;
doc["name"] = nullptr;
doc["unique_id"] = entityID;
doc["availability_topic"] = "hotdog/availability";
doc["state_topic"] = "hotdog/" + shade.name + "/state";
doc["command_topic"] = "hotdog/" + shade.name + "/command";
doc["position_topic"] = "hotdog/" + shade.name + "/position";
doc["set_position_topic"] = "hotdog/" + shade.name + "/set_position";
doc["position_open"] = 100;
doc["position_closed"] = 0;
doc["optimistic"] = false;
addDeviceObject(doc, shade);
serializeJson(doc, jsonBuffer);
client.publish("homeassistant/cover/" + objectID + "/config", jsonBuffer);
}
void publishDiscoveryTopics() {
for (size_t i = 0; i < shades.size(); i++) {
Shade shade = shades[i];
String objectID = String(shade.ID, HEX);
publishCoverDiscoveryTopic(shade);
}
}