Merge pull request #119 from cjkas/test/config-records

test: shades.cfg, backups, rolling codes, firmware update and MQTT discovery
This commit is contained in:
cjkas 2026-10-29 18:08:20 +01:00 • committed by GitHub
commit ddbe106d8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2455 additions and 1084 deletions

View file

@ -132,7 +132,7 @@ build_flags =
platform = native
test_framework = unity
test_build_src = yes
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ItemSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp> +<ProtoEdits.cpp> +<ProtoSettings.cpp> +<ProtoAssembly.cpp>
build_src_filter = -<*> +<Utils.cpp> +<ConfigRecords.cpp> +<MqttDisco.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ItemSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp> +<ProtoEdits.cpp> +<ProtoSettings.cpp> +<ProtoAssembly.cpp>
lib_deps =
bblanchon/ArduinoJson@^7.2.2
build_flags =

File diff suppressed because it is too large Load diff

View file

@ -3,72 +3,41 @@
#include "Somfy.h"
#include "ConfigSettings.h"
#include "ConfigHeader.h"
#include "ConfigRecords.h"
#ifndef configfile_h
#define configfile_h
#define CFG_VALUE_SEP ','
#define CFG_REC_END '\n'
#define CFG_TOK_NONE 0x00
#define CFG_TOK_QUOTE '"'
class ConfigFile {
// Opens shades.cfg or a backup and writes or reads its values
// (ConfigRecords.h), from the file or into a String in RAM.
class ConfigFile : public ConfigReader, public ConfigWriter {
protected:
File file;
String *_ramBuf = nullptr;
bool readOnly = false;
bool begin(const char *filename, bool readOnly = false);
uint32_t startRecPos = 0;
bool _opened = false;
public:
config_header_t header;
bool beginRAM(String *buf);
void end();
bool isOpen();
bool seekRecordByIndex(uint16_t ndx);
bool readHeader();
bool seekChar(const char val);
bool writeHeader(const config_header_t &header);
bool writeHeader();
bool writeSeparator();
bool writeRecordEnd();
bool writeChar(const char val);
bool writeInt8(const int8_t val, const char tok = CFG_VALUE_SEP);
bool writeUInt8(const uint8_t val, const char tok = CFG_VALUE_SEP);
bool writeUInt16(const uint16_t val, const char tok = CFG_VALUE_SEP);
bool writeUInt32(const uint32_t val, const char tok = CFG_VALUE_SEP);
bool writeBool(const bool val, const char tok = CFG_VALUE_SEP);
bool writeFloat(const float val, const uint8_t prec, const char tok = CFG_VALUE_SEP);
bool readString(char *buff, size_t len);
bool readVarString(char *buff, size_t len);
bool skipValue(size_t len);
bool writeString(const char *val, size_t len, const char tok = CFG_VALUE_SEP);
bool writeVarString(const char *val, const char tok = CFG_VALUE_SEP);
char readChar(const char defVal = '\0');
int8_t readInt8(const int8_t defVal = 0);
uint8_t readUInt8(const uint8_t defVal = 0);
uint16_t readUInt16(const uint16_t defVal = 0);
uint32_t readUInt32(const uint32_t defVal = 0);
bool readBool(const bool defVal = false);
float readFloat(const float defVal = 0.00);
bool readByte(uint8_t &val) override;
uint32_t position() override;
bool writeBytes(const uint8_t *buf, size_t len) override;
};
class ShadeConfigFile : public ConfigFile {
protected:
bool writeRepeaterRecord(SomfyShadeController *s);
bool writeRoomRecord(SomfyRoom *room);
bool writeShadeRecord(SomfyShade *shade);
bool writeGroupRecord(SomfyGroup *group);
bool writeSettingsRecord();
bool writeNetRecord();
bool writeTransRecord(transceiver_config_t &cfg);
bool readRepeaterRecord(SomfyShadeController *s);
bool readRoomRecord(SomfyRoom *room);
bool readShadeRecord(SomfyShade *shade);
bool readGroupRecord(SomfyGroup *group);
bool readSettingsRecord();
bool readNetRecord(restore_options_t &opts);
bool readTransRecord(transceiver_config_t &cfg);
// A restore takes the rolling codes in the file (rollingCodeAfterLoad).
bool restoring = false;
bool writeModel(SomfyShadeController *s);
void readModel(SomfyShadeController *s);
void readRoom(SomfyRoom *room);
void readShade(SomfyShade *shade);
void readGroup(SomfyGroup *group);
void readRepeaters(SomfyShadeController *s);
public:
static bool exists();
static bool load(SomfyShadeController *somfy, const char *filename = "/shades.cfg");

490
src/ConfigRecords.cpp Normal file
View file

@ -0,0 +1,490 @@
#include "ConfigRecords.h"
#include "Utils.h"
bool ConfigReader::next(uint8_t &val) {
if(!this->readByte(val)) return false;
this->_last = val;
return true;
}
void ConfigReader::endRecord() {
if(!this->atRecordEnd()) this->seekChar(CFG_REC_END);
}
bool ConfigReader::seekChar(const char val) {
uint8_t ch;
do {
if(!this->next(ch)) return false;
} while(ch != static_cast<uint8_t>(val));
return true;
}
bool ConfigReader::readString(char *buff, size_t len) {
memset(buff, 0x00, len);
if(this->atRecordEnd()) return false;
size_t i = 0;
uint8_t val;
while(this->next(val)) {
if(val == CFG_REC_END || val == CFG_VALUE_SEP) {
_rtrim(buff);
return true;
}
if(i < len - 1) buff[i++] = val;
}
return false;
}
bool ConfigReader::readFixedString(char *buff, size_t len) {
memset(buff, 0x00, len);
if(this->atRecordEnd()) return false;
uint8_t val;
for(size_t i = 0; i < len - 1; i++) {
if(!this->next(val)) return false;
if(val == CFG_REC_END) {
_rtrim(buff);
return true;
}
buff[i] = val;
}
// The separator after the padding.
if(!this->next(val)) return false;
_rtrim(buff);
return true;
}
bool ConfigReader::readVarString(char *buff, size_t len) {
memset(buff, 0x00, len);
if(this->atRecordEnd()) return false;
uint8_t quotes = 0;
size_t i = 0;
uint8_t val;
while(this->next(val)) {
if(val == CFG_REC_END || (val == CFG_VALUE_SEP && quotes >= 2)) {
_rtrim(buff);
return true;
}
if(val == CFG_TOK_QUOTE) {
quotes++;
continue;
}
if(i < len - 1) buff[i++] = val;
}
return false;
}
int8_t ConfigReader::readInt8(const int8_t defVal) {
char buff[5];
if(this->readString(buff, sizeof(buff))) return static_cast<int8_t>(atoi(buff));
return defVal;
}
uint8_t ConfigReader::readUInt8(const uint8_t defVal) {
char buff[4];
if(this->readString(buff, sizeof(buff))) return static_cast<uint8_t>(atoi(buff));
return defVal;
}
uint16_t ConfigReader::readUInt16(const uint16_t defVal) {
char buff[6];
if(this->readString(buff, sizeof(buff))) return static_cast<uint16_t>(atoi(buff));
return defVal;
}
uint32_t ConfigReader::readUInt32(const uint32_t defVal) {
char buff[11];
if(this->readString(buff, sizeof(buff))) return static_cast<uint32_t>(strtoul(buff, nullptr, 10));
return defVal;
}
float ConfigReader::readFloat(const float defVal) {
char buff[25];
if(this->readString(buff, sizeof(buff))) return atof(buff);
return defVal;
}
bool ConfigReader::readBool(const bool defVal) {
char buff[6];
if(this->readString(buff, sizeof(buff))) {
switch(buff[0]) {
case 't':
case 'T':
case '1':
return true;
default:
return false;
}
}
return defVal;
}
bool ConfigWriter::writeChar(const char val) {
const uint8_t b = static_cast<uint8_t>(val);
return this->writeBytes(&b, 1);
}
bool ConfigWriter::writeString(const char *val, size_t len, const char tok) {
size_t slen = strlen(val);
if(slen > 0 && !this->writeBytes(reinterpret_cast<const uint8_t *>(val), slen)) return false;
// Pad the value to its fixed width.
while(slen + 1 < len) {
if(!this->writeChar(' ')) return false;
slen++;
}
if(tok != CFG_TOK_NONE) return this->writeChar(tok);
return true;
}
bool ConfigWriter::writeVarString(const char *val, const char tok) {
this->writeChar(CFG_TOK_QUOTE);
const size_t slen = strlen(val);
if(slen > 0 && !this->writeBytes(reinterpret_cast<const uint8_t *>(val), slen)) return false;
this->writeChar(CFG_TOK_QUOTE);
if(tok != CFG_TOK_NONE) return this->writeChar(tok);
return true;
}
bool ConfigWriter::writeInt8(const int8_t val, const char tok) {
char buff[5];
snprintf(buff, sizeof(buff), "%4d", val);
return this->writeString(buff, sizeof(buff), tok);
}
bool ConfigWriter::writeUInt8(const uint8_t val, const char tok) {
char buff[4];
snprintf(buff, sizeof(buff), "%3u", val);
return this->writeString(buff, sizeof(buff), tok);
}
bool ConfigWriter::writeUInt16(const uint16_t val, const char tok) {
char buff[6];
snprintf(buff, sizeof(buff), "%5u", val);
return this->writeString(buff, sizeof(buff), tok);
}
bool ConfigWriter::writeUInt32(const uint32_t val, const char tok) {
char buff[11];
snprintf(buff, sizeof(buff), "%10u", static_cast<unsigned>(val));
return this->writeString(buff, sizeof(buff), tok);
}
bool ConfigWriter::writeFloat(const float val, const uint8_t prec, const char tok) {
char buff[20];
snprintf(buff, sizeof(buff), "%*.*f", 7 + prec, prec, val);
return this->writeString(buff, 8 + prec, tok);
}
bool ConfigWriter::writeBool(const bool val, const char tok) {
return this->writeString(val ? "true" : "false", 6, tok);
}
bool writeConfigHeader(ConfigWriter &w, const config_header_t &header) {
w.writeUInt8(header.version);
w.writeUInt8(header.length);
w.writeUInt16(header.roomRecordSize);
w.writeUInt8(header.roomRecords);
w.writeUInt16(header.shadeRecordSize);
w.writeUInt8(header.shadeRecords);
w.writeUInt16(header.groupRecordSize);
w.writeUInt8(header.groupRecords);
w.writeUInt16(header.repeaterRecordSize);
w.writeUInt8(header.repeaterRecords);
w.writeUInt16(header.settingsRecordSize);
w.writeUInt16(header.netRecordSize);
w.writeUInt16(header.transRecordSize);
return w.writeString(header.serverId, sizeof(header.serverId), CFG_REC_END);
}
bool readConfigHeader(ConfigReader &r, config_header_t &header) {
r.beginRecord();
header.version = r.readUInt8(header.version);
header.length = r.readUInt8(0);
if(header.version >= 19) {
header.roomRecordSize = r.readUInt16(header.roomRecordSize);
header.roomRecords = r.readUInt8(header.roomRecords);
}
if(header.version >= 13) header.shadeRecordSize = r.readUInt16(header.shadeRecordSize);
else header.shadeRecordSize = r.readUInt8(static_cast<uint8_t>(header.shadeRecordSize));
header.shadeRecords = r.readUInt8(header.shadeRecords);
if(header.version > 10) {
if(header.version >= 13) header.groupRecordSize = r.readUInt16(header.groupRecordSize);
else header.groupRecordSize = r.readUInt8(header.groupRecordSize);
header.groupRecords = r.readUInt8(header.groupRecords);
}
if(header.version >= 21) {
header.repeaterRecordSize = r.readUInt16(header.repeaterRecordSize);
header.repeaterRecords = r.readUInt8(header.repeaterRecords);
}
if(header.version > 13) {
header.settingsRecordSize = r.readUInt16(header.settingsRecordSize);
header.netRecordSize = r.readUInt16(header.netRecordSize);
header.transRecordSize = r.readUInt16(header.transRecordSize);
r.readString(header.serverId, sizeof(header.serverId));
}
return true;
}
bool writeRoomRecord(ConfigWriter &w, const room_record_t &rec) {
w.writeUInt8(rec.roomId);
w.writeString(rec.name, sizeof(rec.name));
return w.writeUInt8(static_cast<uint8_t>(rec.sortOrder), CFG_REC_END);
}
bool readRoomRecord(ConfigReader &r, room_record_t &rec) {
r.beginRecord();
rec.roomId = r.readUInt8(0);
r.readFixedString(rec.name, sizeof(rec.name));
rec.sortOrder = static_cast<int8_t>(r.readUInt8(rec.roomId - 1));
r.endRecord();
return true;
}
bool writeShadeRecord(ConfigWriter &w, const shade_record_t &rec) {
w.writeUInt8(rec.shadeId);
w.writeBool(rec.paired);
w.writeUInt8(static_cast<uint8_t>(rec.shadeType));
w.writeUInt32(rec.remoteAddress);
w.writeString(rec.name, sizeof(rec.name));
w.writeUInt8(static_cast<uint8_t>(rec.tiltType));
w.writeUInt8(static_cast<uint8_t>(rec.proto));
w.writeUInt8(rec.bitLength);
w.writeUInt32(rec.upTime);
w.writeUInt32(rec.downTime);
w.writeUInt32(rec.tiltTime);
w.writeUInt16(rec.stepSize);
for(uint8_t j = 0; j < SOMFY_MAX_LINKED_REMOTES; j++) w.writeUInt32(rec.linkedRemotes[j]);
w.writeUInt16(rec.lastRollingCode);
if(rec.shadeId != 255) {
w.writeUInt8(rec.flags);
w.writeFloat(rec.myPos, 5);
w.writeFloat(rec.myTiltPos, 5);
w.writeFloat(rec.currentPos, 5);
w.writeFloat(rec.currentTiltPos, 5);
}
else {
// A deleted shade is written cleared.
w.writeUInt8(0);
w.writeFloat(-1.0f, 5);
w.writeFloat(-1.0f, 5);
w.writeFloat(0.0f, 5);
w.writeFloat(0.0f, 5);
}
w.writeBool(rec.flipCommands);
w.writeBool(rec.flipPosition);
w.writeUInt8(rec.repeats);
w.writeUInt8(static_cast<uint8_t>(rec.sortOrder));
w.writeUInt8(rec.gpioUp);
w.writeUInt8(rec.gpioDown);
w.writeUInt8(rec.gpioMy);
w.writeUInt8(rec.gpioFlags);
return w.writeUInt8(rec.roomId, CFG_REC_END);
}
bool readShadeRecord(ConfigReader &r, const config_header_t &header, shade_record_t &rec) {
const uint8_t ver = header.version;
r.beginRecord();
rec.shadeId = r.readUInt8(255);
rec.paired = r.readBool(false);
rec.shadeType = static_cast<shade_types>(r.readUInt8(0));
rec.remoteAddress = r.readUInt32(0);
r.readFixedString(rec.name, sizeof(rec.name));
if(ver < 3) rec.tiltType = r.readBool(false) ? tilt_types::none : tilt_types::tiltmotor;
else rec.tiltType = static_cast<tilt_types>(r.readUInt8(0));
if(ver > 6) rec.proto = static_cast<radio_proto>(r.readUInt8(0));
if(ver > 1) rec.bitLength = r.readUInt8(56);
rec.upTime = r.readUInt32(rec.upTime);
rec.downTime = r.readUInt32(rec.downTime);
rec.tiltTime = r.readUInt32(rec.tiltTime);
if(ver > 5) rec.stepSize = r.readUInt16(100);
// Before version 5 a shade had 5 linked remotes.
const uint8_t remotes = ver < 5 ? 5 : SOMFY_MAX_LINKED_REMOTES;
for(uint8_t j = 0; j < remotes; j++) rec.linkedRemotes[j] = r.readUInt32(0);
rec.lastRollingCode = r.readUInt16(0);
if(ver > 7) rec.flags = r.readUInt8(0);
if(ver < 4) rec.myPos = static_cast<float>(r.readUInt8(255));
else {
rec.myPos = r.readFloat(-1);
rec.myTiltPos = r.readFloat(-1);
}
if(rec.myPos > 100 || rec.myPos < 0) rec.myPos = -1;
if(rec.myTiltPos > 100 || rec.myTiltPos < 0) rec.myTiltPos = -1;
rec.currentPos = r.readFloat(0);
rec.currentTiltPos = r.readFloat(0);
if(rec.tiltType == tilt_types::none || rec.shadeType != shade_types::blind) {
rec.myTiltPos = -1;
rec.currentTiltPos = 0;
rec.tiltType = tilt_types::none;
}
if(ver < 3) {
// Positions were fractions.
rec.currentPos = rec.currentPos * 100;
rec.currentTiltPos = rec.currentTiltPos * 100;
}
if(ver >= 9) rec.flipCommands = r.readBool(false);
if(ver >= 10) rec.flipPosition = r.readBool(false);
if(ver >= 12) rec.repeats = r.readUInt8(1);
if(ver >= 13) rec.sortOrder = static_cast<int8_t>(r.readUInt8(rec.shadeId - 1));
else rec.sortOrder = static_cast<int8_t>(rec.shadeId - 1);
if(ver > 14) {
rec.gpioUp = r.readUInt8(rec.gpioUp);
rec.gpioDown = r.readUInt8(rec.gpioDown);
}
if(ver > 15) rec.gpioMy = r.readUInt8(rec.gpioMy);
if(ver > 16) rec.gpioFlags = r.readUInt8(rec.gpioFlags);
if(rec.shadeId != 255 && rec.tiltType == tilt_types::tiltonly) {
// A tilt only shade is always all the way down.
rec.currentPos = 100.0f;
rec.myPos = 100.0f;
}
if(ver >= 19) rec.roomId = r.readUInt8(0);
r.endRecord();
return true;
}
bool writeGroupRecord(ConfigWriter &w, const group_record_t &rec) {
w.writeUInt8(rec.groupId);
w.writeUInt8(static_cast<uint8_t>(rec.groupType));
w.writeUInt32(rec.remoteAddress);
w.writeString(rec.name, sizeof(rec.name));
w.writeUInt8(static_cast<uint8_t>(rec.proto));
w.writeUInt8(rec.bitLength);
for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) w.writeUInt8(rec.linkedShades[j]);
w.writeUInt8(rec.repeats);
w.writeUInt8(static_cast<uint8_t>(rec.sortOrder));
w.writeBool(rec.flipCommands);
w.writeUInt8(rec.roomId);
return w.writeUInt16(rec.lastRollingCode, CFG_REC_END);
}
bool readGroupRecord(ConfigReader &r, const config_header_t &header, group_record_t &rec) {
const uint8_t ver = header.version;
r.beginRecord();
rec.groupId = r.readUInt8(255);
rec.groupType = static_cast<group_types>(r.readUInt8(0));
rec.remoteAddress = r.readUInt32(0);
r.readFixedString(rec.name, sizeof(rec.name));
rec.proto = static_cast<radio_proto>(r.readUInt8(0));
rec.bitLength = r.readUInt8(56);
// Version 23 put the rolling code here, version 24 at the end.
if(ver == 23) rec.lastRollingCode = r.readUInt16(0);
rec.hasRollingCode = ver >= 23;
memset(rec.linkedShades, 0x00, sizeof(rec.linkedShades));
uint8_t linked = 0;
for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) {
const uint8_t shadeId = r.readUInt8(0);
if(shadeId > 0) rec.linkedShades[linked++] = shadeId;
}
if(ver >= 12) rec.repeats = r.readUInt8(1);
if(ver >= 13) rec.sortOrder = static_cast<int8_t>(r.readUInt8(rec.groupId - 1));
else rec.sortOrder = static_cast<int8_t>(rec.groupId - 1);
if(ver >= 18) rec.flipCommands = r.readBool(false);
if(ver >= 19) rec.roomId = r.readUInt8(0);
if(ver >= 24) rec.lastRollingCode = r.readUInt16(0);
r.endRecord();
return true;
}
bool writeRepeaterRecord(ConfigWriter &w, const uint32_t (&repeaters)[SOMFY_MAX_REPEATERS]) {
for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++)
w.writeUInt32(repeaters[i], i == SOMFY_MAX_REPEATERS - 1 ? CFG_REC_END : CFG_VALUE_SEP);
return true;
}
bool readRepeaterRecord(ConfigReader &r, uint32_t (&repeaters)[SOMFY_MAX_REPEATERS]) {
r.beginRecord();
for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) repeaters[i] = r.readUInt32(0);
r.endRecord();
return true;
}
bool writeSettingsRecord(ConfigWriter &w, const settings_record_t &rec) {
w.writeVarString(rec.fwVersion);
w.writeVarString(rec.hostname);
w.writeVarString(rec.ntpServer);
w.writeVarString(rec.posixZone);
w.writeBool(rec.ssdpBroadcast);
return w.writeBool(rec.checkForUpdate, CFG_REC_END);
}
bool readSettingsRecord(ConfigReader &r, const config_header_t &header, settings_record_t &rec) {
r.beginRecord();
r.readVarString(rec.fwVersion, sizeof(rec.fwVersion));
r.readVarString(rec.hostname, sizeof(rec.hostname));
r.readVarString(rec.ntpServer, sizeof(rec.ntpServer));
r.readVarString(rec.posixZone, sizeof(rec.posixZone));
rec.ssdpBroadcast = r.readBool(false);
if(header.version >= 20) rec.checkForUpdate = r.readBool(true);
r.endRecord();
return true;
}
bool writeNetRecord(ConfigWriter &w, const net_record_t &rec, bool withEthernet) {
w.writeUInt8(rec.connType);
w.writeBool(rec.dhcp);
w.writeVarString(rec.ip);
w.writeVarString(rec.gateway);
w.writeVarString(rec.subnet);
w.writeVarString(rec.dns1);
w.writeVarString(rec.dns2);
w.writeVarString(rec.mqttProtocol);
w.writeVarString(rec.mqttHostname);
w.writeUInt16(rec.mqttPort);
w.writeBool(rec.mqttPubDisco);
w.writeVarString(rec.mqttRootTopic);
w.writeVarString(rec.mqttDiscoTopic);
if(!withEthernet) return w.writeUInt8(0, CFG_REC_END);
w.writeUInt8(rec.ethBoardType);
w.writeUInt8(rec.ethPhyType);
w.writeUInt8(rec.ethClkMode);
w.writeInt8(rec.ethPhyAddress);
w.writeInt8(rec.ethPWRPin);
w.writeInt8(rec.ethMDCPin);
return w.writeInt8(rec.ethMDIOPin, CFG_REC_END);
}
bool readNetRecord(ConfigReader &r, const config_header_t &header, net_record_t &rec, bool withEthernet) {
r.beginRecord();
rec.connType = r.readUInt8(0);
rec.dhcp = r.readBool(true);
r.readVarString(rec.ip, sizeof(rec.ip));
r.readVarString(rec.gateway, sizeof(rec.gateway));
r.readVarString(rec.subnet, sizeof(rec.subnet));
r.readVarString(rec.dns1, sizeof(rec.dns1));
r.readVarString(rec.dns2, sizeof(rec.dns2));
if(header.version >= 22) {
r.readVarString(rec.mqttProtocol, sizeof(rec.mqttProtocol));
r.readVarString(rec.mqttHostname, sizeof(rec.mqttHostname));
rec.mqttPort = r.readUInt16(1883);
rec.mqttPubDisco = r.readBool(false);
r.readVarString(rec.mqttRootTopic, sizeof(rec.mqttRootTopic));
r.readVarString(rec.mqttDiscoTopic, sizeof(rec.mqttDiscoTopic));
}
if(withEthernet) {
rec.ethBoardType = r.readUInt8(1);
rec.ethPhyType = r.readUInt8(0);
rec.ethClkMode = r.readUInt8(0);
rec.ethPhyAddress = r.readInt8(1);
rec.ethPWRPin = r.readInt8(1);
rec.ethMDCPin = r.readInt8(16);
rec.ethMDIOPin = r.readInt8(23);
}
r.endRecord();
return true;
}
bool writeTransRecord(ConfigWriter &w, const trans_record_t &rec) {
w.writeBool(rec.enabled);
w.writeUInt8(static_cast<uint8_t>(rec.proto));
w.writeUInt8(rec.type);
w.writeUInt8(rec.SCKPin);
w.writeUInt8(rec.CSNPin);
w.writeUInt8(rec.MOSIPin);
w.writeUInt8(rec.MISOPin);
w.writeUInt8(rec.TXPin);
w.writeUInt8(rec.RXPin);
w.writeFloat(rec.frequency, 3);
w.writeFloat(rec.rxBandwidth, 2);
w.writeFloat(rec.deviation, 2);
w.writeInt8(rec.txPower);
return w.writeBool(rec.noiseDetection, CFG_REC_END);
}
bool readTransRecord(ConfigReader &r, trans_record_t &rec) {
r.beginRecord();
rec.enabled = r.readBool(false);
rec.proto = static_cast<radio_proto>(r.readUInt8(0));
rec.type = r.readUInt8(56);
rec.SCKPin = r.readUInt8(rec.SCKPin);
rec.CSNPin = r.readUInt8(rec.CSNPin);
rec.MOSIPin = r.readUInt8(rec.MOSIPin);
rec.MISOPin = r.readUInt8(rec.MISOPin);
rec.TXPin = r.readUInt8(rec.TXPin);
rec.RXPin = r.readUInt8(rec.RXPin);
rec.frequency = r.readFloat(rec.frequency);
rec.rxBandwidth = r.readFloat(rec.rxBandwidth);
rec.deviation = r.readFloat(rec.deviation);
rec.txPower = r.readInt8(rec.txPower);
rec.noiseDetection = r.readBool(false);
r.endRecord();
return true;
}
bool skipRecords(ConfigReader &r, uint32_t count) {
for(uint32_t i = 0; i < count; i++) {
r.beginRecord();
if(!r.seekChar(CFG_REC_END)) return false;
}
return true;
}

216
src/ConfigRecords.h Normal file
View file

@ -0,0 +1,216 @@
#ifndef CONFIG_RECORDS_H
#define CONFIG_RECORDS_H
#include <Arduino.h>
#include "ConfigHeader.h"
#include "ShadeTypes.h"
#include "SomfyFrame.h"
// The records of shades.cfg and of a backup, and how they are written and
// read. The file is text: each value is padded to a fixed width and followed
// by a comma, strings that vary in length are quoted, and each record ends
// with a newline. Older versions of the file lack the values added since,
// and read as their defaults. Kept free of the file system and the shade
// model so every version can be unit tested on the PC (pio test -e native).
#define CFG_VALUE_SEP ','
#define CFG_REC_END '\n'
#define CFG_TOK_NONE 0x00
#define CFG_TOK_QUOTE '"'
#define SHADE_HDR_VER 24
#define SHADE_HDR_SIZE 76
#define SHADE_REC_SIZE 276
#define GROUP_REC_SIZE 200
#define ROOM_REC_SIZE 29
#define REPEATER_REC_SIZE 77
// Files written before 4.0 declare the transceiver record 74 bytes: it was
// left there when noiseDetection was added.
#define TRANS_REC_SIZE 80
// The sizes of the strings, which are those of the fields they are read into.
#define CFG_NAME_LEN 21
#define CFG_VERSION_LEN 24
#define CFG_HOSTNAME_LEN 32
#define CFG_NTP_SERVER_LEN 65
#define CFG_POSIX_ZONE_LEN 64
#define CFG_IP_LEN 24
#define CFG_MQTT_PROTOCOL_LEN 10
#define CFG_MQTT_HOSTNAME_LEN 65
#define CFG_MQTT_TOPIC_LEN 65
// Reads the values of a file. A record's values past its end read as their
// defaults, so a record shorter than expected never reads into the next one.
class ConfigReader {
private:
uint8_t _last = 0;
bool next(uint8_t &val);
public:
virtual ~ConfigReader() {}
// The next byte of the file; false at its end.
virtual bool readByte(uint8_t &val) = 0;
virtual uint32_t position() = 0;
void beginRecord() { _last = 0; }
// Moves past the rest of the record: the values a newer version added.
void endRecord();
bool atRecordEnd() const { return _last == CFG_REC_END; }
bool seekChar(const char val);
// A value up to the next separator, cut to fit buff.
bool readString(char *buff, size_t len);
// A value padded to len - 1 bytes, read whole so it may hold commas: a
// name.
bool readFixedString(char *buff, size_t len);
// A quoted value of any length, cut to fit buff.
bool readVarString(char *buff, size_t len);
int8_t readInt8(const int8_t defVal = 0);
uint8_t readUInt8(const uint8_t defVal = 0);
uint16_t readUInt16(const uint16_t defVal = 0);
uint32_t readUInt32(const uint32_t defVal = 0);
bool readBool(const bool defVal = false);
float readFloat(const float defVal = 0.00);
};
class ConfigWriter {
public:
virtual ~ConfigWriter() {}
virtual bool writeBytes(const uint8_t *buf, size_t len) = 0;
bool writeChar(const char val);
bool writeString(const char *val, size_t len, const char tok = CFG_VALUE_SEP);
bool writeVarString(const char *val, const char tok = CFG_VALUE_SEP);
bool writeInt8(const int8_t val, const char tok = CFG_VALUE_SEP);
bool writeUInt8(const uint8_t val, const char tok = CFG_VALUE_SEP);
bool writeUInt16(const uint16_t val, const char tok = CFG_VALUE_SEP);
bool writeUInt32(const uint32_t val, const char tok = CFG_VALUE_SEP);
bool writeBool(const bool val, const char tok = CFG_VALUE_SEP);
bool writeFloat(const float val, const uint8_t prec, const char tok = CFG_VALUE_SEP);
};
// Counts what would be written: the size of a record that varies in length.
class ConfigCounter : public ConfigWriter {
public:
uint32_t bytes = 0;
bool writeBytes(const uint8_t *buf, size_t len) override { (void)buf; bytes += len; return true; }
};
struct room_record_t {
uint8_t roomId = 0;
char name[CFG_NAME_LEN] = "";
int8_t sortOrder = 0;
};
struct shade_record_t {
uint8_t shadeId = 255;
bool paired = false;
shade_types shadeType = shade_types::roller;
uint32_t remoteAddress = 0;
char name[CFG_NAME_LEN] = "";
tilt_types tiltType = tilt_types::none;
radio_proto proto = radio_proto::RTS;
uint8_t bitLength = 56;
uint32_t upTime = 10000;
uint32_t downTime = 10000;
uint32_t tiltTime = 7000;
uint16_t stepSize = 100;
uint32_t linkedRemotes[SOMFY_MAX_LINKED_REMOTES] = {};
uint16_t lastRollingCode = 0;
uint8_t flags = 0;
float myPos = -1;
float myTiltPos = -1;
float currentPos = 0;
float currentTiltPos = 0;
bool flipCommands = false;
bool flipPosition = false;
uint8_t repeats = 1;
int8_t sortOrder = 0;
uint8_t gpioUp = 0;
uint8_t gpioDown = 0;
uint8_t gpioMy = 0;
uint8_t gpioFlags = 0;
uint8_t roomId = 0;
};
struct group_record_t {
uint8_t groupId = 255;
group_types groupType = group_types::channel;
uint32_t remoteAddress = 0;
char name[CFG_NAME_LEN] = "";
radio_proto proto = radio_proto::RTS;
uint8_t bitLength = 56;
// Read without the gaps the file may have.
uint8_t linkedShades[SOMFY_MAX_GROUPED_SHADES] = {};
uint8_t repeats = 1;
int8_t sortOrder = 0;
bool flipCommands = false;
uint8_t roomId = 0;
uint16_t lastRollingCode = 0;
// Versions before 23 have none.
bool hasRollingCode = false;
};
struct settings_record_t {
// The firmware that wrote the backup. Not restored.
char fwVersion[CFG_VERSION_LEN] = "";
char hostname[CFG_HOSTNAME_LEN] = "";
char ntpServer[CFG_NTP_SERVER_LEN] = "";
char posixZone[CFG_POSIX_ZONE_LEN] = "";
bool ssdpBroadcast = false;
bool checkForUpdate = true;
};
struct net_record_t {
uint8_t connType = 0;
bool dhcp = true;
char ip[CFG_IP_LEN] = "";
char gateway[CFG_IP_LEN] = "";
char subnet[CFG_IP_LEN] = "";
char dns1[CFG_IP_LEN] = "";
char dns2[CFG_IP_LEN] = "";
char mqttProtocol[CFG_MQTT_PROTOCOL_LEN] = "";
char mqttHostname[CFG_MQTT_HOSTNAME_LEN] = "";
uint16_t mqttPort = 1883;
bool mqttPubDisco = false;
char mqttRootTopic[CFG_MQTT_TOPIC_LEN] = "";
char mqttDiscoTopic[CFG_MQTT_TOPIC_LEN] = "";
// Only boards with Ethernet write these, and only the board that wrote
// them reads them back.
uint8_t ethBoardType = 1;
uint8_t ethPhyType = 0;
uint8_t ethClkMode = 0;
int8_t ethPhyAddress = 1;
int8_t ethPWRPin = 1;
int8_t ethMDCPin = 16;
int8_t ethMDIOPin = 23;
};
struct trans_record_t {
bool enabled = false;
radio_proto proto = radio_proto::RTS;
uint8_t type = 56;
uint8_t SCKPin = 18;
uint8_t CSNPin = 5;
uint8_t MOSIPin = 23;
uint8_t MISOPin = 19;
uint8_t TXPin = 13;
uint8_t RXPin = 12;
float frequency = 433.42;
float rxBandwidth = 99.97;
float deviation = 47.60;
int8_t txPower = 10;
bool noiseDetection = false;
};
// The readers leave alone what the record's version lacks, so a record that
// starts as the shade, group or settings it is read into keeps their values.
bool writeConfigHeader(ConfigWriter &w, const config_header_t &header);
bool readConfigHeader(ConfigReader &r, config_header_t &header);
bool writeRoomRecord(ConfigWriter &w, const room_record_t &rec);
bool readRoomRecord(ConfigReader &r, room_record_t &rec);
bool writeShadeRecord(ConfigWriter &w, const shade_record_t &rec);
bool readShadeRecord(ConfigReader &r, const config_header_t &header, shade_record_t &rec);
bool writeGroupRecord(ConfigWriter &w, const group_record_t &rec);
bool readGroupRecord(ConfigReader &r, const config_header_t &header, group_record_t &rec);
bool writeRepeaterRecord(ConfigWriter &w, const uint32_t (&repeaters)[SOMFY_MAX_REPEATERS]);
bool readRepeaterRecord(ConfigReader &r, uint32_t (&repeaters)[SOMFY_MAX_REPEATERS]);
bool writeSettingsRecord(ConfigWriter &w, const settings_record_t &rec);
bool readSettingsRecord(ConfigReader &r, const config_header_t &header, settings_record_t &rec);
// withEthernet: the Ethernet settings, which a board without Ethernet leaves out.
bool writeNetRecord(ConfigWriter &w, const net_record_t &rec, bool withEthernet);
bool readNetRecord(ConfigReader &r, const config_header_t &header, net_record_t &rec, bool withEthernet);
bool writeTransRecord(ConfigWriter &w, const trans_record_t &rec);
bool readTransRecord(ConfigReader &r, trans_record_t &rec);
// Moves past count records without reading them.
bool skipRecords(ConfigReader &r, uint32_t count);
#endif

View file

@ -233,36 +233,6 @@ void ConfigSettings::print() {
if(this->connType == conn_types_t::ethernet || this->connType == conn_types_t::ethernetpref) this->Ethernet.print();
#endif
}
uint16_t ConfigSettings::calcSettingsRecSize() {
return strlen(this->fwVersion.name) + 3
+ strlen(this->hostname) + 3
+ strlen(this->NTP.ntpServer) + 3
+ strlen(this->NTP.posixZone) + 3
+ 6 // ssdpbroadcast
+ 6; // updateCheck
}
uint16_t ConfigSettings::calcNetRecSize() {
return 4 // connType
+ 6 // dhcp
+ this->IP.ip.toString().length() + 3
+ this->IP.gateway.toString().length() + 3
+ this->IP.subnet.toString().length() + 3
+ this->IP.dns1.toString().length() + 3
+ this->IP.dns2.toString().length() + 3
+ strlen(this->MQTT.protocol) + 3
+ strlen(this->MQTT.hostname) + 3
+ 6 // MQTT Port
+ 6 // PubDisco
+ strlen(this->MQTT.rootTopic) + 3
+ strlen(this->MQTT.discoTopic) + 3
+ 4 // ETH.boardType
+ 4 // ETH.phyType
+ 4 // ETH.clkMode
+ 5 // ETH.phyAddress
+ 5 // ETH.PWRPin
+ 5 // ETH.MDCPin
+ 5; // ETH.MDIOPin
}
bool MQTTSettings::begin() {
this->load();
return true;

View file

@ -189,8 +189,6 @@ class ConfigSettings: BaseSettings {
bool save();
bool load();
void print();
uint16_t calcSettingsRecSize();
uint16_t calcNetRecSize();
bool getAppVersion();
};
#endif

91
src/MqttDisco.cpp Normal file
View file

@ -0,0 +1,91 @@
#include "MqttDisco.h"
bool isDiscoSwitch(shade_types type) { return type == shade_types::drycontact || type == shade_types::drycontact2; }
void shadeDiscoTopic(const char *discoTopic, uint8_t shadeId, bool isSwitch, char *topic, size_t len) {
snprintf(topic, len, "%s/%s/%d/config", discoTopic, isSwitch ? "switch" : "cover", shadeId);
}
static const char *deviceClass(shade_types type) {
switch(type) {
case shade_types::blind:
return "blind";
case shade_types::lgate:
case shade_types::cgate:
case shade_types::rgate:
case shade_types::lgate1:
case shade_types::cgate1:
case shade_types::rgate1:
case shade_types::ldrapery:
case shade_types::rdrapery:
case shade_types::cdrapery:
return "curtain";
case shade_types::garage1:
case shade_types::garage3:
return "garage";
case shade_types::awning:
return "awning";
case shade_types::shutter:
return "shutter";
default:
return "shade";
}
}
void shadeDisco(const shade_disco_t &shade, JsonObject obj, char *topic, size_t len) {
snprintf(topic, len, "%s/shades/%d", shade.rootTopic, shade.shadeId);
obj["~"] = topic;
JsonObject dobj = obj["device"].to<JsonObject>();
dobj["hw_version"] = shade.fwVersion;
dobj["name"] = shade.hostname;
dobj["mf"] = "rstrouse";
JsonArray arrids = dobj["identifiers"].to<JsonArray>();
snprintf(topic, len, "mqtt_espsomfyrts_%s", shade.serverId);
arrids.add(topic);
dobj["model"] = "ESPSomfy-RTS MQTT";
snprintf(topic, len, "%s/status", shade.rootTopic);
obj["availability_topic"] = topic;
obj["payload_available"] = "online";
obj["payload_not_available"] = "offline";
obj["name"] = shade.name;
snprintf(topic, len, "mqtt_%s_shade%d", shade.serverId, shade.shadeId);
obj["unique_id"] = topic;
const bool isSwitch = isDiscoSwitch(shade.shadeType);
if(!isSwitch) {
obj["device_class"] = deviceClass(shade.shadeType);
// Positions count down from open: 0 is open and 100 closed, so an
// upward move (-1) opens. An awning, which opens downward, and a
// flipped position turn that around.
const bool reversed = shade.flipPosition != (shade.shadeType == shade_types::awning);
obj["payload_close"] = reversed ? "-1" : "1";
obj["payload_open"] = reversed ? "1" : "-1";
obj["position_open"] = reversed ? 100 : 0;
obj["position_closed"] = reversed ? 0 : 100;
obj["state_closing"] = reversed ? "-1" : "1";
obj["state_opening"] = reversed ? "1" : "-1";
if(shade.tiltType != tilt_types::tiltonly) {
obj["command_topic"] = "~/direction/set";
obj["position_topic"] = "~/position";
obj["set_position_topic"] = "~/target/set";
obj["state_topic"] = "~/direction";
obj["payload_stop"] = "0";
obj["state_stopped"] = "0";
}
else {
obj["payload_close"] = nullptr;
obj["payload_open"] = nullptr;
obj["payload_stop"] = nullptr;
}
if(shade.tiltType != tilt_types::none) {
obj["tilt_command_topic"] = "~/tiltTarget/set";
obj["tilt_status_topic"] = "~/tiltPosition";
}
}
else {
obj["payload_on"] = 100;
obj["payload_off"] = 0;
obj["state_off"] = 0;
obj["state_on"] = 100;
obj["state_topic"] = "~/position";
obj["command_topic"] = "~/target/set";
}
obj["enabled_by_default"] = true;
shadeDiscoTopic(shade.discoTopic, shade.shadeId, isSwitch, topic, len);
}

29
src/MqttDisco.h Normal file
View file

@ -0,0 +1,29 @@
#ifndef MQTT_DISCO_H
#define MQTT_DISCO_H
#include <ArduinoJson.h>
#include "ShadeTypes.h"
// What Home Assistant's MQTT discovery needs to know about a shade. Kept
// free of the shade model and MQTT so the config it gets can be unit tested
// on the PC (pio test -e native).
struct shade_disco_t {
uint8_t shadeId = 0;
const char *name = "";
shade_types shadeType = shade_types::roller;
tilt_types tiltType = tilt_types::none;
bool flipPosition = false;
// From the MQTT and general settings.
const char *rootTopic = "";
const char *discoTopic = "";
const char *serverId = "";
const char *hostname = "";
const char *fwVersion = "";
};
// Dry contacts are switches, the rest covers.
bool isDiscoSwitch(shade_types type);
// Where the shade's config goes: <discoTopic>/cover/<id>/config or
// <discoTopic>/switch/<id>/config.
void shadeDiscoTopic(const char *discoTopic, uint8_t shadeId, bool isSwitch, char *topic, size_t len);
// Fills obj with the shade's config and topic with where it goes.
void shadeDisco(const shade_disco_t &shade, JsonObject obj, char *topic, size_t len);
#endif

19
src/MqttTopics.h Normal file
View file

@ -0,0 +1,19 @@
#ifndef MQTT_TOPICS_H
#define MQTT_TOPICS_H
#include <stddef.h>
// The topics the broker keeps for a shade (shades/<id>/) and a group
// (groups/<id>/), which deleting one clears. Kept free of MQTT so the lists
// can be checked against what is published in the unit tests on the PC
// (pio test -e native).
inline constexpr const char *SHADE_TOPICS[] = {
"shadeId", "name", "remoteAddress", "shadeType", "tiltType", "flags", "flipCommands", "flipPosition",
"position", "direction", "target", "lastRollingCode", "mypos", "myTiltPos",
"tiltDirection", "tiltPosition", "tiltTarget", "windy", "sunny"
};
inline constexpr const char *GROUP_TOPICS[] = {
"groupId", "name", "remoteAddress", "groupType", "direction", "lastRollingCode", "flags", "sunSensor", "flipCommands"
};
inline constexpr size_t SHADE_TOPIC_COUNT = sizeof(SHADE_TOPICS) / sizeof(SHADE_TOPICS[0]);
inline constexpr size_t GROUP_TOPIC_COUNT = sizeof(GROUP_TOPICS) / sizeof(GROUP_TOPICS[0]);
#endif

View file

@ -2,7 +2,8 @@
#define ROLLING_CODE_H
#include <stdint.h>
// Working out a remote's next rolling code (src/SomfyRemote.cpp). Kept free of NVS
// Working out a remote's next rolling code (src/SomfyRemote.cpp) and the one
// it goes on with after a load (src/ConfigFile.cpp). Kept free of NVS
// so it can be unit tested on the PC (pio test -e native).
// The code the next frame uses. It is only read here and consumed once the
@ -13,4 +14,14 @@ template<typename LoadStored>
inline uint16_t nextRollingCode(bool synced, uint16_t last, LoadStored loadStored) {
return (uint16_t)((synced ? last : loadStored()) + 1);
}
// The rolling code a remote goes on with after shades.cfg or a backup is
// read. NVS holds the last code sent and shades.cfg the code when it was
// saved, so a load keeps the higher. A restore takes the backup's code even
// when it is lower. A motor ignores codes below the last one it heard, so
// after restoring an older backup to the same device it ignores the remote
// until the codes pass that one.
inline uint16_t rollingCodeAfterLoad(uint16_t stored, uint16_t inFile, bool restoring) {
if(restoring) return inFile;
return stored > inFile ? stored : inFile;
}
#endif

View file

@ -3,6 +3,8 @@
#include "Somfy.h"
#include "SomfySnapshot.h"
#include "MqttConnection.h"
#include "MqttDisco.h"
#include "MqttTopics.h"
extern SomfyShadeController somfy;
extern ConfigSettings settings;
@ -48,140 +50,30 @@ void SomfyShade::publishState() {
snapshotShade(*this, s);
publishShadeTopics(shadeStateTopics(s), s, publishShadeTopic, this);
}
// The shade's config for Home Assistant's MQTT discovery.
void SomfyShade::publishDisco() {
if(!mqtt.connected() || !settings.MQTT.pubDisco) return;
shade_disco_t disco;
disco.shadeId = this->shadeId;
disco.name = this->name;
disco.shadeType = this->shadeType;
disco.tiltType = this->tiltType;
disco.flipPosition = this->flipPosition;
disco.rootTopic = settings.MQTT.rootTopic;
disco.discoTopic = settings.MQTT.discoTopic;
disco.serverId = settings.serverId;
disco.hostname = settings.hostname;
disco.fwVersion = settings.fwVersion.name;
char topic[128] = "";
JsonDocument doc;
JsonObject obj = doc.to<JsonObject>();
snprintf(topic, sizeof(topic), "%s/shades/%d", settings.MQTT.rootTopic, this->shadeId);
obj["~"] = topic;
JsonObject dobj = obj.createNestedObject("device");
dobj["hw_version"] = settings.fwVersion.name;
dobj["name"] = settings.hostname;
dobj["mf"] = "rstrouse";
JsonArray arrids = dobj.createNestedArray("identifiers");
//snprintf(topic, sizeof(topic), "mqtt_espsomfyrts_%s_shade%d", settings.serverId, this->shadeId);
snprintf(topic, sizeof(topic), "mqtt_espsomfyrts_%s", settings.serverId);
arrids.add(topic);
//snprintf(topic, sizeof(topic), "ESPSomfy-RTS_%s", settings.serverId);
dobj["model"] = "ESPSomfy-RTS MQTT";
snprintf(topic, sizeof(topic), "%s/status", settings.MQTT.rootTopic);
obj["availability_topic"] = topic;
obj["payload_available"] = "online";
obj["payload_not_available"] = "offline";
obj["name"] = this->name;
snprintf(topic, sizeof(topic), "mqtt_%s_shade%d", settings.serverId, this->shadeId);
obj["unique_id"] = topic;
switch(this->shadeType) {
case shade_types::blind:
obj["device_class"] = "blind";
obj["payload_close"] = this->flipPosition ? "-1" : "1";
obj["payload_open"] = this->flipPosition ? "1" : "-1";
obj["position_open"] = this->flipPosition ? 100 : 0;
obj["position_closed"] = this->flipPosition ? 0 : 100;
obj["state_closing"] = this->flipPosition ? "-1" : "1";
obj["state_opening"] = this->flipPosition ? "1" : "-1";
break;
case shade_types::lgate:
case shade_types::cgate:
case shade_types::rgate:
case shade_types::lgate1:
case shade_types::cgate1:
case shade_types::rgate1:
case shade_types::ldrapery:
case shade_types::rdrapery:
case shade_types::cdrapery:
obj["device_class"] = "curtain";
obj["payload_close"] = this->flipPosition ? "-1" : "1";
obj["payload_open"] = this->flipPosition ? "1" : "-1";
obj["position_open"] = this->flipPosition ? 100 : 0;
obj["position_closed"] = this->flipPosition ? 0 : 100;
obj["state_closing"] = this->flipPosition ? "-1" : "1";
obj["state_opening"] = this->flipPosition ? "1" : "-1";
break;
case shade_types::garage1:
case shade_types::garage3:
obj["device_class"] = "garage";
obj["payload_close"] = this->flipPosition ? "-1" : "1";
obj["payload_open"] = this->flipPosition ? "1" : "-1";
obj["position_open"] = this->flipPosition ? 100 : 0;
obj["position_closed"] = this->flipPosition ? 0 : 100;
obj["state_closing"] = this->flipPosition ? "-1" : "1";
obj["state_opening"] = this->flipPosition ? "1" : "-1";
break;
case shade_types::awning:
obj["device_class"] = "awning";
obj["payload_close"] = this->flipPosition ? "1" : "-1";
obj["payload_open"] = this->flipPosition ? "-1" : "1";
obj["position_open"] = this->flipPosition ? 0 : 100;
obj["position_closed"] = this->flipPosition ? 100 : 0;
obj["state_closing"] = this->flipPosition ? "1" : "-1";
obj["state_opening"] = this->flipPosition ? "-1" : "1";
break;
case shade_types::shutter:
obj["device_class"] = "shutter";
obj["payload_close"] = this->flipPosition ? "-1" : "1";
obj["payload_open"] = this->flipPosition ? "1" : "-1";
obj["position_open"] = this->flipPosition ? 100 : 0;
obj["position_closed"] = this->flipPosition ? 0 : 100;
obj["state_closing"] = this->flipPosition ? "-1" : "1";
obj["state_opening"] = this->flipPosition ? "1" : "-1";
break;
case shade_types::drycontact2:
case shade_types::drycontact:
break;
default:
obj["device_class"] = "shade";
obj["payload_close"] = this->flipPosition ? "-1" : "1";
obj["payload_open"] = this->flipPosition ? "1" : "-1";
obj["position_open"] = this->flipPosition ? 100 : 0;
obj["position_closed"] = this->flipPosition ? 0 : 100;
obj["state_closing"] = this->flipPosition ? "-1" : "1";
obj["state_opening"] = this->flipPosition ? "1" : "-1";
break;
}
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::drycontact2) {
if(this->tiltType != tilt_types::tiltonly) {
obj["command_topic"] = "~/direction/set";
obj["position_topic"] = "~/position";
obj["set_position_topic"] = "~/target/set";
obj["state_topic"] = "~/direction";
obj["payload_stop"] = "0";
obj["state_stopped"] = "0";
}
else {
obj["payload_close"] = nullptr;
obj["payload_open"] = nullptr;
obj["payload_stop"] = nullptr;
}
if(this->tiltType != tilt_types::none) {
obj["tilt_command_topic"] = "~/tiltTarget/set";
obj["tilt_status_topic"] = "~/tiltPosition";
}
snprintf(topic, sizeof(topic), "%s/cover/%d/config", settings.MQTT.discoTopic, this->shadeId);
}
else {
obj["payload_on"] = 100;
obj["payload_off"] = 0;
obj["state_off"] = 0;
obj["state_on"] = 100;
obj["state_topic"] = "~/position";
obj["command_topic"] = "~/target/set";
snprintf(topic, sizeof(topic), "%s/switch/%d/config", settings.MQTT.discoTopic, this->shadeId);
}
obj["enabled_by_default"] = true;
mqtt.publishDisco(topic, obj, true);
shadeDisco(disco, obj, topic, sizeof(topic));
mqtt.publishDisco(topic, obj, true);
}
void SomfyShade::unpublishDisco() {
if(!mqtt.connected() || !settings.MQTT.pubDisco) return;
char topic[128] = "";
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::drycontact2) {
snprintf(topic, sizeof(topic), "%s/cover/%d/config", settings.MQTT.discoTopic, this->shadeId);
}
else
snprintf(topic, sizeof(topic), "%s/switch/%d/config", settings.MQTT.discoTopic, this->shadeId);
shadeDiscoTopic(settings.MQTT.discoTopic, this->shadeId, isDiscoSwitch(this->shadeType), topic, sizeof(topic));
mqtt.unpublish(topic);
}
void SomfyShade::publish() {
@ -227,45 +119,19 @@ void SomfyGroup::unpublish() { SomfyGroup::unpublish(this->groupId); }
void SomfyShade::unpublish() { SomfyShade::unpublish(this->shadeId); }
void SomfyShade::unpublish(uint8_t id) {
if(mqtt.connected()) {
SomfyShade::unpublish(id, "shadeId");
SomfyShade::unpublish(id, "name");
SomfyShade::unpublish(id, "remoteAddress");
SomfyShade::unpublish(id, "shadeType");
SomfyShade::unpublish(id, "tiltType");
SomfyShade::unpublish(id, "flags");
SomfyShade::unpublish(id, "flipCommands");
SomfyShade::unpublish(id, "flipPosition");
SomfyShade::unpublish(id, "position");
SomfyShade::unpublish(id, "direction");
SomfyShade::unpublish(id, "target");
SomfyShade::unpublish(id, "lastRollingCode");
SomfyShade::unpublish(id, "mypos");
SomfyShade::unpublish(id, "myTiltPos");
SomfyShade::unpublish(id, "tiltDirection");
SomfyShade::unpublish(id, "tiltPosition");
SomfyShade::unpublish(id, "tiltTarget");
SomfyShade::unpublish(id, "windy");
SomfyShade::unpublish(id, "sunny");
for(const char *topic : SHADE_TOPICS) SomfyShade::unpublish(id, topic);
if(settings.MQTT.pubDisco) {
char topic[128] = "";
snprintf(topic, sizeof(topic), "%s/cover/%d/config", settings.MQTT.discoTopic, id);
shadeDiscoTopic(settings.MQTT.discoTopic, id, false, topic, sizeof(topic));
mqtt.unpublish(topic);
snprintf(topic, sizeof(topic), "%s/switch/%d/config", settings.MQTT.discoTopic, id);
shadeDiscoTopic(settings.MQTT.discoTopic, id, true, topic, sizeof(topic));
mqtt.unpublish(topic);
}
}
}
void SomfyGroup::unpublish(uint8_t id) {
if(mqtt.connected()) {
SomfyGroup::unpublish(id, "groupId");
SomfyGroup::unpublish(id, "name");
SomfyGroup::unpublish(id, "remoteAddress");
SomfyGroup::unpublish(id, "groupType");
SomfyGroup::unpublish(id, "direction");
SomfyGroup::unpublish(id, "lastRollingCode");
SomfyGroup::unpublish(id, "flags");
SomfyGroup::unpublish(id, "SunSensor");
SomfyGroup::unpublish(id, "flipCommands");
for(const char *topic : GROUP_TOPICS) SomfyGroup::unpublish(id, topic);
}
}
void SomfyGroup::unpublish(uint8_t id, const char *topic) {

View file

@ -12,7 +12,8 @@ Runs against a real device on your network, by hand, never in CI. It checks sock
| `--password`, `--username` / `--pin` | the credentials of a Secured device |
| `--radio --shade <id>` | also send real commands: that shade moves a little and back |
| `--edit` | also add a room and delete it again |
| `--reboot` | also reboot the device and wait for it to come back, and restore the settings from a backup taken a moment before (it changes the NTP server, which the restore puts back) |
| `--reboot` | also reboot the device and wait for it to come back, and restore the settings from a backup taken a moment before (it changes the NTP server, which the restore puts back), and restore the radio settings alone |
| `--firmware <firmware.bin>` | also update the device to that firmware, built for its chip, and check it is still running a minute later, after it marks the update good. Needs a build with an OTA slot |
| `--lockout` | on a Secured device, also fail `/login` until this PC is refused, then wait out the 30 s lockout |
| `--record` | save hello, the sync and the schema to `test/fixtures/proto2/`, for the web UI and Home Assistant tests |
@ -20,4 +21,11 @@ A plain run sends nothing on air, changes no setting (it saves only values the d
The web UI holds one of the device's 5 socket slots for each open tab, and Home Assistant holds one. The tests that fill every slot fill the ones left, so close the UI's tabs for the full load.
A rollback is checked by hand, on a release build with an OTA slot:
1. Note the version the web UI shows.
2. Build firmware that restarts before it is marked good: add `abort();` at the end of `setup()` in `src/SomfyController.cpp`, and change `data-src/appversion` so the two versions differ.
3. Update to it through the web UI, with the serial monitor open.
4. The crash restarts the device, and the bootloader boots the old firmware ([ADR 0003](../../docs/adr/0003-pioarduino-idf55.md)). The web UI shows the version from step 1 again.
The tests run in whatever security mode the device is in; the suite never changes it. To test both modes, run the suite once in each.

View file

@ -19,6 +19,7 @@ def pytest_addoption(parser):
parser.addoption("--shade", type=int, default=None, help="with --radio: the id of the shade the commands may move")
parser.addoption("--edit", action="store_true", help="also run the edits that change the shade model, then undo them")
parser.addoption("--reboot", action="store_true", help="also run the tests that reboot the device")
parser.addoption("--firmware", default=None, help="also update the device to this firmware.bin, built for its chip")
parser.addoption("--lockout", action="store_true", help="also run the test that locks this PC out of /login for 30 s")

View file

@ -9,7 +9,7 @@ import pytest
from proto_client import run_sync
from test_commands import is_ok, op, synced_model
from test_routes import auth, http, restore_without_a_file, upload
from test_settings import desc, settings, uptime, wait_for_restart
from test_settings import boot_time, desc, settings, wait_for_restart
# Saved for the round trip and put back by the restore. .invalid never resolves.
CHANGED_NTP_SERVER = "restore.e2e.invalid"
@ -50,7 +50,7 @@ def test_a_restore_brings_back_the_parts_that_were_ticked(request, host, token,
pytest.skip("restores a backup and reboots the device: pass --reboot")
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
uptime_before = uptime(schema, frames)
booted = boot_time(schema, frames)
general = settings(c, schema, "general")
ntp = {name: general.get(name, schema.default(name)) for name in ("ntpServer", "posixZone")}
assert ntp["ntpServer"] != CHANGED_NTP_SERVER
@ -63,10 +63,50 @@ def test_a_restore_brings_back_the_parts_that_were_ticked(request, host, token,
options = json.dumps({"shades": False, "settings": True})
status, _, body = upload(host, "/restore", auth(token), file=backup, fields={"data": options})
assert status == 200, body
wait_for_restart(host, token, schema, uptime_before)
wait_for_restart(host, token, schema, booted)
finally:
c, _, _ = synced_model(connect, schema)
restored = settings(c, schema, "general").get("ntpServer", schema.default("ntpServer"))
if restored != ntp["ntpServer"]:
op(c, schema, "saveSettings", dict(ntp, section="general"))
assert restored == ntp["ntpServer"]
def test_a_restore_of_the_radio_alone_brings_it_back_as_it_was(request, host, token, connect, schema):
"""4.0 boards without Ethernet declared their network record 28 bytes
longer than they wrote, so a restore that skipped it read the radio from
the middle of its record."""
if not request.config.getoption("--reboot"):
pytest.skip("restores a backup and reboots the device: pass --reboot")
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
booted = boot_time(schema, frames)
radio = settings(c, schema, "radio")
status, _, backup = http(host, "/backup", headers=auth(token))
assert status == 200
options = json.dumps({"shades": False, "transceiver": True})
status, _, body = upload(host, "/restore", auth(token), file=backup, fields={"data": options})
assert status == 200, body
wait_for_restart(host, token, schema, booted)
c, _, _ = synced_model(connect, schema)
assert settings(c, schema, "radio") == radio
def test_a_restore_of_the_shades_brings_them_back_as_they_were(request, host, token, connect, schema):
"""Every shade and group, their rolling codes included, from a backup taken a moment before."""
if not request.config.getoption("--reboot"):
pytest.skip("restores a backup and reboots the device: pass --reboot")
_, shades, groups = synced_model(connect, schema)
_, frames, _ = run_sync(connect(schema=schema.hash))
booted = boot_time(schema, frames)
status, _, backup = http(host, "/backup", headers=auth(token))
assert status == 200
options = json.dumps({"shades": True})
status, _, body = upload(host, "/restore", auth(token), file=backup, fields={"data": options})
assert status == 200, body
wait_for_restart(host, token, schema, booted)
_, shades_after, groups_after = synced_model(connect, schema)
assert shades_after == shades
assert groups_after == groups

View file

@ -119,20 +119,20 @@ def test_reboot(request, host, token, connect, schema):
pytest.skip("reboots the device: pass --reboot")
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
uptime_before = uptime(schema, frames)
booted = boot_time(schema, frames)
assert is_ok(schema, op(c, schema, "reboot", {}))
assert c.closed.wait(15), "the socket stayed open"
wait_for_restart(host, token, schema, uptime_before)
wait_for_restart(host, token, schema, booted)
def wait_for_restart(host, token, schema, uptime_before):
"""Waits until the device is back, and checks it restarted."""
def wait_for_restart(host, token, schema, booted):
"""Waits until the device has restarted since it booted at booted (boot_time)."""
# The device closes its sockets just before it restarts, and may still
# take a connection in between: that one never gets its hello.
time.sleep(3)
end = time.time() + 90
while True:
assert time.time() < end, "the device didn't come back"
assert time.time() < end, "the device didn't restart"
try:
back = ProtoClient(host, params={"apikey": token} if token else None, timeout=3)
except OSError:
@ -142,8 +142,10 @@ def wait_for_restart(host, token, schema, uptime_before):
if back.status == 101:
hello, frames, _ = run_sync(back, timeout=10)
assert hello["d"]["sh"] == schema.hash
assert uptime(schema, frames) < uptime_before, "the device didn't restart"
return
# Compared as boot times: a device that restarted a moment
# ago may have had less uptime than it has now.
if boot_time(schema, frames) > booted + 2:
return
except TimeoutError:
pass # not back yet
finally:
@ -151,6 +153,11 @@ def wait_for_restart(host, token, schema, uptime_before):
time.sleep(2)
def boot_time(schema, frames):
"""When the device booted, as a time.time(), from the status a sync ends with."""
return time.time() - uptime(schema, frames) / 1000
def header(schema, frames):
"""The header a sync starts with."""
key = schema.keys["header"]

52
test/e2e/test_update.py Normal file
View file

@ -0,0 +1,52 @@
"""A firmware update through /updateFirmware, as the web UI sends it.
Pass --firmware with a firmware.bin built for the device's chip, e.g.
.pio/build/esp32s3/firmware.bin. The device must have an OTA slot: the
release builds do, the C5, C6 and debug builds don't.
It can't tell the new firmware from the old when both have the same
version, and it doesn't make the update fail. test/e2e/README.md has the
rollback check by hand.
"""
import time
import pytest
from proto_client import run_sync
from test_routes import auth, upload
from test_settings import boot_time, wait_for_restart
# The new firmware is marked good once it has run this long with the network
# up; a reset before then boots the old one (docs/adr/0003-pioarduino-idf55.md).
PROVEN_AFTER_S = 60
@pytest.fixture
def firmware(request):
path = request.config.getoption("--firmware")
if not path:
pytest.skip("updates the device's firmware: pass --firmware <firmware.bin>")
with open(path, "rb") as f:
return f.read()
def test_an_update_restarts_on_the_new_firmware_and_stays(host, token, connect, schema, firmware):
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
booted = boot_time(schema, frames)
c.close()
status, _, body = upload(host, "/updateFirmware", auth(token), file=firmware)
assert status == 200, body
wait_for_restart(host, token, schema, booted)
# Past the point where it is marked good it is still running, and hasn't
# restarted on the old firmware.
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
restarted = boot_time(schema, frames)
c.close()
time.sleep(PROVEN_AFTER_S + 10)
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
assert abs(boot_time(schema, frames) - restarted) < 5, "the device restarted again"

View file

@ -0,0 +1,850 @@
#include <unity.h>
#include <fstream>
#include <sstream>
#include <string>
#include "ConfigRecords.h"
void setUp() {}
void tearDown() {}
// A file in memory.
class TextWriter : public ConfigWriter {
public:
std::string text;
bool writeBytes(const uint8_t *buf, size_t len) override { text.append(reinterpret_cast<const char *>(buf), len); return true; }
};
class TextReader : public ConfigReader {
public:
std::string text;
size_t pos = 0;
explicit TextReader(const std::string &t) : text(t) {}
bool readByte(uint8_t &val) override {
if(pos >= text.size()) return false;
val = static_cast<uint8_t>(text[pos++]);
return true;
}
uint32_t position() override { return static_cast<uint32_t>(pos); }
};
static config_header_t header(uint8_t version = SHADE_HDR_VER) {
config_header_t h;
h.version = version;
return h;
}
static shade_record_t aShade() {
shade_record_t rec;
rec.shadeId = 7;
rec.paired = true;
rec.shadeType = shade_types::blind;
rec.remoteAddress = 0x123456;
strcpy(rec.name, "Kitchen");
rec.tiltType = tilt_types::tiltmotor;
rec.proto = radio_proto::GP_Remote;
rec.bitLength = 80;
rec.upTime = 21000;
rec.downTime = 19500;
rec.tiltTime = 3200;
rec.stepSize = 250;
for(uint8_t j = 0; j < SOMFY_MAX_LINKED_REMOTES; j++) rec.linkedRemotes[j] = 0x200000 + j;
rec.lastRollingCode = 65000;
rec.flags = 0x21;
rec.myPos = 42.5f;
rec.myTiltPos = 12.25f;
rec.currentPos = 66.0f;
rec.currentTiltPos = 33.5f;
rec.flipCommands = true;
rec.flipPosition = true;
rec.repeats = 4;
rec.sortOrder = 3;
rec.gpioUp = 25;
rec.gpioDown = 26;
rec.gpioMy = 27;
rec.gpioFlags = 2;
rec.roomId = 5;
return rec;
}
static group_record_t aGroup() {
group_record_t rec;
rec.groupId = 3;
rec.remoteAddress = 0x654321;
strcpy(rec.name, "Upstairs");
rec.proto = radio_proto::RTS;
rec.bitLength = 80;
rec.linkedShades[0] = 1;
rec.linkedShades[1] = 7;
rec.linkedShades[2] = 32;
rec.repeats = 2;
rec.sortOrder = 9;
rec.flipCommands = true;
rec.roomId = 4;
rec.lastRollingCode = 1234;
return rec;
}
static net_record_t aNet() {
net_record_t rec;
rec.connType = 2;
rec.dhcp = false;
strcpy(rec.ip, "192.168.100.200");
strcpy(rec.gateway, "192.168.100.1");
strcpy(rec.subnet, "255.255.255.0");
strcpy(rec.dns1, "1.1.1.1");
strcpy(rec.dns2, "8.8.8.8");
strcpy(rec.mqttProtocol, "mqtts://");
strcpy(rec.mqttHostname, "broker.local");
rec.mqttPort = 8883;
rec.mqttPubDisco = true;
strcpy(rec.mqttRootTopic, "somfy");
strcpy(rec.mqttDiscoTopic, "ha");
rec.ethBoardType = 3;
rec.ethPhyType = 1;
rec.ethClkMode = 3;
rec.ethPhyAddress = -1;
rec.ethPWRPin = 12;
rec.ethMDCPin = 23;
rec.ethMDIOPin = 18;
return rec;
}
static trans_record_t aTrans() {
trans_record_t rec;
rec.enabled = true;
rec.type = 80;
rec.SCKPin = 4;
rec.CSNPin = 7;
rec.MOSIPin = 6;
rec.MISOPin = 5;
rec.TXPin = 10;
rec.RXPin = 3;
rec.frequency = 433.25f;
rec.rxBandwidth = 812.5f;
rec.deviation = 1.58f;
rec.txPower = -30;
rec.noiseDetection = true;
return rec;
}
static void assertShadesEqual(const shade_record_t &want, const shade_record_t &got) {
TEST_ASSERT_EQUAL_UINT8(want.shadeId, got.shadeId);
TEST_ASSERT_EQUAL(want.paired, got.paired);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(want.shadeType), static_cast<uint8_t>(got.shadeType));
TEST_ASSERT_EQUAL_HEX32(want.remoteAddress, got.remoteAddress);
TEST_ASSERT_EQUAL_STRING(want.name, got.name);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(want.tiltType), static_cast<uint8_t>(got.tiltType));
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(want.proto), static_cast<uint8_t>(got.proto));
TEST_ASSERT_EQUAL_UINT8(want.bitLength, got.bitLength);
TEST_ASSERT_EQUAL_UINT32(want.upTime, got.upTime);
TEST_ASSERT_EQUAL_UINT32(want.downTime, got.downTime);
TEST_ASSERT_EQUAL_UINT32(want.tiltTime, got.tiltTime);
TEST_ASSERT_EQUAL_UINT16(want.stepSize, got.stepSize);
TEST_ASSERT_EQUAL_HEX32_ARRAY(want.linkedRemotes, got.linkedRemotes, SOMFY_MAX_LINKED_REMOTES);
TEST_ASSERT_EQUAL_UINT16(want.lastRollingCode, got.lastRollingCode);
TEST_ASSERT_EQUAL_HEX8(want.flags, got.flags);
TEST_ASSERT_EQUAL_FLOAT(want.myPos, got.myPos);
TEST_ASSERT_EQUAL_FLOAT(want.myTiltPos, got.myTiltPos);
TEST_ASSERT_EQUAL_FLOAT(want.currentPos, got.currentPos);
TEST_ASSERT_EQUAL_FLOAT(want.currentTiltPos, got.currentTiltPos);
TEST_ASSERT_EQUAL(want.flipCommands, got.flipCommands);
TEST_ASSERT_EQUAL(want.flipPosition, got.flipPosition);
TEST_ASSERT_EQUAL_UINT8(want.repeats, got.repeats);
TEST_ASSERT_EQUAL_INT8(want.sortOrder, got.sortOrder);
TEST_ASSERT_EQUAL_UINT8(want.gpioUp, got.gpioUp);
TEST_ASSERT_EQUAL_UINT8(want.gpioDown, got.gpioDown);
TEST_ASSERT_EQUAL_UINT8(want.gpioMy, got.gpioMy);
TEST_ASSERT_EQUAL_UINT8(want.gpioFlags, got.gpioFlags);
TEST_ASSERT_EQUAL_UINT8(want.roomId, got.roomId);
}
// Every record is the size the header says, or validate() refuses the file.
static void test_each_record_is_the_size_the_header_declares() {
config_header_t h = header();
h.length = SHADE_HDR_SIZE;
strcpy(h.serverId, "a1b2c3d4");
TextWriter w;
writeConfigHeader(w, h);
TEST_ASSERT_EQUAL(SHADE_HDR_SIZE, w.text.size());
room_record_t room;
room.roomId = 16;
strcpy(room.name, "12345678901234567890");
w.text.clear();
writeRoomRecord(w, room);
TEST_ASSERT_EQUAL(ROOM_REC_SIZE, w.text.size());
w.text.clear();
writeShadeRecord(w, aShade());
TEST_ASSERT_EQUAL(SHADE_REC_SIZE, w.text.size());
w.text.clear();
writeGroupRecord(w, aGroup());
TEST_ASSERT_EQUAL(GROUP_REC_SIZE, w.text.size());
uint32_t repeaters[SOMFY_MAX_REPEATERS] = {0xFFFFFF, 1};
w.text.clear();
writeRepeaterRecord(w, repeaters);
TEST_ASSERT_EQUAL(REPEATER_REC_SIZE, w.text.size());
w.text.clear();
writeTransRecord(w, aTrans());
TEST_ASSERT_EQUAL(TRANS_REC_SIZE, w.text.size());
}
// backup() measures the records that vary in length with a ConfigCounter.
static void test_a_counter_measures_what_a_writer_writes() {
TextWriter w;
ConfigCounter c;
writeNetRecord(w, aNet(), false);
writeNetRecord(c, aNet(), false);
TEST_ASSERT_EQUAL(w.text.size(), c.bytes);
}
static void test_a_deleted_shade_is_written_cleared() {
shade_record_t rec = aShade();
rec.shadeId = 255;
TextWriter w;
writeShadeRecord(w, rec);
TEST_ASSERT_EQUAL(SHADE_REC_SIZE, w.text.size());
TextReader r(w.text);
shade_record_t got;
readShadeRecord(r, header(), got);
TEST_ASSERT_EQUAL_FLOAT(-1, got.myPos);
TEST_ASSERT_EQUAL_FLOAT(0, got.currentPos);
TEST_ASSERT_EQUAL_HEX8(0, got.flags);
}
static void test_the_header_reads_back() {
config_header_t h = header();
h.length = SHADE_HDR_SIZE;
h.roomRecordSize = ROOM_REC_SIZE;
h.roomRecords = 3;
h.shadeRecordSize = SHADE_REC_SIZE;
h.shadeRecords = 32;
h.groupRecordSize = GROUP_REC_SIZE;
h.groupRecords = 16;
h.repeaterRecordSize = REPEATER_REC_SIZE;
h.repeaterRecords = 1;
h.settingsRecordSize = 94;
h.netRecordSize = 166;
h.transRecordSize = TRANS_REC_SIZE;
strcpy(h.serverId, "a1b2c3d4");
TextWriter w;
writeConfigHeader(w, h);
TextReader r(w.text);
config_header_t got;
readConfigHeader(r, got);
TEST_ASSERT_EQUAL_UINT8(24, got.version);
TEST_ASSERT_EQUAL_INT8(SHADE_HDR_SIZE, got.length);
TEST_ASSERT_EQUAL_UINT8(3, got.roomRecords);
TEST_ASSERT_EQUAL_UINT8(32, got.shadeRecords);
TEST_ASSERT_EQUAL_UINT8(16, got.groupRecords);
TEST_ASSERT_EQUAL_UINT8(REPEATER_REC_SIZE, got.repeaterRecordSize);
TEST_ASSERT_EQUAL_UINT16(94, got.settingsRecordSize);
TEST_ASSERT_EQUAL_UINT16(166, got.netRecordSize);
TEST_ASSERT_EQUAL_UINT16(TRANS_REC_SIZE, got.transRecordSize);
TEST_ASSERT_EQUAL_STRING("a1b2c3d4", got.serverId);
TEST_ASSERT_EQUAL(SHADE_HDR_SIZE, r.position());
}
static void test_a_version_12_header_has_no_rooms_repeaters_or_settings() {
// version, length, shade size and count (one byte each then), group size and count.
TextReader r("12, 20,200, 2,150, 1\n");
config_header_t got;
readConfigHeader(r, got);
TEST_ASSERT_EQUAL_UINT8(12, got.version);
TEST_ASSERT_EQUAL_UINT16(200, got.shadeRecordSize);
TEST_ASSERT_EQUAL_UINT8(2, got.shadeRecords);
TEST_ASSERT_EQUAL_UINT16(150, got.groupRecordSize);
TEST_ASSERT_EQUAL_UINT8(1, got.groupRecords);
TEST_ASSERT_EQUAL_UINT8(0, got.roomRecords);
TEST_ASSERT_EQUAL_UINT8(0, got.repeaterRecords);
TEST_ASSERT_EQUAL_UINT16(0, got.settingsRecordSize);
}
static void test_a_shade_reads_back() {
TextWriter w;
writeShadeRecord(w, aShade());
TextReader r(w.text);
shade_record_t got;
readShadeRecord(r, header(), got);
assertShadesEqual(aShade(), got);
TEST_ASSERT_EQUAL(w.text.size(), r.position());
}
static void test_a_room_reads_back() {
room_record_t room;
room.roomId = 2;
strcpy(room.name, "Living room");
room.sortOrder = 5;
TextWriter w;
writeRoomRecord(w, room);
TextReader r(w.text);
room_record_t got;
readRoomRecord(r, got);
TEST_ASSERT_EQUAL_UINT8(2, got.roomId);
TEST_ASSERT_EQUAL_STRING("Living room", got.name);
TEST_ASSERT_EQUAL_INT8(5, got.sortOrder);
}
static void test_a_group_reads_back() {
TextWriter w;
writeGroupRecord(w, aGroup());
TextReader r(w.text);
group_record_t got;
readGroupRecord(r, header(), got);
const group_record_t want = aGroup();
TEST_ASSERT_EQUAL_UINT8(want.groupId, got.groupId);
TEST_ASSERT_EQUAL_HEX32(want.remoteAddress, got.remoteAddress);
TEST_ASSERT_EQUAL_STRING(want.name, got.name);
TEST_ASSERT_EQUAL_UINT8(want.bitLength, got.bitLength);
TEST_ASSERT_EQUAL_UINT8_ARRAY(want.linkedShades, got.linkedShades, SOMFY_MAX_GROUPED_SHADES);
TEST_ASSERT_EQUAL_UINT8(want.repeats, got.repeats);
TEST_ASSERT_EQUAL_INT8(want.sortOrder, got.sortOrder);
TEST_ASSERT_TRUE(got.flipCommands);
TEST_ASSERT_EQUAL_UINT8(want.roomId, got.roomId);
TEST_ASSERT_EQUAL_UINT16(want.lastRollingCode, got.lastRollingCode);
}
static void test_a_group_reads_its_shades_without_gaps() {
group_record_t rec = aGroup();
memset(rec.linkedShades, 0, sizeof(rec.linkedShades));
rec.linkedShades[2] = 4;
rec.linkedShades[10] = 9;
TextWriter w;
writeGroupRecord(w, rec);
TextReader r(w.text);
group_record_t got;
readGroupRecord(r, header(), got);
TEST_ASSERT_EQUAL_UINT8(4, got.linkedShades[0]);
TEST_ASSERT_EQUAL_UINT8(9, got.linkedShades[1]);
TEST_ASSERT_EQUAL_UINT8(0, got.linkedShades[2]);
}
static void test_the_settings_read_back() {
settings_record_t rec;
strcpy(rec.fwVersion, "v4.0.0");
strcpy(rec.hostname, "somfy");
strcpy(rec.ntpServer, "time.google.com");
strcpy(rec.posixZone, "CET-1CEST,M3.5.0,M10.5.0/3");
rec.ssdpBroadcast = true;
rec.checkForUpdate = false;
TextWriter w;
writeSettingsRecord(w, rec);
TextReader r(w.text);
settings_record_t got;
readSettingsRecord(r, header(), got);
TEST_ASSERT_EQUAL_STRING("v4.0.0", got.fwVersion);
TEST_ASSERT_EQUAL_STRING("somfy", got.hostname);
TEST_ASSERT_EQUAL_STRING("time.google.com", got.ntpServer);
// The commas are inside the quotes.
TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", got.posixZone);
TEST_ASSERT_TRUE(got.ssdpBroadcast);
TEST_ASSERT_FALSE(got.checkForUpdate);
}
static void test_the_network_reads_back_with_ethernet() {
TextWriter w;
writeNetRecord(w, aNet(), true);
TextReader r(w.text);
net_record_t got;
readNetRecord(r, header(), got, true);
TEST_ASSERT_EQUAL_UINT8(2, got.connType);
TEST_ASSERT_FALSE(got.dhcp);
TEST_ASSERT_EQUAL_STRING("192.168.100.200", got.ip);
TEST_ASSERT_EQUAL_STRING("192.168.100.1", got.gateway);
TEST_ASSERT_EQUAL_STRING("255.255.255.0", got.subnet);
TEST_ASSERT_EQUAL_STRING("1.1.1.1", got.dns1);
TEST_ASSERT_EQUAL_STRING("8.8.8.8", got.dns2);
TEST_ASSERT_EQUAL_STRING("mqtts://", got.mqttProtocol);
TEST_ASSERT_EQUAL_STRING("broker.local", got.mqttHostname);
TEST_ASSERT_EQUAL_UINT16(8883, got.mqttPort);
TEST_ASSERT_TRUE(got.mqttPubDisco);
TEST_ASSERT_EQUAL_STRING("somfy", got.mqttRootTopic);
TEST_ASSERT_EQUAL_STRING("ha", got.mqttDiscoTopic);
TEST_ASSERT_EQUAL_UINT8(3, got.ethBoardType);
TEST_ASSERT_EQUAL_UINT8(1, got.ethPhyType);
TEST_ASSERT_EQUAL_UINT8(3, got.ethClkMode);
TEST_ASSERT_EQUAL_INT8(-1, got.ethPhyAddress);
TEST_ASSERT_EQUAL_INT8(12, got.ethPWRPin);
TEST_ASSERT_EQUAL_INT8(23, got.ethMDCPin);
TEST_ASSERT_EQUAL_INT8(18, got.ethMDIOPin);
TEST_ASSERT_EQUAL(w.text.size(), r.position());
}
static void test_the_radio_reads_back() {
TextWriter w;
writeTransRecord(w, aTrans());
TextReader r(w.text);
trans_record_t got;
readTransRecord(r, got);
TEST_ASSERT_TRUE(got.enabled);
TEST_ASSERT_EQUAL_UINT8(80, got.type);
TEST_ASSERT_EQUAL_UINT8(4, got.SCKPin);
TEST_ASSERT_EQUAL_UINT8(7, got.CSNPin);
TEST_ASSERT_EQUAL_UINT8(6, got.MOSIPin);
TEST_ASSERT_EQUAL_UINT8(5, got.MISOPin);
TEST_ASSERT_EQUAL_UINT8(10, got.TXPin);
TEST_ASSERT_EQUAL_UINT8(3, got.RXPin);
TEST_ASSERT_EQUAL_FLOAT(433.25f, got.frequency);
TEST_ASSERT_EQUAL_FLOAT(812.5f, got.rxBandwidth);
TEST_ASSERT_EQUAL_FLOAT(1.58f, got.deviation);
TEST_ASSERT_EQUAL_INT8(-30, got.txPower);
TEST_ASSERT_TRUE(got.noiseDetection);
}
// The web UI takes any name, and the record would shift by a value at the
// comma.
static void test_a_name_with_a_comma_keeps_the_record_in_line() {
shade_record_t rec = aShade();
strcpy(rec.name, "Left, by the door");
TextWriter w;
writeShadeRecord(w, rec);
TEST_ASSERT_EQUAL(SHADE_REC_SIZE, w.text.size());
TextReader r(w.text);
shade_record_t got;
readShadeRecord(r, header(), got);
assertShadesEqual(rec, got);
}
static void test_the_longest_name_reads_back() {
room_record_t room;
room.roomId = 1;
strcpy(room.name, "12345678901234567890");
room.sortOrder = 7;
TextWriter w;
writeRoomRecord(w, room);
TextReader r(w.text);
room_record_t got;
readRoomRecord(r, got);
TEST_ASSERT_EQUAL_STRING("12345678901234567890", got.name);
TEST_ASSERT_EQUAL_INT8(7, got.sortOrder);
}
// A quoted value as long as its field, with its quotes and comma, is longer
// than the field. Reading stopped at the field's size and left the rest to
// the next value.
static void test_the_longest_settings_read_back() {
settings_record_t rec;
strcpy(rec.fwVersion, "v4.0.0");
memset(rec.hostname, 'h', sizeof(rec.hostname) - 1);
memset(rec.ntpServer, 'n', sizeof(rec.ntpServer) - 1);
memset(rec.posixZone, 'z', sizeof(rec.posixZone) - 1);
rec.ssdpBroadcast = true;
rec.checkForUpdate = false;
TextWriter w;
writeSettingsRecord(w, rec);
TextReader r(w.text);
settings_record_t got;
readSettingsRecord(r, header(), got);
TEST_ASSERT_EQUAL_STRING(rec.hostname, got.hostname);
TEST_ASSERT_EQUAL_STRING(rec.ntpServer, got.ntpServer);
TEST_ASSERT_EQUAL_STRING(rec.posixZone, got.posixZone);
TEST_ASSERT_TRUE(got.ssdpBroadcast);
TEST_ASSERT_FALSE(got.checkForUpdate);
}
static void test_the_longest_mqtt_settings_read_back() {
net_record_t rec = aNet();
memset(rec.mqttHostname, 'm', sizeof(rec.mqttHostname) - 1);
memset(rec.mqttRootTopic, 'r', sizeof(rec.mqttRootTopic) - 1);
TextWriter w;
writeNetRecord(w, rec, false);
TextReader r(w.text);
net_record_t got;
readNetRecord(r, header(), got, false);
TEST_ASSERT_EQUAL_STRING(rec.mqttHostname, got.mqttHostname);
TEST_ASSERT_EQUAL_UINT16(8883, got.mqttPort);
TEST_ASSERT_EQUAL_STRING(rec.mqttRootTopic, got.mqttRootTopic);
TEST_ASSERT_EQUAL_STRING("ha", got.mqttDiscoTopic);
}
// A value too long for its field is cut, and the next value still reads.
static void test_a_value_too_long_is_cut() {
TextReader r("\"0123456789012345678901234567890123456789\",\"next\"\n");
char ip[CFG_IP_LEN];
char next[CFG_IP_LEN];
r.beginRecord();
r.readVarString(ip, sizeof(ip));
r.readVarString(next, sizeof(next));
TEST_ASSERT_EQUAL_STRING("01234567890123456789012", ip);
TEST_ASSERT_EQUAL_STRING("next", next);
}
// The layout of a restore past the shades: the network record of a board
// without Ethernet, then the radio record.
static std::string backupTail(const net_record_t &net, const trans_record_t &trans) {
TextWriter w;
writeNetRecord(w, net, false);
writeTransRecord(w, trans);
return w.text;
}
// 4.0 boards without Ethernet declared the network record 28 bytes longer
// than they wrote, and a restore that skipped it by its size read the radio
// from the middle of its record.
static void test_skipping_the_network_of_a_board_without_ethernet_finds_the_radio() {
TextReader r(backupTail(aNet(), aTrans()));
skipRecords(r, 1);
trans_record_t got;
readTransRecord(r, got);
TEST_ASSERT_EQUAL_UINT8(4, got.SCKPin);
TEST_ASSERT_EQUAL_UINT8(3, got.RXPin);
TEST_ASSERT_EQUAL_FLOAT(433.25f, got.frequency);
TEST_ASSERT_TRUE(got.noiseDetection);
}
// The board that wrote a backup reads its Ethernet settings back; a board
// with Ethernet restoring a backup from one without finds none.
static void test_a_network_record_without_ethernet_keeps_the_ethernet_defaults() {
TextReader r(backupTail(aNet(), aTrans()));
net_record_t got;
readNetRecord(r, header(), got, true);
TEST_ASSERT_EQUAL_UINT8(0, got.ethBoardType);
TEST_ASSERT_EQUAL_INT8(1, got.ethPhyAddress);
TEST_ASSERT_EQUAL_INT8(23, got.ethMDIOPin);
trans_record_t trans;
readTransRecord(r, trans);
TEST_ASSERT_EQUAL_UINT8(4, trans.SCKPin);
}
// Before 4.0 the radio record was declared 74 bytes and is 80.
static void test_the_radio_reads_whole_whatever_its_declared_size() {
TextWriter w;
writeTransRecord(w, aTrans());
TEST_ASSERT_NOT_EQUAL(74, w.text.size());
TextReader r(w.text);
trans_record_t got;
readTransRecord(r, got);
TEST_ASSERT_TRUE(got.noiseDetection);
TEST_ASSERT_EQUAL(w.text.size(), r.position());
}
// A record from a newer version has values this one doesn't know.
static void test_values_a_newer_version_added_are_skipped() {
TextWriter w;
writeRoomRecord(w, room_record_t{1, "Hall", 0});
w.text.insert(w.text.size() - 1, ", 9, 9");
writeRoomRecord(w, room_record_t{2, "Attic", 1});
TextReader r(w.text);
room_record_t got;
readRoomRecord(r, got);
readRoomRecord(r, got);
TEST_ASSERT_EQUAL_UINT8(2, got.roomId);
TEST_ASSERT_EQUAL_STRING("Attic", got.name);
}
// A record shorter than its version says reads defaults for the rest and
// leaves the next record alone.
static void test_a_short_record_never_reads_the_next() {
TextWriter w;
writeRoomRecord(w, room_record_t{1, "Hall", 0});
// Ends after the name.
std::string text = w.text.substr(0, 4 + CFG_NAME_LEN - 1) + CFG_REC_END;
w.text.clear();
writeRoomRecord(w, room_record_t{2, "Attic", 1});
TextReader r(text + w.text);
room_record_t got;
readRoomRecord(r, got);
TEST_ASSERT_EQUAL_UINT8(1, got.roomId);
TEST_ASSERT_EQUAL_STRING("Hall", got.name);
TEST_ASSERT_EQUAL_INT8(0, got.sortOrder);
readRoomRecord(r, got);
TEST_ASSERT_EQUAL_UINT8(2, got.roomId);
TEST_ASSERT_EQUAL_INT8(1, got.sortOrder);
}
static void test_skipping_records_passes_their_newlines() {
TextWriter w;
writeShadeRecord(w, aShade());
writeGroupRecord(w, aGroup());
writeRoomRecord(w, room_record_t{4, "Last", 2});
TextReader r(w.text);
TEST_ASSERT_TRUE(skipRecords(r, 2));
room_record_t got;
readRoomRecord(r, got);
TEST_ASSERT_EQUAL_STRING("Last", got.name);
TEST_ASSERT_FALSE(skipRecords(r, 1));
}
// Older versions, written field by field in the layout they had.
static void writeShadeStart(TextWriter &w, uint8_t id) {
w.writeUInt8(id);
w.writeBool(true);
w.writeUInt8(static_cast<uint8_t>(shade_types::roller));
w.writeUInt32(0x111111);
w.writeString("Old", CFG_NAME_LEN);
}
static std::string version4Shade() {
TextWriter w;
writeShadeStart(w, 2);
w.writeUInt8(0); // tiltType
w.writeUInt8(56); // bitLength
w.writeUInt32(15000); // upTime
w.writeUInt32(14000); // downTime
w.writeUInt32(0); // tiltTime
for(uint8_t j = 0; j < 5; j++) w.writeUInt32(j == 0 ? 0x222222 : 0);
w.writeUInt16(300); // lastRollingCode
w.writeFloat(50, 5); // myPos
w.writeFloat(-1, 5); // myTiltPos
w.writeFloat(25, 5); // currentPos
w.writeFloat(0, 5, CFG_REC_END);
return w.text;
}
static void test_a_version_4_shade() {
const std::string text = version4Shade();
TextReader r(text);
shade_record_t got;
readShadeRecord(r, header(4), got);
TEST_ASSERT_EQUAL_UINT8(2, got.shadeId);
TEST_ASSERT_TRUE(got.paired);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(shade_types::roller), static_cast<uint8_t>(got.shadeType));
TEST_ASSERT_EQUAL_HEX32(0x111111, got.remoteAddress);
TEST_ASSERT_EQUAL_STRING("Old", got.name);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(tilt_types::none), static_cast<uint8_t>(got.tiltType));
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(radio_proto::RTS), static_cast<uint8_t>(got.proto));
TEST_ASSERT_EQUAL_UINT8(56, got.bitLength);
TEST_ASSERT_EQUAL_UINT32(15000, got.upTime);
TEST_ASSERT_EQUAL_UINT32(14000, got.downTime);
TEST_ASSERT_EQUAL_UINT32(0, got.tiltTime);
TEST_ASSERT_EQUAL_UINT16(100, got.stepSize);
TEST_ASSERT_EQUAL_HEX32(0x222222, got.linkedRemotes[0]);
TEST_ASSERT_EQUAL_HEX32(0, got.linkedRemotes[4]);
TEST_ASSERT_EQUAL_HEX32(0, got.linkedRemotes[5]);
TEST_ASSERT_EQUAL_UINT16(300, got.lastRollingCode);
TEST_ASSERT_EQUAL_HEX8(0, got.flags);
TEST_ASSERT_EQUAL_FLOAT(50, got.myPos);
TEST_ASSERT_EQUAL_FLOAT(-1, got.myTiltPos);
TEST_ASSERT_EQUAL_FLOAT(25, got.currentPos);
TEST_ASSERT_EQUAL_FLOAT(0, got.currentTiltPos);
TEST_ASSERT_FALSE(got.flipCommands);
TEST_ASSERT_FALSE(got.flipPosition);
TEST_ASSERT_EQUAL_UINT8(1, got.repeats);
TEST_ASSERT_EQUAL_INT8(1, got.sortOrder);
TEST_ASSERT_EQUAL_UINT8(0, got.gpioUp);
TEST_ASSERT_EQUAL_UINT8(0, got.roomId);
TEST_ASSERT_EQUAL(text.size(), r.position());
}
// A restore reads into a record that starts as the shade it replaces, and
// what an older version lacks stays as it was.
static void test_what_an_older_version_lacks_stays_as_it_was() {
const std::string text = version4Shade();
TextReader r(text);
shade_record_t got = aShade();
readShadeRecord(r, header(4), got);
TEST_ASSERT_EQUAL_UINT32(15000, got.upTime);
TEST_ASSERT_EQUAL_HEX32(0x222222, got.linkedRemotes[0]);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(radio_proto::GP_Remote), static_cast<uint8_t>(got.proto));
TEST_ASSERT_EQUAL_UINT16(250, got.stepSize);
TEST_ASSERT_EQUAL_HEX32(0x200005, got.linkedRemotes[5]);
TEST_ASSERT_EQUAL_HEX8(0x21, got.flags);
TEST_ASSERT_TRUE(got.flipCommands);
TEST_ASSERT_TRUE(got.flipPosition);
TEST_ASSERT_EQUAL_UINT8(4, got.repeats);
TEST_ASSERT_EQUAL_UINT8(25, got.gpioUp);
TEST_ASSERT_EQUAL_UINT8(27, got.gpioMy);
TEST_ASSERT_EQUAL_UINT8(2, got.gpioFlags);
TEST_ASSERT_EQUAL_UINT8(5, got.roomId);
}
// Version 2 saved positions as fractions and the favorite as a byte.
static void test_a_version_2_shade_has_fractions() {
TextWriter w;
writeShadeStart(w, 1);
w.writeBool(false); // tilt
w.writeUInt8(56);
w.writeUInt32(10000);
w.writeUInt32(10000);
w.writeUInt32(0);
for(uint8_t j = 0; j < 5; j++) w.writeUInt32(0);
w.writeUInt16(12);
w.writeUInt8(40); // myPos
w.writeFloat(0.5f, 5); // currentPos
w.writeFloat(0, 5, CFG_REC_END);
TextReader r(w.text);
shade_record_t got;
readShadeRecord(r, header(2), got);
TEST_ASSERT_EQUAL_FLOAT(40, got.myPos);
TEST_ASSERT_EQUAL_FLOAT(50, got.currentPos);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(tilt_types::none), static_cast<uint8_t>(got.tiltType));
TEST_ASSERT_EQUAL(w.text.size(), r.position());
}
// Version 13 had the sort order and no GPIOs or room.
static void test_a_version_13_shade() {
TextWriter w;
writeShadeStart(w, 5);
w.writeUInt8(0);
w.writeUInt8(static_cast<uint8_t>(radio_proto::RTS));
w.writeUInt8(80);
w.writeUInt32(10000);
w.writeUInt32(10000);
w.writeUInt32(0);
w.writeUInt16(200);
for(uint8_t j = 0; j < SOMFY_MAX_LINKED_REMOTES; j++) w.writeUInt32(0);
w.writeUInt16(77);
w.writeUInt8(0x01); // flags
w.writeFloat(-1, 5);
w.writeFloat(-1, 5);
w.writeFloat(100, 5);
w.writeFloat(0, 5);
w.writeBool(true); // flipCommands
w.writeBool(false); // flipPosition
w.writeUInt8(3); // repeats
w.writeUInt8(8, CFG_REC_END);
TextReader r(w.text);
shade_record_t got;
readShadeRecord(r, header(13), got);
TEST_ASSERT_EQUAL_UINT8(80, got.bitLength);
TEST_ASSERT_EQUAL_UINT16(200, got.stepSize);
TEST_ASSERT_EQUAL_UINT16(77, got.lastRollingCode);
TEST_ASSERT_EQUAL_HEX8(0x01, got.flags);
TEST_ASSERT_EQUAL_FLOAT(100, got.currentPos);
TEST_ASSERT_TRUE(got.flipCommands);
TEST_ASSERT_EQUAL_UINT8(3, got.repeats);
TEST_ASSERT_EQUAL_INT8(8, got.sortOrder);
TEST_ASSERT_EQUAL_UINT8(0, got.gpioUp);
TEST_ASSERT_EQUAL_UINT8(0, got.roomId);
TEST_ASSERT_EQUAL(w.text.size(), r.position());
}
static void writeGroupStart(TextWriter &w) {
w.writeUInt8(2);
w.writeUInt8(0);
w.writeUInt32(0x333333);
w.writeString("Group", CFG_NAME_LEN);
w.writeUInt8(0);
w.writeUInt8(56);
}
// Version 23 put the group's rolling code before its shades.
static void test_a_version_23_group() {
TextWriter w;
writeGroupStart(w);
w.writeUInt16(4321);
for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) w.writeUInt8(j < 2 ? j + 1 : 0);
w.writeUInt8(1);
w.writeUInt8(1);
w.writeBool(false);
w.writeUInt8(6, CFG_REC_END);
TextReader r(w.text);
group_record_t got;
readGroupRecord(r, header(23), got);
TEST_ASSERT_EQUAL_UINT16(4321, got.lastRollingCode);
TEST_ASSERT_TRUE(got.hasRollingCode);
TEST_ASSERT_EQUAL_UINT8(2, got.linkedShades[1]);
TEST_ASSERT_EQUAL_UINT8(6, got.roomId);
TEST_ASSERT_EQUAL(w.text.size(), r.position());
}
static void test_a_version_11_group() {
TextWriter w;
writeGroupStart(w);
for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) w.writeUInt8(j == 0 ? 4 : 0, j == SOMFY_MAX_GROUPED_SHADES - 1 ? CFG_REC_END : CFG_VALUE_SEP);
TextReader r(w.text);
group_record_t got;
readGroupRecord(r, header(11), got);
TEST_ASSERT_EQUAL_UINT8(4, got.linkedShades[0]);
TEST_ASSERT_EQUAL_UINT8(1, got.repeats);
TEST_ASSERT_EQUAL_INT8(1, got.sortOrder);
TEST_ASSERT_FALSE(got.flipCommands);
TEST_ASSERT_EQUAL_UINT16(0, got.lastRollingCode);
}
// Before version 23 a group had no rolling code, so a restore leaves the one
// NVS holds.
static void test_a_group_before_version_23_has_no_rolling_code() {
TextWriter w;
writeGroupStart(w);
for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) w.writeUInt8(0, j == SOMFY_MAX_GROUPED_SHADES - 1 ? CFG_REC_END : CFG_VALUE_SEP);
TextReader r(w.text);
group_record_t got = aGroup();
readGroupRecord(r, header(11), got);
TEST_ASSERT_FALSE(got.hasRollingCode);
TEST_ASSERT_EQUAL_UINT16(1234, got.lastRollingCode);
TEST_ASSERT_TRUE(got.flipCommands);
TEST_ASSERT_EQUAL_UINT8(4, got.roomId);
TextWriter v24;
writeGroupRecord(v24, aGroup());
TextReader r24(v24.text);
readGroupRecord(r24, header(), got);
TEST_ASSERT_TRUE(got.hasRollingCode);
}
// Before version 20 the settings had no update check, which stays as it was.
static void test_version_19_settings_leave_the_update_check() {
TextReader r("\"v2.0.0\",\"host\",\"pool.ntp.org\",\"\",true \n");
settings_record_t got;
got.checkForUpdate = false;
readSettingsRecord(r, header(19), got);
TEST_ASSERT_EQUAL_STRING("host", got.hostname);
TEST_ASSERT_TRUE(got.ssdpBroadcast);
TEST_ASSERT_FALSE(got.checkForUpdate);
}
// Before version 22 the network record had no MQTT settings.
static void test_a_version_21_network_has_no_mqtt() {
TextReader r(" 1,true ,\"\",\"\",\"\",\"\",\"\", 1, 0, 0, 0, -1, 23, 18\n 9\n");
net_record_t got;
readNetRecord(r, header(21), got, true);
TEST_ASSERT_EQUAL_UINT8(1, got.connType);
TEST_ASSERT_EQUAL_STRING("", got.mqttHostname);
TEST_ASSERT_EQUAL_UINT16(1883, got.mqttPort);
TEST_ASSERT_EQUAL_INT8(-1, got.ethPWRPin);
r.beginRecord();
TEST_ASSERT_EQUAL_UINT8(9, r.readUInt8(0));
}
// A real backup, which isn't in the repo: it holds the remotes' addresses.
// CONFIG_BACKUP=<path> pio test -e native -f test_configrecords
static void test_a_real_backup_reads_to_its_end() {
const char *path = getenv("CONFIG_BACKUP");
if(!path) TEST_IGNORE_MESSAGE("set CONFIG_BACKUP to a backup file");
std::ifstream in(path, std::ios::binary);
TEST_ASSERT_TRUE_MESSAGE(in.good(), "can't open CONFIG_BACKUP");
std::stringstream ss;
ss << in.rdbuf();
TextReader r(ss.str());
config_header_t h;
readConfigHeader(r, h);
TEST_ASSERT_EQUAL(static_cast<uint32_t>(h.length), r.position());
for(uint8_t i = 0; i < h.roomRecords; i++) {
room_record_t rec;
readRoomRecord(r, rec);
TEST_ASSERT_NOT_EQUAL(0, rec.roomId);
}
for(uint8_t i = 0; i < h.shadeRecords; i++) {
shade_record_t rec;
readShadeRecord(r, h, rec);
TEST_ASSERT_TRUE(rec.shadeId >= 1 && rec.shadeId <= SOMFY_MAX_SHADES);
TEST_ASSERT_TRUE(rec.currentPos >= 0 && rec.currentPos <= 100);
// Written back, it is the record it was read from.
TextWriter w;
writeShadeRecord(w, rec);
TEST_ASSERT_EQUAL(h.shadeRecordSize, w.text.size());
}
for(uint8_t i = 0; i < h.groupRecords; i++) {
group_record_t rec;
readGroupRecord(r, h, rec);
TEST_ASSERT_TRUE(rec.groupId >= 1 && rec.groupId <= SOMFY_MAX_GROUPS);
}
for(uint8_t i = 0; i < h.repeaterRecords; i++) {
uint32_t repeaters[SOMFY_MAX_REPEATERS];
readRepeaterRecord(r, repeaters);
}
settings_record_t settings;
readSettingsRecord(r, h, settings);
TEST_ASSERT_EQUAL_UINT8('v', settings.fwVersion[0]);
net_record_t net;
readNetRecord(r, h, net, true);
trans_record_t trans;
readTransRecord(r, trans);
TEST_ASSERT_TRUE(trans.frequency > 300 && trans.frequency < 950);
TEST_ASSERT_EQUAL(ss.str().size(), r.position());
}
int main(int argc, char **argv) {
UNITY_BEGIN();
RUN_TEST(test_each_record_is_the_size_the_header_declares);
RUN_TEST(test_a_counter_measures_what_a_writer_writes);
RUN_TEST(test_a_deleted_shade_is_written_cleared);
RUN_TEST(test_the_header_reads_back);
RUN_TEST(test_a_version_12_header_has_no_rooms_repeaters_or_settings);
RUN_TEST(test_a_shade_reads_back);
RUN_TEST(test_a_room_reads_back);
RUN_TEST(test_a_group_reads_back);
RUN_TEST(test_a_group_reads_its_shades_without_gaps);
RUN_TEST(test_the_settings_read_back);
RUN_TEST(test_the_network_reads_back_with_ethernet);
RUN_TEST(test_the_radio_reads_back);
RUN_TEST(test_a_name_with_a_comma_keeps_the_record_in_line);
RUN_TEST(test_the_longest_name_reads_back);
RUN_TEST(test_the_longest_settings_read_back);
RUN_TEST(test_the_longest_mqtt_settings_read_back);
RUN_TEST(test_a_value_too_long_is_cut);
RUN_TEST(test_skipping_the_network_of_a_board_without_ethernet_finds_the_radio);
RUN_TEST(test_a_network_record_without_ethernet_keeps_the_ethernet_defaults);
RUN_TEST(test_the_radio_reads_whole_whatever_its_declared_size);
RUN_TEST(test_values_a_newer_version_added_are_skipped);
RUN_TEST(test_a_short_record_never_reads_the_next);
RUN_TEST(test_skipping_records_passes_their_newlines);
RUN_TEST(test_a_version_4_shade);
RUN_TEST(test_what_an_older_version_lacks_stays_as_it_was);
RUN_TEST(test_a_version_2_shade_has_fractions);
RUN_TEST(test_a_version_13_shade);
RUN_TEST(test_a_version_23_group);
RUN_TEST(test_a_version_11_group);
RUN_TEST(test_a_group_before_version_23_has_no_rolling_code);
RUN_TEST(test_version_19_settings_leave_the_update_check);
RUN_TEST(test_a_version_21_network_has_no_mqtt);
RUN_TEST(test_a_real_backup_reads_to_its_end);
return UNITY_END();
}

View file

@ -0,0 +1,216 @@
#include <unity.h>
#include <string>
#include <vector>
#include "MqttDisco.h"
#include "MqttTopics.h"
#include "ShadeChanges.h"
void setUp() {}
void tearDown() {}
static shade_disco_t shade(shade_types type = shade_types::roller) {
shade_disco_t s;
s.shadeId = 3;
s.name = "Kitchen";
s.shadeType = type;
s.rootTopic = "somfy";
s.discoTopic = "homeassistant";
s.serverId = "abc123";
s.hostname = "ESPSomfyRTS";
s.fwVersion = "v4.0.0";
return s;
}
// The config Home Assistant gets and the topic it goes to.
struct disco_t {
JsonDocument doc;
char topic[128] = "";
};
static void build(const shade_disco_t &s, disco_t &d) {
shadeDisco(s, d.doc.to<JsonObject>(), d.topic, sizeof(d.topic));
}
static void test_a_roller_is_a_cover_under_the_discovery_topic() {
disco_t d;
build(shade(), d);
TEST_ASSERT_EQUAL_STRING("homeassistant/cover/3/config", d.topic);
TEST_ASSERT_EQUAL_STRING("somfy/shades/3", d.doc["~"]);
TEST_ASSERT_EQUAL_STRING("Kitchen", d.doc["name"]);
TEST_ASSERT_EQUAL_STRING("mqtt_abc123_shade3", d.doc["unique_id"]);
TEST_ASSERT_EQUAL_STRING("shade", d.doc["device_class"]);
TEST_ASSERT_TRUE(d.doc["enabled_by_default"].as<bool>());
}
// Every shade of a device is grouped under it in Home Assistant.
static void test_the_device_is_the_esp() {
disco_t d;
build(shade(), d);
JsonObject device = d.doc["device"];
TEST_ASSERT_EQUAL_STRING("mqtt_espsomfyrts_abc123", device["identifiers"][0]);
TEST_ASSERT_EQUAL_STRING("ESPSomfyRTS", device["name"]);
TEST_ASSERT_EQUAL_STRING("v4.0.0", device["hw_version"]);
TEST_ASSERT_EQUAL_STRING("ESPSomfy-RTS MQTT", device["model"]);
TEST_ASSERT_EQUAL_STRING("somfy/status", d.doc["availability_topic"]);
TEST_ASSERT_EQUAL_STRING("online", d.doc["payload_available"]);
TEST_ASSERT_EQUAL_STRING("offline", d.doc["payload_not_available"]);
}
// The topics under ~ are the ones MqttCommands listens on and the shade
// publishes.
static void test_a_cover_moves_through_the_shade_topics() {
disco_t d;
build(shade(), d);
TEST_ASSERT_EQUAL_STRING("~/direction/set", d.doc["command_topic"]);
TEST_ASSERT_EQUAL_STRING("~/target/set", d.doc["set_position_topic"]);
TEST_ASSERT_EQUAL_STRING("~/position", d.doc["position_topic"]);
TEST_ASSERT_EQUAL_STRING("~/direction", d.doc["state_topic"]);
TEST_ASSERT_EQUAL_STRING("0", d.doc["payload_stop"]);
TEST_ASSERT_EQUAL_STRING("0", d.doc["state_stopped"]);
TEST_ASSERT_FALSE(d.doc["tilt_command_topic"].is<const char *>());
}
// 0 is open: an upward move opens.
static void test_a_roller_opens_up() {
disco_t d;
build(shade(), d);
TEST_ASSERT_EQUAL_STRING("-1", d.doc["payload_open"]);
TEST_ASSERT_EQUAL_STRING("1", d.doc["payload_close"]);
TEST_ASSERT_EQUAL(0, d.doc["position_open"].as<int>());
TEST_ASSERT_EQUAL(100, d.doc["position_closed"].as<int>());
TEST_ASSERT_EQUAL_STRING("-1", d.doc["state_opening"]);
TEST_ASSERT_EQUAL_STRING("1", d.doc["state_closing"]);
}
static void test_a_flipped_position_opens_at_100() {
shade_disco_t s = shade();
s.flipPosition = true;
disco_t d;
build(s, d);
TEST_ASSERT_EQUAL_STRING("1", d.doc["payload_open"]);
TEST_ASSERT_EQUAL_STRING("-1", d.doc["payload_close"]);
TEST_ASSERT_EQUAL(100, d.doc["position_open"].as<int>());
TEST_ASSERT_EQUAL(0, d.doc["position_closed"].as<int>());
TEST_ASSERT_EQUAL_STRING("1", d.doc["state_opening"]);
TEST_ASSERT_EQUAL_STRING("-1", d.doc["state_closing"]);
}
// An awning opens downward, and flipped opens up again.
static void test_an_awning_opens_down() {
disco_t d;
build(shade(shade_types::awning), d);
TEST_ASSERT_EQUAL_STRING("awning", d.doc["device_class"]);
TEST_ASSERT_EQUAL_STRING("1", d.doc["payload_open"]);
TEST_ASSERT_EQUAL(100, d.doc["position_open"].as<int>());
shade_disco_t s = shade(shade_types::awning);
s.flipPosition = true;
disco_t flipped;
build(s, flipped);
TEST_ASSERT_EQUAL_STRING("-1", flipped.doc["payload_open"]);
TEST_ASSERT_EQUAL(0, flipped.doc["position_open"].as<int>());
}
static void test_each_kind_of_shade_has_its_device_class() {
const struct { shade_types type; const char *deviceClass; } kinds[] = {
{shade_types::roller, "shade"},
{shade_types::blind, "blind"},
{shade_types::ldrapery, "curtain"},
{shade_types::rdrapery, "curtain"},
{shade_types::cdrapery, "curtain"},
{shade_types::lgate, "curtain"},
{shade_types::cgate1, "curtain"},
{shade_types::garage1, "garage"},
{shade_types::garage3, "garage"},
{shade_types::shutter, "shutter"},
{shade_types::awning, "awning"},
};
for(const auto &kind : kinds) {
disco_t d;
build(shade(kind.type), d);
TEST_ASSERT_EQUAL_STRING(kind.deviceClass, d.doc["device_class"]);
}
}
static void test_a_tilting_blind_has_the_tilt_topics() {
shade_disco_t s = shade(shade_types::blind);
s.tiltType = tilt_types::tiltmotor;
disco_t d;
build(s, d);
TEST_ASSERT_EQUAL_STRING("~/tiltTarget/set", d.doc["tilt_command_topic"]);
TEST_ASSERT_EQUAL_STRING("~/tiltPosition", d.doc["tilt_status_topic"]);
TEST_ASSERT_EQUAL_STRING("~/direction/set", d.doc["command_topic"]);
}
// A tilt only blind doesn't open or close, so it has only the tilt.
static void test_a_tilt_only_blind_has_only_the_tilt() {
shade_disco_t s = shade(shade_types::blind);
s.tiltType = tilt_types::tiltonly;
disco_t d;
build(s, d);
TEST_ASSERT_EQUAL_STRING("~/tiltTarget/set", d.doc["tilt_command_topic"]);
TEST_ASSERT_FALSE(d.doc["command_topic"].is<const char *>());
TEST_ASSERT_FALSE(d.doc["set_position_topic"].is<const char *>());
// Sent as null so Home Assistant drops its defaults.
std::string json;
serializeJson(d.doc, json);
TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "\"payload_open\":null"));
TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "\"payload_close\":null"));
TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "\"payload_stop\":null"));
}
// A dry contact is on (100) or off (0).
static void test_a_dry_contact_is_a_switch() {
for(shade_types type : {shade_types::drycontact, shade_types::drycontact2}) {
disco_t d;
build(shade(type), d);
TEST_ASSERT_EQUAL_STRING("homeassistant/switch/3/config", d.topic);
TEST_ASSERT_FALSE(d.doc["device_class"].is<const char *>());
TEST_ASSERT_EQUAL(100, d.doc["payload_on"].as<int>());
TEST_ASSERT_EQUAL(0, d.doc["payload_off"].as<int>());
TEST_ASSERT_EQUAL(100, d.doc["state_on"].as<int>());
TEST_ASSERT_EQUAL(0, d.doc["state_off"].as<int>());
TEST_ASSERT_EQUAL_STRING("~/position", d.doc["state_topic"]);
TEST_ASSERT_EQUAL_STRING("~/target/set", d.doc["command_topic"]);
TEST_ASSERT_FALSE(d.doc["payload_open"].is<const char *>());
}
}
// Deleting a shade clears both, as its kind may have changed.
static void test_the_config_topic_of_each_kind() {
char topic[64];
shadeDiscoTopic("ha", 12, false, topic, sizeof(topic));
TEST_ASSERT_EQUAL_STRING("ha/cover/12/config", topic);
shadeDiscoTopic("ha", 12, true, topic, sizeof(topic));
TEST_ASSERT_EQUAL_STRING("ha/switch/12/config", topic);
TEST_ASSERT_TRUE(isDiscoSwitch(shade_types::drycontact2));
TEST_ASSERT_FALSE(isDiscoSwitch(shade_types::garage1));
}
static bool hasTopic(const char *const *topics, size_t count, const std::string &topic) {
for(size_t i = 0; i < count; i++) if(topic == topics[i]) return true;
return false;
}
static void keepRetained(const char *topic, int32_t value, bool retain, void *ctx) {
(void)value;
if(retain) static_cast<std::vector<std::string> *>(ctx)->push_back(topic);
}
// A deleted shade leaves none of its topics at the broker.
static void test_deleting_a_shade_clears_every_state_topic_the_broker_keeps() {
shade_snapshot_t shade{};
std::vector<std::string> retained;
publishShadeTopics(SHADE_CHANGED_TOPICS, shade, keepRetained, &retained);
TEST_ASSERT_FALSE(retained.empty());
for(const std::string &topic : retained)
TEST_ASSERT_TRUE_MESSAGE(hasTopic(SHADE_TOPICS, SHADE_TOPIC_COUNT, topic), topic.c_str());
}
// Group unpublish named it SunSensor, so a deleted group's stayed.
static void test_deleting_a_group_clears_its_sun_sensor_topic() {
TEST_ASSERT_TRUE(hasTopic(GROUP_TOPICS, GROUP_TOPIC_COUNT, "sunSensor"));
TEST_ASSERT_FALSE(hasTopic(GROUP_TOPICS, GROUP_TOPIC_COUNT, "SunSensor"));
}
int main(int argc, char **argv) {
UNITY_BEGIN();
RUN_TEST(test_a_roller_is_a_cover_under_the_discovery_topic);
RUN_TEST(test_the_device_is_the_esp);
RUN_TEST(test_a_cover_moves_through_the_shade_topics);
RUN_TEST(test_a_roller_opens_up);
RUN_TEST(test_a_flipped_position_opens_at_100);
RUN_TEST(test_an_awning_opens_down);
RUN_TEST(test_each_kind_of_shade_has_its_device_class);
RUN_TEST(test_a_tilting_blind_has_the_tilt_topics);
RUN_TEST(test_a_tilt_only_blind_has_only_the_tilt);
RUN_TEST(test_a_dry_contact_is_a_switch);
RUN_TEST(test_the_config_topic_of_each_kind);
RUN_TEST(test_deleting_a_shade_clears_every_state_topic_the_broker_keeps);
RUN_TEST(test_deleting_a_group_clears_its_sun_sensor_topic);
return UNITY_END();
}

View file

@ -47,6 +47,16 @@ static void test_wraps_to_zero_after_0xFFFF() {
TEST_ASSERT_EQUAL_UINT16(0, next(true, 0xFFFF, store));
}
// NVS is newer than shades.cfg, so a load keeps the higher code.
static void test_a_load_keeps_the_higher_rolling_code() {
TEST_ASSERT_EQUAL_UINT16(500, rollingCodeAfterLoad(500, 300, false));
TEST_ASSERT_EQUAL_UINT16(700, rollingCodeAfterLoad(500, 700, false));
}
static void test_a_restore_takes_the_backups_rolling_code() {
TEST_ASSERT_EQUAL_UINT16(300, rollingCodeAfterLoad(500, 300, true));
TEST_ASSERT_EQUAL_UINT16(700, rollingCodeAfterLoad(500, 700, true));
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
@ -55,5 +65,7 @@ int main(int argc, char **argv) {
RUN_TEST(test_synced_never_reads_the_store);
RUN_TEST(test_reading_does_not_use_up_a_code);
RUN_TEST(test_wraps_to_zero_after_0xFFFF);
RUN_TEST(test_a_load_keeps_the_higher_rolling_code);
RUN_TEST(test_a_restore_takes_the_backups_rolling_code);
return UNITY_END();
}