mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
feat: settings ops, the WiFi and frequency scans, and reboot for socket protocol 2
Step 1d of the protocol 2 core (docs/SECURITY_PLAN.md). - getSettings/saveSettings by section: general, network, mqtt, security, radio. A save is the settings route's request, so loop() applies it with the settings' own fromJSON(). ProtoSettings checks every field first: kind, range, string length, IP addresses. - Secrets are only reported as has*. A security save replies with the client's new token and closes the other sockets; other saves send settingsChanged to the other clients. - scanWifi runs an async scan from ESPNetwork, kept apart from the roaming scan, and wifiNetworks goes to the clients that asked. - beginFrequencyScan, endFrequencyScan and reboot run at once on async_tcp, as their routes do: a scan's end mustn't wait in the loop queue behind an unpair that waits for the scan to end (new op kind 'a'). - e2e: test_settings.py and a --reboot flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
97a4e98183
commit
2517f40709
22 changed files with 1221 additions and 49 deletions
202
test/test_protosettings/test_protosettings.cpp
Normal file
202
test/test_protosettings/test_protosettings.cpp
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
#include <unity.h>
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
#include "ProtoSettings.h"
|
||||
|
||||
// Socket protocol 2's settings ops: the sections, and the settings route's
|
||||
// request that a save's payload becomes (docs/PROTOCOL.md).
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static JsonDocument payload;
|
||||
static JsonDocument doc;
|
||||
static settings_section_t section;
|
||||
|
||||
static void load(const char *json) {
|
||||
payload.clear();
|
||||
TEST_ASSERT_EQUAL(DeserializationError::Ok, deserializeJson(payload, json).code());
|
||||
}
|
||||
static const char *expand(const char *json, bool hasEthernet = true) {
|
||||
load(json);
|
||||
return expandSettingsPayload(payload.as<JsonObjectConst>(), hasEthernet, section, doc);
|
||||
}
|
||||
static const char *sectionOf(const char *json) {
|
||||
load(json);
|
||||
return parseSettingsSection(payload.as<JsonObjectConst>(), section);
|
||||
}
|
||||
static std::string written() {
|
||||
std::string out;
|
||||
serializeJson(doc, out);
|
||||
return out;
|
||||
}
|
||||
static uint16_t parts() { return settingsParts(section, doc.as<JsonObjectConst>()); }
|
||||
static uint16_t bit(settings_part_t part) { return 1u << static_cast<uint8_t>(part); }
|
||||
#define TEST_ASSERT_GOOD(why) TEST_ASSERT_NULL_MESSAGE(why, why)
|
||||
|
||||
/*********************************************************************
|
||||
* Sections
|
||||
********************************************************************/
|
||||
static void test_every_section_by_its_name() {
|
||||
const settings_section_t all[] = {settings_section_t::general, settings_section_t::network, settings_section_t::mqtt,
|
||||
settings_section_t::security, settings_section_t::radio};
|
||||
for(settings_section_t s : all) {
|
||||
std::string json = std::string(R"({"se":")") + settingsSectionName(s) + "\"}";
|
||||
TEST_ASSERT_GOOD(sectionOf(json.c_str()));
|
||||
TEST_ASSERT_EQUAL(s, section);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_STRING("general", settingsSectionName(settings_section_t::general));
|
||||
TEST_ASSERT_EQUAL_STRING("radio", settingsSectionName(settings_section_t::radio));
|
||||
}
|
||||
static void test_a_section_must_be_named() {
|
||||
TEST_ASSERT_EQUAL_STRING("section is missing", sectionOf(R"({})"));
|
||||
TEST_ASSERT_EQUAL_STRING("section is missing", sectionOf(R"({"se":2})"));
|
||||
TEST_ASSERT_EQUAL_STRING("Unknown section", sectionOf(R"({"se":"wifi"})"));
|
||||
// Names are matched as they are.
|
||||
TEST_ASSERT_EQUAL_STRING("Unknown section", sectionOf(R"({"se":"General"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("section is missing", expand(R"({"hn":"x"})"));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* A save's payload as the route's request
|
||||
********************************************************************/
|
||||
static void test_short_keys_become_the_route_names() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"general","hn":"Somfy","ns":"pool.ntp.org"})"));
|
||||
TEST_ASSERT_EQUAL(settings_section_t::general, section);
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"hostname":"Somfy","ntpServer":"pool.ntp.org"})", written().c_str());
|
||||
}
|
||||
static void test_fields_named_apart_from_the_dictionary() {
|
||||
// connType in the header is the connection in use; the setting is connMode.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","cn":2})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"connType":2})", written().c_str());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"security","sy":1,"pn":"1234"})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"type":1,"pin":"1234"})", written().c_str());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","bl":80})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"type":80})", written().c_str());
|
||||
}
|
||||
static void test_booleans_become_true_and_false() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"mqtt","en":1,"pd":0})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"enabled":true,"pubDisco":false})", written().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("enabled is not a boolean (1 or 0)", expand(R"({"se":"radio","en":true})"));
|
||||
}
|
||||
static void test_values_are_checked_against_their_kind() {
|
||||
TEST_ASSERT_EQUAL_STRING("port is not a whole number", expand(R"({"se":"mqtt","po":"1883"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hostname is not a string", expand(R"({"se":"general","hn":5})"));
|
||||
TEST_ASSERT_EQUAL_STRING("frequency is not a number", expand(R"({"se":"radio","fq":"433"})"));
|
||||
}
|
||||
static void test_a_number_may_be_whole() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","fq":433,"bw":99.97})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"frequency":433,"rxBandwidth":99.97})", written().c_str());
|
||||
}
|
||||
static void test_whole_numbers_are_checked_against_what_the_setting_holds() {
|
||||
TEST_ASSERT_EQUAL_STRING("connMode must be 1-3", expand(R"({"se":"network","cn":4})"));
|
||||
TEST_ASSERT_EQUAL_STRING("connMode must be 1-3", expand(R"({"se":"network","cn":0})"));
|
||||
TEST_ASSERT_EQUAL_STRING("port must be 1-65535", expand(R"({"se":"mqtt","po":65536})"));
|
||||
TEST_ASSERT_EQUAL_STRING("txPower must be -30-12", expand(R"({"se":"radio","tp":13})"));
|
||||
TEST_ASSERT_EQUAL_STRING("TXPin must be 0-48", expand(R"({"se":"radio","tx":-1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("securityType must be 0-2", expand(R"({"se":"security","sy":3})"));
|
||||
TEST_ASSERT_EQUAL_STRING("port must be 1-65535", expand(R"({"se":"mqtt","po":18446744073709551615})"));
|
||||
// A pin of -1 is none.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","pw":-1})"));
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","tp":-30})"));
|
||||
}
|
||||
static void test_the_bit_length_is_56_or_80() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","bl":56})"));
|
||||
TEST_ASSERT_EQUAL_STRING("bitLength must be 56 or 80", expand(R"({"se":"radio","bl":60})"));
|
||||
TEST_ASSERT_EQUAL_STRING("bitLength must be 56 or 80", expand(R"({"se":"radio","bl":0})"));
|
||||
}
|
||||
static void test_strings_must_fit_their_setting() {
|
||||
// hostname holds 31 characters, a PIN 4.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"general","hn":"0123456789012345678901234567890"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hostname is longer than 31 characters", expand(R"({"se":"general","hn":"01234567890123456789012345678901"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("pin is longer than 4 characters", expand(R"({"se":"security","pn":"12345"})"));
|
||||
// The broker's hostname is another setting, with room for 64.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"mqtt","hn":"01234567890123456789012345678901"})"));
|
||||
}
|
||||
static void test_addresses_are_checked_and_empty_clears_them() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","dh":0,"ip":"192.168.1.20","gw":"192.168.1.1","su":"255.255.255.0","d1":"","d2":""})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"dhcp":false,"ip":"192.168.1.20","gateway":"192.168.1.1","subnet":"255.255.255.0","dns1":"0.0.0.0","dns2":"0.0.0.0"})", written().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ip is not an IP address", expand(R"({"se":"network","ip":"192.168.1"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("gateway is not an IP address", expand(R"({"se":"network","gw":"192.168.1.256"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("dns1 is not an IP address", expand(R"({"se":"network","d1":"1.2.3.4.5"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("dns2 is not an IP address", expand(R"({"se":"network","d2":"a.b.c.d"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("ip is not an IP address", expand(R"({"se":"network","ip":"1..2.3"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("ip is longer than 15 characters", expand(R"({"se":"network","ip":"0001.0002.0003.0004"})"));
|
||||
}
|
||||
static void test_only_what_was_sent_is_in_the_request() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","ro":0})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"roaming":false})", written().c_str());
|
||||
}
|
||||
static void test_a_save_needs_something_to_save() {
|
||||
TEST_ASSERT_EQUAL_STRING("Nothing to save", expand(R"({"se":"mqtt"})"));
|
||||
}
|
||||
static void test_an_unknown_key_is_refused() {
|
||||
TEST_ASSERT_EQUAL_STRING("Unknown field zz", expand(R"({"se":"general","zz":1})"));
|
||||
}
|
||||
static void test_what_is_only_sent_is_refused() {
|
||||
// Secrets are only said to be set; a save sends the secret itself.
|
||||
TEST_ASSERT_EQUAL_STRING("hasPassword can't be saved in mqtt", expand(R"({"se":"mqtt","hw":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hasPin can't be saved in security", expand(R"({"se":"security","hi":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hasPassphrase can't be saved in network", expand(R"({"se":"network","hq":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hasEthernet can't be saved in network", expand(R"({"se":"network","he":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("radioInit can't be saved in radio", expand(R"({"se":"radio","ri":1})"));
|
||||
}
|
||||
static void test_a_field_of_another_section_is_refused() {
|
||||
TEST_ASSERT_EQUAL_STRING("ssid can't be saved in general", expand(R"({"se":"general","sd":"home"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("ntpServer can't be saved in network", expand(R"({"se":"network","ns":"pool.ntp.org"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("TXPin can't be saved in mqtt", expand(R"({"se":"mqtt","tx":12})"));
|
||||
TEST_ASSERT_EQUAL_STRING("shadeId can't be saved in radio", expand(R"({"se":"radio","s":1})"));
|
||||
}
|
||||
static void test_ethernet_needs_a_chip_with_it() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","bo":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("This device has no Ethernet", expand(R"({"se":"network","bo":1})", false));
|
||||
// The rest of the section saves without it.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","sd":"home","pp":"secret"})", false));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* The parts a save sets
|
||||
********************************************************************/
|
||||
static void test_a_save_sets_the_parts_it_has_fields_of() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","sd":"home","dh":1})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::wifi) | bit(settings_part_t::ip), parts());
|
||||
TEST_ASSERT_TRUE(settingsSets(parts(), settings_part_t::wifi));
|
||||
TEST_ASSERT_FALSE(settingsSets(parts(), settings_part_t::conn));
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","cn":3,"ck":1})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::conn) | bit(settings_part_t::ethernet), parts());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"general","sb":1,"pz":"CET-1CEST"})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::general) | bit(settings_part_t::ntp), parts());
|
||||
}
|
||||
static void test_a_route_name_belongs_to_its_section() {
|
||||
// hostname is the broker's in mqtt, and type the radio's bit length in radio.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"mqtt","hn":"broker"})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::mqtt), parts());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","bl":56})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::radio), parts());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"security","sy":0})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::security), parts());
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_every_section_by_its_name);
|
||||
RUN_TEST(test_a_section_must_be_named);
|
||||
RUN_TEST(test_short_keys_become_the_route_names);
|
||||
RUN_TEST(test_fields_named_apart_from_the_dictionary);
|
||||
RUN_TEST(test_booleans_become_true_and_false);
|
||||
RUN_TEST(test_values_are_checked_against_their_kind);
|
||||
RUN_TEST(test_a_number_may_be_whole);
|
||||
RUN_TEST(test_whole_numbers_are_checked_against_what_the_setting_holds);
|
||||
RUN_TEST(test_the_bit_length_is_56_or_80);
|
||||
RUN_TEST(test_strings_must_fit_their_setting);
|
||||
RUN_TEST(test_addresses_are_checked_and_empty_clears_them);
|
||||
RUN_TEST(test_only_what_was_sent_is_in_the_request);
|
||||
RUN_TEST(test_a_save_needs_something_to_save);
|
||||
RUN_TEST(test_an_unknown_key_is_refused);
|
||||
RUN_TEST(test_what_is_only_sent_is_refused);
|
||||
RUN_TEST(test_a_field_of_another_section_is_refused);
|
||||
RUN_TEST(test_ethernet_needs_a_chip_with_it);
|
||||
RUN_TEST(test_a_save_sets_the_parts_it_has_fields_of);
|
||||
RUN_TEST(test_a_route_name_belongs_to_its_section);
|
||||
return UNITY_END();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue