diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1cb808931..3f92a5b0a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -5001,7 +5001,8 @@ bool MyMesh::loadFloodPacketFilters() { bool version_7 = success && memcmp(magic, "FPF7", sizeof(magic)) == 0; success = (version_6 || version_7) && file.read(&count, sizeof(count)) == sizeof(count) - && count <= FLOOD_PACKET_FILTER_SLOTS; + && FloodFilterPolicy::forwardPersistenceCountSupported( + count, FLOOD_PACKET_FILTER_SLOTS); for (int i = 0; success && i < count; i++) { uint8_t active = 0; @@ -5762,14 +5763,13 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, }; const uint8_t magic[4] = {'F', 'P', 'F', '7'}; - uint8_t count = FLOOD_PACKET_FILTER_SLOTS; + const uint8_t count = FloodFilterPolicy::forwardPersistenceCount( + flood_packet_filters, FLOOD_PACKET_FILTER_SLOTS, + empty_forward_phase); bool success = writeExact(magic, sizeof(magic)) && writeExact(&count, sizeof(count)); - FloodPacketFilterEntry empty_entry; - memset(&empty_entry, 0, sizeof(empty_entry)); - for (int i = 0; success && i < FLOOD_PACKET_FILTER_SLOTS; i++) { - const auto& entry = empty_forward_phase - ? empty_entry : flood_packet_filters[i]; + for (int i = 0; success && i < count; i++) { + const auto& entry = flood_packet_filters[i]; uint8_t active = entry.active ? 1 : 0; uint8_t suspend_on_temp_radio = entry.suspend_on_temp_radio ? 1 : 0; uint8_t match_blacklisted_path = entry.match_blacklisted_path ? 1 : 0; diff --git a/src/helpers/FloodFilterPolicy.h b/src/helpers/FloodFilterPolicy.h index c8a1875b1..021f8adbe 100644 --- a/src/helpers/FloodFilterPolicy.h +++ b/src/helpers/FloodFilterPolicy.h @@ -40,6 +40,25 @@ enum ChannelScopeGate { CHANNEL_SCOPE_REQUIRED_REJECTED, }; +// Persist only through the highest occupied forward-rule slot. The on-disk +// formats are dense, so inactive holes before that slot must remain present, +// while the usually-large inactive tail need not consume LittleFS space. +// `empty_forward_phase` deliberately wins over the live table for atomic +// phase-clearing transactions. +template +inline uint8_t forwardPersistenceCount(const Entry entries[], + uint8_t slot_count, + bool empty_forward_phase) { + if (empty_forward_phase || entries == NULL) return 0; + while (slot_count > 0 && !entries[slot_count - 1].active) slot_count--; + return slot_count; +} + +inline bool forwardPersistenceCountSupported(uint8_t count, + uint8_t slot_count) { + return count <= slot_count; +} + inline ChannelScopeGate channelScopeGate(bool table_active, bool is_group_channel_packet, bool channel_requires_scope, diff --git a/test/test_flood_filter_policy/test_flood_filter_policy.cpp b/test/test_flood_filter_policy/test_flood_filter_policy.cpp index a25dafad1..291287f10 100644 --- a/test/test_flood_filter_policy/test_flood_filter_policy.cpp +++ b/test/test_flood_filter_policy/test_flood_filter_policy.cpp @@ -3,6 +3,41 @@ #include #include +struct FloodPersistenceRow { + bool active; +}; + +TEST(FloodFilterPersistence, DefaultRowsTrimTheInactiveTail) { + FloodPersistenceRow rows[63] = {}; + rows[0].active = true; + rows[1].active = true; + + EXPECT_EQ(2, FloodFilterPolicy::forwardPersistenceCount( + rows, 63, false)); +} + +TEST(FloodFilterPersistence, SparseHighSlotPreservesEveryEarlierPosition) { + FloodPersistenceRow rows[63] = {}; + rows[0].active = true; + rows[47].active = true; + + EXPECT_EQ(48, FloodFilterPolicy::forwardPersistenceCount( + rows, 63, false)); +} + +TEST(FloodFilterPersistence, EmptyForwardPhaseSerializesNoRows) { + FloodPersistenceRow rows[63] = {}; + rows[62].active = true; + + EXPECT_EQ(0, FloodFilterPolicy::forwardPersistenceCount( + rows, 63, true)); +} + +TEST(FloodFilterPersistence, OldFullCountRemainsLoadCompatible) { + EXPECT_TRUE(FloodFilterPolicy::forwardPersistenceCountSupported(63, 63)); + EXPECT_FALSE(FloodFilterPolicy::forwardPersistenceCountSupported(64, 63)); +} + static mesh::Packet makeFloodPacket(uint8_t hash_size, const uint8_t* path, uint8_t path_hops) {