From a951415e463a332a9a5bbf4840bdda7d5acf507b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gjels=C3=B8?= <36234524+gjelsoe@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:19:43 +0200 Subject: [PATCH 1/3] Packet Filter V2 --- build-repeaters-filter.sh | 10 + examples/simple_repeater/ChannelFilter.cpp | 463 ++++++++++++++++++ examples/simple_repeater/ChannelFilter.h | 60 +++ examples/simple_repeater/FILTER.md | 279 +++++++++++ examples/simple_repeater/FilterParser.cpp | 408 +++++++++++++++ examples/simple_repeater/FilterParser.h | 62 +++ examples/simple_repeater/FilterRule.h | 81 +++ examples/simple_repeater/MyMesh.cpp | 15 +- examples/simple_repeater/MyMesh.h | 2 + .../filter test suite/ChannelFilter.cpp | 463 ++++++++++++++++++ .../filter test suite/ChannelFilter.h | 60 +++ .../filter test suite/FilterParser.cpp | 408 +++++++++++++++ .../filter test suite/FilterParser.h | 62 +++ .../filter test suite/FilterRule.h | 81 +++ .../filter test suite/Makefile | 18 + .../filter test suite/auto_test.cpp | 415 ++++++++++++++++ .../filter test suite/mock_mesh.h | 145 ++++++ .../filter test suite/readme.md | 128 +++++ .../filter test suite/shell.cpp | 153 ++++++ 19 files changed, 3310 insertions(+), 3 deletions(-) create mode 100644 build-repeaters-filter.sh create mode 100644 examples/simple_repeater/ChannelFilter.cpp create mode 100644 examples/simple_repeater/ChannelFilter.h create mode 100644 examples/simple_repeater/FILTER.md create mode 100644 examples/simple_repeater/FilterParser.cpp create mode 100644 examples/simple_repeater/FilterParser.h create mode 100644 examples/simple_repeater/FilterRule.h create mode 100644 examples/simple_repeater/filter test suite/ChannelFilter.cpp create mode 100644 examples/simple_repeater/filter test suite/ChannelFilter.h create mode 100644 examples/simple_repeater/filter test suite/FilterParser.cpp create mode 100644 examples/simple_repeater/filter test suite/FilterParser.h create mode 100644 examples/simple_repeater/filter test suite/FilterRule.h create mode 100644 examples/simple_repeater/filter test suite/Makefile create mode 100644 examples/simple_repeater/filter test suite/auto_test.cpp create mode 100644 examples/simple_repeater/filter test suite/mock_mesh.h create mode 100644 examples/simple_repeater/filter test suite/readme.md create mode 100644 examples/simple_repeater/filter test suite/shell.cpp diff --git a/build-repeaters-filter.sh b/build-repeaters-filter.sh new file mode 100644 index 0000000000..4f2ebfbff5 --- /dev/null +++ b/build-repeaters-filter.sh @@ -0,0 +1,10 @@ +# sh ./build-repeaters-filter.sh +export FIRMWARE_VERSION="PowerSaving16-Filter" + +############# Repeaters ############# +# Commonly-used boards +## ESP32 - 17 boards +sh build.sh build-firmware \ +heltec_v4_repeater \ +Heltec_t096_repeater \ +RAK_4631_repeater diff --git a/examples/simple_repeater/ChannelFilter.cpp b/examples/simple_repeater/ChannelFilter.cpp new file mode 100644 index 0000000000..8fda074acc --- /dev/null +++ b/examples/simple_repeater/ChannelFilter.cpp @@ -0,0 +1,463 @@ +#include "MyMesh.h" +#include "ChannelFilter.h" +#include +#include + +// --------------------------------------------------------------------------- +// Persistence layout (binary blob, fixed size): +// [uint8_t mode] +// [FilterRule * MAX_FILTER_RULES] +// --------------------------------------------------------------------------- + +ChannelFilter::ChannelFilter() { + memset(_rules, 0, sizeof(_rules)); + _mode = FilterMode::ALLOW; // safe default: pass all packets if no rules loaded +} + +// --------------------------------------------------------------------------- +// Load / Save +// --------------------------------------------------------------------------- + +void ChannelFilter::load(FILESYSTEM& fs) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "r"); +#else + File f = fs.open(FILTER_RULES_FILE); +#endif + if (!f) return; + + uint8_t mode_byte; + if (f.read(&mode_byte, 1) != 1) { f.close(); return; } + // Validate mode byte — default to ALLOW if file is corrupt + _mode = (mode_byte <= (uint8_t)FilterMode::DROP) + ? (FilterMode)mode_byte + : FilterMode::ALLOW; + + // Check read length — if truncated, zero remaining slots (in_use=false = harmless) + size_t bytes_read = f.read((uint8_t*)_rules, sizeof(_rules)); + if (bytes_read < sizeof(_rules)) { + memset((uint8_t*)_rules + bytes_read, 0, sizeof(_rules) - bytes_read); + } + f.close(); +} + +void ChannelFilter::save(FILESYSTEM& fs) const { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "w"); +#else + if (fs.exists(FILTER_RULES_FILE)) fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, "w"); +#endif + if (!f) return; + + uint8_t mode_byte = (uint8_t)_mode; + f.write(&mode_byte, 1); + f.write((const uint8_t*)_rules, sizeof(_rules)); + f.close(); +} + +// --------------------------------------------------------------------------- +// Evaluation helpers +// --------------------------------------------------------------------------- + +static bool applyOp(FilterOp op, int16_t pkt_val, int16_t rule_val) { + switch (op) { + case FilterOp::EQ: return pkt_val == rule_val; + case FilterOp::NEQ: return pkt_val != rule_val; + case FilterOp::GT: return pkt_val > rule_val; + case FilterOp::LT: return pkt_val < rule_val; + default: return false; + } +} + +bool ChannelFilter::_ruleMatches(const FilterRule& rule, const mesh::Packet* pkt, int16_t rssi) const { + // PATH field has its own OR-list logic — handle separately + if (rule.field == FilterField::PATH) { + uint8_t hash_size = pkt->getPathHashSize(); + uint8_t hash_count = pkt->getPathHashCount(); + + if (hash_count == 0) return false; + if (hash_size != rule.path_hash_len) return false; + + uint16_t last_hop_offset = (uint16_t)(hash_count - 1) * hash_size; + if (last_hop_offset + hash_size > MAX_PATH_SIZE) return false; + + const uint8_t* last_hop = pkt->path + last_hop_offset; + bool found = false; + for (uint8_t i = 0; i < rule.path_hash_count; i++) { + if (memcmp(rule.path_hashes[i], last_hop, hash_size) == 0) { + found = true; + break; + } + } + + bool primary_match = (rule.op == FilterOp::EQ) ? found : !found; + if (!primary_match) return false; + + // AND condition (PATH as primary can still have a scalar AND) + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) + return false; + } + return true; + } + + // Scalar primary condition + if (!_evalScalar(rule.field, rule.op, rule.value, pkt, rssi)) return false; + + // AND condition if present + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) return false; + } + + return true; +} + +// Evaluate a single scalar condition against a packet. +// PATH field is not handled here — it has its own block in the switch above. +bool ChannelFilter::_evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet* pkt, int16_t rssi) const { + switch (field) { + case FilterField::ROUTE: + return applyOp(op, (int16_t)pkt->getRouteType(), val); + + case FilterField::TYPE: + return applyOp(op, (int16_t)pkt->getPayloadType(), val); + + case FilterField::HOPS: + return applyOp(op, (int16_t)pkt->getPathHashCount(), val); + + case FilterField::PATHSIZE: + return applyOp(op, (int16_t)pkt->getPathHashSize(), val); + + case FilterField::CHANNEL: { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; + if (pkt->payload_len < 1) return false; + return applyOp(op, (int16_t)pkt->payload[0], val); + } + + case FilterField::SNR: + return applyOp(op, (int16_t)pkt->_snr, val); + + case FilterField::RSSI: + return applyOp(op, rssi, val); + + default: + return false; + } +} + +bool ChannelFilter::evaluate(const mesh::Packet* pkt, int16_t rssi) const { + if (!pkt) return false; // null guard — pass unknown packets rather than crash + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + const FilterRule& rule = _rules[i]; + if (!rule.in_use || !rule.enabled) continue; + + if (_ruleMatches(rule, pkt, rssi)) { + return rule.action == FilterAction::DROP; + } + } + // No rule matched — apply default policy + return _mode == FilterMode::DROP; +} + +// --------------------------------------------------------------------------- +// Slot helpers +// --------------------------------------------------------------------------- + +int ChannelFilter::_firstFreeSlot() const { + for (int i = 0; i < MAX_FILTER_RULES; i++) { + if (!_rules[i].in_use) return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// List formatting +// --------------------------------------------------------------------------- + +// Return a short token string for a FilterField value +static const char* fieldStr(FilterField f) { + switch (f) { + case FilterField::ROUTE: return "route"; + case FilterField::TYPE: return "payload"; + case FilterField::HOPS: return "hops"; + case FilterField::PATHSIZE: return "pathsize"; + case FilterField::PATH: return "path"; + case FilterField::CHANNEL: return "channel"; + case FilterField::SNR: return "snr"; + case FilterField::RSSI: return "rssi"; + default: return "?"; + } +} + +static const char* opStr(FilterOp op) { + switch (op) { + case FilterOp::EQ: return "eq"; + case FilterOp::NEQ: return "neq"; + case FilterOp::GT: return "gt"; + case FilterOp::LT: return "lt"; + default: return "?"; + } +} + +// Translate ROUTE_TYPE_* numeric value to token string +static const char* routeValueStr(int16_t v) { + switch (v) { + case 0x00: return "tflood"; + case 0x01: return "flood"; + case 0x02: return "direct"; + case 0x03: return "tdirect"; + default: return "?"; + } +} + +// Translate PAYLOAD_TYPE_* numeric value to token string +static const char* payloadTypeValueStr(int16_t v) { + switch (v) { + case 0x00: return "req"; + case 0x01: return "resp"; + case 0x02: return "txt"; + case 0x03: return "ack"; + case 0x04: return "advert"; + case 0x05: return "grptxt"; + case 0x06: return "grpdata"; + case 0x07: return "anonreq"; + case 0x08: return "path"; + case 0x09: return "trace"; + case 0x0A: return "multi"; + case 0x0B: return "ctrl"; + case 0x0F: return "raw"; + default: return "?"; + } +} + +static void formatRuleValue(const FilterRule& rule, char* out, int outlen) { + if (outlen <= 0) return; + if (rule.field == FilterField::PATH) { + int pos = 0; + for (uint8_t i = 0; i < rule.path_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + for (uint8_t b = 0; b < rule.path_hash_len && pos < outlen - 3; b++) { + pos += snprintf(out + pos, outlen - pos, "%02X", rule.path_hashes[i][b]); + } + } + out[pos] = '\0'; + } else if (rule.field == FilterField::ROUTE) { + snprintf(out, outlen, "%s", routeValueStr(rule.value)); + } else if (rule.field == FilterField::TYPE) { + snprintf(out, outlen, "%s", payloadTypeValueStr(rule.value)); + } else if (rule.field == FilterField::CHANNEL) { + snprintf(out, outlen, "0x%02X", (uint8_t)rule.value); + } else if (rule.field == FilterField::SNR) { + // Convert stored quarter-dB back to whole dB for display + snprintf(out, outlen, "%d", (int)(rule.value / 4)); + } else { + snprintf(out, outlen, "%d", (int)rule.value); + } +} + +// Maximum reply length — stay safely below the 138-char packet limit +#define FILTER_REPLY_BUDGET 128 +// Reserved for header and hint line +#define FILTER_REPLY_HEADER_MAX 32 +#define FILTER_REPLY_HINT_LEN 18 // "-> filter list N\0" + +// Format a single rule line into buf (null-terminated). Returns number of chars written. +static int formatRuleLine(const FilterRule& rule, uint8_t idx, char* buf, int buflen) { + char val_buf[32]; + formatRuleValue(rule, val_buf, (int)sizeof(val_buf)); + + char and_buf[48] = ""; + if (rule.and_field != FILTER_FIELD_NONE) { + char and_val_buf[32]; + FilterField af = (FilterField)rule.and_field; + if (af == FilterField::ROUTE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", routeValueStr(rule.and_value)); + } else if (af == FilterField::TYPE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", payloadTypeValueStr(rule.and_value)); + } else if (af == FilterField::CHANNEL) { + snprintf(and_val_buf, sizeof(and_val_buf), "0x%02X", (uint8_t)rule.and_value); + } else if (af == FilterField::SNR) { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)(rule.and_value / 4)); + } else { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)rule.and_value); + } + snprintf(and_buf, sizeof(and_buf), " and %s %s %s", + fieldStr(af), opStr(rule.and_op), and_val_buf); + } + + return snprintf(buf, buflen, "%d%s %s %s %s %s%s\n", + idx, + rule.enabled ? "" : "*", + rule.action == FilterAction::DROP ? "drop" : "allow", + fieldStr(rule.field), + opStr(rule.op), + val_buf, + and_buf + ); +} + +void ChannelFilter::_listRules(char* reply, uint8_t page) const { + // Count in-use rules and collect their indexes + uint8_t indexes[MAX_FILTER_RULES]; + uint8_t total = 0; + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + if (_rules[i].in_use) indexes[total++] = i; + } + + const char* mode_str = (_mode == FilterMode::DROP) ? "drop" : "allow"; + + // Pre-scan: determine page boundaries dynamically based on actual line lengths. + // Each page gets as many rules as fit within FILTER_REPLY_BUDGET minus header and hint. + uint8_t page_start[MAX_FILTER_RULES + 1]; // start index into indexes[] for each page + uint8_t num_pages = 0; + page_start[0] = 0; + + { + uint8_t i = 0; + while (i < total) { + // Available budget for rule lines on this page + int budget = FILTER_REPLY_BUDGET - FILTER_REPLY_HEADER_MAX - FILTER_REPLY_HINT_LEN; + uint8_t page_end = i; + + while (page_end < total) { + char line_buf[80]; + int line_len = formatRuleLine(_rules[indexes[page_end]], indexes[page_end], + line_buf, sizeof(line_buf)); + if (budget - line_len < 0) break; // doesn't fit + budget -= line_len; + page_end++; + } + + // Safety: always advance at least one rule to avoid infinite loop + if (page_end == i) page_end = i + 1; + + num_pages++; + i = page_end; + page_start[num_pages] = i; + } + } + + if (total == 0) num_pages = 1; + + if (page >= num_pages) { + snprintf(reply, 80, "Err - page %d out of range (0-%d)", page, num_pages - 1); + return; + } + + // Write header + int pos; + if (num_pages > 1) { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d p%d/%d\n", + mode_str, total, MAX_FILTER_RULES, page + 1, num_pages); + } else { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d\n", + mode_str, total, MAX_FILTER_RULES); + } + + if (total == 0) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "(no rules)"); + return; + } + + // Write rule lines for this page + uint8_t start = page_start[page]; + uint8_t end = page_start[page + 1]; + + for (uint8_t i = start; i < end; i++) { + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + pos += snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "%s", line_buf); + } + + // Hint if more pages follow + if (page + 1 < num_pages) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "-> filter list %d", page + 1); + } +} + +// --------------------------------------------------------------------------- +// CLI dispatch +// --------------------------------------------------------------------------- + +void ChannelFilter::handleCommand(const char* args, char* reply, FILESYSTEM& fs) { + FilterParseResult res = parseFilterCommand(args); + + if (res.error != FilterParseError::OK) { + snprintf(reply, 80, "%s", filterParseErrorStr(res.error)); + return; + } + + switch (res.command) { + case FilterCommand::ADD: { + int slot = _firstFreeSlot(); + if (slot < 0) { + snprintf(reply, 80, "Err - rules full (max %d)", MAX_FILTER_RULES); + return; + } + _rules[slot] = res.rule; + save(fs); + snprintf(reply, 80, "OK - rule %d added", slot); + break; + } + + case FilterCommand::DEL: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + memset(&_rules[id], 0, sizeof(FilterRule)); + save(fs); + snprintf(reply, 80, "OK - rule %d deleted", id); + break; + } + + case FilterCommand::DISABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = false; + save(fs); + snprintf(reply, 80, "OK - rule %d disabled", id); + break; + } + + case FilterCommand::ENABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = true; + save(fs); + snprintf(reply, 80, "OK - rule %d enabled", id); + break; + } + + case FilterCommand::LIST: + _listRules(reply, res.rule_id); + break; + + case FilterCommand::CLEAR: + memset(_rules, 0, sizeof(_rules)); + save(fs); + snprintf(reply, 80, "OK - all rules cleared"); + break; + + case FilterCommand::MODE: + _mode = res.mode; + save(fs); + snprintf(reply, 80, "OK - mode: %s", res.mode == FilterMode::DROP ? "drop" : "allow"); + break; + } +} \ No newline at end of file diff --git a/examples/simple_repeater/ChannelFilter.h b/examples/simple_repeater/ChannelFilter.h new file mode 100644 index 0000000000..f38a2cedae --- /dev/null +++ b/examples/simple_repeater/ChannelFilter.h @@ -0,0 +1,60 @@ +#pragma once + +// NOTE: This header relies on FILESYSTEM being defined before inclusion. +// MyMesh.h includes the platform-specific filesystem headers before including +// this file, so FILESYSTEM is always defined in that context. + +#include "FilterRule.h" +#include "FilterParser.h" +#include + +// Persistence file path +#define FILTER_RULES_FILE "/filter_rules.bin" + +// --------------------------------------------------------------------------- +// ChannelFilter +// --------------------------------------------------------------------------- + +class ChannelFilter { +public: + ChannelFilter(); + + // --- Lifecycle ---------------------------------------------------------- + + // Load rules and mode from filesystem. Call once at startup. + void load(FILESYSTEM& fs); + + // Save rules and mode to filesystem. + void save(FILESYSTEM& fs) const; + + // --- Evaluation --------------------------------------------------------- + + // Evaluate all active rules against a received packet. + // 'rssi' is passed separately as it lives in the radio driver, not in Packet. + // Returns true if the packet should be DROPPED, false if it should pass. + bool evaluate(const mesh::Packet* pkt, int16_t rssi) const; + + // --- CLI dispatch ------------------------------------------------------- + + // Handle a "filter ..." command string (everything after "filter "). + // Writes a human-readable result into 'reply' (assumed >= 80 bytes). + void handleCommand(const char* args, char* reply, FILESYSTEM& fs); + +private: + FilterRule _rules[MAX_FILTER_RULES]; + FilterMode _mode; // default policy when no rule matches + + // --- Rule helpers ------------------------------------------------------- + + // Find the first free slot. Returns index or -1 if full. + int _firstFreeSlot() const; + + // Evaluate a single rule against a packet + rssi. + // Returns true if the rule matches. + bool _ruleMatches(const FilterRule& rule, const mesh::Packet* pkt, int16_t rssi) const; + bool _evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet* pkt, int16_t rssi) const; + + // --- list command ------------------------------------------------------- + void _listRules(char* reply, uint8_t page) const; +}; \ No newline at end of file diff --git a/examples/simple_repeater/FILTER.md b/examples/simple_repeater/FILTER.md new file mode 100644 index 0000000000..536efe8d7f --- /dev/null +++ b/examples/simple_repeater/FILTER.md @@ -0,0 +1,279 @@ +# MeshCore Repeater — Packet Filter Engine + +The filter engine allows fine-grained control over which packets a repeater forwards. Rules are evaluated in order — the first matching rule wins. If no rule matches, the default policy (`mode`) applies. + +Rules survive reboot and are stored in `/filter_rules.bin`. + +> **Note:** The current rule file format does not include a version field. If you upgrade from a version without AND condition support, delete `/filter_rules.bin` and re-enter your rules. + +--- + +## Commands + +All commands are prefixed with `filter`. + +| Command | Description | +|---|---| +| `filter add [and ]` | Add a new rule | +| `filter del ` | Delete rule by ID | +| `filter disable ` | Temporarily disable a rule | +| `filter enable ` | Re-enable a disabled rule | +| `filter list` | List rules — page 0 | +| `filter list ` | List rules — specific page (0-indexed) | +| `filter clear` | Delete all rules | +| `filter mode ` | Set default policy when no rule matches | + +--- + +## Actions + +| Token | Description | +|---|---| +| `drop` | Drop the packet — do not forward | +| `allow` | Allow the packet — forward it | + +--- + +## Fields + +| Token | Matches | Operators | +|---|---|---| +| `route` | Route type of the packet | `eq` `neq` | +| `payload` | Payload type of the packet | `eq` `neq` `gt` `lt` | +| `hops` | Number of hops the packet has travelled | `eq` `neq` `gt` `lt` | +| `pathsize` | Hash size per hop entry in path (1–3 bytes) | `eq` `neq` `gt` `lt` | +| `path` | Last hop repeater hash (OR-match against a list) | `eq` `neq` | +| `channel` | Channel hash byte (GRP_TXT and GRP_DATA only) | `eq` `neq` | +| `snr` | SNR of the received packet in whole dB | `eq` `neq` `gt` `lt` | +| `rssi` | RSSI of the received packet in dBm | `eq` `neq` `gt` `lt` | + +--- + +## Operators + +| Token | Meaning | +|---|---| +| `eq` | Equal to | +| `neq` | Not equal to | +| `gt` | Greater than | +| `lt` | Less than | + +> `gt` and `lt` are not meaningful for `path` and are silently ignored. + +--- + +## AND condition + +A rule can have an optional AND condition. Both the primary and the AND condition must match for the rule to fire. + +``` +filter add and +``` + +**Restrictions:** +- Maximum one AND condition per rule +- `path` is not supported as the AND field (it can still be the primary field) +- The AND field must be different from the primary field + +--- + +## Values + +### `route` values + +| Token | Constant | +|---|---| +| `tflood` | `ROUTE_TYPE_TRANSPORT_FLOOD` | +| `flood` | `ROUTE_TYPE_FLOOD` | +| `direct` | `ROUTE_TYPE_DIRECT` | +| `tdirect` | `ROUTE_TYPE_TRANSPORT_DIRECT` | + +### `payload` values + +| Token | Constant | Description | +|---|---|---| +| `req` | `PAYLOAD_TYPE_REQ` | Request | +| `resp` | `PAYLOAD_TYPE_RESPONSE` | Response | +| `txt` | `PAYLOAD_TYPE_TXT_MSG` | Direct text message | +| `ack` | `PAYLOAD_TYPE_ACK` | Acknowledgement | +| `advert` | `PAYLOAD_TYPE_ADVERT` | Node advertisement | +| `grptxt` | `PAYLOAD_TYPE_GRP_TXT` | Group text message | +| `grpdata` | `PAYLOAD_TYPE_GRP_DATA` | Group data packet | +| `anonreq` | `PAYLOAD_TYPE_ANON_REQ` | Anonymous request | +| `path` | `PAYLOAD_TYPE_PATH` | Path packet | +| `trace` | `PAYLOAD_TYPE_TRACE` | Trace packet | +| `multi` | `PAYLOAD_TYPE_MULTIPART` | Multipart packet | +| `ctrl` | `PAYLOAD_TYPE_CONTROL` | Control/discovery packet | +| `raw` | `PAYLOAD_TYPE_RAW_CUSTOM` | Raw custom packet | + +Numeric values (decimal or hex) are also accepted for `payload`, e.g. `5` or `0x05`. + +### `path` values + +One or more hex strings separated by spaces. Each hash must be the same length — 2, 4 or 6 hex characters (1, 2 or 3 bytes). The size must match the hash size your network is configured to use. The rule matches if the last hop in the packet path equals **any** of the listed hashes (OR logic). + +> `path` cannot be used as an AND field. It can only be the primary field. + +### `channel` values + +A single hex byte with or without `0x` prefix, e.g. `0xAB` or `AB`. Only applies to `grptxt` and `grpdata` packets. + +### `snr` values + +Signed integer in whole dB, e.g. `-10` or `5`. Stored internally in quarter-dB units to match the radio driver. + +### `rssi` values + +Signed integer in dBm, e.g. `-110`. + +--- + +## Default mode + +When no rule matches, the default policy applies: + +| Token | Behaviour | +|---|---| +| `allow` | Forward the packet *(default at first boot)* | +| `drop` | Drop the packet | + +Use `filter mode drop` together with explicit `allow` rules to build a whitelist. Use `filter mode allow` (the default) with `drop` rules to build a blacklist. + +--- + +## `filter list` output format + +The number of rules per page is determined dynamically — longer rules (with AND) may result in fewer rules per page to stay within the packet size limit. If more pages exist, the last line shows the next command to run. + +``` +mode:allow rules:6/8 p1/2 +0 drop payload eq grptxt +1 drop route eq tflood and hops gt 5 +2* drop snr lt -10 +-> filter list 1 +``` + +``` +mode:allow rules:6/8 p2/2 +3 allow path eq AB 12 +4 drop channel eq 0xAB and rssi lt -100 +5 drop rssi lt -110 +``` + +- The number at the start is the rule ID used with `del`, `disable`, and `enable`. +- A `*` after the ID means the rule is currently **disabled**. +- Page numbers are 0-indexed in the command, but displayed as 1-indexed in the header. + +--- + +## Examples + +### Drop all group text messages +``` +filter add drop payload eq grptxt +``` + +### Drop all transport flood packets +``` +filter add drop route eq tflood +``` + +### Drop packets that have travelled more than 5 hops +``` +filter add drop hops gt 5 +``` + +### Drop packets with poor SNR +``` +filter add drop snr lt -10 +``` + +### Drop packets with weak signal +``` +filter add drop rssi lt -110 +``` + +### Drop packets arriving via a specific repeater +``` +filter add drop path eq AB +``` + +### Drop packets arriving via any of several repeaters +``` +filter add drop path eq AB 12 CD +``` + +### Drop packets on a specific channel +``` +filter add drop channel eq 0xAB +``` + +### Drop group messages on a specific channel only when more than 8 hops away +``` +filter add drop channel eq 0x11 and hops gt 8 +``` + +### Drop packets with poor SNR AND weak signal (both must be true) +``` +filter add drop snr lt -10 and rssi lt -100 +``` + +### Drop packets via a specific repeater only if they have travelled far +``` +filter add drop path eq AB and hops gt 4 +``` + +### Whitelist mode — only forward flood packets, drop everything else +``` +filter mode drop +filter add allow route eq flood +``` + +### Whitelist mode — only forward direct text messages with good signal +``` +filter mode drop +filter add allow payload eq txt and rssi gt -90 +``` + +### Disable a rule temporarily without deleting it +``` +filter disable 2 +``` + +### Re-enable it +``` +filter enable 2 +``` + +### View all rules +``` +filter list +``` + +### View second page +``` +filter list 1 +``` + +### Delete rule 1 +``` +filter del 1 +``` + +### Delete all rules and reset to default policy +``` +filter clear +filter mode allow +``` + +--- + +## Limits + +| Parameter | Value | +|---|---| +| Maximum rules | 8 | +| Maximum AND conditions per rule | 1 | +| Maximum path hashes per rule | 4 | +| Maximum path hash size | 3 bytes (2, 4 or 6 hex chars — must match the network's configured hash size) | +| `path` as AND field | Not supported | \ No newline at end of file diff --git a/examples/simple_repeater/FilterParser.cpp b/examples/simple_repeater/FilterParser.cpp new file mode 100644 index 0000000000..6c116fe2bb --- /dev/null +++ b/examples/simple_repeater/FilterParser.cpp @@ -0,0 +1,408 @@ +#include "FilterParser.h" +#include +#include +#include + +// --------------------------------------------------------------------------- +// Internal tokenizer +// --------------------------------------------------------------------------- + +// Maximum token length (no single token should exceed this) +#define MAX_TOKEN_LEN 16 + +struct Tokenizer { + const char* pos; // current position in input string +}; + +// Copy the next whitespace-delimited token into 'out' (null-terminated). +// Returns false if no more tokens are available. +static bool nextToken(Tokenizer& tz, char out[MAX_TOKEN_LEN + 1]) { + // Skip leading whitespace + while (*tz.pos == ' ' || *tz.pos == '\t') tz.pos++; + + if (*tz.pos == '\0') return false; + + int i = 0; + while (*tz.pos != '\0' && *tz.pos != ' ' && *tz.pos != '\t') { + if (i < MAX_TOKEN_LEN) { + out[i++] = (char)tolower((unsigned char)*tz.pos); + } + tz.pos++; + } + out[i] = '\0'; + return true; +} + +// Peek at next token without advancing position. +static bool peekToken(Tokenizer tz, char out[MAX_TOKEN_LEN + 1]) { + return nextToken(tz, out); // tz passed by value — copy is intentional +} + +// --------------------------------------------------------------------------- +// Token → enum helpers +// --------------------------------------------------------------------------- + +static bool parseAction(const char* tok, FilterAction& out) { + if (strcmp(tok, "drop") == 0) { out = FilterAction::DROP; return true; } + if (strcmp(tok, "allow") == 0) { out = FilterAction::ALLOW; return true; } + return false; +} + +static bool parseField(const char* tok, FilterField& out) { + if (strcmp(tok, "route") == 0) { out = FilterField::ROUTE; return true; } + if (strcmp(tok, "payload") == 0) { out = FilterField::TYPE; return true; } + if (strcmp(tok, "hops") == 0) { out = FilterField::HOPS; return true; } + if (strcmp(tok, "pathsize") == 0) { out = FilterField::PATHSIZE; return true; } + if (strcmp(tok, "path") == 0) { out = FilterField::PATH; return true; } + if (strcmp(tok, "channel") == 0) { out = FilterField::CHANNEL; return true; } + if (strcmp(tok, "snr") == 0) { out = FilterField::SNR; return true; } + if (strcmp(tok, "rssi") == 0) { out = FilterField::RSSI; return true; } + return false; +} + +static bool parseOp(const char* tok, FilterOp& out) { + if (strcmp(tok, "eq") == 0) { out = FilterOp::EQ; return true; } + if (strcmp(tok, "neq") == 0) { out = FilterOp::NEQ; return true; } + if (strcmp(tok, "gt") == 0) { out = FilterOp::GT; return true; } + if (strcmp(tok, "lt") == 0) { out = FilterOp::LT; return true; } + return false; +} + +static bool parseMode(const char* tok, FilterMode& out) { + if (strcmp(tok, "allow") == 0) { out = FilterMode::ALLOW; return true; } + if (strcmp(tok, "drop") == 0) { out = FilterMode::DROP; return true; } + return false; +} + +// --------------------------------------------------------------------------- +// Value parsers per field +// --------------------------------------------------------------------------- + +// Parse ROUTE value token → uint8_t ROUTE_TYPE_* equivalent +static bool parseRouteValue(const char* tok, int16_t& out) { + if (strcmp(tok, "tflood") == 0) { out = 0x00; return true; } // ROUTE_TYPE_TRANSPORT_FLOOD + if (strcmp(tok, "flood") == 0) { out = 0x01; return true; } // ROUTE_TYPE_FLOOD + if (strcmp(tok, "direct") == 0) { out = 0x02; return true; } // ROUTE_TYPE_DIRECT + if (strcmp(tok, "tdirect") == 0) { out = 0x03; return true; } // ROUTE_TYPE_TRANSPORT_DIRECT + return false; +} + +// Parse PAYLOAD_TYPE value token → uint8_t PAYLOAD_TYPE_* equivalent +static bool parseTypeValue(const char* tok, int16_t& out) { + if (strcmp(tok, "req") == 0) { out = 0x00; return true; } + if (strcmp(tok, "resp") == 0) { out = 0x01; return true; } + if (strcmp(tok, "txt") == 0) { out = 0x02; return true; } + if (strcmp(tok, "ack") == 0) { out = 0x03; return true; } + if (strcmp(tok, "advert") == 0) { out = 0x04; return true; } + if (strcmp(tok, "grptxt") == 0) { out = 0x05; return true; } + if (strcmp(tok, "grpdata") == 0) { out = 0x06; return true; } + if (strcmp(tok, "anonreq") == 0) { out = 0x07; return true; } + if (strcmp(tok, "path") == 0) { out = 0x08; return true; } + if (strcmp(tok, "trace") == 0) { out = 0x09; return true; } + if (strcmp(tok, "multi") == 0) { out = 0x0A; return true; } + if (strcmp(tok, "ctrl") == 0) { out = 0x0B; return true; } + if (strcmp(tok, "raw") == 0) { out = 0x0F; return true; } + // Also accept raw numeric values (decimal or hex) + char* end; + long v = strtol(tok, &end, 0); + if (*end == '\0' && v >= 0 && v <= 0x0F) { out = (int16_t)v; return true; } + return false; +} + +// Parse a hex string (with or without 0x prefix) into up to MAX_PATH_HASH_SIZE bytes. +// Returns number of bytes written, or 0 on failure. +static uint8_t parseHexBytes(const char* tok, uint8_t* out) { + // Skip optional 0x / 0X prefix — check both chars exist first + if (tok[0] == '0' && tok[1] != '\0' && (tok[1] == 'x' || tok[1] == 'X')) tok += 2; + + size_t hexlen = strlen(tok); + if (hexlen == 0 || hexlen > (MAX_PATH_HASH_SIZE * 2) || (hexlen & 1) != 0) return 0; + + for (size_t i = 0; i < hexlen; i += 2) { + char hi = tok[i]; + char lo = tok[i + 1]; + + auto hexdig = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + + int h = hexdig(hi); + int l = hexdig(lo); + if (h < 0 || l < 0) return 0; + + out[i / 2] = (uint8_t)((h << 4) | l); + } + return (uint8_t)(hexlen / 2); +} + +// Parse a scalar value token for a given field into out. +// Returns false if the token is not valid for the field. +static bool parseScalarValue(const char* tok, FilterField field, int16_t& out) { + switch (field) { + case FilterField::ROUTE: + return parseRouteValue(tok, out); + case FilterField::TYPE: + return parseTypeValue(tok, out); + case FilterField::HOPS: + case FilterField::PATHSIZE: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) return false; + out = (int16_t)v; + return true; + } + case FilterField::CHANNEL: { + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 1) { out = bytes[0]; return true; } + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) return false; + out = (int16_t)v; + return true; + } + case FilterField::SNR: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -128 || v > 127) return false; + out = (int16_t)(v * 4); // store as quarter-dB + return true; + } + case FilterField::RSSI: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -32768 || v > 32767) return false; + out = (int16_t)v; + return true; + } + default: + return false; + } +} + +static FilterParseResult parseAddCommand(Tokenizer& tz) { + FilterParseResult result; + result.error = FilterParseError::OK; + result.command = FilterCommand::ADD; + memset(&result.rule, 0, sizeof(FilterRule)); + result.rule.enabled = false; // rules are added disabled — use 'filter enable ' to activate + result.rule.in_use = true; + result.rule.and_field = FILTER_FIELD_NONE; // no AND condition by default + + char tok[MAX_TOKEN_LEN + 1]; + + // --- action --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseAction(tok, result.rule.action)) { result.error = FilterParseError::UNKNOWN_ACTION; return result; } + + // --- field --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseField(tok, result.rule.field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // --- operator --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, result.rule.op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // --- value (field-specific) --- + if (result.rule.field == FilterField::PATH) { + // PATH: one or more hex hash tokens (OR-list), stops at "and" or end of input + uint8_t count = 0; + uint8_t hashlen = 0; + + while (peekToken(tz, tok)) { + // Stop consuming hashes when we see the "and" keyword + if (strcmp(tok, "and") == 0) break; + + nextToken(tz, tok); // consume + + if (count >= MAX_PATH_HASHES_PER_RULE) { + result.error = FilterParseError::TOO_MANY_HASHES; + return result; + } + + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 0) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + + if (hashlen == 0) { + hashlen = len; + } else if (len != hashlen) { + result.error = FilterParseError::HASH_SIZE_MISMATCH; + return result; + } + + memcpy(result.rule.path_hashes[count], bytes, len); + count++; + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.path_hash_len = hashlen; + result.rule.path_hash_count = count; + + } else { + // Scalar field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, result.rule.field, result.rule.value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + } + + // --- optional AND condition --- + if (peekToken(tz, tok) && strcmp(tok, "and") == 0) { + nextToken(tz, tok); // consume "and" + + // AND field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + FilterField and_field; + if (!parseField(tok, and_field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // PATH not supported as AND condition + if (and_field == FilterField::PATH) { + result.error = FilterParseError::AND_PATH_NOT_ALLOWED; + return result; + } + + // Duplicate field not allowed + if (and_field == result.rule.field) { + result.error = FilterParseError::AND_DUPLICATE_FIELD; + return result; + } + + // AND operator + FilterOp and_op; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, and_op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // AND value + int16_t and_value = 0; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, and_field, and_value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + + result.rule.and_field = (uint8_t)and_field; + result.rule.and_op = and_op; + result.rule.and_value = and_value; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +FilterParseResult parseFilterCommand(const char* input) { + FilterParseResult result; + memset(&result, 0, sizeof(result)); // zero all fields including rule — safe default for all error paths + + Tokenizer tz = { input }; + char tok[MAX_TOKEN_LEN + 1]; + + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + // --- route to sub-command --- + if (strcmp(tok, "add") == 0) { + return parseAddCommand(tz); + } + + if (strcmp(tok, "list") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::LIST; + result.rule_id = 0; // default page 0 + // Optional page number: "filter list 1" + if (peekToken(tz, tok)) { + nextToken(tz, tok); + char* end; + long page = strtol(tok, &end, 10); + if (*end != '\0' || page < 0) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.rule_id = (uint8_t)page; + } + return result; + } + + if (strcmp(tok, "clear") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::CLEAR; + return result; + } + + if (strcmp(tok, "del") == 0 || strcmp(tok, "disable") == 0 || strcmp(tok, "enable") == 0) { + FilterCommand cmd = (strcmp(tok, "del") == 0) ? FilterCommand::DEL : + (strcmp(tok, "disable") == 0) ? FilterCommand::DISABLE : + FilterCommand::ENABLE; + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + char* end; + long id = strtol(tok, &end, 10); + if (*end != '\0' || id < 0 || id >= MAX_FILTER_RULES) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.error = FilterParseError::OK; + result.command = cmd; + result.rule_id = (uint8_t)id; + return result; + } + + if (strcmp(tok, "mode") == 0) { + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + if (!parseMode(tok, result.mode)) { + result.error = FilterParseError::UNKNOWN_MODE; + return result; + } + result.error = FilterParseError::OK; + result.command = FilterCommand::MODE; + return result; + } + + result.error = FilterParseError::UNKNOWN_COMMAND; + return result; +} + +// --------------------------------------------------------------------------- +// Error string helper +// --------------------------------------------------------------------------- + +const char* filterParseErrorStr(FilterParseError err) { + switch (err) { + case FilterParseError::OK: return "OK"; + case FilterParseError::UNKNOWN_COMMAND: return "Err - unknown command"; + case FilterParseError::UNKNOWN_ACTION: return "Err - unknown action (use: drop, allow)"; + case FilterParseError::UNKNOWN_FIELD: return "Err - unknown field (use: route, payload, hops, pathsize, path, channel, snr, rssi)"; + case FilterParseError::UNKNOWN_OP: return "Err - unknown operator (use: eq, neq, gt, lt)"; + case FilterParseError::UNKNOWN_VALUE: return "Err - unknown or out-of-range value"; + case FilterParseError::MISSING_TOKEN: return "Err - missing token"; + case FilterParseError::INVALID_RULE_ID: return "Err - invalid rule id"; + case FilterParseError::INVALID_HEX: return "Err - invalid hex value"; + case FilterParseError::TOO_MANY_HASHES: return "Err - too many path hashes (max 4)"; + case FilterParseError::HASH_SIZE_MISMATCH:return "Err - mixed hash sizes in path rule"; + case FilterParseError::UNKNOWN_MODE: return "Err - unknown mode (use: allow, drop)"; + case FilterParseError::AND_PATH_NOT_ALLOWED: return "Err - path field not supported as AND condition"; + case FilterParseError::AND_DUPLICATE_FIELD: return "Err - AND condition cannot use same field as primary"; + default: return "Err - unknown error"; + } +} \ No newline at end of file diff --git a/examples/simple_repeater/FilterParser.h b/examples/simple_repeater/FilterParser.h new file mode 100644 index 0000000000..993ed097b4 --- /dev/null +++ b/examples/simple_repeater/FilterParser.h @@ -0,0 +1,62 @@ +#pragma once + +#include "FilterRule.h" + +// --------------------------------------------------------------------------- +// Parse errors +// --------------------------------------------------------------------------- + +enum class FilterParseError : uint8_t { + OK = 0, + UNKNOWN_COMMAND, // unrecognised sub-command after "filter" + UNKNOWN_ACTION, // unrecognised action token (expected drop/allow) + UNKNOWN_FIELD, // unrecognised field token + UNKNOWN_OP, // unrecognised operator token + UNKNOWN_VALUE, // unrecognised or out-of-range value token + MISSING_TOKEN, // expected another token but input ended + INVALID_RULE_ID, // rule id out of range or not a number + INVALID_HEX, // malformed hex string + TOO_MANY_HASHES, // more path hashes than MAX_PATH_HASHES_PER_RULE + HASH_SIZE_MISMATCH, // mixed hash sizes in a single path rule + UNKNOWN_MODE, // unrecognised mode token (expected allow/drop) + AND_PATH_NOT_ALLOWED, // path field not supported as AND condition + AND_DUPLICATE_FIELD, // AND condition uses same field as primary condition +}; + +// --------------------------------------------------------------------------- +// Command types returned by the parser +// --------------------------------------------------------------------------- + +enum class FilterCommand : uint8_t { + ADD, // add a new rule — result.rule is populated + DEL, // delete by id — result.rule_id is populated + LIST, + DISABLE, // disable by id — result.rule_id is populated + ENABLE, // enable by id — result.rule_id is populated + CLEAR, + MODE, // set default policy — result.mode is populated +}; + +// --------------------------------------------------------------------------- +// Parse result +// --------------------------------------------------------------------------- + +struct FilterParseResult { + FilterParseError error; + FilterCommand command; + FilterRule rule; // valid when command == ADD and error == OK + uint8_t rule_id; // valid when command == DEL / DISABLE / ENABLE + FilterMode mode; // valid when command == MODE +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Parse a full "filter ..." command string. +// 'input' must be a null-terminated C string starting after "filter ". +// The returned FilterParseResult is valid for the lifetime of the call. +FilterParseResult parseFilterCommand(const char* input); + +// Return a short human-readable description of a parse error. +const char* filterParseErrorStr(FilterParseError err); \ No newline at end of file diff --git a/examples/simple_repeater/FilterRule.h b/examples/simple_repeater/FilterRule.h new file mode 100644 index 0000000000..bc4ff72a6d --- /dev/null +++ b/examples/simple_repeater/FilterRule.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +#define MAX_FILTER_RULES 8 +#define MAX_PATH_HASHES_PER_RULE 4 +#define MAX_PATH_HASH_SIZE 3 // 1, 2 or 3 bytes per hash + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +enum class FilterAction : uint8_t { + DROP = 0, + ALLOW = 1, +}; + +enum class FilterField : uint8_t { + ROUTE = 0, // getRouteType() — ROUTE_TYPE_* values + TYPE = 1, // getPayloadType() — PAYLOAD_TYPE_* values + HOPS = 2, // getPathHashCount() — number of hops + PATHSIZE = 3, // getPathHashSize() — bytes per path hash (1-3) + PATH = 4, // last hop in path — OR-match against hash list + CHANNEL = 5, // payload[0] — channel hash (GRP_TXT / GRP_DATA) + SNR = 6, // packet->_snr — stored in quarter-dB, compared in whole dB + RSSI = 7, // passed in at eval — dBm +}; + +enum class FilterOp : uint8_t { + EQ = 0, // equal + NEQ = 1, // not equal + GT = 2, // greater than + LT = 3, // less than +}; + +// --------------------------------------------------------------------------- +// Default policy when no rule matches +// --------------------------------------------------------------------------- + +enum class FilterMode : uint8_t { + ALLOW = 0, // default-allow (blacklist mode) + DROP = 1, // default-drop (whitelist mode) +}; + +// --------------------------------------------------------------------------- +// Rule struct +// --------------------------------------------------------------------------- + +// Sentinel value meaning "no AND condition" +#define FILTER_FIELD_NONE 0xFF + +struct FilterRule { + FilterAction action; + FilterField field; + FilterOp op; + + // Scalar comparison value. + // SNR : stored in quarter-dB (matches packet->_snr units), parser converts from whole dB + // RSSI : stored in dBm (int16_t) + // All other fields: uint8_t cast to int16_t + int16_t value; + + // Optional AND condition — active only when and_field != FILTER_FIELD_NONE. + // PATH field is not supported as an AND condition. + uint8_t and_field; // raw uint8_t so FILTER_FIELD_NONE (0xFF) fits without casting + FilterOp and_op; + int16_t and_value; + + // PATH field only: list of hashes for OR-match against the last hop in path. + // Unused slots are zero-filled. + uint8_t path_hashes[MAX_PATH_HASHES_PER_RULE][MAX_PATH_HASH_SIZE]; + uint8_t path_hash_len; // bytes per hash (1, 2 or 3) — same for all hashes in this rule + uint8_t path_hash_count; // number of valid hashes in path_hashes (1..MAX_PATH_HASHES_PER_RULE) + + bool enabled; // false = rule is defined but temporarily inactive + bool in_use; // false = slot is empty +}; \ No newline at end of file diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1b0ca1916c..e8b8d65d87 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -344,7 +344,7 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t int results_offset = 0; uint8_t results_buffer[130]; for(int index = 0; index < count && index + offset < neighbours_count; index++){ - + // stop if we can't fit another entry in results int entry_size = pubkey_prefix_length + 4 + 1; if(results_offset + entry_size > sizeof(results_buffer)){ @@ -562,6 +562,11 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } + // Run packet through filter engine (channel, path, route, type, SNR, RSSI, etc.) + if (_filter.evaluate(pkt, (int16_t)_radio->getLastRSSI())) { + MESH_DEBUG_PRINTLN("*** FILTER: packet dropped by rule ***"); + return true; + } // do normal processing return false; } @@ -935,6 +940,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); + _filter.load(*_fs); // establish default-scope { @@ -1152,7 +1158,7 @@ void MyMesh::formatRadioStatsReply(char *reply) { } void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), + StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); } @@ -1390,7 +1396,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } - } else{ + } else if (strncmp(command, "filter", 6) == 0 && (command[6] == ' ' || command[6] == '\0')) { + const char* filter_args = (command[6] == ' ') ? command + 7 : ""; + _filter.handleCommand(filter_args, reply, *_fs); + } else { _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index fbc756f471..19cc32b25e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -34,6 +34,7 @@ #include #include #include "RateLimiter.h" +#include "ChannelFilter.h" #ifdef WITH_BRIDGE extern AbstractBridge* bridge; @@ -103,6 +104,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long pending_discover_until; bool region_load_active; unsigned long dirty_contacts_expiry; + ChannelFilter _filter; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif diff --git a/examples/simple_repeater/filter test suite/ChannelFilter.cpp b/examples/simple_repeater/filter test suite/ChannelFilter.cpp new file mode 100644 index 0000000000..2fe97a2baf --- /dev/null +++ b/examples/simple_repeater/filter test suite/ChannelFilter.cpp @@ -0,0 +1,463 @@ +#include "mock_mesh.h" +#include "ChannelFilter.h" +#include +#include + +// --------------------------------------------------------------------------- +// Persistence layout (binary blob, fixed size): +// [uint8_t mode] +// [FilterRule * MAX_FILTER_RULES] +// --------------------------------------------------------------------------- + +ChannelFilter::ChannelFilter() { + memset(_rules, 0, sizeof(_rules)); + _mode = FilterMode::ALLOW; // safe default: pass all packets if no rules loaded +} + +// --------------------------------------------------------------------------- +// Load / Save +// --------------------------------------------------------------------------- + +void ChannelFilter::load(FILESYSTEM& fs) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "r"); +#else + File f = fs.open(FILTER_RULES_FILE); +#endif + if (!f) return; + + uint8_t mode_byte; + if (f.read(&mode_byte, 1) != 1) { f.close(); return; } + // Validate mode byte — default to ALLOW if file is corrupt + _mode = (mode_byte <= (uint8_t)FilterMode::DROP) + ? (FilterMode)mode_byte + : FilterMode::ALLOW; + + // Check read length — if truncated, zero remaining slots (in_use=false = harmless) + size_t bytes_read = f.read((uint8_t*)_rules, sizeof(_rules)); + if (bytes_read < sizeof(_rules)) { + memset((uint8_t*)_rules + bytes_read, 0, sizeof(_rules) - bytes_read); + } + f.close(); +} + +void ChannelFilter::save(FILESYSTEM& fs) const { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = fs.open(FILTER_RULES_FILE, "w"); +#else + if (fs.exists(FILTER_RULES_FILE)) fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, "w"); +#endif + if (!f) return; + + uint8_t mode_byte = (uint8_t)_mode; + f.write(&mode_byte, 1); + f.write((const uint8_t*)_rules, sizeof(_rules)); + f.close(); +} + +// --------------------------------------------------------------------------- +// Evaluation helpers +// --------------------------------------------------------------------------- + +static bool applyOp(FilterOp op, int16_t pkt_val, int16_t rule_val) { + switch (op) { + case FilterOp::EQ: return pkt_val == rule_val; + case FilterOp::NEQ: return pkt_val != rule_val; + case FilterOp::GT: return pkt_val > rule_val; + case FilterOp::LT: return pkt_val < rule_val; + default: return false; + } +} + +bool ChannelFilter::_ruleMatches(const FilterRule& rule, const mesh::Packet* pkt, int16_t rssi) const { + // PATH field has its own OR-list logic — handle separately + if (rule.field == FilterField::PATH) { + uint8_t hash_size = pkt->getPathHashSize(); + uint8_t hash_count = pkt->getPathHashCount(); + + if (hash_count == 0) return false; + if (hash_size != rule.path_hash_len) return false; + + uint16_t last_hop_offset = (uint16_t)(hash_count - 1) * hash_size; + if (last_hop_offset + hash_size > MAX_PATH_SIZE) return false; + + const uint8_t* last_hop = pkt->path + last_hop_offset; + bool found = false; + for (uint8_t i = 0; i < rule.path_hash_count; i++) { + if (memcmp(rule.path_hashes[i], last_hop, hash_size) == 0) { + found = true; + break; + } + } + + bool primary_match = (rule.op == FilterOp::EQ) ? found : !found; + if (!primary_match) return false; + + // AND condition (PATH as primary can still have a scalar AND) + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) + return false; + } + return true; + } + + // Scalar primary condition + if (!_evalScalar(rule.field, rule.op, rule.value, pkt, rssi)) return false; + + // AND condition if present + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) return false; + } + + return true; +} + +// Evaluate a single scalar condition against a packet. +// PATH field is not handled here — it has its own block in the switch above. +bool ChannelFilter::_evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet* pkt, int16_t rssi) const { + switch (field) { + case FilterField::ROUTE: + return applyOp(op, (int16_t)pkt->getRouteType(), val); + + case FilterField::TYPE: + return applyOp(op, (int16_t)pkt->getPayloadType(), val); + + case FilterField::HOPS: + return applyOp(op, (int16_t)pkt->getPathHashCount(), val); + + case FilterField::PATHSIZE: + return applyOp(op, (int16_t)pkt->getPathHashSize(), val); + + case FilterField::CHANNEL: { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; + if (pkt->payload_len < 1) return false; + return applyOp(op, (int16_t)pkt->payload[0], val); + } + + case FilterField::SNR: + return applyOp(op, (int16_t)pkt->_snr, val); + + case FilterField::RSSI: + return applyOp(op, rssi, val); + + default: + return false; + } +} + +bool ChannelFilter::evaluate(const mesh::Packet* pkt, int16_t rssi) const { + if (!pkt) return false; // null guard — pass unknown packets rather than crash + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + const FilterRule& rule = _rules[i]; + if (!rule.in_use || !rule.enabled) continue; + + if (_ruleMatches(rule, pkt, rssi)) { + return rule.action == FilterAction::DROP; + } + } + // No rule matched — apply default policy + return _mode == FilterMode::DROP; +} + +// --------------------------------------------------------------------------- +// Slot helpers +// --------------------------------------------------------------------------- + +int ChannelFilter::_firstFreeSlot() const { + for (int i = 0; i < MAX_FILTER_RULES; i++) { + if (!_rules[i].in_use) return i; + } + return -1; +} + +// --------------------------------------------------------------------------- +// List formatting +// --------------------------------------------------------------------------- + +// Return a short token string for a FilterField value +static const char* fieldStr(FilterField f) { + switch (f) { + case FilterField::ROUTE: return "route"; + case FilterField::TYPE: return "payload"; + case FilterField::HOPS: return "hops"; + case FilterField::PATHSIZE: return "pathsize"; + case FilterField::PATH: return "path"; + case FilterField::CHANNEL: return "channel"; + case FilterField::SNR: return "snr"; + case FilterField::RSSI: return "rssi"; + default: return "?"; + } +} + +static const char* opStr(FilterOp op) { + switch (op) { + case FilterOp::EQ: return "eq"; + case FilterOp::NEQ: return "neq"; + case FilterOp::GT: return "gt"; + case FilterOp::LT: return "lt"; + default: return "?"; + } +} + +// Translate ROUTE_TYPE_* numeric value to token string +static const char* routeValueStr(int16_t v) { + switch (v) { + case 0x00: return "tflood"; + case 0x01: return "flood"; + case 0x02: return "direct"; + case 0x03: return "tdirect"; + default: return "?"; + } +} + +// Translate PAYLOAD_TYPE_* numeric value to token string +static const char* payloadTypeValueStr(int16_t v) { + switch (v) { + case 0x00: return "req"; + case 0x01: return "resp"; + case 0x02: return "txt"; + case 0x03: return "ack"; + case 0x04: return "advert"; + case 0x05: return "grptxt"; + case 0x06: return "grpdata"; + case 0x07: return "anonreq"; + case 0x08: return "path"; + case 0x09: return "trace"; + case 0x0A: return "multi"; + case 0x0B: return "ctrl"; + case 0x0F: return "raw"; + default: return "?"; + } +} + +static void formatRuleValue(const FilterRule& rule, char* out, int outlen) { + if (outlen <= 0) return; + if (rule.field == FilterField::PATH) { + int pos = 0; + for (uint8_t i = 0; i < rule.path_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + for (uint8_t b = 0; b < rule.path_hash_len && pos < outlen - 3; b++) { + pos += snprintf(out + pos, outlen - pos, "%02X", rule.path_hashes[i][b]); + } + } + out[pos] = '\0'; + } else if (rule.field == FilterField::ROUTE) { + snprintf(out, outlen, "%s", routeValueStr(rule.value)); + } else if (rule.field == FilterField::TYPE) { + snprintf(out, outlen, "%s", payloadTypeValueStr(rule.value)); + } else if (rule.field == FilterField::CHANNEL) { + snprintf(out, outlen, "0x%02X", (uint8_t)rule.value); + } else if (rule.field == FilterField::SNR) { + // Convert stored quarter-dB back to whole dB for display + snprintf(out, outlen, "%d", (int)(rule.value / 4)); + } else { + snprintf(out, outlen, "%d", (int)rule.value); + } +} + +// Maximum reply length — stay safely below the 138-char packet limit +#define FILTER_REPLY_BUDGET 128 +// Reserved for header and hint line +#define FILTER_REPLY_HEADER_MAX 32 +#define FILTER_REPLY_HINT_LEN 18 // "-> filter list N\0" + +// Format a single rule line into buf (null-terminated). Returns number of chars written. +static int formatRuleLine(const FilterRule& rule, uint8_t idx, char* buf, int buflen) { + char val_buf[32]; + formatRuleValue(rule, val_buf, (int)sizeof(val_buf)); + + char and_buf[48] = ""; + if (rule.and_field != FILTER_FIELD_NONE) { + char and_val_buf[32]; + FilterField af = (FilterField)rule.and_field; + if (af == FilterField::ROUTE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", routeValueStr(rule.and_value)); + } else if (af == FilterField::TYPE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", payloadTypeValueStr(rule.and_value)); + } else if (af == FilterField::CHANNEL) { + snprintf(and_val_buf, sizeof(and_val_buf), "0x%02X", (uint8_t)rule.and_value); + } else if (af == FilterField::SNR) { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)(rule.and_value / 4)); + } else { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)rule.and_value); + } + snprintf(and_buf, sizeof(and_buf), " and %s %s %s", + fieldStr(af), opStr(rule.and_op), and_val_buf); + } + + return snprintf(buf, buflen, "%d%s %s %s %s %s%s\n", + idx, + rule.enabled ? "" : "*", + rule.action == FilterAction::DROP ? "drop" : "allow", + fieldStr(rule.field), + opStr(rule.op), + val_buf, + and_buf + ); +} + +void ChannelFilter::_listRules(char* reply, uint8_t page) const { + // Count in-use rules and collect their indexes + uint8_t indexes[MAX_FILTER_RULES]; + uint8_t total = 0; + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + if (_rules[i].in_use) indexes[total++] = i; + } + + const char* mode_str = (_mode == FilterMode::DROP) ? "drop" : "allow"; + + // Pre-scan: determine page boundaries dynamically based on actual line lengths. + // Each page gets as many rules as fit within FILTER_REPLY_BUDGET minus header and hint. + uint8_t page_start[MAX_FILTER_RULES + 1]; // start index into indexes[] for each page + uint8_t num_pages = 0; + page_start[0] = 0; + + { + uint8_t i = 0; + while (i < total) { + // Available budget for rule lines on this page + int budget = FILTER_REPLY_BUDGET - FILTER_REPLY_HEADER_MAX - FILTER_REPLY_HINT_LEN; + uint8_t page_end = i; + + while (page_end < total) { + char line_buf[80]; + int line_len = formatRuleLine(_rules[indexes[page_end]], indexes[page_end], + line_buf, sizeof(line_buf)); + if (budget - line_len < 0) break; // doesn't fit + budget -= line_len; + page_end++; + } + + // Safety: always advance at least one rule to avoid infinite loop + if (page_end == i) page_end = i + 1; + + num_pages++; + i = page_end; + page_start[num_pages] = i; + } + } + + if (total == 0) num_pages = 1; + + if (page >= num_pages) { + snprintf(reply, 80, "Err - page %d out of range (0-%d)", page, num_pages - 1); + return; + } + + // Write header + int pos; + if (num_pages > 1) { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d p%d/%d\n", + mode_str, total, MAX_FILTER_RULES, page + 1, num_pages); + } else { + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d\n", + mode_str, total, MAX_FILTER_RULES); + } + + if (total == 0) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "(no rules)"); + return; + } + + // Write rule lines for this page + uint8_t start = page_start[page]; + uint8_t end = page_start[page + 1]; + + for (uint8_t i = start; i < end; i++) { + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + pos += snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "%s", line_buf); + } + + // Hint if more pages follow + if (page + 1 < num_pages) { + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "-> filter list %d", page + 1); + } +} + +// --------------------------------------------------------------------------- +// CLI dispatch +// --------------------------------------------------------------------------- + +void ChannelFilter::handleCommand(const char* args, char* reply, FILESYSTEM& fs) { + FilterParseResult res = parseFilterCommand(args); + + if (res.error != FilterParseError::OK) { + snprintf(reply, 80, "%s", filterParseErrorStr(res.error)); + return; + } + + switch (res.command) { + case FilterCommand::ADD: { + int slot = _firstFreeSlot(); + if (slot < 0) { + snprintf(reply, 80, "Err - rules full (max %d)", MAX_FILTER_RULES); + return; + } + _rules[slot] = res.rule; + save(fs); + snprintf(reply, 80, "OK - rule %d added", slot); + break; + } + + case FilterCommand::DEL: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + memset(&_rules[id], 0, sizeof(FilterRule)); + save(fs); + snprintf(reply, 80, "OK - rule %d deleted", id); + break; + } + + case FilterCommand::DISABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = false; + save(fs); + snprintf(reply, 80, "OK - rule %d disabled", id); + break; + } + + case FilterCommand::ENABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = true; + save(fs); + snprintf(reply, 80, "OK - rule %d enabled", id); + break; + } + + case FilterCommand::LIST: + _listRules(reply, res.rule_id); + break; + + case FilterCommand::CLEAR: + memset(_rules, 0, sizeof(_rules)); + save(fs); + snprintf(reply, 80, "OK - all rules cleared"); + break; + + case FilterCommand::MODE: + _mode = res.mode; + save(fs); + snprintf(reply, 80, "OK - mode: %s", res.mode == FilterMode::DROP ? "drop" : "allow"); + break; + } +} diff --git a/examples/simple_repeater/filter test suite/ChannelFilter.h b/examples/simple_repeater/filter test suite/ChannelFilter.h new file mode 100644 index 0000000000..881d08400a --- /dev/null +++ b/examples/simple_repeater/filter test suite/ChannelFilter.h @@ -0,0 +1,60 @@ +#pragma once + +// NOTE: This header relies on FILESYSTEM being defined before inclusion. +// MyMesh.h includes the platform-specific filesystem headers before including +// this file, so FILESYSTEM is always defined in that context. + +#include "FilterRule.h" +#include "FilterParser.h" + + +// Persistence file path +#define FILTER_RULES_FILE "/filter_rules.bin" + +// --------------------------------------------------------------------------- +// ChannelFilter +// --------------------------------------------------------------------------- + +class ChannelFilter { +public: + ChannelFilter(); + + // --- Lifecycle ---------------------------------------------------------- + + // Load rules and mode from filesystem. Call once at startup. + void load(FILESYSTEM& fs); + + // Save rules and mode to filesystem. + void save(FILESYSTEM& fs) const; + + // --- Evaluation --------------------------------------------------------- + + // Evaluate all active rules against a received packet. + // 'rssi' is passed separately as it lives in the radio driver, not in Packet. + // Returns true if the packet should be DROPPED, false if it should pass. + bool evaluate(const mesh::Packet* pkt, int16_t rssi) const; + + // --- CLI dispatch ------------------------------------------------------- + + // Handle a "filter ..." command string (everything after "filter "). + // Writes a human-readable result into 'reply' (assumed >= 80 bytes). + void handleCommand(const char* args, char* reply, FILESYSTEM& fs); + +private: + FilterRule _rules[MAX_FILTER_RULES]; + FilterMode _mode; // default policy when no rule matches + + // --- Rule helpers ------------------------------------------------------- + + // Find the first free slot. Returns index or -1 if full. + int _firstFreeSlot() const; + + // Evaluate a single rule against a packet + rssi. + // Returns true if the rule matches. + bool _ruleMatches(const FilterRule& rule, const mesh::Packet* pkt, int16_t rssi) const; + bool _evalScalar(FilterField field, FilterOp op, int16_t val, + const mesh::Packet* pkt, int16_t rssi) const; + + // --- list command ------------------------------------------------------- + void _listRules(char* reply, uint8_t page) const; +}; diff --git a/examples/simple_repeater/filter test suite/FilterParser.cpp b/examples/simple_repeater/filter test suite/FilterParser.cpp new file mode 100644 index 0000000000..58aefeb5e0 --- /dev/null +++ b/examples/simple_repeater/filter test suite/FilterParser.cpp @@ -0,0 +1,408 @@ +#include "FilterParser.h" +#include +#include +#include + +// --------------------------------------------------------------------------- +// Internal tokenizer +// --------------------------------------------------------------------------- + +// Maximum token length (no single token should exceed this) +#define MAX_TOKEN_LEN 16 + +struct Tokenizer { + const char* pos; // current position in input string +}; + +// Copy the next whitespace-delimited token into 'out' (null-terminated). +// Returns false if no more tokens are available. +static bool nextToken(Tokenizer& tz, char out[MAX_TOKEN_LEN + 1]) { + // Skip leading whitespace + while (*tz.pos == ' ' || *tz.pos == '\t') tz.pos++; + + if (*tz.pos == '\0') return false; + + int i = 0; + while (*tz.pos != '\0' && *tz.pos != ' ' && *tz.pos != '\t') { + if (i < MAX_TOKEN_LEN) { + out[i++] = (char)tolower((unsigned char)*tz.pos); + } + tz.pos++; + } + out[i] = '\0'; + return true; +} + +// Peek at next token without advancing position. +static bool peekToken(Tokenizer tz, char out[MAX_TOKEN_LEN + 1]) { + return nextToken(tz, out); // tz passed by value — copy is intentional +} + +// --------------------------------------------------------------------------- +// Token → enum helpers +// --------------------------------------------------------------------------- + +static bool parseAction(const char* tok, FilterAction& out) { + if (strcmp(tok, "drop") == 0) { out = FilterAction::DROP; return true; } + if (strcmp(tok, "allow") == 0) { out = FilterAction::ALLOW; return true; } + return false; +} + +static bool parseField(const char* tok, FilterField& out) { + if (strcmp(tok, "route") == 0) { out = FilterField::ROUTE; return true; } + if (strcmp(tok, "payload") == 0) { out = FilterField::TYPE; return true; } + if (strcmp(tok, "hops") == 0) { out = FilterField::HOPS; return true; } + if (strcmp(tok, "pathsize") == 0) { out = FilterField::PATHSIZE; return true; } + if (strcmp(tok, "path") == 0) { out = FilterField::PATH; return true; } + if (strcmp(tok, "channel") == 0) { out = FilterField::CHANNEL; return true; } + if (strcmp(tok, "snr") == 0) { out = FilterField::SNR; return true; } + if (strcmp(tok, "rssi") == 0) { out = FilterField::RSSI; return true; } + return false; +} + +static bool parseOp(const char* tok, FilterOp& out) { + if (strcmp(tok, "eq") == 0) { out = FilterOp::EQ; return true; } + if (strcmp(tok, "neq") == 0) { out = FilterOp::NEQ; return true; } + if (strcmp(tok, "gt") == 0) { out = FilterOp::GT; return true; } + if (strcmp(tok, "lt") == 0) { out = FilterOp::LT; return true; } + return false; +} + +static bool parseMode(const char* tok, FilterMode& out) { + if (strcmp(tok, "allow") == 0) { out = FilterMode::ALLOW; return true; } + if (strcmp(tok, "drop") == 0) { out = FilterMode::DROP; return true; } + return false; +} + +// --------------------------------------------------------------------------- +// Value parsers per field +// --------------------------------------------------------------------------- + +// Parse ROUTE value token → uint8_t ROUTE_TYPE_* equivalent +static bool parseRouteValue(const char* tok, int16_t& out) { + if (strcmp(tok, "tflood") == 0) { out = 0x00; return true; } // ROUTE_TYPE_TRANSPORT_FLOOD + if (strcmp(tok, "flood") == 0) { out = 0x01; return true; } // ROUTE_TYPE_FLOOD + if (strcmp(tok, "direct") == 0) { out = 0x02; return true; } // ROUTE_TYPE_DIRECT + if (strcmp(tok, "tdirect") == 0) { out = 0x03; return true; } // ROUTE_TYPE_TRANSPORT_DIRECT + return false; +} + +// Parse PAYLOAD_TYPE value token → uint8_t PAYLOAD_TYPE_* equivalent +static bool parseTypeValue(const char* tok, int16_t& out) { + if (strcmp(tok, "req") == 0) { out = 0x00; return true; } + if (strcmp(tok, "resp") == 0) { out = 0x01; return true; } + if (strcmp(tok, "txt") == 0) { out = 0x02; return true; } + if (strcmp(tok, "ack") == 0) { out = 0x03; return true; } + if (strcmp(tok, "advert") == 0) { out = 0x04; return true; } + if (strcmp(tok, "grptxt") == 0) { out = 0x05; return true; } + if (strcmp(tok, "grpdata") == 0) { out = 0x06; return true; } + if (strcmp(tok, "anonreq") == 0) { out = 0x07; return true; } + if (strcmp(tok, "path") == 0) { out = 0x08; return true; } + if (strcmp(tok, "trace") == 0) { out = 0x09; return true; } + if (strcmp(tok, "multi") == 0) { out = 0x0A; return true; } + if (strcmp(tok, "ctrl") == 0) { out = 0x0B; return true; } + if (strcmp(tok, "raw") == 0) { out = 0x0F; return true; } + // Also accept raw numeric values (decimal or hex) + char* end; + long v = strtol(tok, &end, 0); + if (*end == '\0' && v >= 0 && v <= 0x0F) { out = (int16_t)v; return true; } + return false; +} + +// Parse a hex string (with or without 0x prefix) into up to MAX_PATH_HASH_SIZE bytes. +// Returns number of bytes written, or 0 on failure. +static uint8_t parseHexBytes(const char* tok, uint8_t* out) { + // Skip optional 0x / 0X prefix — check both chars exist first + if (tok[0] == '0' && tok[1] != '\0' && (tok[1] == 'x' || tok[1] == 'X')) tok += 2; + + size_t hexlen = strlen(tok); + if (hexlen == 0 || hexlen > (MAX_PATH_HASH_SIZE * 2) || (hexlen & 1) != 0) return 0; + + for (size_t i = 0; i < hexlen; i += 2) { + char hi = tok[i]; + char lo = tok[i + 1]; + + auto hexdig = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + + int h = hexdig(hi); + int l = hexdig(lo); + if (h < 0 || l < 0) return 0; + + out[i / 2] = (uint8_t)((h << 4) | l); + } + return (uint8_t)(hexlen / 2); +} + +// Parse a scalar value token for a given field into out. +// Returns false if the token is not valid for the field. +static bool parseScalarValue(const char* tok, FilterField field, int16_t& out) { + switch (field) { + case FilterField::ROUTE: + return parseRouteValue(tok, out); + case FilterField::TYPE: + return parseTypeValue(tok, out); + case FilterField::HOPS: + case FilterField::PATHSIZE: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) return false; + out = (int16_t)v; + return true; + } + case FilterField::CHANNEL: { + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 1) { out = bytes[0]; return true; } + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) return false; + out = (int16_t)v; + return true; + } + case FilterField::SNR: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -128 || v > 127) return false; + out = (int16_t)(v * 4); // store as quarter-dB + return true; + } + case FilterField::RSSI: { + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < -32768 || v > 32767) return false; + out = (int16_t)v; + return true; + } + default: + return false; + } +} + +static FilterParseResult parseAddCommand(Tokenizer& tz) { + FilterParseResult result; + result.error = FilterParseError::OK; + result.command = FilterCommand::ADD; + memset(&result.rule, 0, sizeof(FilterRule)); + result.rule.enabled = false; // rules are added disabled — use 'filter enable ' to activate + result.rule.in_use = true; + result.rule.and_field = FILTER_FIELD_NONE; // no AND condition by default + + char tok[MAX_TOKEN_LEN + 1]; + + // --- action --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseAction(tok, result.rule.action)) { result.error = FilterParseError::UNKNOWN_ACTION; return result; } + + // --- field --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseField(tok, result.rule.field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // --- operator --- + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, result.rule.op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // --- value (field-specific) --- + if (result.rule.field == FilterField::PATH) { + // PATH: one or more hex hash tokens (OR-list), stops at "and" or end of input + uint8_t count = 0; + uint8_t hashlen = 0; + + while (peekToken(tz, tok)) { + // Stop consuming hashes when we see the "and" keyword + if (strcmp(tok, "and") == 0) break; + + nextToken(tz, tok); // consume + + if (count >= MAX_PATH_HASHES_PER_RULE) { + result.error = FilterParseError::TOO_MANY_HASHES; + return result; + } + + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 0) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + + if (hashlen == 0) { + hashlen = len; + } else if (len != hashlen) { + result.error = FilterParseError::HASH_SIZE_MISMATCH; + return result; + } + + memcpy(result.rule.path_hashes[count], bytes, len); + count++; + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.path_hash_len = hashlen; + result.rule.path_hash_count = count; + + } else { + // Scalar field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, result.rule.field, result.rule.value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + } + + // --- optional AND condition --- + if (peekToken(tz, tok) && strcmp(tok, "and") == 0) { + nextToken(tz, tok); // consume "and" + + // AND field + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + FilterField and_field; + if (!parseField(tok, and_field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + + // PATH not supported as AND condition + if (and_field == FilterField::PATH) { + result.error = FilterParseError::AND_PATH_NOT_ALLOWED; + return result; + } + + // Duplicate field not allowed + if (and_field == result.rule.field) { + result.error = FilterParseError::AND_DUPLICATE_FIELD; + return result; + } + + // AND operator + FilterOp and_op; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseOp(tok, and_op)) { result.error = FilterParseError::UNKNOWN_OP; return result; } + + // AND value + int16_t and_value = 0; + if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } + if (!parseScalarValue(tok, and_field, and_value)) { + result.error = FilterParseError::UNKNOWN_VALUE; + return result; + } + + result.rule.and_field = (uint8_t)and_field; + result.rule.and_op = and_op; + result.rule.and_value = and_value; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +FilterParseResult parseFilterCommand(const char* input) { + FilterParseResult result; + memset(&result, 0, sizeof(result)); // zero all fields including rule — safe default for all error paths + + Tokenizer tz = { input }; + char tok[MAX_TOKEN_LEN + 1]; + + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + // --- route to sub-command --- + if (strcmp(tok, "add") == 0) { + return parseAddCommand(tz); + } + + if (strcmp(tok, "list") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::LIST; + result.rule_id = 0; // default page 0 + // Optional page number: "filter list 1" + if (peekToken(tz, tok)) { + nextToken(tz, tok); + char* end; + long page = strtol(tok, &end, 10); + if (*end != '\0' || page < 0) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.rule_id = (uint8_t)page; + } + return result; + } + + if (strcmp(tok, "clear") == 0) { + result.error = FilterParseError::OK; + result.command = FilterCommand::CLEAR; + return result; + } + + if (strcmp(tok, "del") == 0 || strcmp(tok, "disable") == 0 || strcmp(tok, "enable") == 0) { + FilterCommand cmd = (strcmp(tok, "del") == 0) ? FilterCommand::DEL : + (strcmp(tok, "disable") == 0) ? FilterCommand::DISABLE : + FilterCommand::ENABLE; + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + char* end; + long id = strtol(tok, &end, 10); + if (*end != '\0' || id < 0 || id >= MAX_FILTER_RULES) { + result.error = FilterParseError::INVALID_RULE_ID; + return result; + } + result.error = FilterParseError::OK; + result.command = cmd; + result.rule_id = (uint8_t)id; + return result; + } + + if (strcmp(tok, "mode") == 0) { + if (!nextToken(tz, tok)) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + if (!parseMode(tok, result.mode)) { + result.error = FilterParseError::UNKNOWN_MODE; + return result; + } + result.error = FilterParseError::OK; + result.command = FilterCommand::MODE; + return result; + } + + result.error = FilterParseError::UNKNOWN_COMMAND; + return result; +} + +// --------------------------------------------------------------------------- +// Error string helper +// --------------------------------------------------------------------------- + +const char* filterParseErrorStr(FilterParseError err) { + switch (err) { + case FilterParseError::OK: return "OK"; + case FilterParseError::UNKNOWN_COMMAND: return "Err - unknown command"; + case FilterParseError::UNKNOWN_ACTION: return "Err - unknown action (use: drop, allow)"; + case FilterParseError::UNKNOWN_FIELD: return "Err - unknown field (use: route, payload, hops, pathsize, path, channel, snr, rssi)"; + case FilterParseError::UNKNOWN_OP: return "Err - unknown operator (use: eq, neq, gt, lt)"; + case FilterParseError::UNKNOWN_VALUE: return "Err - unknown or out-of-range value"; + case FilterParseError::MISSING_TOKEN: return "Err - missing token"; + case FilterParseError::INVALID_RULE_ID: return "Err - invalid rule id"; + case FilterParseError::INVALID_HEX: return "Err - invalid hex value"; + case FilterParseError::TOO_MANY_HASHES: return "Err - too many path hashes (max 4)"; + case FilterParseError::HASH_SIZE_MISMATCH:return "Err - mixed hash sizes in path rule"; + case FilterParseError::UNKNOWN_MODE: return "Err - unknown mode (use: allow, drop)"; + case FilterParseError::AND_PATH_NOT_ALLOWED: return "Err - path field not supported as AND condition"; + case FilterParseError::AND_DUPLICATE_FIELD: return "Err - AND condition cannot use same field as primary"; + default: return "Err - unknown error"; + } +} diff --git a/examples/simple_repeater/filter test suite/FilterParser.h b/examples/simple_repeater/filter test suite/FilterParser.h new file mode 100644 index 0000000000..fb14972d71 --- /dev/null +++ b/examples/simple_repeater/filter test suite/FilterParser.h @@ -0,0 +1,62 @@ +#pragma once + +#include "FilterRule.h" + +// --------------------------------------------------------------------------- +// Parse errors +// --------------------------------------------------------------------------- + +enum class FilterParseError : uint8_t { + OK = 0, + UNKNOWN_COMMAND, // unrecognised sub-command after "filter" + UNKNOWN_ACTION, // unrecognised action token (expected drop/allow) + UNKNOWN_FIELD, // unrecognised field token + UNKNOWN_OP, // unrecognised operator token + UNKNOWN_VALUE, // unrecognised or out-of-range value token + MISSING_TOKEN, // expected another token but input ended + INVALID_RULE_ID, // rule id out of range or not a number + INVALID_HEX, // malformed hex string + TOO_MANY_HASHES, // more path hashes than MAX_PATH_HASHES_PER_RULE + HASH_SIZE_MISMATCH, // mixed hash sizes in a single path rule + UNKNOWN_MODE, // unrecognised mode token (expected allow/drop) + AND_PATH_NOT_ALLOWED, // path field not supported as AND condition + AND_DUPLICATE_FIELD, // AND condition uses same field as primary condition +}; + +// --------------------------------------------------------------------------- +// Command types returned by the parser +// --------------------------------------------------------------------------- + +enum class FilterCommand : uint8_t { + ADD, // add a new rule — result.rule is populated + DEL, // delete by id — result.rule_id is populated + LIST, + DISABLE, // disable by id — result.rule_id is populated + ENABLE, // enable by id — result.rule_id is populated + CLEAR, + MODE, // set default policy — result.mode is populated +}; + +// --------------------------------------------------------------------------- +// Parse result +// --------------------------------------------------------------------------- + +struct FilterParseResult { + FilterParseError error; + FilterCommand command; + FilterRule rule; // valid when command == ADD and error == OK + uint8_t rule_id; // valid when command == DEL / DISABLE / ENABLE + FilterMode mode; // valid when command == MODE +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Parse a full "filter ..." command string. +// 'input' must be a null-terminated C string starting after "filter ". +// The returned FilterParseResult is valid for the lifetime of the call. +FilterParseResult parseFilterCommand(const char* input); + +// Return a short human-readable description of a parse error. +const char* filterParseErrorStr(FilterParseError err); diff --git a/examples/simple_repeater/filter test suite/FilterRule.h b/examples/simple_repeater/filter test suite/FilterRule.h new file mode 100644 index 0000000000..1037269db4 --- /dev/null +++ b/examples/simple_repeater/filter test suite/FilterRule.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +#define MAX_FILTER_RULES 8 +#define MAX_PATH_HASHES_PER_RULE 4 +#define MAX_PATH_HASH_SIZE 3 // 1, 2 or 3 bytes per hash + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +enum class FilterAction : uint8_t { + DROP = 0, + ALLOW = 1, +}; + +enum class FilterField : uint8_t { + ROUTE = 0, // getRouteType() — ROUTE_TYPE_* values + TYPE = 1, // getPayloadType() — PAYLOAD_TYPE_* values + HOPS = 2, // getPathHashCount() — number of hops + PATHSIZE = 3, // getPathHashSize() — bytes per path hash (1-3) + PATH = 4, // last hop in path — OR-match against hash list + CHANNEL = 5, // payload[0] — channel hash (GRP_TXT / GRP_DATA) + SNR = 6, // packet->_snr — stored in quarter-dB, compared in whole dB + RSSI = 7, // passed in at eval — dBm +}; + +enum class FilterOp : uint8_t { + EQ = 0, // equal + NEQ = 1, // not equal + GT = 2, // greater than + LT = 3, // less than +}; + +// --------------------------------------------------------------------------- +// Default policy when no rule matches +// --------------------------------------------------------------------------- + +enum class FilterMode : uint8_t { + ALLOW = 0, // default-allow (blacklist mode) + DROP = 1, // default-drop (whitelist mode) +}; + +// --------------------------------------------------------------------------- +// Rule struct +// --------------------------------------------------------------------------- + +// Sentinel value meaning "no AND condition" +#define FILTER_FIELD_NONE 0xFF + +struct FilterRule { + FilterAction action; + FilterField field; + FilterOp op; + + // Scalar comparison value. + // SNR : stored in quarter-dB (matches packet->_snr units), parser converts from whole dB + // RSSI : stored in dBm (int16_t) + // All other fields: uint8_t cast to int16_t + int16_t value; + + // Optional AND condition — active only when and_field != FILTER_FIELD_NONE. + // PATH field is not supported as an AND condition. + uint8_t and_field; // raw uint8_t so FILTER_FIELD_NONE (0xFF) fits without casting + FilterOp and_op; + int16_t and_value; + + // PATH field only: list of hashes for OR-match against the last hop in path. + // Unused slots are zero-filled. + uint8_t path_hashes[MAX_PATH_HASHES_PER_RULE][MAX_PATH_HASH_SIZE]; + uint8_t path_hash_len; // bytes per hash (1, 2 or 3) — same for all hashes in this rule + uint8_t path_hash_count; // number of valid hashes in path_hashes (1..MAX_PATH_HASHES_PER_RULE) + + bool enabled; // false = rule is defined but temporarily inactive + bool in_use; // false = slot is empty +}; diff --git a/examples/simple_repeater/filter test suite/Makefile b/examples/simple_repeater/filter test suite/Makefile new file mode 100644 index 0000000000..e047841b70 --- /dev/null +++ b/examples/simple_repeater/filter test suite/Makefile @@ -0,0 +1,18 @@ +CXX = g++ +CXXFLAGS = -std=c++11 -Wall -Wextra -I. + +SRCS_COMMON = FilterParser.cpp ChannelFilter.cpp + +all: auto_test shell + +auto_test: $(SRCS_COMMON) auto_test.cpp + $(CXX) $(CXXFLAGS) -o auto_test $(SRCS_COMMON) auto_test.cpp + +shell: $(SRCS_COMMON) shell.cpp + $(CXX) $(CXXFLAGS) -o shell $(SRCS_COMMON) shell.cpp + +run_tests: auto_test + ./auto_test + +clean: + rm -f auto_test shell diff --git a/examples/simple_repeater/filter test suite/auto_test.cpp b/examples/simple_repeater/filter test suite/auto_test.cpp new file mode 100644 index 0000000000..146dc4598f --- /dev/null +++ b/examples/simple_repeater/filter test suite/auto_test.cpp @@ -0,0 +1,415 @@ +#include "mock_mesh.h" +#include "FilterRule.h" +#include "FilterParser.h" +#include "ChannelFilter.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Minimal test framework +// --------------------------------------------------------------------------- + +static int _pass = 0, _fail = 0; + +#define CHECK(desc, expr) do { \ + if (expr) { \ + printf(" PASS %s\n", desc); \ + _pass++; \ + } else { \ + printf(" FAIL %s (line %d)\n", desc, __LINE__); \ + _fail++; \ + } \ +} while(0) + +static void section(const char* name) { + printf("\n── %s\n", name); +} + +// --------------------------------------------------------------------------- +// Packet builder helpers +// --------------------------------------------------------------------------- + +static mesh::Packet makePacket(uint8_t route, uint8_t type, + int8_t snr_qdB = 0, + uint8_t channel = 0, + uint8_t hop_count = 0, uint8_t hash_size = 1, + const uint8_t* path_data = nullptr) { + mesh::Packet p; + p.setHeader(route, type); + p._snr = snr_qdB; + if (type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA) { + p.payload[0] = channel; + p.payload_len = 1; + } + if (hop_count > 0 && path_data) { + p.setPath(path_data, hash_size, hop_count); + } + return p; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +static void test_parser() { + section("Parser — valid commands"); + + auto r = parseFilterCommand("add drop payload eq grptxt"); + CHECK("add drop payload eq grptxt -> OK", r.error == FilterParseError::OK); + CHECK(" command == ADD", r.command == FilterCommand::ADD); + CHECK(" action == DROP", r.rule.action == FilterAction::DROP); + CHECK(" field == TYPE", r.rule.field == FilterField::TYPE); + CHECK(" op == EQ", r.rule.op == FilterOp::EQ); + CHECK(" value == 0x05 (grptxt)", r.rule.value == 0x05); + + r = parseFilterCommand("add allow route eq flood"); + CHECK("add allow route eq flood -> OK", r.error == FilterParseError::OK); + CHECK(" action == ALLOW", r.rule.action == FilterAction::ALLOW); + CHECK(" field == ROUTE", r.rule.field == FilterField::ROUTE); + CHECK(" value == 0x01 (flood)", r.rule.value == 0x01); + + r = parseFilterCommand("add drop hops gt 5"); + CHECK("add drop hops gt 5 -> OK", r.error == FilterParseError::OK); + CHECK(" field == HOPS, op == GT, value == 5", + r.rule.field == FilterField::HOPS && r.rule.op == FilterOp::GT && r.rule.value == 5); + + r = parseFilterCommand("add drop snr lt -10"); + CHECK("add drop snr lt -10 -> OK", r.error == FilterParseError::OK); + CHECK(" SNR stored as quarter-dB (-40)", r.rule.value == -40); + + r = parseFilterCommand("add drop rssi lt -110"); + CHECK("add drop rssi lt -110 -> OK", r.error == FilterParseError::OK); + CHECK(" RSSI value == -110", r.rule.value == -110); + + r = parseFilterCommand("add drop channel eq 0xAB"); + CHECK("add drop channel eq 0xAB -> OK", r.error == FilterParseError::OK); + CHECK(" channel value == 0xAB", r.rule.value == 0xAB); + + r = parseFilterCommand("add drop path eq AB 12 CD"); + CHECK("add drop path eq AB 12 CD -> OK", r.error == FilterParseError::OK); + CHECK(" path_hash_count == 3", r.rule.path_hash_count == 3); + CHECK(" path_hash_len == 1", r.rule.path_hash_len == 1); + CHECK(" hash[0] == 0xAB", r.rule.path_hashes[0][0] == 0xAB); + CHECK(" hash[1] == 0x12", r.rule.path_hashes[1][0] == 0x12); + CHECK(" hash[2] == 0xCD", r.rule.path_hashes[2][0] == 0xCD); + + r = parseFilterCommand("del 3"); + CHECK("del 3 -> OK, rule_id == 3", + r.error == FilterParseError::OK && r.command == FilterCommand::DEL && r.rule_id == 3); + + r = parseFilterCommand("disable 0"); + CHECK("disable 0 -> OK", + r.error == FilterParseError::OK && r.command == FilterCommand::DISABLE); + + r = parseFilterCommand("enable 7"); + CHECK("enable 7 -> OK", + r.error == FilterParseError::OK && r.command == FilterCommand::ENABLE); + + r = parseFilterCommand("list"); + CHECK("list -> OK, page 0", + r.error == FilterParseError::OK && r.command == FilterCommand::LIST && r.rule_id == 0); + + r = parseFilterCommand("list 1"); + CHECK("list 1 -> OK, page 1", + r.error == FilterParseError::OK && r.rule_id == 1); + + r = parseFilterCommand("clear"); + CHECK("clear -> OK", + r.error == FilterParseError::OK && r.command == FilterCommand::CLEAR); + + r = parseFilterCommand("mode drop"); + CHECK("mode drop -> OK", + r.error == FilterParseError::OK && r.mode == FilterMode::DROP); + + r = parseFilterCommand("mode allow"); + CHECK("mode allow -> OK", + r.error == FilterParseError::OK && r.mode == FilterMode::ALLOW); + + section("Parser — error cases"); + + r = parseFilterCommand("add drop payload eq grptxt_TYPO"); + CHECK("unknown value -> UNKNOWN_VALUE", r.error == FilterParseError::UNKNOWN_VALUE); + + r = parseFilterCommand("add drop BADFIELD eq 5"); + CHECK("unknown field -> UNKNOWN_FIELD", r.error == FilterParseError::UNKNOWN_FIELD); + + r = parseFilterCommand("add drop payload BADOP grptxt"); + CHECK("unknown op -> UNKNOWN_OP", r.error == FilterParseError::UNKNOWN_OP); + + r = parseFilterCommand("add drop payload eq"); + CHECK("missing value -> MISSING_TOKEN", r.error == FilterParseError::MISSING_TOKEN); + + r = parseFilterCommand("del 99"); + CHECK("del out-of-range -> INVALID_RULE_ID", r.error == FilterParseError::INVALID_RULE_ID); + + r = parseFilterCommand("BADCMD"); + CHECK("unknown command -> UNKNOWN_COMMAND", r.error == FilterParseError::UNKNOWN_COMMAND); + + r = parseFilterCommand("add drop path eq AB 12 ZZZZ"); + CHECK("invalid hex in path -> INVALID_HEX", r.error == FilterParseError::INVALID_HEX); + + r = parseFilterCommand("add drop path eq AB 1234"); + CHECK("mixed hash size -> HASH_SIZE_MISMATCH", r.error == FilterParseError::HASH_SIZE_MISMATCH); + + r = parseFilterCommand("mode BADMODE"); + CHECK("unknown mode -> UNKNOWN_MODE", r.error == FilterParseError::UNKNOWN_MODE); +} + +// Helper: add a rule and immediately enable it (rules are added disabled by default) +static void addRule(ChannelFilter& f, const char* cmd, MockFS& fs) { + char reply[160]; + char enable_cmd[16]; + f.handleCommand(cmd, reply, fs); + // Extract slot id from "OK - rule N added" + int id = -1; + sscanf(reply, "OK - rule %d added", &id); + if (id >= 0) { + snprintf(enable_cmd, sizeof(enable_cmd), "enable %d", id); + f.handleCommand(enable_cmd, reply, fs); + } +} + +static void test_evaluate() { + MockFS fs; + ChannelFilter f; + + section("Evaluate — default mode allow (no rules)"); + mesh::Packet p = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + CHECK("no rules, mode allow -> pass", !f.evaluate(&p, -80)); + + section("Evaluate — route match"); + char reply[160]; + addRule(f, "add drop route eq tflood", fs); + mesh::Packet tflood = makePacket(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_TXT_MSG); + mesh::Packet flood = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("tflood packet -> DROP", f.evaluate(&tflood, -80)); + CHECK("flood packet -> PASS", !f.evaluate(&flood, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — payload type match"); + addRule(f, "add drop payload eq grptxt", fs); + mesh::Packet grptxt = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + mesh::Packet txt = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("grptxt packet -> DROP", f.evaluate(&grptxt, -80)); + CHECK("txt packet -> PASS", !f.evaluate(&txt, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — hops gt"); + addRule(f, "add drop hops gt 3", fs); + uint8_t path4[4] = {0x01, 0x02, 0x03, 0x04}; + uint8_t path2[2] = {0x01, 0x02}; + mesh::Packet p4 = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 4, 1, path4); + mesh::Packet p2 = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 2, 1, path2); + CHECK("4 hops, rule gt 3 -> DROP", f.evaluate(&p4, -80)); + CHECK("2 hops, rule gt 3 -> PASS", !f.evaluate(&p2, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — SNR lt"); + addRule(f, "add drop snr lt -10", fs); + // SNR -12 dB = -48 quarter-dB; SNR -8 dB = -32 quarter-dB + mesh::Packet pLowSNR = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, -48); + mesh::Packet pHighSNR = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, -32); + CHECK("SNR -12 dB < -10 -> DROP", f.evaluate(&pLowSNR, -80)); + CHECK("SNR -8 dB > -10 -> PASS", !f.evaluate(&pHighSNR, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — RSSI lt"); + addRule(f, "add drop rssi lt -100", fs); + CHECK("RSSI -110 < -100 -> DROP", f.evaluate(&p, -110)); + CHECK("RSSI -80 > -100 -> PASS", !f.evaluate(&p, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — channel match"); + addRule(f, "add drop channel eq 0xAB", fs); + mesh::Packet chanAB = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0xAB); + mesh::Packet chanCD = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0xCD); + CHECK("channel 0xAB -> DROP", f.evaluate(&chanAB, -80)); + CHECK("channel 0xCD -> PASS", !f.evaluate(&chanCD, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — path OR-match"); + addRule(f, "add drop path eq AB CD", fs); + uint8_t hopAB[1] = {0xAB}; + uint8_t hopCD[1] = {0xCD}; + uint8_t hopEF[1] = {0xEF}; + mesh::Packet pAB = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 1, 1, hopAB); + mesh::Packet pCD = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 1, 1, hopCD); + mesh::Packet pEF = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG, 0, 0, 1, 1, hopEF); + CHECK("last hop 0xAB in list -> DROP", f.evaluate(&pAB, -80)); + CHECK("last hop 0xCD in list -> DROP", f.evaluate(&pCD, -80)); + CHECK("last hop 0xEF not in list -> PASS",!f.evaluate(&pEF, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — first-match order"); + addRule(f, "add allow payload eq grptxt", fs); + addRule(f, "add drop route eq flood", fs); + mesh::Packet grp = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + mesh::Packet norm = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("grptxt flood: allow rule fires first -> PASS", !f.evaluate(&grp, -80)); + CHECK("txt flood: drop rule fires -> DROP", f.evaluate(&norm, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — disabled rule ignored"); + f.handleCommand("add drop payload eq grptxt", reply, fs); // added disabled — do NOT enable + mesh::Packet g = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + CHECK("added rule is disabled by default -> packet passes", !f.evaluate(&g, -80)); + f.handleCommand("enable 0", reply, fs); + CHECK("re-enabled rule -> packet drops", f.evaluate(&g, -80)); + f.handleCommand("disable 0", reply, fs); + CHECK("disabled again -> packet passes", !f.evaluate(&g, -80)); + f.handleCommand("clear", reply, fs); + + section("Evaluate — default mode drop"); + f.handleCommand("mode drop", reply, fs); + mesh::Packet any = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + CHECK("mode drop, no rules -> DROP", f.evaluate(&any, -80)); + f.handleCommand("mode allow", reply, fs); + CHECK("mode allow, no rules -> PASS", !f.evaluate(&any, -80)); +} + +static void test_persistence() { + section("Persistence — save and reload"); + + MockFS fs; + ChannelFilter f1; + char reply[160]; + + addRule(f1, "add drop payload eq grptxt", fs); // rule 0 — enabled + addRule(f1, "add drop route eq tflood", fs); // rule 1 — enabled + f1.handleCommand("disable 0", reply, fs); + f1.handleCommand("mode drop", reply, fs); + + ChannelFilter f2; + f2.load(fs); + + mesh::Packet grptxt = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT); + mesh::Packet tflood = makePacket(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_TXT_MSG); + mesh::Packet norm = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_TXT_MSG); + + // Rule 0 disabled — grptxt falls through to default policy (drop) + CHECK("rule 0 disabled after reload -> grptxt drops (default policy)", f2.evaluate(&grptxt, -80)); + CHECK("rule 1 active after reload -> tflood drops", f2.evaluate(&tflood, -80)); + CHECK("mode drop after reload -> norm drops", f2.evaluate(&norm, -80)); + + // Verify disabled means rule is skipped — test with mode allow + ChannelFilter f3; + f3.handleCommand("add drop payload eq grptxt", reply, fs); // added disabled + CHECK("added disabled, mode allow -> grptxt passes", !f3.evaluate(&grptxt, -80)); +} + +static void test_and_condition() { + MockFS fs; + ChannelFilter f; + char reply[160]; + + section("Parser — AND condition"); + + auto r = parseFilterCommand("add drop channel eq 0x11 and hops gt 8"); + CHECK("AND parse -> OK", r.error == FilterParseError::OK); + CHECK(" primary field == CHANNEL", r.rule.field == FilterField::CHANNEL); + CHECK(" primary value == 0x11", r.rule.value == 0x11); + CHECK(" and_field == HOPS", r.rule.and_field == (uint8_t)FilterField::HOPS); + CHECK(" and_op == GT", r.rule.and_op == FilterOp::GT); + CHECK(" and_value == 8", r.rule.and_value == 8); + + r = parseFilterCommand("add drop snr lt -10 and rssi lt -100"); + CHECK("AND snr+rssi -> OK", r.error == FilterParseError::OK); + CHECK(" and_field == RSSI", r.rule.and_field == (uint8_t)FilterField::RSSI); + CHECK(" and_value == -100", r.rule.and_value == -100); + + r = parseFilterCommand("add drop path eq AB and hops gt 3"); + CHECK("AND path+hops -> OK", r.error == FilterParseError::OK); + CHECK(" path primary intact", r.rule.path_hash_count == 1); + CHECK(" and_field == HOPS", r.rule.and_field == (uint8_t)FilterField::HOPS); + + r = parseFilterCommand("add drop channel eq 0x11 and path eq AB"); + CHECK("AND with path -> AND_PATH_NOT_ALLOWED", r.error == FilterParseError::AND_PATH_NOT_ALLOWED); + + r = parseFilterCommand("add drop channel eq 0x11 and channel eq 0x22"); + CHECK("AND duplicate field -> AND_DUPLICATE_FIELD",r.error == FilterParseError::AND_DUPLICATE_FIELD); + + section("Evaluate — AND condition"); + + // Rule: drop channel eq 0x11 and hops gt 3 + addRule(f, "add drop channel eq 0x11 and hops gt 3", fs); + + uint8_t path4[4] = {0x01, 0x02, 0x03, 0x04}; + uint8_t path2[2] = {0x01, 0x02}; + + // Both conditions true -> DROP + mesh::Packet p_both = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x11, 4, 1, path4); + CHECK("channel 0x11 AND hops=4 > 3 -> DROP", f.evaluate(&p_both, -80)); + + // Channel matches, hops does not -> PASS + mesh::Packet p_ch_only = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x11, 2, 1, path2); + CHECK("channel 0x11 AND hops=2 not > 3 -> PASS", !f.evaluate(&p_ch_only, -80)); + + // Hops match, channel does not -> PASS + mesh::Packet p_hop_only = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x22, 4, 1, path4); + CHECK("channel 0x22 AND hops=4, ch mismatch -> PASS", !f.evaluate(&p_hop_only, -80)); + + f.handleCommand("clear", reply, fs); + + section("List — AND condition display"); + + f.handleCommand("add drop channel eq 0x11 and hops gt 8", reply, fs); // list doesn't need enabled + f.handleCommand("list", reply, fs); + CHECK("list shows 'and' keyword", strstr(reply, "and") != nullptr); + CHECK("list shows 'hops'", strstr(reply, "hops") != nullptr); + CHECK("list shows '8'", strstr(reply, "8") != nullptr); + CHECK("list within 138 chars", strlen(reply) <= 138); + f.handleCommand("clear", reply, fs); +} + +static void test_list_length() { + section("List output — page length within 138 chars"); + + MockFS fs; + ChannelFilter f; + char reply[160]; + + // Fill with worst-case rules + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + f.handleCommand("add drop payload neq grpdata", reply, fs); + + for (int page = 0; ; page++) { + char pr[16]; + snprintf(pr, sizeof(pr), "list %d", page); + f.handleCommand(pr, reply, fs); + int len = strlen(reply); + char desc[64]; + snprintf(desc, sizeof(desc), "page %d length %d <= 138", page, len); + CHECK(desc, len <= 138); + // Stop when no more pages hinted + if (strstr(reply, "-> filter list") == nullptr) break; + if (page > 10) break; // safety + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +int main() { + printf("═══════════════════════════════════════\n"); + printf(" MeshCore Filter Engine — Test Suite\n"); + printf("═══════════════════════════════════════\n"); + + test_parser(); + test_evaluate(); + test_persistence(); + test_and_condition(); + test_list_length(); + + printf("\n═══════════════════════════════════════\n"); + printf(" Results: %d passed, %d failed\n", _pass, _fail); + printf("═══════════════════════════════════════\n"); + + return (_fail == 0) ? 0 : 1; +} diff --git a/examples/simple_repeater/filter test suite/mock_mesh.h b/examples/simple_repeater/filter test suite/mock_mesh.h new file mode 100644 index 0000000000..74219ac1e0 --- /dev/null +++ b/examples/simple_repeater/filter test suite/mock_mesh.h @@ -0,0 +1,145 @@ +#pragma once + +// --------------------------------------------------------------------------- +// Minimal stubs so FilterParser.cpp and ChannelFilter.cpp compile on Linux +// without Arduino/MeshCore headers. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +// Silence debug prints +#define MESH_DEBUG_PRINTLN(x) do {} while(0) + +// Route type constants (mirrors Packet.h) +#define ROUTE_TYPE_TRANSPORT_FLOOD 0x00 +#define ROUTE_TYPE_FLOOD 0x01 +#define ROUTE_TYPE_DIRECT 0x02 +#define ROUTE_TYPE_TRANSPORT_DIRECT 0x03 + +// Payload type constants (mirrors Packet.h) +#define PAYLOAD_TYPE_REQ 0x00 +#define PAYLOAD_TYPE_RESPONSE 0x01 +#define PAYLOAD_TYPE_TXT_MSG 0x02 +#define PAYLOAD_TYPE_ACK 0x03 +#define PAYLOAD_TYPE_ADVERT 0x04 +#define PAYLOAD_TYPE_GRP_TXT 0x05 +#define PAYLOAD_TYPE_GRP_DATA 0x06 +#define PAYLOAD_TYPE_ANON_REQ 0x07 +#define PAYLOAD_TYPE_PATH 0x08 +#define PAYLOAD_TYPE_TRACE 0x09 +#define PAYLOAD_TYPE_MULTIPART 0x0A +#define PAYLOAD_TYPE_CONTROL 0x0B +#define PAYLOAD_TYPE_RAW_CUSTOM 0x0F + +#define PH_ROUTE_MASK 0x03 +#define PH_TYPE_SHIFT 2 +#define PH_TYPE_MASK 0x0F +#define PH_VER_SHIFT 6 + +#define MAX_PATH_SIZE 64 +#define MAX_PACKET_PAYLOAD 200 + +namespace mesh { + +class Packet { +public: + uint8_t header; + uint16_t payload_len; + uint16_t path_len; + uint8_t path[MAX_PATH_SIZE]; + uint8_t payload[MAX_PACKET_PAYLOAD]; + int8_t _snr; // quarter-dB + + Packet() { memset(this, 0, sizeof(*this)); } + + uint8_t getRouteType() const { return header & PH_ROUTE_MASK; } + uint8_t getPayloadType() const { return (header >> PH_TYPE_SHIFT) & PH_TYPE_MASK; } + uint8_t getPathHashSize() const { return (path_len >> 6) + 1; } + uint8_t getPathHashCount()const { return path_len & 63; } + float getSNR() const { return ((float)_snr) / 4.0f; } + + // Helper: set route + payload type in header + void setHeader(uint8_t route, uint8_t type) { + header = (route & PH_ROUTE_MASK) | ((type & PH_TYPE_MASK) << PH_TYPE_SHIFT); + } + + // Helper: set path with N hops of hash_size bytes each + void setPath(const uint8_t* data, uint8_t hash_size, uint8_t hop_count) { + path_len = ((hash_size - 1) << 6) | (hop_count & 63); + memcpy(path, data, hop_count * hash_size); + } +}; + +} // namespace mesh + +// --------------------------------------------------------------------------- +// Minimal in-memory FILESYSTEM mock +// --------------------------------------------------------------------------- + +#include +#include +#include + +class MockFile { +public: + std::vector* _buf; + size_t _pos; + bool _write; + bool _valid; + + MockFile() : _buf(nullptr), _pos(0), _write(false), _valid(false) {} + + explicit operator bool() const { return _valid; } + + size_t read(uint8_t* dest, size_t len) { + if (!_valid || _write) return 0; + size_t avail = _buf->size() - _pos; + size_t n = len < avail ? len : avail; + memcpy(dest, _buf->data() + _pos, n); + _pos += n; + return n; + } + + size_t write(const uint8_t* src, size_t len) { + if (!_valid || !_write) return 0; + _buf->insert(_buf->end(), src, src + len); + return len; + } + + void close() { _valid = false; } +}; + +class MockFS { + std::map> _files; +public: + MockFile open(const char* path, const char* mode = "r") { + MockFile f; + bool writing = (mode && mode[0] == 'w'); + f._write = writing; + if (writing) { + _files[path].clear(); + f._buf = &_files[path]; + f._valid = true; + } else { + auto it = _files.find(path); + if (it != _files.end()) { + f._buf = &it->second; + f._valid = true; + } + } + f._pos = 0; + return f; + } + + bool exists(const char* path) { return _files.count(path) > 0; } + bool remove(const char* path) { return _files.erase(path) > 0; } + void clear() { _files.clear(); } +}; + +// Alias so ChannelFilter.cpp sees FILESYSTEM +using FILESYSTEM = MockFS; +// File type alias +using File = MockFile; diff --git a/examples/simple_repeater/filter test suite/readme.md b/examples/simple_repeater/filter test suite/readme.md new file mode 100644 index 0000000000..dd66537bae --- /dev/null +++ b/examples/simple_repeater/filter test suite/readme.md @@ -0,0 +1,128 @@ +# MeshCore Filter Engine — Test Suite + +Standalone test suite for the MeshCore repeater packet filter engine. Compiles and runs on Linux without any Arduino or MeshCore dependencies. + +--- + +## Contents + +| File | Description | +|---|---| +| `FilterRule.h` | Rule struct, enums and constants | +| `FilterParser.h/.cpp` | Command parser | +| `ChannelFilter.h/.cpp` | Rule storage, evaluation, persistence and CLI dispatch | +| `mock_mesh.h` | Minimal stubs replacing Arduino/MeshCore/FILESYSTEM | +| `auto_test.cpp` | Automated test suite (pass/fail) | +| `shell.cpp` | Interactive command shell | +| `Makefile` | Build targets | + +--- + +## Build + +```bash +make # builds both auto_test and shell +make run_tests # builds and runs auto_test +make clean # removes binaries +``` + +Requires `g++` with C++11 support. + +--- + +## Automated tests + +Runs 92 tests covering parser, evaluation, persistence, AND conditions and page length. + +```bash +./auto_test +``` + +Example output: + +``` +═══════════════════════════════════════ + MeshCore Filter Engine — Test Suite +═══════════════════════════════════════ + +── Parser — valid commands + PASS add drop payload eq grptxt -> OK + PASS add allow route eq flood -> OK + ... + +═══════════════════════════════════════ + Results: 92 passed, 0 failed +═══════════════════════════════════════ +``` + +Exit code is `0` on success, `1` if any test fails. + +--- + +## Interactive shell + +Lets you type `filter` commands and test packet evaluation interactively. Rules are not persisted between sessions. + +```bash +./shell +``` + +### Filter commands + +``` +filter add drop payload eq grptxt +filter add allow route eq flood and hops lt 4 +filter disable 0 +filter enable 0 +filter list +filter list 1 +filter del 0 +filter clear +filter mode drop +filter mode allow +``` + +### Packet evaluation + +Use `eval` to test a constructed packet against the current rules: + +``` +eval route:<0-3> type:<0-15> snr: rssi: hops: [channel:] [hop:] +``` + +| Parameter | Description | +|---|---| +| `route` | Route type: `0`=tflood `1`=flood `2`=direct `3`=tdirect | +| `type` | Payload type: `0`=req `2`=txt `4`=advert `5`=grptxt `6`=grpdata ... | +| `snr` | SNR in whole dB, e.g. `-10` | +| `rssi` | RSSI in dBm, e.g. `-90` | +| `hops` | Number of hops | +| `channel` | Channel hash byte in hex, e.g. `AB` (only relevant for grptxt/grpdata) | +| `hop` | Last hop repeater hash in hex, e.g. `CD` | + +Example session: + +``` +> filter add drop channel eq 0x11 and hops gt 8 + -> OK - rule 0 added +> filter enable 0 + -> OK - rule 0 enabled +> filter list + -> mode:allow rules:1/8 + 0 drop channel eq 0x11 and hops gt 8 +> eval route:1 type:5 snr:-5 rssi:-90 hops:9 channel:11 + Packet: route=1 type=5 snr=-5dB rssi=-90dBm hops=9 channel=0x11 + Result: *** DROPPED *** +> eval route:1 type:5 snr:-5 rssi:-90 hops:3 channel:11 + Packet: route=1 type=5 snr=-5dB rssi=-90dBm hops=3 channel=0x11 + Result: PASSED (forwarded) +> quit +``` + +--- + +## Notes + +- Rules added via `filter add` are **disabled by default**. Use `filter enable ` to activate them. +- The `mock_mesh.h` stub provides an in-memory filesystem — rules saved during a shell session are lost on exit. +- The patched `ChannelFilter.h/.cpp` in this directory have Arduino/MeshCore includes removed. Do not copy these back into the main project — use the originals there. \ No newline at end of file diff --git a/examples/simple_repeater/filter test suite/shell.cpp b/examples/simple_repeater/filter test suite/shell.cpp new file mode 100644 index 0000000000..911cfef6c5 --- /dev/null +++ b/examples/simple_repeater/filter test suite/shell.cpp @@ -0,0 +1,153 @@ +#include "mock_mesh.h" +#include "FilterRule.h" +#include "FilterParser.h" +#include "ChannelFilter.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Interactive shell — type "filter ..." commands and see output +// --------------------------------------------------------------------------- + +static void print_help() { + printf("\n"); + printf(" Commands:\n"); + printf(" filter add \n"); + printf(" filter del \n"); + printf(" filter disable / enable \n"); + printf(" filter list [page]\n"); + printf(" filter clear\n"); + printf(" filter mode allow|drop\n"); + printf("\n"); + printf(" Test packet evaluation:\n"); + printf(" eval route:<0-3> type:<0-15> snr: rssi: hops: [channel:] [hop:]\n"); + printf(" route: 0=tflood 1=flood 2=direct 3=tdirect\n"); + printf(" type: 0=req 2=txt 4=advert 5=grptxt 6=grpdata ...\n"); + printf("\n"); + printf(" eval example: eval route:1 type:5 snr:-5 rssi:-90 hops:2 channel:AB hop:CD\n"); + printf("\n"); + printf(" help — show this message\n"); + printf(" quit — exit\n"); + printf("\n"); +} + +// Simple key:value parser for eval command +static bool getIntArg(const char* line, const char* key, long* out) { + const char* p = strstr(line, key); + if (!p) return false; + p += strlen(key); + char* end; + *out = strtol(p, &end, 0); + return end != p; +} + +static bool getHexArg(const char* line, const char* key, uint8_t* out) { + const char* p = strstr(line, key); + if (!p) return false; + p += strlen(key); + // skip optional 0x + if (p[0] == '0' && (p[1] == 'x' || p[1] == 'X')) p += 2; + char* end; + long v = strtol(p, &end, 16); + if (end == p) return false; + *out = (uint8_t)v; + return true; +} + +static void handle_eval(const char* line, ChannelFilter& f) { + long route = 1, type = 2, snr_db = 0, rssi = -80, hops = 0; + uint8_t channel = 0, hop_hash = 0; + bool has_channel = false, has_hop = false; + + getIntArg(line, "route:", &route); + getIntArg(line, "type:", &type); + getIntArg(line, "snr:", &snr_db); + getIntArg(line, "rssi:", &rssi); + getIntArg(line, "hops:", &hops); + has_channel = getHexArg(line, "channel:", &channel); + has_hop = getHexArg(line, "hop:", &hop_hash); + + mesh::Packet pkt; + pkt.setHeader((uint8_t)route, (uint8_t)type); + pkt._snr = (int8_t)(snr_db * 4); + + if (has_channel) { + pkt.payload[0] = channel; + pkt.payload_len = 1; + } + + uint8_t path_data[64]; + memset(path_data, 0, sizeof(path_data)); + if (hops > 0) { + // Fill path with zeros except last hop + for (int i = 0; i < hops - 1; i++) path_data[i] = 0x00; + path_data[hops - 1] = has_hop ? hop_hash : 0x00; + pkt.setPath(path_data, 1, (uint8_t)hops); + } + + bool dropped = f.evaluate(&pkt, (int16_t)rssi); + + printf(" Packet: route=%ld type=%ld snr=%lddB rssi=%lddBm hops=%ld", + route, type, snr_db, rssi, hops); + if (has_channel) printf(" channel=0x%02X", channel); + if (has_hop) printf(" last_hop=0x%02X", hop_hash); + printf("\n"); + printf(" Result: %s\n", dropped ? "*** DROPPED ***" : "PASSED (forwarded)"); +} + +int main() { + MockFS fs; + ChannelFilter filter; + + printf("═══════════════════════════════════════\n"); + printf(" MeshCore Filter Engine — Interactive Shell\n"); + printf(" (rules are not persisted between sessions)\n"); + printf("═══════════════════════════════════════\n"); + print_help(); + + char line[256]; + while (true) { + printf("> "); + fflush(stdout); + + if (!fgets(line, sizeof(line), stdin)) break; + + // Strip trailing newline + size_t len = strlen(line); + while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r')) { + line[--len] = '\0'; + } + + if (len == 0) continue; + + if (strcmp(line, "quit") == 0 || strcmp(line, "exit") == 0) { + printf("Bye.\n"); + break; + } + + if (strcmp(line, "help") == 0) { + print_help(); + continue; + } + + if (strncmp(line, "eval", 4) == 0) { + handle_eval(line + 4, filter); + continue; + } + + if (strncmp(line, "filter", 6) == 0 && + (line[6] == ' ' || line[6] == '\0')) { + char reply[160]; + const char* args = (line[6] == ' ') ? line + 7 : ""; + filter.handleCommand(args, reply, fs); + printf(" -> %s\n", reply); + continue; + } + + printf(" Unknown command. Type 'help' for usage.\n"); + } + + return 0; +} From 78bd277ecb3b055b4ca4b2be84c44b81204af9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gjels=C3=B8?= <36234524+gjelsoe@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:31:51 +0200 Subject: [PATCH 2/3] Location update Moves location for Filter Test Suite. --- .../{simple_repeater => }/filter test suite/ChannelFilter.cpp | 0 examples/{simple_repeater => }/filter test suite/ChannelFilter.h | 0 examples/{simple_repeater => }/filter test suite/FilterParser.cpp | 0 examples/{simple_repeater => }/filter test suite/FilterParser.h | 0 examples/{simple_repeater => }/filter test suite/FilterRule.h | 0 examples/{simple_repeater => }/filter test suite/Makefile | 0 examples/{simple_repeater => }/filter test suite/auto_test.cpp | 0 examples/{simple_repeater => }/filter test suite/mock_mesh.h | 0 examples/{simple_repeater => }/filter test suite/readme.md | 0 examples/{simple_repeater => }/filter test suite/shell.cpp | 0 10 files changed, 0 insertions(+), 0 deletions(-) rename examples/{simple_repeater => }/filter test suite/ChannelFilter.cpp (100%) rename examples/{simple_repeater => }/filter test suite/ChannelFilter.h (100%) rename examples/{simple_repeater => }/filter test suite/FilterParser.cpp (100%) rename examples/{simple_repeater => }/filter test suite/FilterParser.h (100%) rename examples/{simple_repeater => }/filter test suite/FilterRule.h (100%) rename examples/{simple_repeater => }/filter test suite/Makefile (100%) rename examples/{simple_repeater => }/filter test suite/auto_test.cpp (100%) rename examples/{simple_repeater => }/filter test suite/mock_mesh.h (100%) rename examples/{simple_repeater => }/filter test suite/readme.md (100%) rename examples/{simple_repeater => }/filter test suite/shell.cpp (100%) diff --git a/examples/simple_repeater/filter test suite/ChannelFilter.cpp b/examples/filter test suite/ChannelFilter.cpp similarity index 100% rename from examples/simple_repeater/filter test suite/ChannelFilter.cpp rename to examples/filter test suite/ChannelFilter.cpp diff --git a/examples/simple_repeater/filter test suite/ChannelFilter.h b/examples/filter test suite/ChannelFilter.h similarity index 100% rename from examples/simple_repeater/filter test suite/ChannelFilter.h rename to examples/filter test suite/ChannelFilter.h diff --git a/examples/simple_repeater/filter test suite/FilterParser.cpp b/examples/filter test suite/FilterParser.cpp similarity index 100% rename from examples/simple_repeater/filter test suite/FilterParser.cpp rename to examples/filter test suite/FilterParser.cpp diff --git a/examples/simple_repeater/filter test suite/FilterParser.h b/examples/filter test suite/FilterParser.h similarity index 100% rename from examples/simple_repeater/filter test suite/FilterParser.h rename to examples/filter test suite/FilterParser.h diff --git a/examples/simple_repeater/filter test suite/FilterRule.h b/examples/filter test suite/FilterRule.h similarity index 100% rename from examples/simple_repeater/filter test suite/FilterRule.h rename to examples/filter test suite/FilterRule.h diff --git a/examples/simple_repeater/filter test suite/Makefile b/examples/filter test suite/Makefile similarity index 100% rename from examples/simple_repeater/filter test suite/Makefile rename to examples/filter test suite/Makefile diff --git a/examples/simple_repeater/filter test suite/auto_test.cpp b/examples/filter test suite/auto_test.cpp similarity index 100% rename from examples/simple_repeater/filter test suite/auto_test.cpp rename to examples/filter test suite/auto_test.cpp diff --git a/examples/simple_repeater/filter test suite/mock_mesh.h b/examples/filter test suite/mock_mesh.h similarity index 100% rename from examples/simple_repeater/filter test suite/mock_mesh.h rename to examples/filter test suite/mock_mesh.h diff --git a/examples/simple_repeater/filter test suite/readme.md b/examples/filter test suite/readme.md similarity index 100% rename from examples/simple_repeater/filter test suite/readme.md rename to examples/filter test suite/readme.md diff --git a/examples/simple_repeater/filter test suite/shell.cpp b/examples/filter test suite/shell.cpp similarity index 100% rename from examples/simple_repeater/filter test suite/shell.cpp rename to examples/filter test suite/shell.cpp From 5efd5048ec7035b233101042d5f56516d792d0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gjels=C3=B8?= <36234524+gjelsoe@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:58:39 +0200 Subject: [PATCH 3/3] Misc changes Formatting. Different output from over Serial vs LoRa, no need for compact view over Serial. --- examples/simple_repeater/ChannelFilter.cpp | 691 +++++++++++---------- examples/simple_repeater/ChannelFilter.h | 4 +- examples/simple_repeater/MyMesh.cpp | 2 +- 3 files changed, 376 insertions(+), 321 deletions(-) diff --git a/examples/simple_repeater/ChannelFilter.cpp b/examples/simple_repeater/ChannelFilter.cpp index 8fda074acc..704d3f3e74 100644 --- a/examples/simple_repeater/ChannelFilter.cpp +++ b/examples/simple_repeater/ChannelFilter.cpp @@ -10,55 +10,58 @@ // --------------------------------------------------------------------------- ChannelFilter::ChannelFilter() { - memset(_rules, 0, sizeof(_rules)); - _mode = FilterMode::ALLOW; // safe default: pass all packets if no rules loaded + memset(_rules, 0, sizeof(_rules)); + _mode = FilterMode::ALLOW; // safe default: pass all packets if no rules loaded } // --------------------------------------------------------------------------- // Load / Save // --------------------------------------------------------------------------- -void ChannelFilter::load(FILESYSTEM& fs) { +void ChannelFilter::load(FILESYSTEM &fs) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - File f = fs.open(FILTER_RULES_FILE, FILE_O_READ); + File f = fs.open(FILTER_RULES_FILE, FILE_O_READ); #elif defined(RP2040_PLATFORM) - File f = fs.open(FILTER_RULES_FILE, "r"); + File f = fs.open(FILTER_RULES_FILE, "r"); #else - File f = fs.open(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE); #endif - if (!f) return; + if (!f) return; - uint8_t mode_byte; - if (f.read(&mode_byte, 1) != 1) { f.close(); return; } - // Validate mode byte — default to ALLOW if file is corrupt - _mode = (mode_byte <= (uint8_t)FilterMode::DROP) + uint8_t mode_byte; + if (f.read(&mode_byte, 1) != 1) { + f.close(); + return; + } + // Validate mode byte — default to ALLOW if file is corrupt + _mode = (mode_byte <= (uint8_t)FilterMode::DROP) ? (FilterMode)mode_byte : FilterMode::ALLOW; - // Check read length — if truncated, zero remaining slots (in_use=false = harmless) - size_t bytes_read = f.read((uint8_t*)_rules, sizeof(_rules)); - if (bytes_read < sizeof(_rules)) { - memset((uint8_t*)_rules + bytes_read, 0, sizeof(_rules) - bytes_read); - } - f.close(); + // Check read length — if truncated, zero remaining slots (in_use=false = harmless) + size_t bytes_read = f.read((uint8_t *)_rules, sizeof(_rules)); + if (bytes_read < sizeof(_rules)) { + memset((uint8_t *)_rules + bytes_read, 0, sizeof(_rules) - bytes_read); + } + f.close(); } -void ChannelFilter::save(FILESYSTEM& fs) const { +void ChannelFilter::save(FILESYSTEM &fs) const { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - fs.remove(FILTER_RULES_FILE); - File f = fs.open(FILTER_RULES_FILE, FILE_O_WRITE); + fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, FILE_O_WRITE); #elif defined(RP2040_PLATFORM) - File f = fs.open(FILTER_RULES_FILE, "w"); + File f = fs.open(FILTER_RULES_FILE, "w"); #else - if (fs.exists(FILTER_RULES_FILE)) fs.remove(FILTER_RULES_FILE); - File f = fs.open(FILTER_RULES_FILE, "w"); + if (fs.exists(FILTER_RULES_FILE)) fs.remove(FILTER_RULES_FILE); + File f = fs.open(FILTER_RULES_FILE, "w"); #endif - if (!f) return; + if (!f) return; - uint8_t mode_byte = (uint8_t)_mode; - f.write(&mode_byte, 1); - f.write((const uint8_t*)_rules, sizeof(_rules)); - f.close(); + uint8_t mode_byte = (uint8_t)_mode; + f.write(&mode_byte, 1); + f.write((const uint8_t *)_rules, sizeof(_rules)); + f.close(); } // --------------------------------------------------------------------------- @@ -66,105 +69,110 @@ void ChannelFilter::save(FILESYSTEM& fs) const { // --------------------------------------------------------------------------- static bool applyOp(FilterOp op, int16_t pkt_val, int16_t rule_val) { - switch (op) { - case FilterOp::EQ: return pkt_val == rule_val; - case FilterOp::NEQ: return pkt_val != rule_val; - case FilterOp::GT: return pkt_val > rule_val; - case FilterOp::LT: return pkt_val < rule_val; - default: return false; - } + switch (op) { + case FilterOp::EQ: + return pkt_val == rule_val; + case FilterOp::NEQ: + return pkt_val != rule_val; + case FilterOp::GT: + return pkt_val > rule_val; + case FilterOp::LT: + return pkt_val < rule_val; + default: + return false; + } } -bool ChannelFilter::_ruleMatches(const FilterRule& rule, const mesh::Packet* pkt, int16_t rssi) const { - // PATH field has its own OR-list logic — handle separately - if (rule.field == FilterField::PATH) { - uint8_t hash_size = pkt->getPathHashSize(); - uint8_t hash_count = pkt->getPathHashCount(); - - if (hash_count == 0) return false; - if (hash_size != rule.path_hash_len) return false; - - uint16_t last_hop_offset = (uint16_t)(hash_count - 1) * hash_size; - if (last_hop_offset + hash_size > MAX_PATH_SIZE) return false; - - const uint8_t* last_hop = pkt->path + last_hop_offset; - bool found = false; - for (uint8_t i = 0; i < rule.path_hash_count; i++) { - if (memcmp(rule.path_hashes[i], last_hop, hash_size) == 0) { - found = true; - break; - } - } - - bool primary_match = (rule.op == FilterOp::EQ) ? found : !found; - if (!primary_match) return false; - - // AND condition (PATH as primary can still have a scalar AND) - if (rule.and_field != FILTER_FIELD_NONE) { - if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) - return false; - } - return true; +bool ChannelFilter::_ruleMatches(const FilterRule &rule, const mesh::Packet *pkt, int16_t rssi) const { + // PATH field has its own OR-list logic — handle separately + if (rule.field == FilterField::PATH) { + uint8_t hash_size = pkt->getPathHashSize(); + uint8_t hash_count = pkt->getPathHashCount(); + + if (hash_count == 0) return false; + if (hash_size != rule.path_hash_len) return false; + + uint16_t last_hop_offset = (uint16_t)(hash_count - 1) * hash_size; + if (last_hop_offset + hash_size > MAX_PATH_SIZE) return false; + + const uint8_t *last_hop = pkt->path + last_hop_offset; + bool found = false; + for (uint8_t i = 0; i < rule.path_hash_count; i++) { + if (memcmp(rule.path_hashes[i], last_hop, hash_size) == 0) { + found = true; + break; + } } - // Scalar primary condition - if (!_evalScalar(rule.field, rule.op, rule.value, pkt, rssi)) return false; + bool primary_match = (rule.op == FilterOp::EQ) ? found : !found; + if (!primary_match) return false; - // AND condition if present + // AND condition (PATH as primary can still have a scalar AND) if (rule.and_field != FILTER_FIELD_NONE) { - if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) return false; + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) + return false; } - return true; + } + + // Scalar primary condition + if (!_evalScalar(rule.field, rule.op, rule.value, pkt, rssi)) return false; + + // AND condition if present + if (rule.and_field != FILTER_FIELD_NONE) { + if (!_evalScalar((FilterField)rule.and_field, rule.and_op, rule.and_value, pkt, rssi)) return false; + } + + return true; } // Evaluate a single scalar condition against a packet. // PATH field is not handled here — it has its own block in the switch above. bool ChannelFilter::_evalScalar(FilterField field, FilterOp op, int16_t val, - const mesh::Packet* pkt, int16_t rssi) const { - switch (field) { - case FilterField::ROUTE: - return applyOp(op, (int16_t)pkt->getRouteType(), val); + const mesh::Packet *pkt, int16_t rssi) const { + switch (field) { + case FilterField::ROUTE: + return applyOp(op, (int16_t)pkt->getRouteType(), val); - case FilterField::TYPE: - return applyOp(op, (int16_t)pkt->getPayloadType(), val); + case FilterField::TYPE: + return applyOp(op, (int16_t)pkt->getPayloadType(), val); - case FilterField::HOPS: - return applyOp(op, (int16_t)pkt->getPathHashCount(), val); + case FilterField::HOPS: + return applyOp(op, (int16_t)pkt->getPathHashCount(), val); - case FilterField::PATHSIZE: - return applyOp(op, (int16_t)pkt->getPathHashSize(), val); + case FilterField::PATHSIZE: + return applyOp(op, (int16_t)pkt->getPathHashSize(), val); - case FilterField::CHANNEL: { - uint8_t pt = pkt->getPayloadType(); - if (pt != 0x05 && pt != 0x06) return false; - if (pkt->payload_len < 1) return false; - return applyOp(op, (int16_t)pkt->payload[0], val); - } + case FilterField::CHANNEL: { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; + if (pkt->payload_len < 1) return false; + return applyOp(op, (int16_t)pkt->payload[0], val); + } - case FilterField::SNR: - return applyOp(op, (int16_t)pkt->_snr, val); + case FilterField::SNR: + return applyOp(op, (int16_t)pkt->_snr, val); - case FilterField::RSSI: - return applyOp(op, rssi, val); + case FilterField::RSSI: + return applyOp(op, rssi, val); - default: - return false; - } + default: + return false; + } } -bool ChannelFilter::evaluate(const mesh::Packet* pkt, int16_t rssi) const { - if (!pkt) return false; // null guard — pass unknown packets rather than crash - for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { - const FilterRule& rule = _rules[i]; - if (!rule.in_use || !rule.enabled) continue; +bool ChannelFilter::evaluate(const mesh::Packet *pkt, int16_t rssi) const { + if (!pkt) return false; // null guard — pass unknown packets rather than crash + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + const FilterRule &rule = _rules[i]; + if (!rule.in_use || !rule.enabled) continue; - if (_ruleMatches(rule, pkt, rssi)) { - return rule.action == FilterAction::DROP; - } + if (_ruleMatches(rule, pkt, rssi)) { + return rule.action == FilterAction::DROP; } - // No rule matched — apply default policy - return _mode == FilterMode::DROP; + } + // No rule matched — apply default policy + return _mode == FilterMode::DROP; } // --------------------------------------------------------------------------- @@ -172,10 +180,10 @@ bool ChannelFilter::evaluate(const mesh::Packet* pkt, int16_t rssi) const { // --------------------------------------------------------------------------- int ChannelFilter::_firstFreeSlot() const { - for (int i = 0; i < MAX_FILTER_RULES; i++) { - if (!_rules[i].in_use) return i; - } - return -1; + for (int i = 0; i < MAX_FILTER_RULES; i++) { + if (!_rules[i].in_use) return i; + } + return -1; } // --------------------------------------------------------------------------- @@ -183,84 +191,117 @@ int ChannelFilter::_firstFreeSlot() const { // --------------------------------------------------------------------------- // Return a short token string for a FilterField value -static const char* fieldStr(FilterField f) { - switch (f) { - case FilterField::ROUTE: return "route"; - case FilterField::TYPE: return "payload"; - case FilterField::HOPS: return "hops"; - case FilterField::PATHSIZE: return "pathsize"; - case FilterField::PATH: return "path"; - case FilterField::CHANNEL: return "channel"; - case FilterField::SNR: return "snr"; - case FilterField::RSSI: return "rssi"; - default: return "?"; - } +static const char *fieldStr(FilterField f) { + switch (f) { + case FilterField::ROUTE: + return "route"; + case FilterField::TYPE: + return "payload"; + case FilterField::HOPS: + return "hops"; + case FilterField::PATHSIZE: + return "pathsize"; + case FilterField::PATH: + return "path"; + case FilterField::CHANNEL: + return "channel"; + case FilterField::SNR: + return "snr"; + case FilterField::RSSI: + return "rssi"; + default: + return "?"; + } } -static const char* opStr(FilterOp op) { - switch (op) { - case FilterOp::EQ: return "eq"; - case FilterOp::NEQ: return "neq"; - case FilterOp::GT: return "gt"; - case FilterOp::LT: return "lt"; - default: return "?"; - } +static const char *opStr(FilterOp op) { + switch (op) { + case FilterOp::EQ: + return "eq"; + case FilterOp::NEQ: + return "neq"; + case FilterOp::GT: + return "gt"; + case FilterOp::LT: + return "lt"; + default: + return "?"; + } } // Translate ROUTE_TYPE_* numeric value to token string -static const char* routeValueStr(int16_t v) { - switch (v) { - case 0x00: return "tflood"; - case 0x01: return "flood"; - case 0x02: return "direct"; - case 0x03: return "tdirect"; - default: return "?"; - } +static const char *routeValueStr(int16_t v) { + switch (v) { + case 0x00: + return "tflood"; + case 0x01: + return "flood"; + case 0x02: + return "direct"; + case 0x03: + return "tdirect"; + default: + return "?"; + } } // Translate PAYLOAD_TYPE_* numeric value to token string -static const char* payloadTypeValueStr(int16_t v) { - switch (v) { - case 0x00: return "req"; - case 0x01: return "resp"; - case 0x02: return "txt"; - case 0x03: return "ack"; - case 0x04: return "advert"; - case 0x05: return "grptxt"; - case 0x06: return "grpdata"; - case 0x07: return "anonreq"; - case 0x08: return "path"; - case 0x09: return "trace"; - case 0x0A: return "multi"; - case 0x0B: return "ctrl"; - case 0x0F: return "raw"; - default: return "?"; - } +static const char *payloadTypeValueStr(int16_t v) { + switch (v) { + case 0x00: + return "req"; + case 0x01: + return "resp"; + case 0x02: + return "txt"; + case 0x03: + return "ack"; + case 0x04: + return "advert"; + case 0x05: + return "grptxt"; + case 0x06: + return "grpdata"; + case 0x07: + return "anonreq"; + case 0x08: + return "path"; + case 0x09: + return "trace"; + case 0x0A: + return "multi"; + case 0x0B: + return "ctrl"; + case 0x0F: + return "raw"; + default: + return "?"; + } } -static void formatRuleValue(const FilterRule& rule, char* out, int outlen) { - if (outlen <= 0) return; - if (rule.field == FilterField::PATH) { - int pos = 0; - for (uint8_t i = 0; i < rule.path_hash_count && pos < outlen - 1; i++) { - if (i > 0 && pos < outlen - 2) out[pos++] = ' '; - for (uint8_t b = 0; b < rule.path_hash_len && pos < outlen - 3; b++) { - pos += snprintf(out + pos, outlen - pos, "%02X", rule.path_hashes[i][b]); - } - } - out[pos] = '\0'; - } else if (rule.field == FilterField::ROUTE) { - snprintf(out, outlen, "%s", routeValueStr(rule.value)); - } else if (rule.field == FilterField::TYPE) { - snprintf(out, outlen, "%s", payloadTypeValueStr(rule.value)); - } else if (rule.field == FilterField::CHANNEL) { - snprintf(out, outlen, "0x%02X", (uint8_t)rule.value); - } else if (rule.field == FilterField::SNR) { - // Convert stored quarter-dB back to whole dB for display - snprintf(out, outlen, "%d", (int)(rule.value / 4)); - } else { - snprintf(out, outlen, "%d", (int)rule.value); +static void formatRuleValue(const FilterRule &rule, char *out, int outlen) { + if (outlen <= 0) return; + if (rule.field == FilterField::PATH) { + int pos = 0; + for (uint8_t i = 0; i < rule.path_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + for (uint8_t b = 0; b < rule.path_hash_len && pos < outlen - 3; b++) { + pos += snprintf(out + pos, outlen - pos, "%02X", rule.path_hashes[i][b]); + } } + out[pos] = '\0'; + } else if (rule.field == FilterField::ROUTE) { + snprintf(out, outlen, "%s", routeValueStr(rule.value)); + } else if (rule.field == FilterField::TYPE) { + snprintf(out, outlen, "%s", payloadTypeValueStr(rule.value)); + } else if (rule.field == FilterField::CHANNEL) { + snprintf(out, outlen, "0x%02X", (uint8_t)rule.value); + } else if (rule.field == FilterField::SNR) { + // Convert stored quarter-dB back to whole dB for display + snprintf(out, outlen, "%d", (int)(rule.value / 4)); + } else { + snprintf(out, outlen, "%d", (int)rule.value); + } } // Maximum reply length — stay safely below the 138-char packet limit @@ -270,194 +311,208 @@ static void formatRuleValue(const FilterRule& rule, char* out, int outlen) { #define FILTER_REPLY_HINT_LEN 18 // "-> filter list N\0" // Format a single rule line into buf (null-terminated). Returns number of chars written. -static int formatRuleLine(const FilterRule& rule, uint8_t idx, char* buf, int buflen) { - char val_buf[32]; - formatRuleValue(rule, val_buf, (int)sizeof(val_buf)); - - char and_buf[48] = ""; - if (rule.and_field != FILTER_FIELD_NONE) { - char and_val_buf[32]; - FilterField af = (FilterField)rule.and_field; - if (af == FilterField::ROUTE) { - snprintf(and_val_buf, sizeof(and_val_buf), "%s", routeValueStr(rule.and_value)); - } else if (af == FilterField::TYPE) { - snprintf(and_val_buf, sizeof(and_val_buf), "%s", payloadTypeValueStr(rule.and_value)); - } else if (af == FilterField::CHANNEL) { - snprintf(and_val_buf, sizeof(and_val_buf), "0x%02X", (uint8_t)rule.and_value); - } else if (af == FilterField::SNR) { - snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)(rule.and_value / 4)); - } else { - snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)rule.and_value); - } - snprintf(and_buf, sizeof(and_buf), " and %s %s %s", - fieldStr(af), opStr(rule.and_op), and_val_buf); +static int formatRuleLine(const FilterRule &rule, uint8_t idx, char *buf, int buflen) { + char val_buf[32]; + formatRuleValue(rule, val_buf, (int)sizeof(val_buf)); + + char and_buf[48] = ""; + if (rule.and_field != FILTER_FIELD_NONE) { + char and_val_buf[32]; + FilterField af = (FilterField)rule.and_field; + if (af == FilterField::ROUTE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", routeValueStr(rule.and_value)); + } else if (af == FilterField::TYPE) { + snprintf(and_val_buf, sizeof(and_val_buf), "%s", payloadTypeValueStr(rule.and_value)); + } else if (af == FilterField::CHANNEL) { + snprintf(and_val_buf, sizeof(and_val_buf), "0x%02X", (uint8_t)rule.and_value); + } else if (af == FilterField::SNR) { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)(rule.and_value / 4)); + } else { + snprintf(and_val_buf, sizeof(and_val_buf), "%d", (int)rule.and_value); } - - return snprintf(buf, buflen, "%d%s %s %s %s %s%s\n", - idx, - rule.enabled ? "" : "*", - rule.action == FilterAction::DROP ? "drop" : "allow", - fieldStr(rule.field), - opStr(rule.op), - val_buf, - and_buf - ); + snprintf(and_buf, sizeof(and_buf), " and %s %s %s", + fieldStr(af), opStr(rule.and_op), and_val_buf); + } + + return snprintf(buf, buflen, "%d%s %s %s %s %s%s\n", + idx, + rule.enabled ? "" : "*", + rule.action == FilterAction::DROP ? "drop" : "allow", + fieldStr(rule.field), + opStr(rule.op), + val_buf, + and_buf + ); } -void ChannelFilter::_listRules(char* reply, uint8_t page) const { - // Count in-use rules and collect their indexes - uint8_t indexes[MAX_FILTER_RULES]; - uint8_t total = 0; - for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { - if (_rules[i].in_use) indexes[total++] = i; - } +void ChannelFilter::_listRules(char *reply, uint8_t page, bool is_remote) const { + // Count in-use rules and collect their indexes + uint8_t indexes[MAX_FILTER_RULES]; + uint8_t total = 0; + for (uint8_t i = 0; i < MAX_FILTER_RULES; i++) { + if (_rules[i].in_use) indexes[total++] = i; + } - const char* mode_str = (_mode == FilterMode::DROP) ? "drop" : "allow"; + const char *mode_str = (_mode == FilterMode::DROP) ? "drop" : "allow"; + if (!is_remote) { + Serial.printf("mode:%s rules:%d/%d\n", mode_str, total, MAX_FILTER_RULES); + if (total > 0) { + for (uint8_t i = 0; i < total; i++) { + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + Serial.printf("%s", line_buf); + } + } + reply[0] = '\0'; + return; + } else { // Pre-scan: determine page boundaries dynamically based on actual line lengths. // Each page gets as many rules as fit within FILTER_REPLY_BUDGET minus header and hint. - uint8_t page_start[MAX_FILTER_RULES + 1]; // start index into indexes[] for each page + uint8_t page_start[MAX_FILTER_RULES + 1]; // start index into indexes[] for each page uint8_t num_pages = 0; page_start[0] = 0; { - uint8_t i = 0; - while (i < total) { - // Available budget for rule lines on this page - int budget = FILTER_REPLY_BUDGET - FILTER_REPLY_HEADER_MAX - FILTER_REPLY_HINT_LEN; - uint8_t page_end = i; - - while (page_end < total) { - char line_buf[80]; - int line_len = formatRuleLine(_rules[indexes[page_end]], indexes[page_end], - line_buf, sizeof(line_buf)); - if (budget - line_len < 0) break; // doesn't fit - budget -= line_len; - page_end++; - } - - // Safety: always advance at least one rule to avoid infinite loop - if (page_end == i) page_end = i + 1; - - num_pages++; - i = page_end; - page_start[num_pages] = i; + uint8_t i = 0; + while (i < total) { + // Available budget for rule lines on this page + int budget = FILTER_REPLY_BUDGET - FILTER_REPLY_HEADER_MAX - FILTER_REPLY_HINT_LEN; + uint8_t page_end = i; + + while (page_end < total) { + char line_buf[80]; + int line_len = formatRuleLine(_rules[indexes[page_end]], indexes[page_end], + line_buf, sizeof(line_buf)); + if (budget - line_len < 0) break; // doesn't fit + budget -= line_len; + page_end++; } + + // Safety: always advance at least one rule to avoid infinite loop + if (page_end == i) page_end = i + 1; + + num_pages++; + i = page_end; + page_start[num_pages] = i; + } } if (total == 0) num_pages = 1; if (page >= num_pages) { - snprintf(reply, 80, "Err - page %d out of range (0-%d)", page, num_pages - 1); - return; + snprintf(reply, 80, "Err - page %d out of range (0-%d)", page, num_pages - 1); + return; } // Write header int pos; if (num_pages > 1) { - pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d p%d/%d\n", - mode_str, total, MAX_FILTER_RULES, page + 1, num_pages); + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d p%d/%d\n", + mode_str, total, MAX_FILTER_RULES, page + 1, num_pages); } else { - pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d\n", - mode_str, total, MAX_FILTER_RULES); + pos = snprintf(reply, FILTER_REPLY_BUDGET, "mode:%s rules:%d/%d\n", + mode_str, total, MAX_FILTER_RULES); } if (total == 0) { - snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "(no rules)"); - return; + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "(no rules)"); + return; } // Write rule lines for this page uint8_t start = page_start[page]; - uint8_t end = page_start[page + 1]; + uint8_t end = page_start[page + 1]; for (uint8_t i = start; i < end; i++) { - char line_buf[80]; - formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); - pos += snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "%s", line_buf); + char line_buf[80]; + formatRuleLine(_rules[indexes[i]], indexes[i], line_buf, sizeof(line_buf)); + pos += snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "%s", line_buf); } // Hint if more pages follow if (page + 1 < num_pages) { - snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "-> filter list %d", page + 1); + snprintf(reply + pos, FILTER_REPLY_BUDGET - pos, "-> filter list %d", page + 1); } + } } // --------------------------------------------------------------------------- // CLI dispatch // --------------------------------------------------------------------------- -void ChannelFilter::handleCommand(const char* args, char* reply, FILESYSTEM& fs) { - FilterParseResult res = parseFilterCommand(args); - - if (res.error != FilterParseError::OK) { - snprintf(reply, 80, "%s", filterParseErrorStr(res.error)); - return; +void ChannelFilter::handleCommand(const char *args, char *reply, FILESYSTEM &fs, ClientInfo *sender) { + bool is_remote = (sender != NULL); // Check if commands are from Serial (ClientInfo = NULL). + FilterParseResult res = parseFilterCommand(args); + + if (res.error != FilterParseError::OK) { + snprintf(reply, 80, "%s", filterParseErrorStr(res.error)); + return; + } + + switch (res.command) { + case FilterCommand::ADD: { + int slot = _firstFreeSlot(); + if (slot < 0) { + snprintf(reply, 80, "Err - rules full (max %d)", MAX_FILTER_RULES); + return; } + _rules[slot] = res.rule; + save(fs); + snprintf(reply, 80, "OK - rule %d added", slot); + break; + } + + case FilterCommand::DEL: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + memset(&_rules[id], 0, sizeof(FilterRule)); + save(fs); + snprintf(reply, 80, "OK - rule %d deleted", id); + break; + } + + case FilterCommand::DISABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = false; + save(fs); + snprintf(reply, 80, "OK - rule %d disabled", id); + break; + } + + case FilterCommand::ENABLE: { + uint8_t id = res.rule_id; + if (!_rules[id].in_use) { + snprintf(reply, 80, "Err - rule %d not in use", id); + return; + } + _rules[id].enabled = true; + save(fs); + snprintf(reply, 80, "OK - rule %d enabled", id); + break; + } - switch (res.command) { - case FilterCommand::ADD: { - int slot = _firstFreeSlot(); - if (slot < 0) { - snprintf(reply, 80, "Err - rules full (max %d)", MAX_FILTER_RULES); - return; - } - _rules[slot] = res.rule; - save(fs); - snprintf(reply, 80, "OK - rule %d added", slot); - break; - } - - case FilterCommand::DEL: { - uint8_t id = res.rule_id; - if (!_rules[id].in_use) { - snprintf(reply, 80, "Err - rule %d not in use", id); - return; - } - memset(&_rules[id], 0, sizeof(FilterRule)); - save(fs); - snprintf(reply, 80, "OK - rule %d deleted", id); - break; - } - - case FilterCommand::DISABLE: { - uint8_t id = res.rule_id; - if (!_rules[id].in_use) { - snprintf(reply, 80, "Err - rule %d not in use", id); - return; - } - _rules[id].enabled = false; - save(fs); - snprintf(reply, 80, "OK - rule %d disabled", id); - break; - } - - case FilterCommand::ENABLE: { - uint8_t id = res.rule_id; - if (!_rules[id].in_use) { - snprintf(reply, 80, "Err - rule %d not in use", id); - return; - } - _rules[id].enabled = true; - save(fs); - snprintf(reply, 80, "OK - rule %d enabled", id); - break; - } + case FilterCommand::LIST: + _listRules(reply, res.rule_id, is_remote); + break; - case FilterCommand::LIST: - _listRules(reply, res.rule_id); - break; - - case FilterCommand::CLEAR: - memset(_rules, 0, sizeof(_rules)); - save(fs); - snprintf(reply, 80, "OK - all rules cleared"); - break; - - case FilterCommand::MODE: - _mode = res.mode; - save(fs); - snprintf(reply, 80, "OK - mode: %s", res.mode == FilterMode::DROP ? "drop" : "allow"); - break; - } + case FilterCommand::CLEAR: + memset(_rules, 0, sizeof(_rules)); + save(fs); + snprintf(reply, 80, "OK - all rules cleared"); + break; + + case FilterCommand::MODE: + _mode = res.mode; + save(fs); + snprintf(reply, 80, "OK - mode: %s", res.mode == FilterMode::DROP ? "drop" : "allow"); + break; + } } \ No newline at end of file diff --git a/examples/simple_repeater/ChannelFilter.h b/examples/simple_repeater/ChannelFilter.h index f38a2cedae..b9ff10523d 100644 --- a/examples/simple_repeater/ChannelFilter.h +++ b/examples/simple_repeater/ChannelFilter.h @@ -38,7 +38,7 @@ class ChannelFilter { // Handle a "filter ..." command string (everything after "filter "). // Writes a human-readable result into 'reply' (assumed >= 80 bytes). - void handleCommand(const char* args, char* reply, FILESYSTEM& fs); + void handleCommand(const char* args, char* reply, FILESYSTEM& fs, ClientInfo* sender); private: FilterRule _rules[MAX_FILTER_RULES]; @@ -56,5 +56,5 @@ class ChannelFilter { const mesh::Packet* pkt, int16_t rssi) const; // --- list command ------------------------------------------------------- - void _listRules(char* reply, uint8_t page) const; + void _listRules(char* reply, uint8_t page, bool remote) const; }; \ No newline at end of file diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e8b8d65d87..5af26a0922 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1398,7 +1398,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } } else if (strncmp(command, "filter", 6) == 0 && (command[6] == ' ' || command[6] == '\0')) { const char* filter_args = (command[6] == ' ') ? command + 7 : ""; - _filter.handleCommand(filter_args, reply, *_fs); + _filter.handleCommand(filter_args, reply, *_fs, sender); } else { _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands }