mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
/controller, /discovery, /rooms, /shades, /groups, /room, /shade and /group now take a SnapshotSlots slot, queue a fill on the LoopQueue and send a chunked response whose body waits (RESPONSE_TRY_AGAIN) until loop() has copied the shade model into the slot. SnapshotWriter then streams the document from that copy a unit at a time, so each document describes one moment. At most two reads run at once; a third gets 503. A slot's 5.6 KB snapshot is allocated only while a read holds it, and its atomic state decides whether loop() or the read frees it when the read goes away first. The JSON of each item is unchanged and now lives in ModelSnapshot.cpp, where the native tests check it. Replies still written on the web server task copy their one item with SomfySnapshot.cpp, so serializing a group no longer calls updateFlags(), which emitted groupState and published MQTT from async_tcp. Also: every read route is chunked and leaves out an item too big for its 3 KB unit, like /controller did; /discovery drops its timing log; the SOMFY_MAX_* limits move to ShadeTypes.h. 21 new native tests, 292 in all. Flash: esp32devdbg +440 bytes, esp32dev +2,064 bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
12 KiB
C++
296 lines
12 KiB
C++
#include <unity.h>
|
|
#include <string.h>
|
|
#include <string>
|
|
#include "ModelSnapshot.h"
|
|
|
|
static model_snapshot_t snap;
|
|
static char unitBuf[3072];
|
|
|
|
static room_snapshot_t room(uint8_t id, const char *name) {
|
|
room_snapshot_t r{};
|
|
r.roomId = id;
|
|
strcpy(r.name, name);
|
|
return r;
|
|
}
|
|
static shade_snapshot_t shade(uint8_t id, const char *name) {
|
|
shade_snapshot_t s{};
|
|
s.shadeId = id;
|
|
s.roomId = 2;
|
|
strcpy(s.name, name);
|
|
s.remoteAddress = 1000 + id;
|
|
s.upTime = 10000;
|
|
s.downTime = 11000;
|
|
s.tiltTime = 7000;
|
|
s.stepSize = 100;
|
|
s.paired = true;
|
|
s.lastRollingCode = 42;
|
|
s.position = 30;
|
|
s.target = 30;
|
|
s.myPos = -1;
|
|
s.tiltType = 2;
|
|
s.tiltPosition = 40;
|
|
s.tiltTarget = 40;
|
|
s.myTiltPos = -1;
|
|
s.shadeType = 1;
|
|
s.bitLength = 56;
|
|
s.inGroup = true;
|
|
s.repeats = 1;
|
|
return s;
|
|
}
|
|
static group_snapshot_t group(uint8_t id, const char *name) {
|
|
group_snapshot_t g{};
|
|
g.groupId = id;
|
|
strcpy(g.name, name);
|
|
g.remoteAddress = 2000 + id;
|
|
g.lastRollingCode = 5;
|
|
g.bitLength = 80;
|
|
g.repeats = 2;
|
|
return g;
|
|
}
|
|
static std::string json(void (*write)(JsonFormatter &)) {
|
|
static char buf[2048];
|
|
BufferedJsonFormatter fmt;
|
|
fmt.setBuffer(buf, sizeof(buf));
|
|
fmt.beginObject();
|
|
write(fmt);
|
|
fmt.endObject();
|
|
return std::string(buf, fmt.length());
|
|
}
|
|
static std::string shadeRefJson(uint8_t id) {
|
|
static uint8_t which;
|
|
which = id;
|
|
return json([](JsonFormatter &f) { writeShadeRef(f, *snap.shade(which)); });
|
|
}
|
|
static std::string shadeJson(uint8_t id) {
|
|
static uint8_t which;
|
|
which = id;
|
|
return json([](JsonFormatter &f) { writeShade(f, *snap.shade(which)); });
|
|
}
|
|
static std::string roomJson(uint8_t id) {
|
|
static uint8_t which;
|
|
which = id;
|
|
return json([](JsonFormatter &f) { writeRoom(f, *snap.room(which)); });
|
|
}
|
|
static std::string groupRefJson(uint8_t id) {
|
|
static uint8_t which;
|
|
which = id;
|
|
return json([](JsonFormatter &f) { writeGroupRef(f, *snap.group(which)); });
|
|
}
|
|
static void testHeader(JsonFormatter &json, const model_snapshot_t &s) {
|
|
json.addElem("startingAddress", s.startingAddress);
|
|
}
|
|
// The whole document, taken chunkSize bytes at a time like the web server does.
|
|
static std::string document(snapshot_doc_t doc, uint8_t id = 0, size_t chunkSize = 7, size_t unitSize = sizeof(unitBuf)) {
|
|
SnapshotWriter writer(snap, doc, id, testHeader, unitBuf, unitSize);
|
|
std::string out;
|
|
uint8_t chunk[64];
|
|
for(int guard = 0; guard < 100000; guard++) {
|
|
const size_t n = writer.generate(chunk, chunkSize);
|
|
if(n == 0) break;
|
|
out.append(reinterpret_cast<char *>(chunk), n);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void setUp() {
|
|
snap = model_snapshot_t{};
|
|
snap.startingAddress = 77;
|
|
snap.rooms[snap.roomCount++] = room(2, "Living");
|
|
snap.rooms[snap.roomCount++] = room(3, "Bed");
|
|
snap.shades[snap.shadeCount++] = shade(1, "Kitchen");
|
|
snap.shades[snap.shadeCount++] = shade(4, "Porch");
|
|
group_snapshot_t g = group(6, "All");
|
|
g.linkedShades[g.linkedShadeCount++] = 4;
|
|
g.linkedShades[g.linkedShadeCount++] = 9; // gone from the model
|
|
g.linkedShades[g.linkedShadeCount++] = 1;
|
|
snap.groups[snap.groupCount++] = g;
|
|
snap.repeaters[snap.repeaterCount++] = 12345;
|
|
snap.repeaters[snap.repeaterCount++] = 678;
|
|
}
|
|
void tearDown() {}
|
|
|
|
/*********************************************************************
|
|
* Items, as the routes have always written them
|
|
********************************************************************/
|
|
static void test_a_shade() {
|
|
snap.shades[0].linkedRemotes[snap.shades[0].linkedRemoteCount++] = { 5555, 7 };
|
|
TEST_ASSERT_EQUAL_STRING(
|
|
"{\"shadeId\":1,\"roomId\":2,\"name\":\"Kitchen\",\"remoteAddress\":1001,\"upTime\":10000,\"downTime\":11000,"
|
|
"\"paired\":true,\"lastRollingCode\":42,\"position\":30,\"tiltType\":2,\"tiltPosition\":40,\"tiltDirection\":0,"
|
|
"\"tiltTime\":7000,\"stepSize\":100,\"tiltTarget\":40,\"target\":30,\"myPos\":-1,\"myTiltPos\":-1,\"direction\":0,"
|
|
"\"shadeType\":1,\"bitLength\":56,\"proto\":0,\"flags\":0,\"flipCommands\":false,\"flipPosition\":false,"
|
|
"\"inGroup\":true,\"sunSensor\":false,\"light\":false,\"repeats\":1,\"sortOrder\":0,\"gpioUp\":0,\"gpioDown\":0,"
|
|
"\"gpioMy\":0,\"gpioLLTrigger\":false,\"simMy\":false,\"linkedRemotes\":[{\"remoteAddress\":5555,\"lastRollingCode\":7}]}",
|
|
shadeJson(1).c_str());
|
|
}
|
|
static void test_a_shade_ref() {
|
|
TEST_ASSERT_EQUAL_STRING(
|
|
"{\"shadeId\":1,\"roomId\":2,\"name\":\"Kitchen\",\"remoteAddress\":1001,\"paired\":true,\"shadeType\":1,"
|
|
"\"flipCommands\":false,\"flipPosition\":false,\"bitLength\":56,\"proto\":0,\"flags\":0,\"sunSensor\":false,"
|
|
"\"hasLight\":false,\"repeats\":1}",
|
|
shadeRefJson(1).c_str());
|
|
}
|
|
static void test_a_room() {
|
|
TEST_ASSERT_EQUAL_STRING("{\"roomId\":2,\"name\":\"Living\",\"sortOrder\":0}", roomJson(2).c_str());
|
|
}
|
|
static void test_a_group_ref() {
|
|
TEST_ASSERT_EQUAL_STRING(
|
|
"{\"groupId\":6,\"roomId\":0,\"name\":\"All\",\"remoteAddress\":2006,\"lastRollingCode\":5,\"bitLength\":80,"
|
|
"\"proto\":0,\"sunSensor\":false,\"flipCommands\":false,\"flags\":0,\"repeats\":2,\"sortOrder\":0}",
|
|
groupRefJson(6).c_str());
|
|
}
|
|
static void test_names_are_escaped() {
|
|
strcpy(snap.rooms[0].name, "A \"big\" room");
|
|
TEST_ASSERT_EQUAL_STRING("{\"roomId\":2,\"name\":\"A \\\"big\\\" room\",\"sortOrder\":0}", roomJson(2).c_str());
|
|
}
|
|
|
|
/*********************************************************************
|
|
* Documents
|
|
********************************************************************/
|
|
static std::string groupWithLinked() {
|
|
std::string g = groupRefJson(6);
|
|
g.pop_back(); // reopen it for linkedShades
|
|
return g + ",\"linkedShades\":[" + shadeRefJson(4) + "," + shadeRefJson(1) + "]}";
|
|
}
|
|
static void test_rooms_list() {
|
|
TEST_ASSERT_EQUAL_STRING(("[" + roomJson(2) + "," + roomJson(3) + "]").c_str(), document(snapshot_doc_t::rooms).c_str());
|
|
}
|
|
static void test_shades_list() {
|
|
TEST_ASSERT_EQUAL_STRING(("[" + shadeJson(1) + "," + shadeJson(4) + "]").c_str(), document(snapshot_doc_t::shades).c_str());
|
|
}
|
|
static void test_groups_list_skips_a_linked_shade_that_is_gone() {
|
|
TEST_ASSERT_EQUAL_STRING(("[" + groupWithLinked() + "]").c_str(), document(snapshot_doc_t::groups).c_str());
|
|
}
|
|
static void test_empty_lists() {
|
|
snap = model_snapshot_t{};
|
|
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::rooms).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::shades).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::groups).c_str());
|
|
}
|
|
static void test_controller() {
|
|
const std::string expected = "{\"startingAddress\":77,\"rooms\":[" + roomJson(2) + "," + roomJson(3) +
|
|
"],\"shades\":[" + shadeJson(1) + "," + shadeJson(4) +
|
|
"],\"groups\":[" + groupWithLinked() +
|
|
"],\"repeaters\":[12345,678]}";
|
|
TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::controller).c_str());
|
|
}
|
|
static void test_discovery_has_no_repeaters() {
|
|
const std::string expected = "{\"startingAddress\":77,\"rooms\":[" + roomJson(2) + "," + roomJson(3) +
|
|
"],\"shades\":[" + shadeJson(1) + "," + shadeJson(4) +
|
|
"],\"groups\":[" + groupWithLinked() + "]}";
|
|
TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::discovery).c_str());
|
|
}
|
|
static void test_controller_of_an_empty_model() {
|
|
snap = model_snapshot_t{};
|
|
TEST_ASSERT_EQUAL_STRING("{\"startingAddress\":0,\"rooms\":[],\"shades\":[],\"groups\":[],\"repeaters\":[]}",
|
|
document(snapshot_doc_t::controller).c_str());
|
|
}
|
|
static void test_chunk_size_does_not_change_the_document() {
|
|
const std::string whole = document(snapshot_doc_t::controller, 0, 64);
|
|
for(size_t n = 1; n < 20; n++)
|
|
TEST_ASSERT_EQUAL_STRING(whole.c_str(), document(snapshot_doc_t::controller, 0, n).c_str());
|
|
}
|
|
static void test_single_items() {
|
|
TEST_ASSERT_EQUAL_STRING(roomJson(3).c_str(), document(snapshot_doc_t::room, 3).c_str());
|
|
TEST_ASSERT_EQUAL_STRING(shadeJson(4).c_str(), document(snapshot_doc_t::shade, 4).c_str());
|
|
TEST_ASSERT_EQUAL_STRING(groupWithLinked().c_str(), document(snapshot_doc_t::group, 6).c_str());
|
|
}
|
|
static void test_a_single_item_gone_by_the_snapshot_is_an_error() {
|
|
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}", document(snapshot_doc_t::shade, 9).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}", document(snapshot_doc_t::group, 9).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}", document(snapshot_doc_t::room, 9).c_str());
|
|
}
|
|
static void test_an_item_too_big_for_the_unit_is_left_out() {
|
|
// Room JSON is about 40 bytes and a shade's well over 300, so a 200 byte
|
|
// unit takes the rooms and leaves the shades out, keeping the JSON valid.
|
|
TEST_ASSERT_EQUAL_STRING(("[" + roomJson(2) + "," + roomJson(3) + "]").c_str(),
|
|
document(snapshot_doc_t::rooms, 0, 7, 200).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::shades, 0, 7, 200).c_str());
|
|
}
|
|
static void test_a_left_out_item_does_not_leave_a_stray_comma() {
|
|
// Give the first shade every linked remote so it no longer fits a unit the
|
|
// second one does.
|
|
snap.shades[0].linkedRemoteCount = SOMFY_MAX_LINKED_REMOTES;
|
|
for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) snap.shades[0].linkedRemotes[i] = { 4000000000u, 65535 };
|
|
const size_t fits = shadeJson(4).length() + 8;
|
|
TEST_ASSERT_TRUE(shadeJson(1).length() > fits);
|
|
TEST_ASSERT_EQUAL_STRING(("[" + shadeJson(4) + "]").c_str(), document(snapshot_doc_t::shades, 0, 7, fits).c_str());
|
|
}
|
|
|
|
/*********************************************************************
|
|
* Slots
|
|
********************************************************************/
|
|
static SnapshotSlots slots;
|
|
static int fills = 0;
|
|
static void countFill(model_snapshot_t &s) {
|
|
fills++;
|
|
s.shadeCount = 1;
|
|
}
|
|
|
|
static void test_two_reads_at_once_and_no_more() {
|
|
const int8_t a = slots.acquire();
|
|
const int8_t b = slots.acquire();
|
|
TEST_ASSERT_TRUE(a >= 0 && b >= 0 && a != b);
|
|
TEST_ASSERT_EQUAL_INT8(-1, slots.acquire());
|
|
slots.cancel(a);
|
|
slots.cancel(b);
|
|
}
|
|
static void test_a_read_is_filled_then_freed() {
|
|
fills = 0;
|
|
const int8_t s = slots.acquire();
|
|
TEST_ASSERT_FALSE(slots.filled(s));
|
|
slots.fill(s, countFill);
|
|
TEST_ASSERT_TRUE(slots.filled(s));
|
|
TEST_ASSERT_EQUAL_UINT8(1, slots.snapshot(s).shadeCount);
|
|
slots.release(s);
|
|
TEST_ASSERT_EQUAL_INT8(s, slots.acquire());
|
|
slots.cancel(s);
|
|
TEST_ASSERT_EQUAL_INT(1, fills);
|
|
}
|
|
static void test_a_read_gone_before_its_fill_is_not_filled() {
|
|
fills = 0;
|
|
const int8_t s = slots.acquire();
|
|
const int8_t other = slots.acquire();
|
|
slots.release(s);
|
|
// Not free yet: loop() still has the fill queued.
|
|
TEST_ASSERT_EQUAL_INT8(-1, slots.acquire());
|
|
slots.fill(s, countFill);
|
|
TEST_ASSERT_EQUAL_INT(0, fills);
|
|
TEST_ASSERT_EQUAL_INT8(s, slots.acquire());
|
|
slots.cancel(s);
|
|
slots.cancel(other);
|
|
}
|
|
static void test_a_cancelled_slot_is_free_again() {
|
|
const int8_t s = slots.acquire();
|
|
slots.cancel(s);
|
|
TEST_ASSERT_EQUAL_INT8(s, slots.acquire());
|
|
slots.cancel(s);
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
(void)argc;
|
|
(void)argv;
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_a_shade);
|
|
RUN_TEST(test_a_shade_ref);
|
|
RUN_TEST(test_a_room);
|
|
RUN_TEST(test_a_group_ref);
|
|
RUN_TEST(test_names_are_escaped);
|
|
RUN_TEST(test_rooms_list);
|
|
RUN_TEST(test_shades_list);
|
|
RUN_TEST(test_groups_list_skips_a_linked_shade_that_is_gone);
|
|
RUN_TEST(test_empty_lists);
|
|
RUN_TEST(test_controller);
|
|
RUN_TEST(test_discovery_has_no_repeaters);
|
|
RUN_TEST(test_controller_of_an_empty_model);
|
|
RUN_TEST(test_chunk_size_does_not_change_the_document);
|
|
RUN_TEST(test_single_items);
|
|
RUN_TEST(test_a_single_item_gone_by_the_snapshot_is_an_error);
|
|
RUN_TEST(test_an_item_too_big_for_the_unit_is_left_out);
|
|
RUN_TEST(test_a_left_out_item_does_not_leave_a_stray_comma);
|
|
RUN_TEST(test_two_reads_at_once_and_no_more);
|
|
RUN_TEST(test_a_read_is_filled_then_freed);
|
|
RUN_TEST(test_a_read_gone_before_its_fill_is_not_filled);
|
|
RUN_TEST(test_a_cancelled_slot_is_free_again);
|
|
return UNITY_END();
|
|
}
|