feat!: remove the old API, ports 8081 and 8080 (step 4a)

Step 4a of docs/SECURITY_PLAN.md. Port 80 keeps the eleven HTTP routes of
ADR 0006 and the socket at /ws; everything else moved to protocol 2.

- Port 8081 (ApiRoutes) and the socket on 8080 (SocketEmitter) are gone.
  Protocol 2 owns its frame buffer and loop-task check.
- Port 80 drops every route the old web UI called: reads, edits,
  settings, scans, reboot, /shades.cfg, /discovery. HttpRoutes.cpp holds
  what stays.
- ModelSnapshot, SnapshotSlots and the read documents are gone. The item
  snapshot types stay, as ItemSnapshot, for events, MQTT and the command
  replies.
- Model edits reply only to their protocol 2 op: requestResult goes.
- The settings' toJSON writers, used only by the settings routes, go.
- The _espsomfy_rts._tcp mDNS record moves to port 80, with path=/ws.
- The memory event reports the loop-rate window it closes.
- A deleted room no longer re-publishes its retained MQTT topics.
- e2e: the heap check reads the sync's status; the sync is checked on
  its own instead of against /controller.
- README gets an API and ports section.

BREAKING CHANGE: ports 8081 and 8080 and the old HTTP routes are gone.
Home Assistant needs integration 3.0.0 or newer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-27 21:49:38 +01:00
parent c91c20d444
commit 2a954aa158
53 changed files with 613 additions and 3549 deletions

View file

@ -226,7 +226,14 @@ def http_json(host, path, port=80, method="GET", body=None, headers=None, timeou
return json.loads(r.read())
def heap(host):
"""free, min and uptime from /discovery on 8081 (protocol 1, until it goes)."""
m = http_json(host, "/discovery", port=8081)["memory"]
return {"free": m["free"], "min": m["min"], "max": m["max"], "uptime": m["uptime"]}
def heap(host, token=None):
"""free, min, max and uptime from the status in a sync."""
c = ProtoClient(host, params={"apikey": token} if token else None)
try:
schema = Schema()
_, frames, _ = run_sync(c, schema)
finally:
c.close()
key = schema.keys["status"]
status = schema.expand(next(f["d"][key] for f in frames if key in f["d"]))
return {"free": status["freeHeap"], "min": status["minHeap"], "max": status["maxHeap"], "uptime": status["uptime"]}

View file

@ -16,16 +16,16 @@ MAX_CLIENTS = 5
@pytest.fixture
def watch_heap(host):
def watch_heap(host, token):
"""Fails the test when the device rebooted or its heap fell under the floor.
The device reports its lowest heap since it booted, so a dip under the
floor only shows while that low is still above it."""
before = heap(host)
before = heap(host, token)
if before["min"] < HEAP_FLOOR:
pytest.skip("the heap has been down to %u bytes since boot: reboot the device to check the floor" % before["min"])
yield before
after = heap(host)
after = heap(host, token)
assert after["uptime"] > before["uptime"], "the device rebooted"
assert after["min"] >= HEAP_FLOOR, "the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR)

View file

@ -2,7 +2,7 @@
import json
import os
from proto_client import Schema, http_json, run_sync
from proto_client import Schema, run_sync
FIXTURES = os.path.join(os.path.dirname(__file__), "..", "fixtures", "proto2")
@ -55,24 +55,21 @@ def test_an_old_schema_hash_gets_the_schema_again(connect):
assert schema.complete()
def test_sync_holds_the_same_model_as_controller(connect, schema, host, token):
def test_sync_is_whole(connect, schema):
"""Every group links shades the sync has, every item's room is in it, and
there is one header and one status."""
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
headers = {"apikey": token} if token else {}
controller = http_json(host, "/controller", headers=headers)
shades = [schema.expand(s) for s in items(frames, schema, "shades")]
groups = [schema.expand(g) for g in items(frames, schema, "groups")]
rooms = items(frames, schema, "rooms")
assert sorted(s["shadeId"] for s in shades) == sorted(s["shadeId"] for s in controller["shades"])
assert sorted(g["groupId"] for g in groups) == sorted(g["groupId"] for g in controller["groups"])
assert len(rooms) == len(controller["rooms"])
by_id = {s["shadeId"]: s for s in controller["shades"]}
for s in shades:
assert s.get("name", "") == by_id[s["shadeId"]]["name"]
assert s.get("remoteAddress", 0) == by_id[s["shadeId"]]["remoteAddress"]
rooms = [schema.expand(r) for r in items(frames, schema, "rooms")]
shade_ids = [s["shadeId"] for s in shades]
room_ids = {r["roomId"] for r in rooms} | {0}
assert len(shade_ids) == len(set(shade_ids))
for g in groups:
linked = sorted(x["shadeId"] for x in next(cg for cg in controller["groups"] if cg["groupId"] == g["groupId"])["linkedShades"])
assert sorted(g.get("linkedShades", [])) == linked
assert set(g.get("linkedShades", [])) <= set(shade_ids), g["groupId"]
for item in shades + groups:
assert item.get("roomId", 0) in room_ids
assert len(items(frames, schema, "header")) == 1
assert len(items(frames, schema, "status")) == 1

View file

@ -0,0 +1,79 @@
#include <unity.h>
#include <string.h>
#include <string>
#include "ItemSnapshot.h"
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.paired = true;
s.shadeType = 1;
s.bitLength = 56;
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 shadeRefJson(const shade_snapshot_t &s) {
static char buf[1024];
BufferedJsonFormatter fmt;
fmt.setBuffer(buf, sizeof(buf));
fmt.beginObject();
writeShadeRef(fmt, s);
fmt.endObject();
return std::string(buf, fmt.length());
}
static std::string groupRefJson(const group_snapshot_t &g) {
static char buf[1024];
BufferedJsonFormatter fmt;
fmt.setBuffer(buf, sizeof(buf));
fmt.beginObject();
writeGroupRef(fmt, g);
fmt.endObject();
return std::string(buf, fmt.length());
}
void setUp() {}
void tearDown() {}
/*********************************************************************
* The command replies, as the routes have always written them
********************************************************************/
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(shade(1, "Kitchen")).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(group(6, "All")).c_str());
}
static void test_names_are_escaped() {
const std::string json = shadeRefJson(shade(1, "A \"big\" one"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, json.find("\"name\":\"A \\\"big\\\" one\""));
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_a_shade_ref);
RUN_TEST(test_a_group_ref);
RUN_TEST(test_names_are_escaped);
return UNITY_END();
}

