mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-08-28 02:54:05 +00:00
feat(mqtt): add per-slot packet filters for MQTT slots
Introduce per-slot packet filters to allow users to specify which packet types are uploaded for each MQTT slot. This feature enhances the flexibility of the MQTT bridge by enabling users to configure allowlists for packet types, improving the efficiency of data transmissions. The implementation includes updates to the WebConfig interface, internal handling of packet filters, and necessary modifications to the MQTT preferences structure.
This commit is contained in:
@@ -248,6 +248,7 @@ The MQTT bridge comes with the following defaults for fresh installs (unless ove
|
||||
- **Slot 1**: `analyzer-us`
|
||||
- **Slot 2**: `analyzer-eu`
|
||||
- **Slots 3-6**: `none` (disabled)
|
||||
- **Per-slot packet filters**: `all` (every payload type is uploaded)
|
||||
- **WiFi SSID**: (blank — must be configured)
|
||||
- **WiFi Password**: (blank — optional for open networks)
|
||||
- **WiFi Power Save**: `none` (no power save)
|
||||
@@ -272,6 +273,7 @@ Each slot (1-6) supports the following commands:
|
||||
- `get mqttN.token` - Get per-slot token (e.g., MeshRank account token)
|
||||
- `get mqttN.topic` - Get custom topic template for slot N
|
||||
- `get mqttN.audience` - Get JWT audience for slot N (custom slots only)
|
||||
- `get mqttN.filter` - Get the slot's packet-type allowlist (`all`, `none`, or numeric CSV)
|
||||
|
||||
#### Set Commands
|
||||
- `set mqttN.preset <name>` - Set slot N to a built-in preset. Use any `name` from the [preset table](#slot-based-preset-system) (run `get mqtt.presets` on-device for the full list). Most presets need no further configuration; the exceptions are:
|
||||
@@ -287,9 +289,41 @@ Each slot (1-6) supports the following commands:
|
||||
- `set mqttN.topic <template>` - Set custom topic template (custom preset only, see below)
|
||||
- `set mqttN.audience <audience>` - Set JWT audience for custom slot (enables Ed25519 JWT auth)
|
||||
- `set mqttN.audience` - Clear JWT audience (reverts to username/password auth)
|
||||
- `set mqttN.filter <all|none|CSV>` - Select payload types uploaded to this slot
|
||||
|
||||
**Note:** Custom server/port settings only apply when the slot's preset is `custom`. Username/password also apply to built-in presets that use per-slot credentials (e.g. `inwmesh`); other userpass presets (`tennmesh`, `nashmesh`, `ctmesh`) ship fixed credentials in firmware.
|
||||
|
||||
#### Per-broker packet filters
|
||||
|
||||
Each slot has an independent allowlist. For example, this sends only text
|
||||
messages and adverts to slot 1 while slot 2 keeps its default of all packet
|
||||
types:
|
||||
|
||||
```bash
|
||||
set mqtt1.filter 2,4
|
||||
set mqtt2.filter all
|
||||
```
|
||||
|
||||
Use `none` when a broker should remain connected for status/neighbors but
|
||||
receive no packet traffic. A bare `set mqttN.filter` or an empty WebConfig
|
||||
value resets the slot to `all`.
|
||||
|
||||
| Type | Name | Type | Name |
|
||||
|------|------|------|------|
|
||||
| 0 | REQ | 8 | PATH |
|
||||
| 1 | RESPONSE | 9 | TRACE |
|
||||
| 2 | TXT_MSG | 10 | MULTIPART |
|
||||
| 3 | ACK | 11 | CONTROL |
|
||||
| 4 | ADVERT | 12-14 | Reserved |
|
||||
| 5 | GRP_TXT | 15 | RAW_CUSTOM |
|
||||
| 6 | GRP_DATA | | |
|
||||
| 7 | ANON_REQ | | |
|
||||
|
||||
The filter applies to both structured `packets` and `raw` publications for RX
|
||||
packets and for TX packets permitted by `mqtt.tx`. It does not affect local
|
||||
packet processing, forwarding, capture logs, status, or neighbors. Changes
|
||||
apply live without reconnecting the broker.
|
||||
|
||||
#### Example: Configure MeshRank on Slot 3
|
||||
```bash
|
||||
set mqtt3.preset meshrank
|
||||
|
||||
+6
-3
@@ -100,11 +100,14 @@ fall back to defaults (no downgrade, no misread). `saveMQTTPrefs()` also refuses
|
||||
write while such a file is present (`_mqtt_prefs_hold`), so a `set` command after a
|
||||
firmware downgrade can't clobber the newer config — observer settings changed in that
|
||||
state simply don't persist. The frozen legacy layouts are pinned with `static_assert`s
|
||||
in `CommonCLI.h`, so every target build re-verifies the fleet's file offsets.
|
||||
in `MQTTPrefsStorage.h`, so every target build re-verifies the fleet's file offsets.
|
||||
|
||||
Adding a field to the current version stays backward compatible: append it to the end
|
||||
of `MQTTPrefs`. An older, shorter payload still loads and the missing tail keeps its
|
||||
default; a newer, longer one is truncated harmlessly.
|
||||
of `MQTTPrefs`, give the older exact payload length an explicit decoder boundary, and
|
||||
leave the missing tail at its default. The packet-filter addition follows that rule:
|
||||
the prior 2864-byte v1 payload loads with all six filters set to `all`, while the
|
||||
current payload is 2876 bytes. Older firmware treats that longer same-version payload
|
||||
as unsupported and preserves it rather than loading or overwriting it.
|
||||
|
||||
### Settings upgrade / migration
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ LEN_LIMITS = {
|
||||
"mqtt.email": 63, "mqtt.ntp": 63, "timezone": 31, "snmp.community": 23,
|
||||
}
|
||||
SLOT_LEN_LIMITS = {"server": 63, "username": 31, "password": 63,
|
||||
"token": 47, "topic": 95, "audience": 63}
|
||||
"token": 47, "topic": 95, "audience": 63, "filter": 37}
|
||||
|
||||
# Preset names + what the UI must collect (mirrors handlePresets()).
|
||||
PRESETS = (
|
||||
@@ -99,7 +99,8 @@ def default_config(setup_mode):
|
||||
|
||||
def _slot():
|
||||
return {"preset": "none", "server": "", "port": 8883, "username": "",
|
||||
"password": "", "token": "", "topic": "", "audience": ""}
|
||||
"password": "", "token": "", "topic": "", "audience": "",
|
||||
"filter": "all"}
|
||||
|
||||
|
||||
class State:
|
||||
@@ -253,6 +254,26 @@ def apply_set(cfg, key, val):
|
||||
return True, "OK" # unknown-but-allowlisted: accept (mock is lenient here)
|
||||
|
||||
|
||||
def canonical_packet_filter(val):
|
||||
stripped = val.strip()
|
||||
if stripped == "" or stripped == "all":
|
||||
return "all"
|
||||
if stripped == "none":
|
||||
return "none"
|
||||
mask = 0
|
||||
for part in stripped.split(","):
|
||||
token = part.strip()
|
||||
if not re.fullmatch(r"[0-9]+", token):
|
||||
return None
|
||||
packet_type = int(token)
|
||||
if packet_type > 15:
|
||||
return None
|
||||
mask |= 1 << packet_type
|
||||
if mask == 0xFFFF:
|
||||
return "all"
|
||||
return ",".join(str(i) for i in range(16) if mask & (1 << i))
|
||||
|
||||
|
||||
def apply_slot_set(cfg, idx, field, val):
|
||||
slot = cfg["mqtt"]["slots"][idx]
|
||||
if field in SLOT_LEN_LIMITS and len(val) > SLOT_LEN_LIMITS[field]:
|
||||
@@ -266,6 +287,12 @@ def apply_slot_set(cfg, idx, field, val):
|
||||
return False, "Error: port must be between 1 and 65535"
|
||||
slot["port"] = p
|
||||
return True, "OK"
|
||||
if field == "filter":
|
||||
canonical = canonical_packet_filter(val)
|
||||
if canonical is None:
|
||||
return False, "Error: filter must be all, none, or CSV packet types 0-15"
|
||||
slot["filter"] = canonical
|
||||
return True, "OK - slot %d packet types: %s" % (idx + 1, canonical)
|
||||
if field in ("preset", "server", "username", "password", "token", "topic", "audience"):
|
||||
slot[field] = val
|
||||
if field == "token":
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "bridges/MQTTBridge.h"
|
||||
#include "MQTTConnectionPolicy.h" // classifySlotActivation() — "will this slot connect here?"
|
||||
#include "MQTTDefaults.h"
|
||||
#include "MQTTPacketFilter.h"
|
||||
#endif
|
||||
|
||||
// Local copy of the busted-libc-safe atoi (the original in CommonCLI.cpp is static).
|
||||
@@ -529,6 +530,20 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
|
||||
savePrefs();
|
||||
_callbacks->restartBridgeSlot(slot);
|
||||
sprintf(reply, "OK - slot %d JWT audience cleared (using username/password auth)", slot + 1);
|
||||
} else if (strcmp(subcmd, "filter") == 0 ||
|
||||
strncmp(subcmd, "filter ", 7) == 0) {
|
||||
// Empty/bare input resets to the backwards-compatible all-types default.
|
||||
const char* filter_value = subcmd[6] == '\0' ? "" : &subcmd[7];
|
||||
uint16_t filter_mask = 0;
|
||||
if (!MQTTPacketFilter::parse(filter_value, &filter_mask)) {
|
||||
strcpy(reply, "Error: filter must be all, none, or CSV packet types 0-15");
|
||||
} else {
|
||||
_mqtt_prefs.mqtt_slot_packet_filter[slot] = filter_mask;
|
||||
savePrefs();
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text));
|
||||
snprintf(reply, 160, "OK - slot %d packet types: %s", slot + 1, filter_text);
|
||||
}
|
||||
} else {
|
||||
sprintf(reply, "unknown config: %s", config);
|
||||
}
|
||||
@@ -869,6 +884,14 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
|
||||
} else {
|
||||
strcpy(reply, "> (not set - custom slots use username/password auth)");
|
||||
}
|
||||
} else if (strcmp(subcmd, "filter") == 0) {
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
if (MQTTPacketFilter::format(_mqtt_prefs.mqtt_slot_packet_filter[slot],
|
||||
filter_text, sizeof(filter_text))) {
|
||||
snprintf(reply, 160, "> %s", filter_text);
|
||||
} else {
|
||||
strcpy(reply, "Error: invalid stored packet filter");
|
||||
}
|
||||
} else if (memcmp(subcmd, "diag", 4) == 0) {
|
||||
MQTTBridge::formatSlotDiagReply(reply, 160, slot);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <string.h>
|
||||
#include <MeshCore.h>
|
||||
#include "CommonCLI.h"
|
||||
#include "MQTTPacketFilter.h"
|
||||
#include "MQTTPresets.h"
|
||||
|
||||
// Compile-time defaults for fresh /mqtt_prefs (override via platformio build_flags).
|
||||
@@ -82,6 +83,9 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
|
||||
MQTT_DEFAULT_SLOT5_PRESET);
|
||||
mqttDefaultSlotPreset(prefs->mqtt_slot_preset[5], sizeof(prefs->mqtt_slot_preset[5]),
|
||||
MQTT_DEFAULT_SLOT6_PRESET);
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
prefs->mqtt_slot_packet_filter[i] = MQTTPacketFilter::kAllPacketTypes;
|
||||
}
|
||||
|
||||
if (MQTT_DEFAULT_IATA[0] != '\0') {
|
||||
strncpy(prefs->mqtt_iata, MQTT_DEFAULT_IATA, sizeof(prefs->mqtt_iata) - 1);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// Pure per-broker packet-type allowlist helpers. MeshCore payload types occupy
|
||||
// the low four bits of the packet header, so a uint16_t stores the complete
|
||||
// 0..15 allowlist without dynamic allocation.
|
||||
namespace MQTTPacketFilter {
|
||||
|
||||
static const uint8_t kMinPacketType = 0;
|
||||
static const uint8_t kMaxPacketType = 15;
|
||||
static const uint16_t kAllPacketTypes = 0xFFFFu;
|
||||
// "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" plus the terminator.
|
||||
static const size_t kFilterTextSize = 38;
|
||||
|
||||
inline bool isAsciiSpace(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||
}
|
||||
|
||||
inline bool tokenEquals(const char* begin, size_t len, const char* token) {
|
||||
return token != nullptr && strlen(token) == len && memcmp(begin, token, len) == 0;
|
||||
}
|
||||
|
||||
// Parse a canonical allowlist value. Empty input means "all" so WebConfig can
|
||||
// clear a field and retain the same backwards-compatible default as an older
|
||||
// /mqtt_prefs file. Keywords are deliberately lowercase and cannot be mixed
|
||||
// with numeric entries. Decimal entries may contain surrounding ASCII
|
||||
// whitespace, must be in 0..15, and may be repeated.
|
||||
inline bool parse(const char* input, uint16_t* mask_out) {
|
||||
if (input == nullptr || mask_out == nullptr) return false;
|
||||
|
||||
const char* begin = input;
|
||||
while (*begin && isAsciiSpace(*begin)) begin++;
|
||||
const char* end = begin + strlen(begin);
|
||||
while (end > begin && isAsciiSpace(end[-1])) end--;
|
||||
|
||||
const size_t len = static_cast<size_t>(end - begin);
|
||||
if (len == 0 || tokenEquals(begin, len, "all")) {
|
||||
*mask_out = kAllPacketTypes;
|
||||
return true;
|
||||
}
|
||||
if (tokenEquals(begin, len, "none")) {
|
||||
*mask_out = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t parsed = 0;
|
||||
const char* cursor = begin;
|
||||
while (cursor < end) {
|
||||
while (cursor < end && isAsciiSpace(*cursor)) cursor++;
|
||||
if (cursor >= end || *cursor < '0' || *cursor > '9') return false;
|
||||
|
||||
unsigned value = 0;
|
||||
while (cursor < end && *cursor >= '0' && *cursor <= '9') {
|
||||
value = value * 10u + static_cast<unsigned>(*cursor - '0');
|
||||
if (value > kMaxPacketType) return false;
|
||||
cursor++;
|
||||
}
|
||||
while (cursor < end && isAsciiSpace(*cursor)) cursor++;
|
||||
|
||||
parsed |= static_cast<uint16_t>(1u << value);
|
||||
if (cursor == end) break;
|
||||
if (*cursor != ',') return false;
|
||||
cursor++;
|
||||
if (cursor == end) return false;
|
||||
}
|
||||
|
||||
*mask_out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Format masks deterministically for CLI/API output. Subsets are emitted in
|
||||
// ascending order; the two useful extremes use concise keywords.
|
||||
inline bool format(uint16_t mask, char* output, size_t output_size) {
|
||||
if (output == nullptr || output_size == 0) return false;
|
||||
output[0] = '\0';
|
||||
|
||||
const char* keyword = nullptr;
|
||||
if (mask == kAllPacketTypes) keyword = "all";
|
||||
else if (mask == 0) keyword = "none";
|
||||
if (keyword != nullptr) {
|
||||
const size_t len = strlen(keyword);
|
||||
if (output_size <= len) return false;
|
||||
memcpy(output, keyword, len + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
char formatted[kFilterTextSize];
|
||||
size_t pos = 0;
|
||||
bool first = true;
|
||||
for (uint8_t type = kMinPacketType; type <= kMaxPacketType; ++type) {
|
||||
if ((mask & static_cast<uint16_t>(1u << type)) == 0) continue;
|
||||
if (!first) formatted[pos++] = ',';
|
||||
if (type >= 10) formatted[pos++] = '1';
|
||||
formatted[pos++] = static_cast<char>('0' + (type % 10));
|
||||
first = false;
|
||||
}
|
||||
formatted[pos] = '\0';
|
||||
|
||||
if (output_size <= pos) return false;
|
||||
memcpy(output, formatted, pos + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool allows(uint16_t mask, uint8_t packet_type) {
|
||||
return packet_type <= kMaxPacketType &&
|
||||
(mask & static_cast<uint16_t>(1u << packet_type)) != 0;
|
||||
}
|
||||
|
||||
// Eligibility deliberately excludes connection state. A temporarily
|
||||
// disconnected broker that is configured for this type still requires the
|
||||
// shared queue's existing bounded retry policy.
|
||||
inline bool slotEligible(bool slot_enabled, bool topic_supported,
|
||||
uint16_t mask, uint8_t packet_type) {
|
||||
return slot_enabled && topic_supported && allows(mask, packet_type);
|
||||
}
|
||||
|
||||
// A fully filtered/topic-incompatible packet is intentionally complete. Once
|
||||
// any eligible target exists, at least one actual publish must succeed.
|
||||
inline bool publishComplete(bool has_eligible_target, bool any_publish_succeeded) {
|
||||
return any_publish_succeeded || !has_eligible_target;
|
||||
}
|
||||
|
||||
} // namespace MQTTPacketFilter
|
||||
@@ -37,6 +37,7 @@ struct DecodePlan {
|
||||
|
||||
static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE;
|
||||
static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE;
|
||||
static const size_t kV1PreFilterPayloadSize = MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE;
|
||||
static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE;
|
||||
static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize;
|
||||
|
||||
@@ -100,6 +101,11 @@ inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t fil
|
||||
if (header.payload_len == kV1BaselinePayloadSize) {
|
||||
return {Source::Current, false, false, true, kV1BaselinePayloadSize};
|
||||
}
|
||||
if (header.payload_len == kV1PreFilterPayloadSize) {
|
||||
// Written before the per-slot packet-filter tail. Defaults supply an
|
||||
// all-types mask for every slot.
|
||||
return {Source::Current, false, false, true, kV1PreFilterPayloadSize};
|
||||
}
|
||||
if (header.payload_len == kV1PreNeighborsPayloadSize) {
|
||||
// Written by observer/webconfig firmware before the neighbors tail
|
||||
// existed. The observer fields ARE present; only the neighbors tail is
|
||||
|
||||
@@ -117,6 +117,11 @@ struct MQTTPrefs {
|
||||
// interchangeable (see the offsetof static_asserts below).
|
||||
uint8_t mqtt_neighbors_enabled;
|
||||
uint32_t mqtt_neighbors_interval;
|
||||
|
||||
// Per-slot payload-type allow masks. Bit N controls MeshCore packet type N
|
||||
// for both packets and raw MQTT topics. Appended so older v1 payloads load
|
||||
// with the default all-types masks intact.
|
||||
uint16_t mqtt_slot_packet_filter[MQTT_PREFS_SLOT_COUNT];
|
||||
};
|
||||
|
||||
// Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose
|
||||
@@ -129,14 +134,16 @@ static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERV
|
||||
static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_MS = MQTT_NEIGHBORS_MAX_INTERVAL_HOURS * 3600000UL;
|
||||
static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS * 3600000UL;
|
||||
|
||||
// Version-1 has three payload layouts this firmware can decode. Never infer a
|
||||
// Version-1 has four payload layouts this firmware can decode. Never infer a
|
||||
// compatible payload from an arbitrary shorter size: raw prefs have no checksum.
|
||||
// - PRE_OBSERVER (2736): stops before the observer tail (snmp_*/alert_*).
|
||||
// - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet.
|
||||
// - FULL (2864): current baseline, with the neighbors tail.
|
||||
// - PRE_FILTER (2864): neighbors tail, no per-slot packet filters.
|
||||
// - FULL (2876): current baseline, with six uint16_t filter masks.
|
||||
static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736;
|
||||
static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860;
|
||||
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2864;
|
||||
static const size_t MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE = 2864;
|
||||
static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2876;
|
||||
|
||||
// /mqtt_prefs starts with a self-describing 8-byte header. Headerless files
|
||||
// are deployed legacy layouts and continue to be distinguished by size.
|
||||
@@ -266,6 +273,8 @@ static_assert(offsetof(MQTTPrefs, mqtt_neighbors_enabled) == 2857,
|
||||
"neighbors enable flag must sit at the flex-compatible offset");
|
||||
static_assert(offsetof(MQTTPrefs, mqtt_neighbors_interval) == MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE,
|
||||
"neighbors interval offset must equal the pre-neighbors payload size");
|
||||
static_assert(offsetof(MQTTPrefs, mqtt_slot_packet_filter) == MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE,
|
||||
"packet filters must begin at the pre-filter payload boundary");
|
||||
static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed");
|
||||
static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed");
|
||||
static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144,
|
||||
|
||||
@@ -30,6 +30,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
|
||||
};
|
||||
static const char* const WC_ALLOWED_SLOT_KEYS[] = {
|
||||
"preset", "server", "port", "username", "password", "token", "topic", "audience",
|
||||
"filter",
|
||||
};
|
||||
|
||||
// True when `key` is a well-formed per-slot key ("mqttN.<field>" with N in
|
||||
|
||||
@@ -2816,7 +2816,9 @@ void MQTTBridge::processPacketQueue() {
|
||||
_last_raw_timestamp = millis();
|
||||
}
|
||||
|
||||
bool packet_eligible = false;
|
||||
bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
|
||||
packet_eligible,
|
||||
queued.has_raw_data ? queued.raw_data : nullptr,
|
||||
queued.has_raw_data ? queued.raw_len : 0,
|
||||
queued.snr, queued.rssi);
|
||||
@@ -2824,15 +2826,21 @@ void MQTTBridge::processPacketQueue() {
|
||||
|
||||
// Publish raw if enabled (live from prefs so `set mqtt.raw` applies without
|
||||
// a bridge restart)
|
||||
bool raw_eligible = false;
|
||||
bool raw_published = false;
|
||||
if (_obs->mqtt_raw_enabled) {
|
||||
raw_published = publishRaw(&queued.packet_copy);
|
||||
raw_published = publishRaw(&queued.packet_copy, raw_eligible);
|
||||
}
|
||||
|
||||
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
|
||||
// Decide intentional completion once across the entire queue item. An
|
||||
// ineligible raw path (for example, MeshRank's packets-only topic style)
|
||||
// must not hide a failed eligible structured publish.
|
||||
const bool queue_complete = MQTTPacketFilter::publishComplete(
|
||||
packet_eligible || raw_eligible,
|
||||
MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published));
|
||||
const MQTTPacketQueuePolicy::RetryDecision retry =
|
||||
MQTTPacketQueuePolicy::retryDecision(
|
||||
any_published, queued.retry_attempts,
|
||||
queue_complete, queued.retry_attempts,
|
||||
static_cast<uint32_t>(now_ms));
|
||||
if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
|
||||
queued.retry_attempts = retry.retry_attempts;
|
||||
@@ -2943,22 +2951,30 @@ void MQTTBridge::processPacketQueue() {
|
||||
_last_raw_timestamp = millis();
|
||||
}
|
||||
|
||||
bool packet_eligible = false;
|
||||
bool packet_published = publishPacket(&queued.packet_copy, queued.is_tx,
|
||||
packet_eligible,
|
||||
queued.has_raw_data ? queued.raw_data : nullptr,
|
||||
queued.has_raw_data ? queued.raw_len : 0,
|
||||
queued.snr, queued.rssi);
|
||||
// No taskYIELD() on non-ESP32 platforms (non-FreeRTOS, cooperative scheduling not needed)
|
||||
|
||||
// Live from prefs so `set mqtt.raw` applies without a bridge restart.
|
||||
bool raw_eligible = false;
|
||||
bool raw_published = false;
|
||||
if (_obs->mqtt_raw_enabled) {
|
||||
raw_published = publishRaw(&queued.packet_copy);
|
||||
raw_published = publishRaw(&queued.packet_copy, raw_eligible);
|
||||
}
|
||||
|
||||
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
|
||||
// Decide intentional completion once across the entire queue item. An
|
||||
// ineligible raw path (for example, MeshRank's packets-only topic style)
|
||||
// must not hide a failed eligible structured publish.
|
||||
const bool queue_complete = MQTTPacketFilter::publishComplete(
|
||||
packet_eligible || raw_eligible,
|
||||
MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published));
|
||||
const MQTTPacketQueuePolicy::RetryDecision retry =
|
||||
MQTTPacketQueuePolicy::retryDecision(
|
||||
any_published, queued.retry_attempts,
|
||||
queue_complete, queued.retry_attempts,
|
||||
static_cast<uint32_t>(now_ms));
|
||||
if (retry.action == MQTTPacketQueuePolicy::RetryAction::Schedule) {
|
||||
queued.retry_attempts = retry.retry_attempts;
|
||||
@@ -3005,6 +3021,28 @@ void MQTTBridge::processPacketQueue() {
|
||||
// Publishing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
uint8_t MQTTBridge::eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type) {
|
||||
static_assert(RUNTIME_MQTT_SLOTS <= 8, "eligible slot mask must fit in uint8_t");
|
||||
if (!_obs || (type != MSG_PACKETS && type != MSG_RAW)) return 0;
|
||||
|
||||
uint8_t eligible_slots = 0;
|
||||
char topic[128];
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
|
||||
const bool slot_enabled = _slots[i].enabled && _slots[i].client != nullptr;
|
||||
// Load once so a live CLI/WebConfig update cannot split this packet's
|
||||
// eligibility decision across two different masks.
|
||||
const uint16_t filter_mask = _obs->mqtt_slot_packet_filter[i];
|
||||
if (!slot_enabled || !MQTTPacketFilter::allows(filter_mask, packet_type)) continue;
|
||||
|
||||
const bool topic_supported = buildTopicForSlot(i, type, topic, sizeof(topic));
|
||||
if (MQTTPacketFilter::slotEligible(slot_enabled, topic_supported,
|
||||
filter_mask, packet_type)) {
|
||||
eligible_slots |= static_cast<uint8_t>(1u << i);
|
||||
}
|
||||
}
|
||||
return eligible_slots;
|
||||
}
|
||||
|
||||
bool MQTTBridge::publishStatus() {
|
||||
if (!_cached_has_connected_slots) {
|
||||
return false;
|
||||
@@ -3104,10 +3142,17 @@ bool MQTTBridge::publishStatus() {
|
||||
}
|
||||
|
||||
bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
bool& has_eligible_target,
|
||||
const uint8_t* raw_data, int raw_len,
|
||||
float snr, float rssi) {
|
||||
has_eligible_target = false;
|
||||
if (!packet) return false;
|
||||
|
||||
const uint8_t packet_type = packet->getPayloadType();
|
||||
const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_PACKETS);
|
||||
has_eligible_target = eligible_slots != 0;
|
||||
if (!has_eligible_target) return false;
|
||||
|
||||
refreshOriginFromPrefs();
|
||||
|
||||
// Memory pressure check: Skip publishes when there's not enough contiguous
|
||||
@@ -3213,7 +3258,8 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
bool published = false;
|
||||
char topic[128];
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
if ((eligible_slots & static_cast<uint8_t>(1u << i)) != 0 &&
|
||||
_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
if (buildTopicForSlot(i, MSG_PACKETS, topic, sizeof(topic))) {
|
||||
if (publishToSlot(i, topic, active_buffer, false)) {
|
||||
published = true;
|
||||
@@ -3223,7 +3269,6 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
}
|
||||
return published;
|
||||
} else {
|
||||
uint8_t packet_type = packet->getPayloadType();
|
||||
if (packet_type == 4 || packet_type == 9) {
|
||||
MQTT_DEBUG_PRINTLN("Failed to build packet JSON for type=%d (len=%d), packet not published", packet_type, len);
|
||||
}
|
||||
@@ -3231,9 +3276,15 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MQTTBridge::publishRaw(mesh::Packet* packet) {
|
||||
bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) {
|
||||
has_eligible_target = false;
|
||||
if (!packet) return false;
|
||||
|
||||
const uint8_t packet_type = packet->getPayloadType();
|
||||
const uint8_t eligible_slots = eligiblePacketSlots(packet_type, MSG_RAW);
|
||||
has_eligible_target = eligible_slots != 0;
|
||||
if (!has_eligible_target) return false;
|
||||
|
||||
refreshOriginFromPrefs();
|
||||
|
||||
#if defined(BOARD_HAS_PSRAM)
|
||||
@@ -3264,7 +3315,8 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet) {
|
||||
bool published = false;
|
||||
char topic[128];
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
if ((eligible_slots & static_cast<uint8_t>(1u << i)) != 0 &&
|
||||
_slots[i].enabled && _slots[i].client && _slots[i].connected) {
|
||||
if (buildTopicForSlot(i, MSG_RAW, topic, sizeof(topic))) {
|
||||
if (publishToSlot(i, topic, active_buffer, false)) {
|
||||
published = true;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <WiFiUdp.h>
|
||||
#include <Timezone.h>
|
||||
#include "helpers/JWTHelper.h"
|
||||
#include "helpers/MQTTPacketFilter.h"
|
||||
#include "helpers/MQTTPresets.h"
|
||||
#include "helpers/MQTTLifecycle.h"
|
||||
#include <atomic>
|
||||
@@ -369,6 +370,7 @@ private:
|
||||
enum MQTTMessageType { MSG_STATUS, MSG_PACKETS, MSG_RAW, MSG_NEIGHBORS };
|
||||
bool buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size);
|
||||
bool substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size);
|
||||
uint8_t eligiblePacketSlots(uint8_t packet_type, MQTTMessageType type);
|
||||
|
||||
// Internal methods - slot management
|
||||
// Lifetime model (Phase 1 of MQTT memory-defrag):
|
||||
@@ -404,10 +406,10 @@ private:
|
||||
void mqttTaskLoop(); // Main loop for MQTT task
|
||||
void initializeWiFiInTask(); // WiFi initialization moved to task
|
||||
#endif
|
||||
bool publishPacket(mesh::Packet* packet, bool is_tx,
|
||||
bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target,
|
||||
const uint8_t* raw_data = nullptr, int raw_len = 0,
|
||||
float snr = 0.0f, float rssi = 0.0f);
|
||||
bool publishRaw(mesh::Packet* packet);
|
||||
bool publishRaw(mesh::Packet* packet, bool& has_eligible_target);
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
// Publishes the pending _neighbors_json_buffer to every connected slot's
|
||||
// neighbors topic. Runs on the MQTT task (Core 0) only.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <esp_heap_caps.h>
|
||||
|
||||
#include <helpers/CommonCLI.h>
|
||||
#include <helpers/MQTTPacketFilter.h>
|
||||
#include <helpers/MQTTPresets.h>
|
||||
#include <helpers/WebConfigKeys.h>
|
||||
#include <helpers/bridges/MQTTBridge.h>
|
||||
@@ -618,6 +619,15 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
|
||||
s["token"] = _obs->mqtt_slot_token[i][0] ? SECRET_SENTINEL : "";
|
||||
s["topic"] = (const char*)_obs->mqtt_slot_topic[i];
|
||||
s["audience"] = (const char*)_obs->mqtt_slot_audience[i];
|
||||
char filter_text[MQTTPacketFilter::kFilterTextSize];
|
||||
if (MQTTPacketFilter::format(_obs->mqtt_slot_packet_filter[i],
|
||||
filter_text, sizeof(filter_text))) {
|
||||
// Mutable char input is copied into the ArduinoJson document; the
|
||||
// stack buffer is reused on the next slot.
|
||||
s["filter"] = filter_text;
|
||||
} else {
|
||||
s["filter"] = "all";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ does not reflect the GoogleTest count — run the built binary directly
|
||||
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank packets-only behavior; required identifiers; invalid inputs/slots; exact buffer boundaries |
|
||||
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover |
|
||||
| `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover |
|
||||
| `test_mqtt_packet_filter` | `src/helpers/MQTTPacketFilter.h` | per-slot 0-15 allowlist parsing/formatting; exact bounds; membership; eligible-target and retry-completion policy |
|
||||
| `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers |
|
||||
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation |
|
||||
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "helpers/MQTTPacketFilter.h"
|
||||
|
||||
namespace Filter = MQTTPacketFilter;
|
||||
|
||||
TEST(MQTTPacketFilter, EmptyAndAllMeanEveryType) {
|
||||
for (const char* value : {"", " ", "\tall\r\n"}) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse(value, &mask)) << value;
|
||||
EXPECT_EQ(Filter::kAllPacketTypes, mask);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, NoneMeansNoTypes) {
|
||||
uint16_t mask = Filter::kAllPacketTypes;
|
||||
ASSERT_TRUE(Filter::parse(" none ", &mask));
|
||||
EXPECT_EQ(0u, mask);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, ParsesDecimalCsvWithWhitespaceAndDuplicates) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse(" 2, 4,\t15,2 ", &mask));
|
||||
EXPECT_EQ(static_cast<uint16_t>((1u << 2) | (1u << 4) | (1u << 15)), mask);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, FullNumericListCanonicalizesToAll) {
|
||||
uint16_t mask = 0;
|
||||
ASSERT_TRUE(Filter::parse("0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", &mask));
|
||||
EXPECT_EQ(Filter::kAllPacketTypes, mask);
|
||||
char output[Filter::kFilterTextSize];
|
||||
ASSERT_TRUE(Filter::format(mask, output, sizeof(output)));
|
||||
EXPECT_STREQ("all", output);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, RejectsMalformedOrOutOfRangeValuesWithoutChangingOutput) {
|
||||
const char* invalid[] = {
|
||||
",", "1,", ",1", "1,,2", "1, ,2", "-1", "+1", "0x2", "16",
|
||||
"999999999999999999999", "ALL", "All", "all,2", "none,2", "2-4", "2x",
|
||||
};
|
||||
for (const char* value : invalid) {
|
||||
uint16_t mask = 0x1234;
|
||||
EXPECT_FALSE(Filter::parse(value, &mask)) << value;
|
||||
EXPECT_EQ(0x1234, mask) << value;
|
||||
}
|
||||
EXPECT_FALSE(Filter::parse(nullptr, nullptr));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, FormatsSubsetsInAscendingOrder) {
|
||||
const uint16_t mask = static_cast<uint16_t>(
|
||||
(1u << 15) | (1u << 2) | (1u << 10) | (1u << 0));
|
||||
char output[Filter::kFilterTextSize];
|
||||
ASSERT_TRUE(Filter::format(mask, output, sizeof(output)));
|
||||
EXPECT_STREQ("0,2,10,15", output);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, FormatsNoneAndHonorsExactBufferBoundaries) {
|
||||
char none[5];
|
||||
ASSERT_TRUE(Filter::format(0, none, sizeof(none)));
|
||||
EXPECT_STREQ("none", none);
|
||||
|
||||
char too_small[4] = {'x', 'x', 'x', '\0'};
|
||||
EXPECT_FALSE(Filter::format(0, too_small, sizeof(too_small)));
|
||||
EXPECT_STREQ("", too_small);
|
||||
|
||||
char all_types[Filter::kFilterTextSize];
|
||||
const uint16_t subset = static_cast<uint16_t>(Filter::kAllPacketTypes & ~(1u << 14));
|
||||
ASSERT_TRUE(Filter::format(subset, all_types, sizeof(all_types)));
|
||||
EXPECT_STREQ("0,1,2,3,4,5,6,7,8,9,10,11,12,13,15", all_types);
|
||||
char one_short[34];
|
||||
EXPECT_FALSE(Filter::format(subset, one_short, sizeof(one_short)));
|
||||
EXPECT_STREQ("", one_short);
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, MembershipIsBoundedToFourBitTypes) {
|
||||
EXPECT_TRUE(Filter::allows(static_cast<uint16_t>(1u << 0), 0));
|
||||
EXPECT_TRUE(Filter::allows(static_cast<uint16_t>(1u << 15), 15));
|
||||
EXPECT_FALSE(Filter::allows(Filter::kAllPacketTypes, 16));
|
||||
EXPECT_FALSE(Filter::allows(0, 0));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, EligibilityIgnoresConnectionButRequiresTopicAndFilter) {
|
||||
const uint16_t only_advert = static_cast<uint16_t>(1u << 4);
|
||||
EXPECT_TRUE(Filter::slotEligible(true, true, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotEligible(false, true, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotEligible(true, false, only_advert, 4));
|
||||
EXPECT_FALSE(Filter::slotEligible(true, true, only_advert, 2));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, AllFilteredOrTopicIncompatibleTargetsCompleteIntentionally) {
|
||||
const uint16_t only_text = static_cast<uint16_t>(1u << 2);
|
||||
const bool filtered = Filter::slotEligible(true, true, only_text, 4);
|
||||
const bool topic_incompatible =
|
||||
Filter::slotEligible(true, false, Filter::kAllPacketTypes, 4);
|
||||
EXPECT_FALSE(filtered || topic_incompatible);
|
||||
EXPECT_TRUE(Filter::publishComplete(filtered || topic_incompatible, false));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, EligibleDisconnectedTargetStillRequiresRetry) {
|
||||
const uint16_t only_advert = static_cast<uint16_t>(1u << 4);
|
||||
// Connection state is intentionally absent from slotEligible(): the first
|
||||
// slot remains a target while disconnected. A second connected slot whose
|
||||
// filter rejects the advert cannot turn that into intentional completion.
|
||||
const bool disconnected_eligible =
|
||||
Filter::slotEligible(true, true, only_advert, 4);
|
||||
const bool connected_filtered =
|
||||
Filter::slotEligible(true, true, static_cast<uint16_t>(1u << 2), 4);
|
||||
ASSERT_TRUE(disconnected_eligible);
|
||||
ASSERT_FALSE(connected_filtered);
|
||||
EXPECT_FALSE(Filter::publishComplete(
|
||||
disconnected_eligible || connected_filtered, false));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, PublishFailureRetriesAndAnySuccessCompletes) {
|
||||
EXPECT_FALSE(Filter::publishComplete(true, false));
|
||||
EXPECT_TRUE(Filter::publishComplete(true, true));
|
||||
EXPECT_TRUE(Filter::publishComplete(false, true)); // defensive: success wins
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, UnsupportedRawPathCannotHideEligiblePacketFailure) {
|
||||
const bool packet_eligible = true;
|
||||
const bool packet_published = false;
|
||||
const bool raw_eligible = false; // e.g. MeshRank has no raw topic
|
||||
const bool raw_published = false;
|
||||
|
||||
EXPECT_FALSE(Filter::publishComplete(
|
||||
packet_eligible || raw_eligible,
|
||||
packet_published || raw_published));
|
||||
EXPECT_TRUE(Filter::publishComplete(false, false));
|
||||
}
|
||||
|
||||
TEST(MQTTPacketFilter, PacketAndRawCanShareTheSameMaskDecision) {
|
||||
const uint16_t text_and_advert = static_cast<uint16_t>((1u << 2) | (1u << 4));
|
||||
for (uint8_t type = 0; type <= 15; ++type) {
|
||||
const bool expected = type == 2 || type == 4;
|
||||
EXPECT_EQ(expected, Filter::allows(text_and_advert, type)) << unsigned(type);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#define WITH_MQTT_BRIDGE 1
|
||||
#include "helpers/MQTTPrefsCodec.h"
|
||||
#include "helpers/MQTTPacketFilter.h"
|
||||
|
||||
namespace Codec = MQTTPrefsCodec;
|
||||
|
||||
@@ -20,6 +21,7 @@ MQTTPrefs defaults() {
|
||||
prefs.wifi_power_save = 1;
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
strncpy(prefs.mqtt_slot_preset[i], "none", sizeof(prefs.mqtt_slot_preset[i]) - 1);
|
||||
prefs.mqtt_slot_packet_filter[i] = MQTTPacketFilter::kAllPacketTypes;
|
||||
}
|
||||
strncpy(prefs.snmp_community, "public", sizeof(prefs.snmp_community) - 1);
|
||||
prefs.radio_watchdog_minutes = 5;
|
||||
@@ -218,6 +220,10 @@ TEST(MQTTPrefsCodec, MigratesAllLegacySixSlotPrefixesWithoutClobberingDefaults)
|
||||
} else {
|
||||
EXPECT_STREQ("time.example", prefs.mqtt_ntp_server);
|
||||
}
|
||||
for (int slot = 0; slot < MQTT_PREFS_SLOT_COUNT; ++slot) {
|
||||
EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes,
|
||||
prefs.mqtt_slot_packet_filter[slot]) << size << ":" << slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +234,9 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) {
|
||||
strncpy(source.alert_region, "PNW", sizeof(source.alert_region) - 1);
|
||||
source.mqtt_neighbors_enabled = 1;
|
||||
source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS;
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
source.mqtt_slot_packet_filter[i] = static_cast<uint16_t>(1u << i);
|
||||
}
|
||||
std::vector<uint8_t> bytes(Codec::kEncodedSize);
|
||||
ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size()));
|
||||
|
||||
@@ -240,6 +249,37 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) {
|
||||
EXPECT_EQ(0, memcmp(&source, &loaded, sizeof(source)));
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, PreFilterV1PayloadDefaultsEverySlotToAllTypes) {
|
||||
MQTTPrefs source = defaults();
|
||||
strncpy(source.mqtt_origin, "pre-filter-node", sizeof(source.mqtt_origin) - 1);
|
||||
source.mqtt_neighbors_enabled = 1;
|
||||
source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS;
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
source.mqtt_slot_packet_filter[i] = 0;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreFilterPayloadSize, 0);
|
||||
writeHeader(&bytes, MQTT_PREFS_VERSION,
|
||||
static_cast<uint16_t>(Codec::kV1PreFilterPayloadSize));
|
||||
memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreFilterPayloadSize);
|
||||
|
||||
const Codec::DecodePlan plan = classify(bytes);
|
||||
ASSERT_EQ(Codec::Source::Current, plan.source);
|
||||
ASSERT_EQ(Codec::kV1PreFilterPayloadSize, plan.payload_len);
|
||||
ASSERT_TRUE(plan.observer_fields_present);
|
||||
ASSERT_FALSE(plan.preserve_file);
|
||||
|
||||
MQTTPrefs loaded = defaults();
|
||||
memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len);
|
||||
EXPECT_STREQ("pre-filter-node", loaded.mqtt_origin);
|
||||
EXPECT_EQ(1u, loaded.mqtt_neighbors_enabled);
|
||||
EXPECT_EQ(MQTT_NEIGHBORS_MAX_INTERVAL_MS, loaded.mqtt_neighbors_interval);
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes,
|
||||
loaded.mqtt_slot_packet_filter[i]) << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, CompatibleShortV1PayloadPreservesDefaultsBeyondObserverBoundary) {
|
||||
MQTTPrefs source = defaults();
|
||||
strncpy(source.mqtt_origin, "short-v1-node", sizeof(source.mqtt_origin) - 1);
|
||||
@@ -304,6 +344,10 @@ TEST(MQTTPrefsCodec, PreNeighborsV1PayloadLoadsObserverFieldsAndDefaultsNeighbor
|
||||
// Interval begins at 2860 (beyond the read) -> keeps the caller's default.
|
||||
EXPECT_EQ(0u, loaded.mqtt_neighbors_enabled);
|
||||
EXPECT_EQ(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS, loaded.mqtt_neighbors_interval);
|
||||
for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) {
|
||||
EXPECT_EQ(MQTTPacketFilter::kAllPacketTypes,
|
||||
loaded.mqtt_slot_packet_filter[i]) << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MQTTPrefsCodec, CorruptOrShortVersionedInputsArePreserved) {
|
||||
|
||||
@@ -22,6 +22,7 @@ TEST(WebConfigKeys, AllowsPerSlotKeys) {
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.preset"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.server"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt1.token"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt2.filter"));
|
||||
EXPECT_TRUE(wcIsAllowedSetKey("mqtt6.audience")); // MAX_MQTT_SLOTS == 6
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ TEST(WebConfigKeys, NonSecretKeysNotFlagged) {
|
||||
EXPECT_FALSE(wcIsSecretKey("wifi.ssid"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.username")); // username is not masked
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.server"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt1.filter"));
|
||||
EXPECT_FALSE(wcIsSecretKey("mqtt.origin"));
|
||||
EXPECT_FALSE(wcIsSecretKey("name"));
|
||||
EXPECT_FALSE(wcIsSecretKey(""));
|
||||
|
||||
+3
-2
@@ -627,7 +627,7 @@ function revertAll(){loadConfig().then(function(){toast("Reverted")})}
|
||||
// maxlength per destination MQTTPrefs buffer minus the NUL (host[64],
|
||||
// username[32], password[64], token[48], topic[96], audience[64]). Bounds the
|
||||
// common browser case; the backend still owns authoritative length limits.
|
||||
var SLOT_MAXLEN={token:47,username:31,password:63,server:63,topic:95,audience:63};
|
||||
var SLOT_MAXLEN={token:47,username:31,password:63,server:63,topic:95,audience:63,filter:37};
|
||||
function slotFieldHtml(i,f,type,label,hint){
|
||||
var ml=SLOT_MAXLEN[f]?' maxlength="'+SLOT_MAXLEN[f]+'"':'';
|
||||
return'<div class="f sf-'+f+'"><label>'+label+'</label><input type="'+type+'" data-k="mqtt'+i+'.'+f+'"'+ml+' autocapitalize="off" autocorrect="off" autocomplete="off">'+(hint?'<div class="hint">'+hint+'</div>':'')+'</div>';
|
||||
@@ -649,6 +649,7 @@ function buildSlots(sel){
|
||||
+slotFieldHtml(i,"port","number","Port","")
|
||||
+slotFieldHtml(i,"topic","text","Topic template","Placeholders: {iata} {device} {type} {token}")
|
||||
+slotFieldHtml(i,"audience","text","JWT audience","Optional — blank for user/pass auth")
|
||||
+slotFieldHtml(i,"filter","text","Packet types","all, none, or 0–15 CSV. 0 REQ · 1 RESPONSE · 2 TXT_MSG · 3 ACK · 4 ADVERT · 5 GRP_TXT · 6 GRP_DATA · 7 ANON_REQ · 8 PATH · 9 TRACE · 10 MULTIPART · 11 CONTROL · 12–14 reserved · 15 RAW_CUSTOM. Applies to packet and raw uploads.")
|
||||
+'</div>';
|
||||
}
|
||||
host.innerHTML=html;
|
||||
@@ -662,7 +663,7 @@ function refreshSlotFields(host,i){
|
||||
var needs=name==="custom"?"custom":(def?def.needs:"none");
|
||||
var showMap={token:needs==="token",username:needs==="userpass"||needs==="custom",
|
||||
password:needs==="userpass"||needs==="password"||needs==="custom",server:needs==="custom",
|
||||
port:needs==="custom",topic:needs==="custom",audience:needs==="custom"};
|
||||
port:needs==="custom",topic:needs==="custom",audience:needs==="custom",filter:name!=="none"};
|
||||
Object.keys(showMap).forEach(function(f){
|
||||
var w=card.querySelector(".sf-"+f);if(w)w.classList.toggle("hide",!showMap[f]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user