From 3774aba45ccd2cd9e36c4fed76ec5115c7123191 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:20:39 +0200 Subject: [PATCH] test: cover the frame codec and command names Builds SomfyFrame.cpp in the native env, with a minimal String and no-op ESP_LOGx in the stubs, and tests: - a fixed 56-bit frame, byte for byte, so what goes on air is pinned - encode/decode round trips for 56-bit, 80-bit (repeats, Favorite, Stop, steps, Toggle), RTW and RTV frames - rejection of bad checksums, addresses, rolling codes, keys and 80-bit parity - repeat/synonym tracking in copy() - parsing and naming of commands The command-name round trip fails: names the firmware reports, such as "Step Up" and "My+Up", parse back to the wrong command. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 2 +- test/stubs/Arduino.h | 27 ++++ test/stubs/esp_log.h | 10 ++ test/test_frame/test_frame.cpp | 266 +++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 test/stubs/esp_log.h create mode 100644 test/test_frame/test_frame.cpp diff --git a/platformio.ini b/platformio.ini index a794091..04f0f12 100644 --- a/platformio.ini +++ b/platformio.ini @@ -101,7 +101,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + +build_src_filter = -<*> + + build_flags = -std=gnu++17 -Isrc diff --git a/test/stubs/Arduino.h b/test/stubs/Arduino.h index 3755257..b9584dc 100644 --- a/test/stubs/Arduino.h +++ b/test/stubs/Arduino.h @@ -9,6 +9,10 @@ #include #include #include +#include +#include + +typedef uint8_t byte; // Fixed so anything formatted from it is deterministic. inline unsigned long millis() { return 0; } @@ -17,4 +21,27 @@ inline bool getLocalTime(struct tm *info, uint32_t ms = 5000) { time_t now = time(nullptr); return localtime_r(&now, info) != nullptr; } + +// The parts of Arduino's String the firmware code under test uses. +class String { + std::string _s; + public: + String() {} + String(const char *s) : _s(s ? s : "") {} + String(const std::string &s) : _s(s) {} + // Arduino formats integers in decimal. + template::value, int>::type = 0> + explicit String(T val) : _s(std::to_string(val)) {} + const char *c_str() const { return _s.c_str(); } + unsigned int length() const { return (unsigned int)_s.length(); } + bool equalsIgnoreCase(const String &other) const { + if(_s.length() != other._s.length()) return false; + for(size_t i = 0; i < _s.length(); i++) + if(tolower((unsigned char)_s[i]) != tolower((unsigned char)other._s[i])) return false; + return true; + } + bool startsWith(const String &prefix) const { return _s.compare(0, prefix._s.length(), prefix._s) == 0; } + friend String operator+(const String &a, const String &b) { return String(a._s + b._s); } + friend bool operator==(const String &a, const String &b) { return a._s == b._s; } +}; #endif diff --git a/test/stubs/esp_log.h b/test/stubs/esp_log.h new file mode 100644 index 0000000..681bfdf --- /dev/null +++ b/test/stubs/esp_log.h @@ -0,0 +1,10 @@ +// Logging stand-in for the native test env. The format arguments are never +// evaluated so tests stay quiet and logging can't affect what they check. +#ifndef esp_log_stub_h +#define esp_log_stub_h +#define ESP_LOGE(tag, ...) ((void)(tag)) +#define ESP_LOGW(tag, ...) ((void)(tag)) +#define ESP_LOGI(tag, ...) ((void)(tag)) +#define ESP_LOGD(tag, ...) ((void)(tag)) +#define ESP_LOGV(tag, ...) ((void)(tag)) +#endif diff --git a/test/test_frame/test_frame.cpp b/test/test_frame/test_frame.cpp new file mode 100644 index 0000000..66f00cf --- /dev/null +++ b/test/test_frame/test_frame.cpp @@ -0,0 +1,266 @@ +#include +#include "SomfyFrame.h" + +#define ASSERT_CMD(expected, actual) TEST_ASSERT_EQUAL_HEX8(static_cast(expected), static_cast(actual)) + +static const uint32_t ADDR = 0x123456; + +// A frame as SomfyRemote::sendCommand builds it. +static somfy_frame_t makeFrame(somfy_commands cmd, uint32_t address, uint16_t rollingCode, + uint8_t bitLength = 56, radio_proto proto = radio_proto::RTS) { + somfy_frame_t f; + f.cmd = cmd; + f.remoteAddress = address; + f.rollingCode = rollingCode; + f.bitLength = bitLength; + f.proto = proto; + f.encKey = 0xA0 | (rollingCode & 0x0F); + return f; +} +// What a receiver makes of the bytes, given it heard the same frame length. +static somfy_frame_t decode(const byte *payload, uint8_t bitLength) { + byte buf[10]; + memcpy(buf, payload, sizeof(buf)); + somfy_frame_t d; + d.bitLength = bitLength; + d.decodeFrame(buf); + return d; +} +static somfy_frame_t roundTrip(somfy_frame_t f) { + byte buf[10]; + f.encodeFrame(buf); + return decode(buf, f.bitLength); +} + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * Encoding + ********************************************************************/ +static void test_encode_56bit_golden_frame() { + // Pins exactly what goes on air. If this changes, find out why before + // updating the bytes: motors reject frames that are encoded differently. + somfy_frame_t f = makeFrame(somfy_commands::Up, ADDR, 0x0102); + f.encKey = 0xA7; + byte buf[10]; + f.encodeFrame(buf); + const byte expected[10] = {0xA7, 0x8C, 0x8D, 0x8F, 0x9D, 0xA9, 0xFF, 0x84, 0x00, 0x1D}; + TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, buf, 10); + + somfy_frame_t d = decode(expected, 56); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(somfy_commands::Up, d.cmd); + TEST_ASSERT_EQUAL_HEX32(ADDR, d.remoteAddress); + TEST_ASSERT_EQUAL_HEX16(0x0102, d.rollingCode); + TEST_ASSERT_EQUAL_HEX8(0xA7, d.encKey); +} +static void test_round_trip_56bit_commands() { + const somfy_commands cmds[] = { + somfy_commands::My, somfy_commands::Up, somfy_commands::MyUp, somfy_commands::Down, + somfy_commands::MyDown, somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Prog, + somfy_commands::SunFlag, somfy_commands::Flag, somfy_commands::Toggle, somfy_commands::Sensor + }; + for(somfy_commands cmd : cmds) { + somfy_frame_t f = makeFrame(cmd, ADDR, 0x1234); + somfy_frame_t d = roundTrip(f); + TEST_ASSERT_TRUE_MESSAGE(d.valid, translateSomfyCommand(cmd).c_str()); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL_HEX32(ADDR, d.remoteAddress); + TEST_ASSERT_EQUAL_HEX16(0x1234, d.rollingCode); + TEST_ASSERT_EQUAL_HEX8(f.encKey, d.encKey); + TEST_ASSERT_EQUAL(radio_proto::RTS, d.proto); + } +} +static void test_round_trip_80bit_commands_and_repeats() { + const somfy_commands cmds[] = {somfy_commands::Up, somfy_commands::Down, somfy_commands::My}; + for(somfy_commands cmd : cmds) { + for(uint8_t repeats : {0, 5}) { + somfy_frame_t f = makeFrame(cmd, ADDR, 0x0042, 80); + f.repeats = repeats; + byte buf[10]; + f.encodeFrame(buf); + // The repeat counter is carried in byte 7. + TEST_ASSERT_EQUAL_UINT8(196 + repeats * 4, buf[7]); + somfy_frame_t d = decode(buf, 80); + TEST_ASSERT_TRUE_MESSAGE(d.valid, translateSomfyCommand(cmd).c_str()); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL_HEX16(0x0042, d.rollingCode); + } + } +} +static void test_round_trip_80bit_favorite_and_stop() { + // Sent as My with the real command in an extension nibble. + for(somfy_commands cmd : {somfy_commands::Favorite, somfy_commands::Stop}) { + somfy_frame_t d = roundTrip(makeFrame(cmd, ADDR, 7, 80)); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(cmd, d.cmd); + } +} +static void test_round_trip_80bit_steps() { + for(somfy_commands cmd : {somfy_commands::StepUp, somfy_commands::StepDown}) { + for(uint8_t step : {1, 100}) { + somfy_frame_t f = makeFrame(cmd, ADDR, 7, 80); + f.stepSize = step; + somfy_frame_t d = roundTrip(f); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL_UINT8(step, d.stepSize); + } + } +} +static void test_round_trip_80bit_toggle() { + somfy_frame_t d = roundTrip(makeFrame(somfy_commands::Toggle, ADDR, 7, 80)); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(somfy_commands::Toggle, d.cmd); + TEST_ASSERT_EQUAL_UINT8(164, d.encKey); +} +static void test_round_trip_rtw_and_rtv() { + // These protocols carry the command in the key byte. + for(radio_proto proto : {radio_proto::RTW, radio_proto::RTV}) { + for(somfy_commands cmd : {somfy_commands::Up, somfy_commands::Down}) { + somfy_frame_t d = roundTrip(makeFrame(cmd, ADDR, 9, 56, proto)); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL(proto, d.proto); + } + } +} + +/********************************************************************* + * Rejected frames + ********************************************************************/ +static void test_rejects_bad_checksum() { + byte buf[10]; + makeFrame(somfy_commands::Up, ADDR, 0x1234).encodeFrame(buf); + // Byte 6 is the last obfuscated byte so the flip changes only its decoded value. + buf[6] ^= 0x01; + TEST_ASSERT_FALSE(decode(buf, 56).valid); +} +static void test_rejects_out_of_range_address() { + TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, 0, 0x1234)).valid); + TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, 0xFFFFFF, 0x1234)).valid); +} +static void test_rejects_zero_rolling_code_except_sensor() { + TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, ADDR, 0)).valid); + // Sensor frames carry flags in the rolling code so 0 is fine. + TEST_ASSERT_TRUE(roundTrip(makeFrame(somfy_commands::Sensor, ADDR, 0)).valid); +} +static void test_rejects_zero_key() { + somfy_frame_t f = makeFrame(somfy_commands::Up, ADDR, 0x1234); + f.encKey = 0; + TEST_ASSERT_FALSE(roundTrip(f).valid); +} +static void test_rejects_bad_80bit_parity() { + byte buf[10]; + makeFrame(somfy_commands::Up, ADDR, 0x1234, 80).encodeFrame(buf); + buf[9] ^= 0x01; + TEST_ASSERT_FALSE(decode(buf, 80).valid); +} + +/********************************************************************* + * Helpers + ********************************************************************/ +static void test_encode80Byte7_wraps() { + somfy_frame_t f; + TEST_ASSERT_EQUAL_UINT8(196, f.encode80Byte7(196, 0)); + TEST_ASSERT_EQUAL_UINT8(252, f.encode80Byte7(196, 14)); + // Past 255 the counter steps back by 15 repeats. + TEST_ASSERT_EQUAL_UINT8(196, f.encode80Byte7(196, 15)); + TEST_ASSERT_EQUAL_UINT8(216, f.encode80Byte7(196, 20)); +} +static void test_copy_counts_repeats_and_marks_synonyms() { + somfy_frame_t last = makeFrame(somfy_commands::Up, ADDR, 10); + last.processed = true; + + somfy_frame_t repeat = makeFrame(somfy_commands::Up, ADDR, 10); + last.copy(repeat); + TEST_ASSERT_EQUAL_UINT8(1, last.repeats); + + // Same press reported as another command: keep what was already processed. + somfy_frame_t synonym = makeFrame(somfy_commands::My, ADDR, 10); + last.copy(synonym); + TEST_ASSERT_TRUE(last.synonym); + TEST_ASSERT_TRUE(last.processed); + ASSERT_CMD(somfy_commands::My, last.cmd); + + // A new press replaces it. + somfy_frame_t next = makeFrame(somfy_commands::Down, ADDR, 11); + last.copy(next); + TEST_ASSERT_FALSE(last.synonym); + TEST_ASSERT_FALSE(last.processed); + TEST_ASSERT_EQUAL_UINT16(11, last.rollingCode); + TEST_ASSERT_EQUAL_UINT8(0, last.repeats); +} + +/********************************************************************* + * Command names + ********************************************************************/ +static void test_parse_command_names() { + // What the web UI and the Home Assistant integration send. + ASSERT_CMD(somfy_commands::Up, translateSomfyCommand(String("up"))); + ASSERT_CMD(somfy_commands::Down, translateSomfyCommand(String("DOWN"))); + ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String("my"))); + ASSERT_CMD(somfy_commands::Toggle, translateSomfyCommand(String("toggle"))); + ASSERT_CMD(somfy_commands::Flag, translateSomfyCommand(String("flag"))); + ASSERT_CMD(somfy_commands::SunFlag, translateSomfyCommand(String("sunflag"))); + ASSERT_CMD(somfy_commands::StepUp, translateSomfyCommand(String("StepUp"))); + ASSERT_CMD(somfy_commands::StepDown, translateSomfyCommand(String("StepDown"))); + ASSERT_CMD(somfy_commands::Prog, translateSomfyCommand(String("Prog"))); + // Short forms. + ASSERT_CMD(somfy_commands::Favorite, translateSomfyCommand(String("fav"))); + ASSERT_CMD(somfy_commands::MyUpDown, translateSomfyCommand(String("mud"))); + // A single hex digit is the raw command. + ASSERT_CMD(somfy_commands::Down, translateSomfyCommand(String("4"))); + // Anything else falls back to My. + ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String("xyz"))); + ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String(""))); +} +static void test_command_display_names() { + TEST_ASSERT_EQUAL_STRING("Up", translateSomfyCommand(somfy_commands::Up).c_str()); + TEST_ASSERT_EQUAL_STRING("My+Up", translateSomfyCommand(somfy_commands::MyUp).c_str()); + TEST_ASSERT_EQUAL_STRING("Sun Flag", translateSomfyCommand(somfy_commands::SunFlag).c_str()); + TEST_ASSERT_EQUAL_STRING("Step Up", translateSomfyCommand(somfy_commands::StepUp).c_str()); + TEST_ASSERT_EQUAL_STRING("Favorite", translateSomfyCommand(somfy_commands::Favorite).c_str()); + TEST_ASSERT_EQUAL_STRING("Unknown(13)", translateSomfyCommand(somfy_commands::UnknownD).c_str()); +} +static void test_command_names_round_trip() { + // The names in shadeCommand events (and Home Assistant events) must parse + // back to the same command. + const somfy_commands cmds[] = { + somfy_commands::My, somfy_commands::Up, somfy_commands::MyUp, somfy_commands::Down, + somfy_commands::MyDown, somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Prog, + somfy_commands::SunFlag, somfy_commands::Flag, somfy_commands::StepUp, somfy_commands::StepDown, + somfy_commands::Sensor, somfy_commands::Toggle, somfy_commands::Favorite, somfy_commands::Stop + }; + std::string failed; + for(somfy_commands cmd : cmds) { + String name = translateSomfyCommand(cmd); + if(translateSomfyCommand(name) != cmd) failed += std::string(failed.empty() ? "" : ", ") + name.c_str(); + } + TEST_ASSERT_EQUAL_STRING_MESSAGE("", failed.c_str(), "These names don't parse back to their command"); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_encode_56bit_golden_frame); + RUN_TEST(test_round_trip_56bit_commands); + RUN_TEST(test_round_trip_80bit_commands_and_repeats); + RUN_TEST(test_round_trip_80bit_favorite_and_stop); + RUN_TEST(test_round_trip_80bit_steps); + RUN_TEST(test_round_trip_80bit_toggle); + RUN_TEST(test_round_trip_rtw_and_rtv); + RUN_TEST(test_rejects_bad_checksum); + RUN_TEST(test_rejects_out_of_range_address); + RUN_TEST(test_rejects_zero_rolling_code_except_sensor); + RUN_TEST(test_rejects_zero_key); + RUN_TEST(test_rejects_bad_80bit_parity); + RUN_TEST(test_encode80Byte7_wraps); + RUN_TEST(test_copy_counts_repeats_and_marks_synonyms); + RUN_TEST(test_parse_command_names); + RUN_TEST(test_command_display_names); + RUN_TEST(test_command_names_round_trip); + return UNITY_END(); +}