Merge fix/mqtt-review-2026-09-09 into feat/abstract-networking
#54 lands first, so the network abstraction is reworked on top of the per-slot client ownership model instead of alongside it. Two resolutions are semantic, not textual: - Link-transition teardown goes through the ownership API. It called softDisconnect() on every started client, which bypassed client_state and ignored the typed result. A slot that is still Starting now gets a real stop: softDisconnect() returns immediately on a client that is not yet connected, so its attempt would otherwise complete against the old route and deliver a CONNECTED event indistinguishable from the new one's (F04). On a medium switch the old route can still be briefly usable, so that is not hypothetical. A connected client keeps the cheap bounded path, and a quarantined one is left alone. - NetworkLink::applyPowerPrefs() adopts WifiPowerSavePolicy. Its local `2 ? MAX : NONE` mapping would have reintroduced F11 and read the new stored value 3 (explicit `min`) as none. It also applies the setting when the link starts already associated, which is the case the bridge used to cover. The NTP probe from #54 keeps its validation and its one-attempt-per-server bound, but resolves and gates on the selected link rather than on WiFi, so it works on an Ethernet-preferred node.
This commit is contained in:
commit
7f28231b25
30 files changed
+3075
-451
No files matched your search
@@ -0,0 +1,113 @@
|
||||
#include "helpers/MQTTClientState.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
const MqttClientState kAllStates[] = {
|
||||
MqttClientState::Absent, MqttClientState::Configured,
|
||||
MqttClientState::Starting, MqttClientState::Connected,
|
||||
MqttClientState::Disconnected, MqttClientState::Stopped,
|
||||
MqttClientState::Quarantined,
|
||||
};
|
||||
|
||||
bool ack(std::vector<MqttClientState> states) {
|
||||
return mqttStopMayBeAcknowledged(states.data(), (int)states.size());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(MqttClientState, LiveMeansStartedAndNotProvenStopped) {
|
||||
EXPECT_TRUE(mqttClientStateIsLive(MqttClientState::Starting));
|
||||
EXPECT_TRUE(mqttClientStateIsLive(MqttClientState::Connected));
|
||||
EXPECT_TRUE(mqttClientStateIsLive(MqttClientState::Disconnected));
|
||||
|
||||
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Absent));
|
||||
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Configured));
|
||||
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Stopped));
|
||||
// Quarantined is not "live": it is worse. It may own a task and we can never
|
||||
// find out, which is why it is not simply lumped in with the live states.
|
||||
EXPECT_FALSE(mqttClientStateIsLive(MqttClientState::Quarantined));
|
||||
}
|
||||
|
||||
TEST(MqttClientState, OnlyStartingHasAnAttemptInFlight) {
|
||||
for (MqttClientState s : kAllStates) {
|
||||
EXPECT_EQ(s == MqttClientState::Starting, mqttClientStateHasAttemptInFlight(s))
|
||||
<< mqttClientStateName(s);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MqttClientState, ProvenStoppedIsTheComplementOfLiveAndQuarantined) {
|
||||
for (MqttClientState s : kAllStates) {
|
||||
const bool proven = mqttClientStateIsProvenStopped(s);
|
||||
EXPECT_EQ(!mqttClientStateIsLive(s) && s != MqttClientState::Quarantined, proven)
|
||||
<< mqttClientStateName(s);
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant the shutdown contract rests on: the acknowledgement means the
|
||||
// task destroyed its clients, so it may not be published while any client's
|
||||
// stop is unproven.
|
||||
TEST(MqttClientState, StopIsAcknowledgedOnlyWhenEveryClientIsProvenStopped) {
|
||||
EXPECT_TRUE(ack({})); // no slots configured at all
|
||||
EXPECT_TRUE(ack({MqttClientState::Absent, MqttClientState::Stopped,
|
||||
MqttClientState::Configured}));
|
||||
|
||||
// One quarantined client withholds the acknowledgement, whatever the others did.
|
||||
EXPECT_FALSE(ack({MqttClientState::Quarantined}));
|
||||
EXPECT_FALSE(ack({MqttClientState::Stopped, MqttClientState::Stopped,
|
||||
MqttClientState::Quarantined, MqttClientState::Absent}));
|
||||
EXPECT_FALSE(ack({MqttClientState::Quarantined, MqttClientState::Absent,
|
||||
MqttClientState::Absent, MqttClientState::Absent,
|
||||
MqttClientState::Absent, MqttClientState::Absent}));
|
||||
|
||||
// So does a client that is merely still live: teardown did not finish it.
|
||||
EXPECT_FALSE(ack({MqttClientState::Stopped, MqttClientState::Connected}));
|
||||
EXPECT_FALSE(ack({MqttClientState::Starting}));
|
||||
EXPECT_FALSE(ack({MqttClientState::Disconnected}));
|
||||
|
||||
// An empty set is trivially proven; a null array with a nonzero count is a
|
||||
// caller bug and must never read as proof.
|
||||
EXPECT_TRUE(mqttStopMayBeAcknowledged(nullptr, 0));
|
||||
EXPECT_FALSE(mqttStopMayBeAcknowledged(nullptr, 3));
|
||||
}
|
||||
|
||||
// A link transition closes each slot's transport because the route under it
|
||||
// changed. The two predicates above are what decide how, and getting the
|
||||
// Starting case wrong is not cosmetic: softDisconnect() returns immediately on
|
||||
// a client that is not yet connected, so that attempt would run to completion
|
||||
// against the OLD route and deliver a CONNECTED event the new attempt cannot be
|
||||
// told apart from.
|
||||
TEST(MqttClientState, LinkTransitionStopsOnlyClientsWithAnAttemptInFlight) {
|
||||
for (MqttClientState s : kAllStates) {
|
||||
const bool touched = mqttClientStateIsLive(s);
|
||||
const bool needs_stop = touched && mqttClientStateHasAttemptInFlight(s);
|
||||
|
||||
// Quarantined is never touched: its SDK task was not joined, so it stays
|
||||
// out of service for the rest of the boot.
|
||||
if (s == MqttClientState::Quarantined) EXPECT_FALSE(touched) << mqttClientStateName(s);
|
||||
// Nothing that is already proven stopped is worth a disconnect.
|
||||
if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(touched) << mqttClientStateName(s);
|
||||
|
||||
EXPECT_EQ(s == MqttClientState::Starting, needs_stop) << mqttClientStateName(s);
|
||||
// The cheap bounded path, which keeps the esp-mqtt task alive.
|
||||
EXPECT_EQ(s == MqttClientState::Connected || s == MqttClientState::Disconnected,
|
||||
touched && !needs_stop) << mqttClientStateName(s);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MqttClientState, EveryStateHasAName) {
|
||||
for (MqttClientState s : kAllStates) {
|
||||
ASSERT_NE(nullptr, mqttClientStateName(s));
|
||||
EXPECT_GT(strlen(mqttClientStateName(s)), 0u);
|
||||
}
|
||||
EXPECT_STREQ("quarantined", mqttClientStateName(MqttClientState::Quarantined));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
#include "helpers/MQTTEffectiveConfig.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
const char kPemA[] = "-----BEGIN CERTIFICATE-----A";
|
||||
const char kPemB[] = "-----BEGIN CERTIFICATE-----B";
|
||||
const char kJwtUser[] = "v1_CC5D3CFD9C4C7B84";
|
||||
const char kToken[] = "eyJhbGciOi...";
|
||||
|
||||
MqttEffectiveConfig build(const char* uri,
|
||||
MqttAuth auth = MqttAuth::None,
|
||||
const char* user = nullptr,
|
||||
const char* pass = nullptr,
|
||||
MqttTrust trust = MqttTrust::Plaintext,
|
||||
const char* pem = nullptr,
|
||||
uint16_t buffer = 896) {
|
||||
MqttEffectiveConfig c;
|
||||
mqttBuildEffectiveConfig(uri, auth, user, pass, trust, pem, buffer, 75, &c);
|
||||
return c;
|
||||
}
|
||||
|
||||
MqttRecreateDecision decide(const MqttEffectiveConfig& applied,
|
||||
const MqttEffectiveConfig& desired,
|
||||
uint16_t allocated = 896) {
|
||||
return mqttConfigRecreateDecision(applied, desired, allocated);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(MqttEffectiveConfig, TransportComesFromTheUriScheme) {
|
||||
EXPECT_EQ(MqttTransport::Tcp, mqttTransportFromUri("mqtt://broker:1883"));
|
||||
EXPECT_EQ(MqttTransport::Tls, mqttTransportFromUri("mqtts://broker:8883"));
|
||||
EXPECT_EQ(MqttTransport::Ws, mqttTransportFromUri("ws://broker/mqtt"));
|
||||
EXPECT_EQ(MqttTransport::Wss, mqttTransportFromUri("wss://broker:443/mqtt"));
|
||||
EXPECT_EQ(MqttTransport::Unknown, mqttTransportFromUri("broker:1883"));
|
||||
EXPECT_EQ(MqttTransport::Unknown, mqttTransportFromUri(""));
|
||||
EXPECT_EQ(MqttTransport::Unknown, mqttTransportFromUri(nullptr));
|
||||
|
||||
EXPECT_TRUE(mqttTransportIsEncrypted(MqttTransport::Tls));
|
||||
EXPECT_TRUE(mqttTransportIsEncrypted(MqttTransport::Wss));
|
||||
EXPECT_FALSE(mqttTransportIsEncrypted(MqttTransport::Tcp));
|
||||
EXPECT_FALSE(mqttTransportIsEncrypted(MqttTransport::Ws));
|
||||
}
|
||||
|
||||
TEST(MqttEffectiveConfig, BuildRejectsUnusableUris) {
|
||||
MqttEffectiveConfig c;
|
||||
EXPECT_FALSE(mqttBuildEffectiveConfig("", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Plaintext, nullptr, 896, 75, &c));
|
||||
EXPECT_FALSE(c.valid);
|
||||
EXPECT_FALSE(mqttBuildEffectiveConfig(nullptr, MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Plaintext, nullptr, 896, 75, &c));
|
||||
EXPECT_FALSE(mqttBuildEffectiveConfig("broker:1883", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Plaintext, nullptr, 896, 75, &c));
|
||||
|
||||
char too_long[MQTT_EFFECTIVE_URI_MAX + 16];
|
||||
memset(too_long, 'x', sizeof(too_long));
|
||||
memcpy(too_long, "mqtt://", 7);
|
||||
too_long[sizeof(too_long) - 1] = '\0';
|
||||
EXPECT_FALSE(mqttBuildEffectiveConfig(too_long, MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Plaintext, nullptr, 896, 75, &c));
|
||||
}
|
||||
|
||||
TEST(MqttEffectiveConfig, BuildOwnsItsUriCopy) {
|
||||
char uri[64];
|
||||
strcpy(uri, "mqtt://192.168.50.231:1883");
|
||||
MqttEffectiveConfig c = build(uri);
|
||||
ASSERT_TRUE(c.valid);
|
||||
|
||||
strcpy(uri, "mqtt://other:1883"); // slot.broker_uri is rewritten in place
|
||||
EXPECT_STREQ("mqtt://192.168.50.231:1883", c.uri);
|
||||
}
|
||||
|
||||
// An unencrypted transport verifies nothing, whatever certificate material was
|
||||
// requested. Recording anything else would make a plaintext endpoint look like
|
||||
// it kept a verified policy.
|
||||
TEST(MqttEffectiveConfig, TrustIsNormalisedAgainstTheTransport) {
|
||||
MqttEffectiveConfig plain = build("mqtt://broker:1883", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Bundle, kPemA);
|
||||
EXPECT_EQ(MqttTrust::Plaintext, plain.trust);
|
||||
EXPECT_EQ(nullptr, plain.pem);
|
||||
|
||||
MqttEffectiveConfig bundle = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
|
||||
MqttTrust::Bundle, nullptr);
|
||||
EXPECT_EQ(MqttTrust::Bundle, bundle.trust);
|
||||
|
||||
MqttEffectiveConfig pem = build("mqtts://broker:8883", MqttAuth::UserPass, "u", "p",
|
||||
MqttTrust::PemCert, kPemA);
|
||||
EXPECT_EQ(MqttTrust::PemCert, pem.trust);
|
||||
EXPECT_EQ(kPemA, pem.pem);
|
||||
|
||||
// Encrypted transport with no usable material is not a verified policy.
|
||||
MqttEffectiveConfig none = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::PemCert, nullptr);
|
||||
EXPECT_EQ(MqttTrust::Plaintext, none.trust);
|
||||
EXPECT_EQ(nullptr, none.pem);
|
||||
}
|
||||
|
||||
// The six F02 transitions from the review, plus the one reproduced on hardware.
|
||||
// None of them needs a new client: credentials are rewritten unconditionally.
|
||||
TEST(MqttEffectiveConfig, CredentialAndAuthChangesReuseTheClient) {
|
||||
const MqttEffectiveConfig jwt = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
|
||||
MqttTrust::Bundle, nullptr);
|
||||
const MqttEffectiveConfig anon = build("wss://broker:443/mqtt", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Bundle, nullptr);
|
||||
const MqttEffectiveConfig userpass = build("wss://broker:443/mqtt", MqttAuth::UserPass, "u", "p",
|
||||
MqttTrust::Bundle, nullptr);
|
||||
const MqttEffectiveConfig renewed = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser,
|
||||
"eyJhbGciOi...new", MqttTrust::Bundle, nullptr);
|
||||
|
||||
EXPECT_FALSE(decide(jwt, anon).recreate); // JWT -> anonymous
|
||||
EXPECT_FALSE(decide(userpass, anon).recreate); // user/pass -> anonymous
|
||||
EXPECT_FALSE(decide(anon, jwt).recreate); // none -> configured
|
||||
EXPECT_FALSE(decide(userpass, jwt).recreate); // user/pass -> JWT
|
||||
EXPECT_FALSE(decide(jwt, renewed).recreate); // token renewal
|
||||
EXPECT_FALSE(decide(jwt, jwt).recreate); // ordinary reconnect
|
||||
EXPECT_STREQ("reuse", decide(jwt, jwt).reason);
|
||||
}
|
||||
|
||||
// Host/port/path within one scheme is an endpoint move, not a structural one.
|
||||
TEST(MqttEffectiveConfig, EndpointMoveWithinOneSchemeReusesTheClient) {
|
||||
const MqttEffectiveConfig a = build("mqtt://192.168.50.231:1883");
|
||||
const MqttEffectiveConfig b = build("mqtt://192.168.50.9:1884");
|
||||
EXPECT_FALSE(decide(a, b).recreate);
|
||||
|
||||
const MqttEffectiveConfig wss_a = build("wss://a.example:443/mqtt", MqttAuth::Jwt, kJwtUser,
|
||||
kToken, MqttTrust::Bundle, nullptr);
|
||||
const MqttEffectiveConfig wss_b = build("wss://b.example:443/other", MqttAuth::Jwt, kJwtUser,
|
||||
kToken, MqttTrust::Bundle, nullptr);
|
||||
EXPECT_FALSE(decide(wss_a, wss_b).recreate);
|
||||
}
|
||||
|
||||
TEST(MqttEffectiveConfig, TransportChangeForcesRecreate) {
|
||||
const MqttEffectiveConfig wss = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
|
||||
MqttTrust::Bundle, nullptr);
|
||||
const MqttEffectiveConfig tcp = build("mqtt://192.168.50.231:1883");
|
||||
const MqttEffectiveConfig tls = build("mqtts://broker:8883", MqttAuth::UserPass, "u", "p",
|
||||
MqttTrust::Bundle, nullptr);
|
||||
const MqttEffectiveConfig ws = build("ws://broker/mqtt");
|
||||
|
||||
EXPECT_TRUE(decide(wss, tcp).recreate);
|
||||
EXPECT_STREQ("transport-change", decide(wss, tcp).reason);
|
||||
EXPECT_TRUE(decide(tcp, wss).recreate);
|
||||
EXPECT_TRUE(decide(wss, tls).recreate); // both encrypted, still a scheme change
|
||||
EXPECT_TRUE(decide(tcp, ws).recreate); // both plaintext, still a scheme change
|
||||
}
|
||||
|
||||
TEST(MqttEffectiveConfig, TrustChangeForcesRecreate) {
|
||||
const MqttEffectiveConfig bundle = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Bundle, nullptr);
|
||||
const MqttEffectiveConfig pem_a = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::PemCert, kPemA);
|
||||
const MqttEffectiveConfig pem_b = build("mqtts://broker:8883", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::PemCert, kPemB);
|
||||
|
||||
EXPECT_TRUE(decide(bundle, pem_a).recreate);
|
||||
EXPECT_STREQ("trust-change", decide(bundle, pem_a).reason);
|
||||
EXPECT_TRUE(decide(pem_a, bundle).recreate);
|
||||
EXPECT_TRUE(decide(pem_a, pem_b).recreate);
|
||||
EXPECT_STREQ("ca-cert-change", decide(pem_a, pem_b).reason);
|
||||
EXPECT_FALSE(decide(pem_a, pem_a).recreate);
|
||||
}
|
||||
|
||||
// IDF 4.4 allocates the MQTT buffers at client init and does not resize them,
|
||||
// and the wrapper's reassembly buffer is allocated once for the client's life.
|
||||
TEST(MqttEffectiveConfig, BufferGrowthForcesRecreateButShrinkDoesNot) {
|
||||
const MqttEffectiveConfig small = build("mqtt://broker:1883", MqttAuth::None, nullptr, nullptr,
|
||||
MqttTrust::Plaintext, nullptr, 512);
|
||||
const MqttEffectiveConfig large = build("mqtt://broker:1883", MqttAuth::Jwt, kJwtUser, kToken,
|
||||
MqttTrust::Plaintext, nullptr, 896);
|
||||
|
||||
EXPECT_TRUE(decide(small, large, /*allocated=*/512).recreate);
|
||||
EXPECT_STREQ("buffer-growth", decide(small, large, 512).reason);
|
||||
EXPECT_FALSE(decide(small, large, /*allocated=*/896).recreate)
|
||||
<< "a client that already owns the capacity does not need recreating";
|
||||
EXPECT_FALSE(decide(large, small, /*allocated=*/896).recreate);
|
||||
}
|
||||
|
||||
TEST(MqttEffectiveConfig, FirstApplyAndInvalidDesiredNeverRecreate) {
|
||||
const MqttEffectiveConfig unset;
|
||||
const MqttEffectiveConfig wss = build("wss://broker:443/mqtt", MqttAuth::Jwt, kJwtUser, kToken,
|
||||
MqttTrust::Bundle, nullptr);
|
||||
EXPECT_FALSE(unset.valid);
|
||||
EXPECT_FALSE(decide(unset, wss).recreate);
|
||||
EXPECT_STREQ("first-apply", decide(unset, wss).reason);
|
||||
|
||||
EXPECT_FALSE(decide(wss, unset).recreate);
|
||||
EXPECT_STREQ("invalid-desired", decide(wss, unset).reason);
|
||||
}
|
||||
|
||||
// Rule 1: absent is a value that gets written, never an omission.
|
||||
TEST(MqttEffectiveConfig, AbsentFieldsAreWrittenAsEmptyStrings) {
|
||||
EXPECT_STREQ("", mqttFieldOrEmpty(nullptr));
|
||||
EXPECT_STREQ("alice", mqttFieldOrEmpty("alice"));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#include "helpers/bridges/MQTTErrorLabels.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
using namespace MQTTErrorLabels;
|
||||
|
||||
namespace {
|
||||
|
||||
bool labelIs(const char* actual, const char* expected) {
|
||||
return actual != nullptr && strcmp(actual, expected) == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// F14: these four were the mislabels found in review. 201 is "no AP found"
|
||||
// (usually a wrong/hidden SSID), not a security mismatch; 202 is an auth
|
||||
// failure (usually a wrong password), not a rejected auth mode.
|
||||
TEST(MQTTErrorLabels, WifiReasonsMatchTheSdkMeaning) {
|
||||
EXPECT_TRUE(labelIs(wifiReason(201), "SSID not found"));
|
||||
EXPECT_TRUE(labelIs(wifiReason(202), "auth failed (check password)"));
|
||||
EXPECT_TRUE(labelIs(wifiReason(39), "802.11 timeout")); // was "SSID not found"
|
||||
EXPECT_TRUE(labelIs(wifiReason(34), "AP saw no acks")); // was "AP state mismatch"
|
||||
}
|
||||
|
||||
TEST(MQTTErrorLabels, TlsErrorsMatchTheSdkMeaning) {
|
||||
EXPECT_TRUE(labelIs(tlsError(0x8008), "server closed connection")); // was "connection timeout"
|
||||
EXPECT_TRUE(labelIs(tlsError(0x8010), "cert chain partly parsed")); // was "mbedTLS error"
|
||||
EXPECT_TRUE(labelIs(tlsError(0x8006), "connection timeout"));
|
||||
EXPECT_TRUE(labelIs(tlsError(0x8001), "DNS failed"));
|
||||
EXPECT_TRUE(labelIs(tlsError(0x801A), "TLS handshake failed"));
|
||||
}
|
||||
|
||||
// An unknown code must fall through to the caller's raw-number formatting
|
||||
// rather than borrow a neighbouring code's label. 0x800B is the one the review
|
||||
// caught: no such esp-tls error exists, but it was labelled "cert verify failed".
|
||||
TEST(MQTTErrorLabels, UnknownCodesHaveNoLabel) {
|
||||
EXPECT_EQ(nullptr, tlsError(0x800B));
|
||||
EXPECT_EQ(nullptr, tlsError(0));
|
||||
EXPECT_EQ(nullptr, tlsError(-1));
|
||||
EXPECT_EQ(nullptr, tlsError(0x8099));
|
||||
|
||||
EXPECT_EQ(nullptr, wifiReason(0));
|
||||
EXPECT_EQ(nullptr, wifiReason(61)); // not defined by the installed SDK
|
||||
EXPECT_EQ(nullptr, wifiReason(88));
|
||||
EXPECT_EQ(nullptr, wifiReason(168));
|
||||
EXPECT_EQ(nullptr, wifiReason(255));
|
||||
}
|
||||
|
||||
TEST(MQTTErrorLabels, ConnackReasonsCoverTheRefusalCodes) {
|
||||
EXPECT_TRUE(labelIs(connackReason(1), "protocol rejected"));
|
||||
EXPECT_TRUE(labelIs(connackReason(2), "client id rejected"));
|
||||
EXPECT_TRUE(labelIs(connackReason(3), "server unavailable"));
|
||||
EXPECT_TRUE(labelIs(connackReason(4), "bad user/password"));
|
||||
EXPECT_TRUE(labelIs(connackReason(5), "not authorized"));
|
||||
EXPECT_EQ(nullptr, connackReason(0)); // accepted: not an error to report
|
||||
EXPECT_EQ(nullptr, connackReason(6));
|
||||
}
|
||||
|
||||
// Labels share a bounded LoRa reply with the rest of the diagnostic line.
|
||||
TEST(MQTTErrorLabels, LabelsStayShortEnoughForABoundedReply) {
|
||||
for (int i = 0; i <= 255; i++) {
|
||||
const char* w = wifiReason((uint8_t)i);
|
||||
if (w) EXPECT_LE(strlen(w), 30u) << "wifi reason " << i;
|
||||
const char* c = connackReason((uint8_t)i);
|
||||
if (c) EXPECT_LE(strlen(c), 30u) << "connack code " << i;
|
||||
}
|
||||
for (int32_t e = 0x8000; e <= 0x8040; e++) {
|
||||
const char* t = tlsError(e);
|
||||
if (t) EXPECT_LE(strlen(t), 30u) << "tls error " << e;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -174,9 +174,12 @@ TEST(MQTTLifecycle, RestartAfterStop) {
|
||||
|
||||
// --- Timeout / fallback ----------------------------------------------------
|
||||
|
||||
// Handoff: "Timeout/fallback behavior when the MQTT task or client does not
|
||||
// acknowledge." Models the reviewed fallback replacing the abrupt vTaskDelete.
|
||||
TEST(MQTTLifecycle, StopTimeoutFiresReviewedFallback) {
|
||||
// F01: a stop that passes its deadline unacknowledged releases NOTHING. The
|
||||
// MQTT task may still be inside mbedTLS or holding the SDK's API mutex, so the
|
||||
// queue, buffers, clients and the task itself all stay owned. The OTA barrier
|
||||
// is still unblocked — so it can abort — but nothing is freed and the bridge
|
||||
// may not restart.
|
||||
TEST(MQTTLifecycle, StopTimeoutReleasesNothingAndBlocksRestart) {
|
||||
FakeOps ops;
|
||||
L::Coordinator c(ops, kStopTimeoutMs);
|
||||
bringUpToRunning(c);
|
||||
@@ -191,18 +194,70 @@ TEST(MQTTLifecycle, StopTimeoutFiresReviewedFallback) {
|
||||
EXPECT_EQ(L::State::StopRequested, c.state());
|
||||
EXPECT_EQ(0, ops.release_calls);
|
||||
|
||||
// At the deadline the fallback fires: forced release, dirty OTA signal.
|
||||
ops.now = 1000 + kStopTimeoutMs;
|
||||
c.tick();
|
||||
EXPECT_EQ(L::State::Stopped, c.state());
|
||||
|
||||
EXPECT_EQ(L::State::StopUnproven, c.state());
|
||||
EXPECT_TRUE(c.isStopUnproven());
|
||||
EXPECT_TRUE(c.stopTimedOut());
|
||||
EXPECT_EQ(1, ops.release_calls);
|
||||
EXPECT_EQ(0, ops.release_calls) << "an unproven stop must not release";
|
||||
EXPECT_EQ(1, ops.stop_complete_calls);
|
||||
EXPECT_FALSE(ops.last_stop_clean);
|
||||
|
||||
// A late ack after the fallback does not double-release.
|
||||
// Everything the task can reach is still owned, and neither a restart nor a
|
||||
// flash is permitted. A repeated tick does not change that.
|
||||
EXPECT_TRUE(c.mayTouchOwnedState());
|
||||
EXPECT_FALSE(c.mayRestart());
|
||||
EXPECT_FALSE(c.mayBeginFlash());
|
||||
EXPECT_FALSE(c.acceptsNewWork());
|
||||
EXPECT_FALSE(c.isStopInProgress()) << "end()'s wait loop must terminate";
|
||||
|
||||
ops.now += kStopTimeoutMs * 10;
|
||||
c.tick();
|
||||
EXPECT_EQ(L::State::StopUnproven, c.state());
|
||||
EXPECT_EQ(0, ops.release_calls);
|
||||
|
||||
// A start attempt is refused outright: a new start is not proof that the
|
||||
// previous clients stopped.
|
||||
EXPECT_FALSE(c.requestStart());
|
||||
EXPECT_EQ(L::State::StopUnproven, c.state());
|
||||
EXPECT_EQ(1, ops.start_task_calls) << "no second task while unproven";
|
||||
EXPECT_TRUE(c.stopTimedOut()) << "a refused start cannot clear the dirty latch";
|
||||
}
|
||||
|
||||
// The deadline is an availability policy, not a statement about what an ack
|
||||
// means: the task publishes it only after tearing its clients down, so a late
|
||||
// ack is exactly as trustworthy as a timely one. Honouring it releases the
|
||||
// resources and makes the bridge restartable without a reboot.
|
||||
TEST(MQTTLifecycle, LateAckRecoversFromAnUnprovenStop) {
|
||||
FakeOps ops;
|
||||
L::Coordinator c(ops, kStopTimeoutMs);
|
||||
bringUpToRunning(c);
|
||||
|
||||
ops.now = 1000;
|
||||
ASSERT_TRUE(c.requestStop());
|
||||
ops.now = 1000 + kStopTimeoutMs;
|
||||
c.tick();
|
||||
ASSERT_EQ(L::State::StopUnproven, c.state());
|
||||
ASSERT_EQ(0, ops.release_calls);
|
||||
|
||||
ops.now = 1000 + kStopTimeoutMs * 4;
|
||||
ASSERT_TRUE(c.onTaskStopped());
|
||||
|
||||
EXPECT_EQ(L::State::Stopped, c.state());
|
||||
EXPECT_EQ(1, ops.release_calls) << "the proof arrived; release now";
|
||||
EXPECT_TRUE(c.mayRestart());
|
||||
// The OTA barrier already reported dirty and its caller acted on that, so the
|
||||
// flash gate stays shut until a clean start/stop cycle.
|
||||
EXPECT_EQ(1, ops.stop_complete_calls);
|
||||
EXPECT_FALSE(c.mayBeginFlash());
|
||||
EXPECT_TRUE(c.stopTimedOut());
|
||||
|
||||
// Restarting from the proven stop clears the latch, and a second ack is inert.
|
||||
EXPECT_FALSE(c.onTaskStopped());
|
||||
EXPECT_EQ(1, ops.release_calls);
|
||||
ASSERT_TRUE(c.requestStart());
|
||||
EXPECT_FALSE(c.stopTimedOut());
|
||||
}
|
||||
|
||||
TEST(MQTTLifecycle, TickWithoutPendingStopIsNoop) {
|
||||
@@ -371,7 +426,9 @@ TEST(MQTTLifecycle, OtaFlashBlockedUntilCleanStopAcknowledged) {
|
||||
}
|
||||
|
||||
// Handoff OTA barrier: "MQTT stop times out: OTA aborts safely rather than
|
||||
// writing under uncertain ownership."
|
||||
// writing under uncertain ownership." The barrier is still released so the
|
||||
// caller aborts, but flashing stays shut and — unlike the pre-F01 contract —
|
||||
// the bridge cannot be restarted to paper over it.
|
||||
TEST(MQTTLifecycle, OtaAbortsWhenStopTimesOut) {
|
||||
FakeOps ops;
|
||||
L::Coordinator c(ops, kStopTimeoutMs);
|
||||
@@ -382,11 +439,16 @@ TEST(MQTTLifecycle, OtaAbortsWhenStopTimesOut) {
|
||||
ops.now = 500 + kStopTimeoutMs;
|
||||
c.tick();
|
||||
|
||||
EXPECT_EQ(L::State::Stopped, c.state());
|
||||
EXPECT_FALSE(c.mayBeginFlash()); // dirty stop => flashing withheld
|
||||
EXPECT_FALSE(ops.last_stop_clean);
|
||||
EXPECT_EQ(L::State::StopUnproven, c.state());
|
||||
EXPECT_EQ(1, ops.stop_complete_calls); // barrier released...
|
||||
EXPECT_FALSE(ops.last_stop_clean); // ...with "do not flash"
|
||||
EXPECT_FALSE(c.mayBeginFlash());
|
||||
EXPECT_FALSE(c.requestStart()); // and no restart to hide it
|
||||
|
||||
// A fresh clean start/stop cycle clears the latch and re-enables flashing.
|
||||
// Only the task's late acknowledgement makes the bridge usable again, and
|
||||
// only a clean cycle after that re-enables flashing.
|
||||
ASSERT_TRUE(c.onTaskStopped());
|
||||
EXPECT_FALSE(c.mayBeginFlash());
|
||||
ASSERT_TRUE(c.requestStart());
|
||||
EXPECT_FALSE(c.stopTimedOut());
|
||||
ASSERT_TRUE(c.onTaskStarted());
|
||||
@@ -415,8 +477,13 @@ TEST(MQTTLifecycle, NoRestartWhileStopInProgress) {
|
||||
}
|
||||
|
||||
// Handoff OTA barrier: "Repeated failed OTA attempts do not ... leave the
|
||||
// bridge permanently stopped."
|
||||
TEST(MQTTLifecycle, RepeatedFailedStopsLeaveBridgeRestartable) {
|
||||
// bridge permanently stopped." Re-encoded for the F01 contract: what keeps the
|
||||
// bridge usable across repeated slow stops is the task's acknowledgement, not
|
||||
// the deadline. A stop that is merely slow recovers every time; a stop that is
|
||||
// never acknowledged deliberately does NOT, because restarting on top of
|
||||
// clients that may still be running is the failure this state exists to
|
||||
// prevent. That is an availability trade, made knowingly.
|
||||
TEST(MQTTLifecycle, RepeatedSlowStopsRecoverOnTheirLateAck) {
|
||||
FakeOps ops;
|
||||
L::Coordinator c(ops, kStopTimeoutMs);
|
||||
|
||||
@@ -427,19 +494,51 @@ TEST(MQTTLifecycle, RepeatedFailedStopsLeaveBridgeRestartable) {
|
||||
ops.now += 1000;
|
||||
ASSERT_TRUE(c.requestStop());
|
||||
ops.now += kStopTimeoutMs;
|
||||
c.tick(); // times out (dirty)
|
||||
c.tick(); // deadline passes with no ack
|
||||
EXPECT_EQ(L::State::StopUnproven, c.state());
|
||||
EXPECT_FALSE(c.mayRestart()); // not until the task proves it finished
|
||||
EXPECT_EQ(attempt, ops.release_calls);
|
||||
|
||||
ops.now += 1000;
|
||||
ASSERT_TRUE(c.onTaskStopped()); // the slow teardown completes
|
||||
EXPECT_EQ(L::State::Stopped, c.state());
|
||||
EXPECT_TRUE(c.mayRestart()); // never permanently stuck
|
||||
EXPECT_TRUE(c.mayRestart());
|
||||
EXPECT_EQ(attempt + 1, ops.release_calls);
|
||||
}
|
||||
EXPECT_EQ(3, ops.start_task_calls);
|
||||
}
|
||||
|
||||
// A never-acknowledged stop stays unusable for the rest of the boot: no
|
||||
// release, no restart, no flash, however long the owner waits or however many
|
||||
// times it asks.
|
||||
TEST(MQTTLifecycle, NeverAcknowledgedStopStaysUnproven) {
|
||||
FakeOps ops;
|
||||
L::Coordinator c(ops, kStopTimeoutMs);
|
||||
bringUpToRunning(c);
|
||||
|
||||
ASSERT_TRUE(c.requestStop());
|
||||
ops.now += kStopTimeoutMs;
|
||||
c.tick();
|
||||
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
ops.now += 60'000;
|
||||
c.tick();
|
||||
EXPECT_FALSE(c.requestStart());
|
||||
EXPECT_FALSE(c.requestStop());
|
||||
EXPECT_EQ(L::State::StopUnproven, c.state());
|
||||
}
|
||||
EXPECT_EQ(0, ops.release_calls);
|
||||
EXPECT_FALSE(c.mayBeginFlash());
|
||||
EXPECT_TRUE(c.mayTouchOwnedState());
|
||||
}
|
||||
|
||||
// --- Diagnostics -----------------------------------------------------------
|
||||
|
||||
TEST(MQTTLifecycle, StateAndEventNamesAreStable) {
|
||||
EXPECT_STREQ("Stopped", L::stateName(L::State::Stopped));
|
||||
EXPECT_STREQ("Running", L::stateName(L::State::Running));
|
||||
EXPECT_STREQ("StopRequested", L::stateName(L::State::StopRequested));
|
||||
EXPECT_STREQ("StopUnproven", L::stateName(L::State::StopUnproven));
|
||||
EXPECT_STREQ("StartRequested", L::eventName(L::Event::StartRequested));
|
||||
EXPECT_STREQ("StopTimedOut", L::eventName(L::Event::StopTimedOut));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#include "helpers/NtpValidation.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
using namespace NtpValidation;
|
||||
|
||||
namespace {
|
||||
|
||||
const uint32_t MIN_EPOCH = 1767225600UL; // 2026-01-01, the bridge's floor
|
||||
const uint32_t MAX_EPOCH = 4102444800UL; // 2100-01-01
|
||||
const Nonce NONCE = { 0xA1B2C3D4UL, 0x0F1E2D3CUL };
|
||||
|
||||
// A well-formed reply to buildRequest(): stratum 2, mode 4, our nonce echoed.
|
||||
void makeReply(uint8_t out[48], uint32_t unix_epoch = 1789000000UL) {
|
||||
memset(out, 0, 48);
|
||||
out[0] = (0 << 6) | (4 << 3) | 4; // LI=0, VN=4, mode=4 (server)
|
||||
out[1] = 2; // stratum
|
||||
writeU32(out + 24, NONCE.seconds); // originate = our transmit timestamp
|
||||
writeU32(out + 28, NONCE.fraction);
|
||||
writeU32(out + 32, unix_epoch + kUnixEpochOffset); // receive
|
||||
writeU32(out + 40, unix_epoch + kUnixEpochOffset); // transmit
|
||||
}
|
||||
|
||||
Result check(const uint8_t* data, size_t len, bool source_matches = true) {
|
||||
return validate(data, len, source_matches, NONCE, MIN_EPOCH, MAX_EPOCH);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(NtpValidation, RequestIsAClientPacketCarryingTheNonce) {
|
||||
uint8_t req[48];
|
||||
memset(req, 0xFF, sizeof(req));
|
||||
buildRequest(req, NONCE);
|
||||
|
||||
EXPECT_EQ(0u, (req[0] >> 6) & 0x03); // LI = 0, not the library's bogus alarm
|
||||
EXPECT_EQ(4u, (req[0] >> 3) & 0x07); // version 4
|
||||
EXPECT_EQ(3u, req[0] & 0x07); // mode 3 = client
|
||||
EXPECT_EQ(NONCE.seconds, readU32(req + 40));
|
||||
EXPECT_EQ(NONCE.fraction, readU32(req + 44));
|
||||
}
|
||||
|
||||
TEST(NtpValidation, ValidReplyIsAccepted) {
|
||||
uint8_t pkt[48];
|
||||
makeReply(pkt, 1789000000UL);
|
||||
|
||||
Result r = check(pkt, sizeof(pkt));
|
||||
|
||||
EXPECT_EQ(kAccepted, r.reject);
|
||||
EXPECT_EQ(1789000000UL, r.epoch);
|
||||
}
|
||||
|
||||
// F07's reproduction: the installed NTPClient accepted a one-byte non-NTP
|
||||
// datagram and derived epoch 2085978496 from the uninitialised buffer.
|
||||
TEST(NtpValidation, OneByteNonNtpDatagramIsRejected) {
|
||||
const uint8_t junk[1] = { 0x00 };
|
||||
|
||||
Result r = check(junk, sizeof(junk));
|
||||
|
||||
EXPECT_EQ(kShortPacket, r.reject);
|
||||
EXPECT_EQ(0u, r.epoch);
|
||||
}
|
||||
|
||||
TEST(NtpValidation, ShortAndEmptyDatagramsAreRejected) {
|
||||
uint8_t pkt[48];
|
||||
makeReply(pkt);
|
||||
|
||||
for (size_t len = 0; len < 48; len++) {
|
||||
EXPECT_EQ(kShortPacket, check(pkt, len).reject) << "len " << len;
|
||||
}
|
||||
EXPECT_EQ(kShortPacket, check(nullptr, 48).reject);
|
||||
}
|
||||
|
||||
TEST(NtpValidation, ReplyFromAnotherSourceIsRejected) {
|
||||
uint8_t pkt[48];
|
||||
makeReply(pkt);
|
||||
|
||||
EXPECT_EQ(kWrongSource, check(pkt, sizeof(pkt), /*source_matches=*/false).reject);
|
||||
}
|
||||
|
||||
// An off-path sender that never saw the query cannot echo the nonce.
|
||||
TEST(NtpValidation, UnsolicitedReplyIsRejected) {
|
||||
uint8_t pkt[48];
|
||||
makeReply(pkt);
|
||||
writeU32(pkt + 24, 0);
|
||||
writeU32(pkt + 28, 0);
|
||||
|
||||
EXPECT_EQ(kOriginMismatch, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt);
|
||||
writeU32(pkt + 28, NONCE.fraction ^ 1u); // one bit off is still not ours
|
||||
EXPECT_EQ(kOriginMismatch, check(pkt, sizeof(pkt)).reject);
|
||||
}
|
||||
|
||||
TEST(NtpValidation, ClientModeAndWrongVersionAreRejected) {
|
||||
uint8_t pkt[48];
|
||||
|
||||
makeReply(pkt);
|
||||
pkt[0] = (0 << 6) | (4 << 3) | 3; // our own request echoed back
|
||||
EXPECT_EQ(kBadMode, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt);
|
||||
pkt[0] = (0 << 6) | (5 << 3) | 4; // version 5 does not exist
|
||||
EXPECT_EQ(kBadVersion, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt);
|
||||
pkt[0] = (0 << 6) | (3 << 3) | 4; // NTPv3 servers are fine
|
||||
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
|
||||
}
|
||||
|
||||
TEST(NtpValidation, UnsynchronisedServersAreRejected) {
|
||||
uint8_t pkt[48];
|
||||
|
||||
makeReply(pkt);
|
||||
pkt[0] |= (3 << 6); // LI = 3: alarm, clock not set
|
||||
EXPECT_EQ(kLeapAlarm, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt);
|
||||
pkt[1] = 0; // stratum 0: kiss-of-death
|
||||
EXPECT_EQ(kBadStratum, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt);
|
||||
pkt[1] = 16; // unsynchronised
|
||||
EXPECT_EQ(kBadStratum, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt);
|
||||
pkt[1] = 15; // last usable stratum
|
||||
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
|
||||
}
|
||||
|
||||
TEST(NtpValidation, MissingTransmitTimestampIsRejected) {
|
||||
uint8_t pkt[48];
|
||||
makeReply(pkt);
|
||||
writeU32(pkt + 40, 0);
|
||||
|
||||
EXPECT_EQ(kZeroTransmit, check(pkt, sizeof(pkt)).reject);
|
||||
}
|
||||
|
||||
// The bridge's floor is the only defence against a stale or rolled-back clock,
|
||||
// and nothing previously stopped an absurd forward jump.
|
||||
TEST(NtpValidation, ImplausibleTimesAreRejected) {
|
||||
uint8_t pkt[48];
|
||||
|
||||
makeReply(pkt, MIN_EPOCH - 1);
|
||||
EXPECT_EQ(kEpochTooEarly, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt, MIN_EPOCH);
|
||||
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt, MAX_EPOCH + 1);
|
||||
EXPECT_EQ(kEpochTooLate, check(pkt, sizeof(pkt)).reject);
|
||||
|
||||
makeReply(pkt, MAX_EPOCH);
|
||||
EXPECT_EQ(kAccepted, check(pkt, sizeof(pkt)).reject);
|
||||
}
|
||||
|
||||
// Era 1 begins 2036-02-07; a naive subtraction turns those seconds into 1900.
|
||||
TEST(NtpValidation, Era1TimestampsConvertForward) {
|
||||
EXPECT_EQ(0u, unixFromNtpSeconds(kUnixEpochOffset));
|
||||
EXPECT_EQ(2085978496UL, unixFromNtpSeconds(0)); // 2036-02-07
|
||||
EXPECT_GT(unixFromNtpSeconds(1000), unixFromNtpSeconds(0xFFFFFFFFu));
|
||||
|
||||
uint8_t pkt[48];
|
||||
makeReply(pkt);
|
||||
writeU32(pkt + 40, 1000); // 1000 s into era 1
|
||||
Result r = check(pkt, sizeof(pkt));
|
||||
EXPECT_EQ(kAccepted, r.reject);
|
||||
EXPECT_EQ(2085979496UL, r.epoch);
|
||||
}
|
||||
|
||||
TEST(NtpValidation, EveryRejectionHasAShortReason) {
|
||||
for (int i = 0; i <= (int)kEpochTooLate; i++) {
|
||||
const char* reason = rejectReason((Reject)i);
|
||||
ASSERT_NE(nullptr, reason);
|
||||
EXPECT_GT(strlen(reason), 0u);
|
||||
EXPECT_LE(strlen(reason), 20u) << "reject " << i;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "helpers/RadioWatchdog.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace {
|
||||
|
||||
const uint32_t WATCHDOG_MS = 300000UL; // RADIO_WATCHDOG_MS default: 5 minutes
|
||||
|
||||
// Mirrors Dispatcher::loop(): the radio's three timestamps are sampled every
|
||||
// pass, and a recovery is acknowledged as soon as it is decided.
|
||||
struct Radio {
|
||||
uint32_t last_recv = 0;
|
||||
uint32_t last_irq = 0;
|
||||
uint32_t last_tx = 0;
|
||||
};
|
||||
|
||||
RadioWatchdogDecision run(RadioWatchdog& w, const Radio& r, uint32_t now,
|
||||
bool in_recv_mode = true, uint32_t watchdog_ms = WATCHDOG_MS) {
|
||||
RadioWatchdogDecision d = w.update(now, in_recv_mode, watchdog_ms,
|
||||
r.last_recv, r.last_irq, r.last_tx);
|
||||
if (d.recover) w.noteRecovery();
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(RadioWatchdog, NoActivitySinceBootNeverTrips) {
|
||||
RadioWatchdog w;
|
||||
w.reset(0);
|
||||
Radio r; // radio has never received, interrupted or transmitted
|
||||
|
||||
for (uint32_t t = 0; t <= 4 * WATCHDOG_MS; t += 10000) {
|
||||
RadioWatchdogDecision d = run(w, r, t);
|
||||
EXPECT_FALSE(d.recover) << "tripped at t=" << t;
|
||||
EXPECT_FALSE(d.measurable);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RadioWatchdog, ContinuousReceiveNeverTrips) {
|
||||
RadioWatchdog w;
|
||||
w.reset(0);
|
||||
Radio r;
|
||||
|
||||
for (uint32_t t = 1000; t <= 3 * WATCHDOG_MS; t += 1000) {
|
||||
r.last_recv = t;
|
||||
r.last_irq = t;
|
||||
RadioWatchdogDecision d = run(w, r, t);
|
||||
EXPECT_FALSE(d.recover) << "tripped at t=" << t;
|
||||
EXPECT_LE(d.silent_ms, 1000u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RadioWatchdog, SilentRadioTripsOncePerInterval) {
|
||||
RadioWatchdog w;
|
||||
w.reset(0);
|
||||
Radio r;
|
||||
r.last_recv = 1000;
|
||||
run(w, r, 1000);
|
||||
|
||||
int trips = 0;
|
||||
uint32_t first_trip = 0;
|
||||
for (uint32_t t = 2000; t <= 1000000; t += 1000) {
|
||||
if (run(w, r, t).recover) {
|
||||
if (trips == 0) first_trip = t;
|
||||
trips++;
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_EQ(302000u, first_trip); // strictly past the interval, from the last receive
|
||||
EXPECT_EQ(3, trips); // ~1 M ms / 300 s, never per-loop
|
||||
}
|
||||
|
||||
// F13: a transmit timestamp from just before the millis() wrap is numerically
|
||||
// larger than every fresh post-wrap receive. Selecting the largest timestamp
|
||||
// reported ~24 days of silence and reset a radio that was receiving normally.
|
||||
TEST(RadioWatchdog, PreWrapTransmitDoesNotSilenceFreshPostWrapReceives) {
|
||||
const uint32_t WRAP = 0xFFFFFFFFu;
|
||||
const uint32_t TX_AT = 0x80000000u; // upper half: beats any post-wrap value
|
||||
|
||||
RadioWatchdog w;
|
||||
w.reset(TX_AT - 60000);
|
||||
Radio r;
|
||||
r.last_recv = TX_AT - 60000;
|
||||
r.last_irq = TX_AT - 60000;
|
||||
run(w, r, TX_AT - 60000);
|
||||
|
||||
r.last_tx = TX_AT;
|
||||
run(w, r, TX_AT);
|
||||
|
||||
// Receive steadily right up to the wrap...
|
||||
for (uint32_t t = TX_AT + 1000; t < WRAP - 1000; t += 1000) {
|
||||
r.last_recv = t;
|
||||
r.last_irq = t;
|
||||
ASSERT_FALSE(run(w, r, t).recover) << "tripped before wrap at t=" << t;
|
||||
}
|
||||
|
||||
// ...and straight through it. The stale transmit is still the numerically
|
||||
// largest of the three timestamps for the next ~24 days.
|
||||
for (uint32_t t = 1000; t < 2 * WATCHDOG_MS; t += 1000) {
|
||||
r.last_recv = t;
|
||||
r.last_irq = t;
|
||||
RadioWatchdogDecision d = run(w, r, t);
|
||||
ASSERT_FALSE(d.recover) << "tripped after wrap at t=" << t;
|
||||
ASSERT_LE(d.silent_ms, 1000u);
|
||||
}
|
||||
}
|
||||
|
||||
// The wrap must not hide a genuinely silent radio either.
|
||||
TEST(RadioWatchdog, SilenceAcrossTheWrapIsStillDetected) {
|
||||
const uint32_t LAST_RX = 0xFFFF0000u; // ~65 s before the wrap
|
||||
|
||||
RadioWatchdog w;
|
||||
w.reset(LAST_RX - 1000);
|
||||
Radio r;
|
||||
r.last_recv = LAST_RX;
|
||||
r.last_irq = LAST_RX;
|
||||
run(w, r, LAST_RX);
|
||||
|
||||
bool tripped = false;
|
||||
uint64_t silent_at_trip = 0;
|
||||
for (uint32_t step = 1000; step <= WATCHDOG_MS + 5000; step += 1000) {
|
||||
uint32_t t = LAST_RX + step; // wraps on its own
|
||||
RadioWatchdogDecision d = run(w, r, t);
|
||||
if (d.recover) {
|
||||
tripped = true;
|
||||
silent_at_trip = d.silent_ms;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(tripped);
|
||||
EXPECT_GT(silent_at_trip, (uint64_t)WATCHDOG_MS);
|
||||
}
|
||||
|
||||
// Uptime past 2^32 ms must not alias a long silence back to "recent".
|
||||
TEST(RadioWatchdog, SilenceBeyondOneMillisCycleKeepsGrowing) {
|
||||
RadioWatchdog w;
|
||||
w.reset(0);
|
||||
Radio r;
|
||||
r.last_recv = 1000;
|
||||
run(w, r, 1000);
|
||||
|
||||
// Walk a full cycle in 1-minute steps; the raw timestamp aliases back to its
|
||||
// starting value, the tracked age does not.
|
||||
uint64_t last_silent = 0;
|
||||
for (uint64_t step = 60000; step <= 0x100000000ull + 600000ull; step += 60000) {
|
||||
uint32_t t = (uint32_t)((1000ull + step) & 0xFFFFFFFFull);
|
||||
RadioWatchdogDecision d = w.update(t, true, WATCHDOG_MS, r.last_recv, r.last_irq, r.last_tx);
|
||||
if (d.recover) w.noteRecovery();
|
||||
ASSERT_GT(d.silent_ms, last_silent);
|
||||
last_silent = d.silent_ms;
|
||||
}
|
||||
|
||||
EXPECT_GT(last_silent, 0x100000000ull);
|
||||
}
|
||||
|
||||
TEST(RadioWatchdog, DisabledWatchdogAndNonRecvModeNeverTrip) {
|
||||
RadioWatchdog w;
|
||||
w.reset(0);
|
||||
Radio r;
|
||||
r.last_recv = 1000;
|
||||
run(w, r, 1000);
|
||||
|
||||
for (uint32_t t = 2000; t <= 1000000; t += 10000) {
|
||||
EXPECT_FALSE(run(w, r, t, /*in_recv_mode=*/true, /*watchdog_ms=*/0).recover);
|
||||
EXPECT_FALSE(run(w, r, t, /*in_recv_mode=*/false).recover);
|
||||
}
|
||||
}
|
||||
|
||||
// Transmit-only traffic still counts as a live radio.
|
||||
TEST(RadioWatchdog, TransmitActivityRefreshesSilence) {
|
||||
RadioWatchdog w;
|
||||
w.reset(0);
|
||||
Radio r;
|
||||
r.last_recv = 1000;
|
||||
run(w, r, 1000);
|
||||
|
||||
for (uint32_t t = 100000; t <= 900000; t += 100000) {
|
||||
r.last_tx = t;
|
||||
EXPECT_FALSE(run(w, r, t).recover) << "tripped at t=" << t;
|
||||
}
|
||||
}
|
||||
|
||||
// A blocked mesh loop (e.g. a 30 s NTP probe) collapses several events into one
|
||||
// observation. That may only shorten measured silence, never lengthen it.
|
||||
TEST(RadioWatchdog, LoopStallDoesNotManufactureSilence) {
|
||||
RadioWatchdog w;
|
||||
w.reset(0);
|
||||
Radio r;
|
||||
r.last_recv = 1000;
|
||||
run(w, r, 1000);
|
||||
|
||||
r.last_recv = 20000; // arrived during the stall
|
||||
r.last_irq = 20000;
|
||||
RadioWatchdogDecision d = run(w, r, 31000);
|
||||
|
||||
EXPECT_FALSE(d.recover);
|
||||
EXPECT_EQ(0u, d.silent_ms);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "helpers/WifiPowerSavePolicy.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
using namespace WifiPowerSavePolicy;
|
||||
|
||||
// F11: the CLI applied stored 0 as MIN_MODEM while the reconnect path applied it
|
||||
// as NONE, so a node configured for `min` changed behaviour after every
|
||||
// reconnect and `get wifi.powersave` still reported min. One table, one answer.
|
||||
TEST(WifiPowerSavePolicy, StoredValuesMapToOneModeEach) {
|
||||
EXPECT_EQ(kModeMinModem, modeFor(kMin));
|
||||
EXPECT_EQ(kModeNone, modeFor(kNone));
|
||||
EXPECT_EQ(kModeMaxModem, modeFor(kMax));
|
||||
}
|
||||
|
||||
// Stored 0 was the default for nodes set up 2026-01-02..03-28, and they have
|
||||
// always run with power save off; reading it as `min` would put them to sleep.
|
||||
TEST(WifiPowerSavePolicy, LegacyDefaultKeepsPowerSaveOff) {
|
||||
EXPECT_EQ(kModeNone, modeFor(kLegacyDefault));
|
||||
EXPECT_STREQ("none", nameFor(kLegacyDefault));
|
||||
uint8_t parsed = 0xFF;
|
||||
ASSERT_TRUE(parseName("min", &parsed));
|
||||
EXPECT_NE(kLegacyDefault, parsed);
|
||||
}
|
||||
|
||||
TEST(WifiPowerSavePolicy, NamesRoundTripWithStoredValues) {
|
||||
for (uint8_t stored = kNone; stored <= kMaxStoredValue; stored++) {
|
||||
uint8_t parsed = 0xFF;
|
||||
ASSERT_TRUE(parseName(nameFor(stored), &parsed)) << "stored " << (int)stored;
|
||||
EXPECT_EQ(stored, parsed);
|
||||
EXPECT_EQ(modeFor(stored), modeFor(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
// A byte outside the stored range must read as the product default, not as
|
||||
// whatever mode happens to sit at that index.
|
||||
TEST(WifiPowerSavePolicy, OutOfRangeStoredValueReadsAsDefault) {
|
||||
for (int stored = kMaxStoredValue + 1; stored <= 255; stored++) {
|
||||
EXPECT_EQ(kModeNone, modeFor((uint8_t)stored)) << "stored " << stored;
|
||||
EXPECT_STREQ("none", nameFor((uint8_t)stored));
|
||||
}
|
||||
}
|
||||
|
||||
// The setters take the remainder of the command line, so a trailing argument
|
||||
// must still parse — and a longer word starting with a valid name must not.
|
||||
TEST(WifiPowerSavePolicy, ParsesCliArgumentsExactly) {
|
||||
uint8_t stored = 0xFF;
|
||||
|
||||
EXPECT_TRUE(parseName("min", &stored));
|
||||
EXPECT_EQ(kMin, stored);
|
||||
EXPECT_TRUE(parseName("none extra", &stored));
|
||||
EXPECT_EQ(kNone, stored);
|
||||
EXPECT_TRUE(parseName("max ", &stored));
|
||||
EXPECT_EQ(kMax, stored);
|
||||
|
||||
stored = 0xFF;
|
||||
EXPECT_FALSE(parseName("minimum", &stored));
|
||||
EXPECT_FALSE(parseName("", &stored));
|
||||
EXPECT_FALSE(parseName("off", &stored));
|
||||
EXPECT_FALSE(parseName("MIN", &stored));
|
||||
EXPECT_FALSE(parseName(nullptr, &stored));
|
||||
EXPECT_EQ(0xFF, stored); // rejected input leaves the caller's value alone
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
Reference in new issue
Block a user