View file

@ -21,7 +21,7 @@ static const shade_request_type_t OFF_RADIO_REQUESTS[] = {
shade_request_type_t::set_positions, shade_request_type_t::set_rolling_code
};
static const loop_item_type_t OTHER_ITEMS[] = {
loop_item_type_t::restore_shades, loop_item_type_t::load_shades, loop_item_type_t::model_edit, loop_item_type_t::proto_read,
loop_item_type_t::restore_shades, loop_item_type_t::model_edit, loop_item_type_t::proto_read,
// A frequency scan's end among them, so it never waits for the radio.
loop_item_type_t::proto_device
};

View file

@ -1,354 +0,0 @@
#include <unity.h>
#include <string.h>
#include <string>
#include "ModelSnapshot.h"
static model_snapshot_t snap;
static char unitBuf[SNAPSHOT_UNIT_SIZE];
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());
}
/*********************************************************************
* Repeaters, what a new item would get, and group options
********************************************************************/
static void test_repeaters_list() {
TEST_ASSERT_EQUAL_STRING("[12345,678]", document(snapshot_doc_t::repeaters).c_str());
}
static void test_next_room_fills_the_lowest_gap() {
TEST_ASSERT_EQUAL_STRING("{\"roomId\":1}", document(snapshot_doc_t::nextRoom).c_str());
}
static void test_next_shade() {
// Ids 1 and 4 are taken, so 2; its address is startingAddress + id unless taken.
snap.radioBitLength = 80;
snap.radioProto = 1;
TEST_ASSERT_EQUAL_STRING("{\"shadeId\":2,\"remoteAddress\":79,\"bitLength\":80,\"stepSize\":100,\"proto\":1}",
document(snapshot_doc_t::nextShade).c_str());
}
static void test_next_address_skips_one_already_used() {
snap.shades[0].remoteAddress = 79; // what shade 2 would get
snap.shades[1].remoteAddress = 80;
TEST_ASSERT_EQUAL_UINT32(81, nextRemoteAddress(snap, 2));
}
static void test_next_group() {
TEST_ASSERT_EQUAL_STRING("{\"groupId\":1,\"remoteAddress\":78,\"bitLength\":0,\"proto\":0}",
document(snapshot_doc_t::nextGroup).c_str());
}
static void test_the_last_ids_are_given_out() {
// Ids used to stop two short: 30 shades where the limit says 32.
snap.shadeCount = 0;
for(uint8_t id = 1; id <= SOMFY_MAX_SHADES - 2; id++) snap.shades[snap.shadeCount++] = shade(id, "S");
TEST_ASSERT_EQUAL_UINT8(SOMFY_MAX_SHADES - 1, nextShadeId(snap));
snap.shades[snap.shadeCount++] = shade(SOMFY_MAX_SHADES - 1, "S");
TEST_ASSERT_EQUAL_UINT8(SOMFY_MAX_SHADES, nextShadeId(snap));
}
static void test_no_room_for_another_shade() {
snap.shadeCount = 0;
for(uint8_t id = 1; id <= SOMFY_MAX_SHADES; id++) snap.shades[snap.shadeCount++] = shade(id, "S");
TEST_ASSERT_EQUAL_UINT8(255, nextShadeId(snap));
}
static void test_group_options_lists_the_shades_not_in_it() {
snap.shades[snap.shadeCount++] = shade(7, "Spare");
const std::string expected = groupWithLinked().substr(0, groupWithLinked().length() - 1) +
",\"availShades\":[" + shadeRefJson(7) + "]}";
TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::groupOptions, 6).c_str());
}
static void test_group_options_of_a_group_gone_by_the_snapshot() {
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}",
document(snapshot_doc_t::groupOptions, 9).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_repeaters_list);
RUN_TEST(test_next_room_fills_the_lowest_gap);
RUN_TEST(test_next_shade);
RUN_TEST(test_next_address_skips_one_already_used);
RUN_TEST(test_next_group);
RUN_TEST(test_the_last_ids_are_given_out);
RUN_TEST(test_no_room_for_another_shade);
RUN_TEST(test_group_options_lists_the_shades_not_in_it);
RUN_TEST(test_group_options_of_a_group_gone_by_the_snapshot);
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();
}

View file

@ -1,143 +0,0 @@
#include <unity.h>
#include <stdio.h>
#include <string.h>
#include <string>
#include "ModelSnapshot.h"
// The read routes format each room, shade, group and linked shade into a
// SNAPSHOT_UNIT_SIZE unit and leave out one that doesn't fit, without telling
// the client (KNOWN_ISSUES.md). These tests fill the snapshot with the
// largest possible items and check every document comes out whole.
static model_snapshot_t snap;
static char unitBuf[SNAPSHOT_UNIT_SIZE];
// Big enough for any item, so nothing is ever left out with it.
static char bigUnit[64 * 1024];
// The widest name: 20 control characters, each escaped to \u00XX.
static void widestName(char *name) {
memset(name, 0x01, 20);
name[20] = '\0';
}
static void fill() {
snap = model_snapshot_t{};
snap.startingAddress = 0xFFFFFFFF;
snap.radioBitLength = 255;
snap.radioProto = 255;
for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) {
room_snapshot_t &r = snap.rooms[snap.roomCount++];
r.roomId = i + 1;
widestName(r.name);
r.sortOrder = -128;
}
for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) {
shade_snapshot_t &s = snap.shades[snap.shadeCount++];
s.shadeId = i + 1;
s.roomId = 255;
widestName(s.name);
s.remoteAddress = s.upTime = s.downTime = s.tiltTime = 0xFFFFFFFF;
s.stepSize = s.lastRollingCode = 0xFFFF;
s.position = s.target = s.myPos = s.direction = -128;
s.tiltPosition = s.tiltTarget = s.myTiltPos = s.tiltDirection = -128;
s.tiltType = s.shadeType = s.bitLength = s.proto = s.flags = s.repeats = 255;
s.gpioUp = s.gpioDown = s.gpioMy = 255;
s.sortOrder = -128;
s.paired = s.flipCommands = s.flipPosition = s.inGroup = false;
s.sunSensor = s.light = s.simMy = s.gpioLLTrigger = false;
s.linkedRemoteCount = SOMFY_MAX_LINKED_REMOTES;
for(uint8_t j = 0; j < SOMFY_MAX_LINKED_REMOTES; j++) s.linkedRemotes[j] = {0xFFFFFFFF, 0xFFFF};
}
for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) {
group_snapshot_t &g = snap.groups[snap.groupCount++];
g.groupId = i + 1;
g.roomId = 255;
widestName(g.name);
g.remoteAddress = 0xFFFFFFFF;
g.lastRollingCode = 0xFFFF;
g.bitLength = g.proto = g.flags = g.repeats = 255;
g.sortOrder = -128;
g.sunSensor = g.flipCommands = false;
g.linkedShadeCount = SOMFY_MAX_GROUPED_SHADES;
for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) g.linkedShades[j] = j + 1;
}
for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) snap.repeaters[snap.repeaterCount++] = 0xFFFFFFFF;
}
// The device details aren't in the snapshot (Web.cpp writes them), so the test
// header writes nothing.
static void noHeader(JsonFormatter &, const model_snapshot_t &) {}
static std::string document(snapshot_doc_t doc, uint8_t id, char *unit, size_t unitSize) {
SnapshotWriter writer(snap, doc, id, noHeader, unit, unitSize);
std::string out;
uint8_t chunk[1460];
for(int guard = 0; guard < 100000; guard++) {
const size_t n = writer.generate(chunk, sizeof(chunk));
if(n == 0) break;
out.append(reinterpret_cast<char *>(chunk), n);
}
return out;
}
// The document as the device writes it is the one nothing is left out of.
static void assertWhole(snapshot_doc_t doc, uint8_t id = 0) {
const std::string full = document(doc, id, bigUnit, sizeof(bigUnit));
const std::string device = document(doc, id, unitBuf, sizeof(unitBuf));
TEST_ASSERT_TRUE(full.length() > 2);
TEST_ASSERT_EQUAL_UINT32(full.length(), device.length());
TEST_ASSERT_TRUE(full == device);
}
// How big an item is, formatted as the writer does on its own.
static size_t itemSize(void (*write)(JsonFormatter &)) {
BufferedJsonFormatter fmt;
fmt.setBuffer(bigUnit, sizeof(bigUnit));
write(fmt);
return fmt.length();
}
void setUp() { fill(); }
void tearDown() {}
static void test_largest_items_fit_the_unit() {
const size_t room = itemSize([](JsonFormatter &j) { j.beginObject(); writeRoom(j, snap.rooms[0]); j.endObject(); });
const size_t shade = itemSize([](JsonFormatter &j) { j.beginObject(); writeShade(j, snap.shades[0]); j.endObject(); });
const size_t shadeRef = itemSize([](JsonFormatter &j) { j.beginObject(); writeShadeRef(j, snap.shades[0]); j.endObject(); });
const size_t group = itemSize([](JsonFormatter &j) { j.beginObject(); writeGroupRef(j, snap.groups[0]); j.beginArray("linkedShades"); });
char msg[160];
snprintf(msg, sizeof(msg), "largest items in a %u-byte unit: room %u, shade %u, linked shade %u, group %u",
(unsigned)SNAPSHOT_UNIT_SIZE, (unsigned)room, (unsigned)shade, (unsigned)shadeRef, (unsigned)group);
TEST_MESSAGE(msg);
// One byte for the comma before an item, one for the end of the string.
TEST_ASSERT_TRUE(room + 2 <= SNAPSHOT_UNIT_SIZE);
TEST_ASSERT_TRUE(shade + 2 <= SNAPSHOT_UNIT_SIZE);
TEST_ASSERT_TRUE(shadeRef + 2 <= SNAPSHOT_UNIT_SIZE);
TEST_ASSERT_TRUE(group + 2 <= SNAPSHOT_UNIT_SIZE);
}
static void test_controller_is_whole() { assertWhole(snapshot_doc_t::controller); }
static void test_discovery_is_whole() { assertWhole(snapshot_doc_t::discovery); }
static void test_rooms_are_whole() { assertWhole(snapshot_doc_t::rooms); }
static void test_shades_are_whole() { assertWhole(snapshot_doc_t::shades); }
static void test_groups_are_whole() { assertWhole(snapshot_doc_t::groups); }
static void test_single_items_are_whole() {
assertWhole(snapshot_doc_t::room, SOMFY_MAX_ROOMS);
assertWhole(snapshot_doc_t::shade, SOMFY_MAX_SHADES);
assertWhole(snapshot_doc_t::group, SOMFY_MAX_GROUPS);
assertWhole(snapshot_doc_t::groupOptions, SOMFY_MAX_GROUPS);
assertWhole(snapshot_doc_t::repeaters);
}
static void test_a_too_small_unit_is_caught() {
// The comparison itself works: a unit too small for a shade loses shades.
const std::string full = document(snapshot_doc_t::shades, 0, bigUnit, sizeof(bigUnit));
const std::string small = document(snapshot_doc_t::shades, 0, unitBuf, 200);
TEST_ASSERT_TRUE(small.length() < full.length());
}
int main(int argc, char **argv) {
UNITY_BEGIN();
RUN_TEST(test_largest_items_fit_the_unit);
RUN_TEST(test_controller_is_whole);
RUN_TEST(test_discovery_is_whole);
RUN_TEST(test_rooms_are_whole);
RUN_TEST(test_shades_are_whole);
RUN_TEST(test_groups_are_whole);
RUN_TEST(test_single_items_are_whole);
RUN_TEST(test_a_too_small_unit_is_caught);
return UNITY_END();
}

View file

@ -199,7 +199,7 @@ static void test_rssiNeedsEmit_either_direction() {
********************************************************************/
static RssiBroadcast broadcastOf(int rssi, int channel) {
RssiBroadcast b;
b.sent(true, rssi, channel);
b.sent(rssi, channel);
return b;
}
static void test_rssiBroadcast_first_status_is_due() {
@ -208,17 +208,9 @@ static void test_rssiBroadcast_first_status_is_due() {
RssiBroadcast b;
TEST_ASSERT_TRUE(b.due(-60, 6, 3));
}
static void test_rssiBroadcast_client_emit_keeps_baseline() {
// A new client gets the current RSSI right as it drops 5 dBm. The clients
// already connected must still get the change.
RssiBroadcast b = broadcastOf(-60, 6);
b.sent(false, -65, 6);
TEST_ASSERT_EQUAL_INT(-60, b.rssi);
TEST_ASSERT_TRUE(b.due(-65, 6, 3));
}
static void test_rssiBroadcast_broadcast_moves_baseline() {
RssiBroadcast b = broadcastOf(-60, 6);
b.sent(true, -65, 6);
b.sent(-65, 6);
TEST_ASSERT_EQUAL_INT(-65, b.rssi);
// Measured from the new baseline.
TEST_ASSERT_FALSE(b.due(-66, 6, 3));
@ -228,12 +220,6 @@ static void test_rssiBroadcast_channel_change_is_due() {
RssiBroadcast b = broadcastOf(-60, 6);
TEST_ASSERT_TRUE(b.due(-60, 11, 3));
}
static void test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline() {
RssiBroadcast b = broadcastOf(-60, 6);
b.sent(false, -100, -1);
TEST_ASSERT_EQUAL_INT(6, b.channel);
TEST_ASSERT_FALSE(b.due(-60, 6, 3));
}
/*********************************************************************
* emitAllowed
@ -434,10 +420,8 @@ int main(int argc, char **argv) {
RUN_TEST(test_rssiNeedsEmit_real_change_emits);
RUN_TEST(test_rssiNeedsEmit_either_direction);
RUN_TEST(test_rssiBroadcast_first_status_is_due);
RUN_TEST(test_rssiBroadcast_client_emit_keeps_baseline);
RUN_TEST(test_rssiBroadcast_broadcast_moves_baseline);
RUN_TEST(test_rssiBroadcast_channel_change_is_due);
RUN_TEST(test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline);
RUN_TEST(test_emitAllowed_on_the_loop_task);
RUN_TEST(test_emitAllowed_refuses_another_task);
RUN_TEST(test_emitAllowed_before_the_owner_is_known);