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:
agessaman committed 2026-09-19 15:15:07 -07:00
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();
}
+115 -16
View File
@@ -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();
}