From 9844b541b6e37b9fbcbe789f28590fede7c72515 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Thu, 26 Feb 2026 20:37:27 -0800 Subject: [PATCH 01/18] fix: JSON parsing regression in build --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 313c4c47a0..23e31ab25c 100755 --- a/build.sh +++ b/build.sh @@ -93,7 +93,7 @@ get_pio_envs_ending_with_string() { # $1 should be the environment name get_platform_for_env() { local env_name=$1 - echo "$PIO_CONFIG_JSON" | python3 -c " + printf '%s' "$PIO_CONFIG_JSON" | python3 -c " import sys, json, re data = json.load(sys.stdin) for section, options in data: From dea5ed790fe2166f7248da97270f186c1f05e752 Mon Sep 17 00:00:00 2001 From: Nick Le Mouton Date: Fri, 5 Jun 2026 21:25:25 +1200 Subject: [PATCH 02/18] Add SECURITY.md --- SECURITY.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..a4b2207d55 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +## Supported Versions + +Security fixes are applied to the latest release only. We do not backport +fixes to older versions. + +| Version | Supported | +|---------|-----------| +| 1.15+ | ✅ | +| <1.15 | ❌ | + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Use GitHub's private vulnerability reporting instead: +1. Go to the **Security** tab of this repository +2. Click **Report a vulnerability** +3. Fill in the details and submit + +### What to include + +A useful report tells us: +- Which component or file is affected +- What an attacker can do (impact) and under what conditions +- A minimal reproduction case or proof-of-concept if you have one +- Whether you believe it is remotely exploitable + +You do not need a working exploit to report. An incomplete report is better +than no report. + +## What to expect + +This is a volunteer-maintained open-source project. We will do our best to +respond in a reasonable timeframe, but cannot commit to specific deadlines. + +We ask that you give us a fair opportunity to investigate and address the +issue before any public disclosure. If you have not heard back after +**90 days**, feel free to follow up or proceed with disclosure at your +discretion. + +## Scope + +In scope: +- Remote code execution, memory corruption, or denial-of-service via crafted + radio packets +- Authentication or encryption bypasses +- Vulnerabilities in the packet routing or path handling logic + +Out of scope: +- Physical access attacks (e.g., JTAG, UART extraction of keys) +- Regulatory compliance (duty cycle, frequency restrictions) +- Jamming or other physical-layer radio interference +- Issues in third-party libraries (RadioLib, Crypto, etc.) — report those + upstream +- "Best practice" suggestions without a demonstrated attack path From c94ed29ca36e73d9026319af18b8977648ec8809 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Fri, 12 Jun 2026 03:18:10 +1200 Subject: [PATCH 03/18] add github workflow to close stale issues --- .github/workflows/stale-bot.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/stale-bot.yml diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 0000000000..afe874f801 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,32 @@ +name: 'Run Stale Bot' +on: + schedule: + - cron: '30 1 * * *' # daily at 1:30am + workflow_dispatch: {} + +permissions: + actions: write + issues: write + pull-requests: write + +jobs: + close-issues: + # only run on main repo, not forks + if: github.repository == "meshcore-dev/MeshCore" + runs-on: ubuntu-latest + steps: + - name: Close Stale Issues + uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # auto close issues + days-before-issue-stale: 60 + days-before-issue-close: 7 + exempt-issue-labels: "keep-open" + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for 60 days with no activity. Remove the stale label or add a comment if this issue is still relevant, otherwise this issue will automatically close in 7 days." + close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." + # don't auto close prs + days-before-pr-stale: -1 + days-before-pr-close: -1 + \ No newline at end of file From 3b3992539945b861801ab709b2361cb03edee657 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Fri, 12 Jun 2026 03:20:44 +1200 Subject: [PATCH 04/18] use single quotes for repo name --- .github/workflows/stale-bot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index afe874f801..ec16658756 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -12,7 +12,7 @@ permissions: jobs: close-issues: # only run on main repo, not forks - if: github.repository == "meshcore-dev/MeshCore" + if: github.repository == 'meshcore-dev/MeshCore' runs-on: ubuntu-latest steps: - name: Close Stale Issues From 06130dce29c907ebc014dc52156fbb1e52210903 Mon Sep 17 00:00:00 2001 From: formtapez Date: Fri, 12 Jun 2026 12:11:12 +0200 Subject: [PATCH 05/18] added some missing CLI commands --- docs/cli_commands.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9accb2998d..f482cfcb68 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -28,12 +28,25 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Usage:** - `reboot` +**Note:** No reply is sent. + +--- + +### Power-off the node +**Usage:** +- `poweroff`, or +- `shutdown +` +**Note:** No reply is sent. + --- ### Reset the clock and reboot **Usage:** - `clkreboot` +**Note:** No reply is sent. + --- ### Sync the clock with the remote device @@ -632,10 +645,21 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `value`: Maximum flood hop count (0-64) for a packet without a scope (no region set) -**Default:** `0xFF` - indicates it hasn't been set, will track flood.max until it is. +**Default:** `64` - indicates it hasn't been set, will track flood.max until it is. **Note:** An alternative to `region denyf *`, setting `flood.max.unscoped` to a lower value such as `3` would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region. +--- + +#### Limit the number of hops for an advert flood message +**Usage:** +- `get flood.max.advert` +- `set flood.max.advert ` + +**Parameters:** +- `value`: Maximum flood hop count (0-64) for an advert packet + +**Default:** `8` --- From d3444e6b0be513f982f3bcb7d2f05a091f16a2fd Mon Sep 17 00:00:00 2001 From: formtapez Date: Fri, 12 Jun 2026 12:14:46 +0200 Subject: [PATCH 06/18] fix formatting --- docs/cli_commands.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index f482cfcb68..66a9b77afe 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -35,8 +35,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore ### Power-off the node **Usage:** - `poweroff`, or -- `shutdown -` +- `shutdown` + **Note:** No reply is sent. --- @@ -645,7 +645,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `value`: Maximum flood hop count (0-64) for a packet without a scope (no region set) -**Default:** `64` - indicates it hasn't been set, will track flood.max until it is. +**Default:** `64` - (`0xFF` indicates it hasn't been set, will track flood.max until it is.) **Note:** An alternative to `region denyf *`, setting `flood.max.unscoped` to a lower value such as `3` would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region. 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 07/18] 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 08/18] 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 09/18] 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 } From 57563ab8f92be3a6396b3eead4898b36f186a44b Mon Sep 17 00:00:00 2001 From: liamcottle Date: Mon, 6 Jul 2026 02:04:56 +1200 Subject: [PATCH 10/18] update qr code docs --- docs/qr_codes.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/qr_codes.md b/docs/qr_codes.md index 364efa8acc..3516a76a49 100644 --- a/docs/qr_codes.md +++ b/docs/qr_codes.md @@ -12,8 +12,10 @@ meshcore://channel/add?name=Public&secret=8b3387e9c5cdea6ac9e5edbaa115cd72 **Parameters**: -- `name`: Channel name (URL-encoded if needed) +- `name`: Channel name (URL-encoded) - `secret`: 16-byte secret represented as 32 hex characters +- `region_scope`: Region Scope (optional, URL-encoded if provided) + - Supported by MeshCore App v1.47.0+ ## Add Contact From 3c65fcd373183e7e94055bfaba13dc56f90d158d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gjels=C3=B8?= <36234524+gjelsoe@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:00:27 +0200 Subject: [PATCH 11/18] Added FILE version file format on /filter_rules.bin --- examples/simple_repeater/ChannelFilter.cpp | 17 +++++++++++++++++ examples/simple_repeater/ChannelFilter.h | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/examples/simple_repeater/ChannelFilter.cpp b/examples/simple_repeater/ChannelFilter.cpp index 704d3f3e74..c31be43e1c 100644 --- a/examples/simple_repeater/ChannelFilter.cpp +++ b/examples/simple_repeater/ChannelFilter.cpp @@ -5,6 +5,8 @@ // --------------------------------------------------------------------------- // Persistence layout (binary blob, fixed size): +// [uint8_t magic = FILTER_FILE_MAGIC] +// [uint8_t version = FILTER_FILE_VERSION] // [uint8_t mode] // [FilterRule * MAX_FILTER_RULES] // --------------------------------------------------------------------------- @@ -28,6 +30,21 @@ void ChannelFilter::load(FILESYSTEM &fs) { #endif if (!f) return; + // Validate magic byte — old or corrupt file starts fresh + uint8_t magic; + if (f.read(&magic, 1) != 1 || magic != FILTER_FILE_MAGIC) { + f.close(); + return; + } + + // Validate version — discard files older than current version + uint8_t version; + if (f.read(&version, 1) != 1 || version < FILTER_FILE_VERSION) { + f.close(); + return; + } + + // Read mode byte uint8_t mode_byte; if (f.read(&mode_byte, 1) != 1) { f.close(); diff --git a/examples/simple_repeater/ChannelFilter.h b/examples/simple_repeater/ChannelFilter.h index b9ff10523d..ef095fe248 100644 --- a/examples/simple_repeater/ChannelFilter.h +++ b/examples/simple_repeater/ChannelFilter.h @@ -11,6 +11,11 @@ // Persistence file path #define FILTER_RULES_FILE "/filter_rules.bin" +// magic : 0xFC — identifies this as a valid filter rules file +// version: 2 — current struct version; files with version < 2 are discarded +#define FILTER_FILE_MAGIC 0xFC +#define FILTER_FILE_VERSION 2 + // --------------------------------------------------------------------------- // ChannelFilter // --------------------------------------------------------------------------- From e67bdd21879ec245b74bd0f671a322d29520e126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gjels=C3=B8?= <36234524+gjelsoe@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:43:46 +0200 Subject: [PATCH 12/18] Missing save version to file --- examples/filter test suite/auto_test | Bin 0 -> 116016 bytes examples/simple_repeater/ChannelFilter.cpp | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100755 examples/filter test suite/auto_test diff --git a/examples/filter test suite/auto_test b/examples/filter test suite/auto_test new file mode 100755 index 0000000000000000000000000000000000000000..85f6632a5a3fc8ac4617808204d65551b7e61001 GIT binary patch literal 116016 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 W0YALLXKmx~QjD5i#n*bkkN*X^V6&qD literal 0 HcmV?d00001 diff --git a/examples/simple_repeater/ChannelFilter.cpp b/examples/simple_repeater/ChannelFilter.cpp index c31be43e1c..b18ce301ad 100644 --- a/examples/simple_repeater/ChannelFilter.cpp +++ b/examples/simple_repeater/ChannelFilter.cpp @@ -75,8 +75,8 @@ void ChannelFilter::save(FILESYSTEM &fs) const { #endif if (!f) return; - uint8_t mode_byte = (uint8_t)_mode; - f.write(&mode_byte, 1); + uint8_t header[3] = { FILTER_FILE_MAGIC, FILTER_FILE_VERSION, (uint8_t)_mode }; + f.write(header, sizeof(header)); f.write((const uint8_t *)_rules, sizeof(_rules)); f.close(); } From 56c173f95e3d473ec2a22165585b34a27f2e3a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gjels=C3=B8?= <36234524+gjelsoe@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:44:27 +0200 Subject: [PATCH 13/18] Test suite updated --- examples/filter test suite/ChannelFilter.cpp | 25 +++- examples/filter test suite/ChannelFilter.h | 8 +- examples/filter test suite/README.md | 128 +++++++++++++++++++ examples/filter test suite/auto_test.cpp | 51 +++++++- 4 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 examples/filter test suite/README.md diff --git a/examples/filter test suite/ChannelFilter.cpp b/examples/filter test suite/ChannelFilter.cpp index 2fe97a2baf..6814eb148c 100644 --- a/examples/filter test suite/ChannelFilter.cpp +++ b/examples/filter test suite/ChannelFilter.cpp @@ -17,6 +17,9 @@ ChannelFilter::ChannelFilter() { // --------------------------------------------------------------------------- // Load / Save // --------------------------------------------------------------------------- +// File format: [uint8_t magic=0xFC] [uint8_t version] [uint8_t mode] [FilterRule * MAX_FILTER_RULES] +// If magic is missing or version < FILTER_FILE_VERSION, file is discarded and +// a fresh default state is used. The file is not rewritten until the next save(). void ChannelFilter::load(FILESYSTEM& fs) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -28,14 +31,28 @@ void ChannelFilter::load(FILESYSTEM& fs) { #endif if (!f) return; + // Validate magic byte + uint8_t magic; + if (f.read(&magic, 1) != 1 || magic != FILTER_FILE_MAGIC) { + f.close(); + return; // old or corrupt file — start fresh + } + + // Validate version + uint8_t version; + if (f.read(&version, 1) != 1 || version < FILTER_FILE_VERSION) { + f.close(); + return; // outdated format — start fresh + } + + // Read mode byte 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) + // Read rules — 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); @@ -55,8 +72,8 @@ void ChannelFilter::save(FILESYSTEM& fs) const { #endif if (!f) return; - uint8_t mode_byte = (uint8_t)_mode; - f.write(&mode_byte, 1); + uint8_t header[3] = { FILTER_FILE_MAGIC, FILTER_FILE_VERSION, (uint8_t)_mode }; + f.write(header, sizeof(header)); f.write((const uint8_t*)_rules, sizeof(_rules)); f.close(); } diff --git a/examples/filter test suite/ChannelFilter.h b/examples/filter test suite/ChannelFilter.h index 881d08400a..4c02d30c42 100644 --- a/examples/filter test suite/ChannelFilter.h +++ b/examples/filter test suite/ChannelFilter.h @@ -9,7 +9,13 @@ // Persistence file path -#define FILTER_RULES_FILE "/filter_rules.bin" +#define FILTER_RULES_FILE "/filter_rules.bin" + +// File format: [magic] [version] [mode] [FilterRule * MAX_FILTER_RULES] +// magic : 0xFC — identifies this as a valid filter rules file +// version: 2 — current struct version; files with version < 2 are discarded +#define FILTER_FILE_MAGIC 0xFC +#define FILTER_FILE_VERSION 2 // --------------------------------------------------------------------------- // ChannelFilter diff --git a/examples/filter test suite/README.md b/examples/filter test suite/README.md new file mode 100644 index 0000000000..a84910c7d8 --- /dev/null +++ b/examples/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. diff --git a/examples/filter test suite/auto_test.cpp b/examples/filter test suite/auto_test.cpp index 146dc4598f..c1fb00c93b 100644 --- a/examples/filter test suite/auto_test.cpp +++ b/examples/filter test suite/auto_test.cpp @@ -276,8 +276,8 @@ static void test_persistence() { 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 + addRule(f1, "add drop payload eq grptxt", fs); + addRule(f1, "add drop route eq tflood", fs); f1.handleCommand("disable 0", reply, fs); f1.handleCommand("mode drop", reply, fs); @@ -288,15 +288,54 @@ static void test_persistence() { 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)); + 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)); + + section("Persistence — magic and version validation"); + + // Old format file: starts with mode byte (no magic) + MockFS fs_old; + { + uint8_t old_file[3] = { 0x00, 0x00, 0x00 }; // mode=allow + garbage, no magic + auto f = fs_old.open(FILTER_RULES_FILE, "w"); + f.write(old_file, sizeof(old_file)); + f.close(); + } + ChannelFilter f_old; + f_old.load(fs_old); // should discard — no magic + CHECK("old format (no magic) discarded -> mode defaults to allow", !f_old.evaluate(&norm, -80)); + + // File with correct magic but old version (1) + MockFS fs_v1; + { + uint8_t v1_file[3] = { 0xFC, 0x01, 0x01 }; // magic + version=1 + mode=drop + auto f = fs_v1.open(FILTER_RULES_FILE, "w"); + f.write(v1_file, sizeof(v1_file)); + f.close(); + } + ChannelFilter f_v1; + f_v1.load(fs_v1); // should discard — version < 2 + CHECK("version 1 file discarded -> mode defaults to allow", !f_v1.evaluate(&norm, -80)); + + // Verify save writes correct magic and version + MockFS fs_save; + ChannelFilter f_save; + f_save.handleCommand("mode drop", reply, fs_save); + f_save.save(fs_save); + { + auto f = fs_save.open(FILTER_RULES_FILE, "r"); + uint8_t magic = 0, version = 0; + f.read(&magic, 1); + f.read(&version, 1); + f.close(); + CHECK("saved file has correct magic", magic == 0xFC); + CHECK("saved file has correct version", version == 2); + } } static void test_and_condition() { From 54234b58371d2ecbf3031d807156277e1afff3a1 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Mon, 6 Jul 2026 22:18:49 +1200 Subject: [PATCH 14/18] implement matrix builds for faster firmware releases --- .../setup-build-environment/action.yml | 13 ++- .../workflows/build-companion-firmwares.yml | 35 ++------ .../workflows/build-repeater-firmwares.yml | 35 ++------ .../workflows/build-room-server-firmwares.yml | 35 ++------ .github/workflows/firmware-builder.yml | 90 +++++++++++++++++++ build.sh | 10 +++ 6 files changed, 126 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/firmware-builder.yml diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index 02aaf4249b..cba2d2e562 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -25,5 +25,14 @@ runs: - name: Extract Version from Git Tag shell: bash run: | - GIT_TAG_NAME="${GITHUB_REF#refs/tags/}" - echo "GIT_TAG_VERSION=${GIT_TAG_NAME##*-}" >> $GITHUB_ENV + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + # triggered by a tag push (e.g: refs/tags/companion-v1.2.3) + GIT_TAG_NAME="${GITHUB_REF#refs/tags/}" + VERSION_STRING="${GIT_TAG_NAME##*-}" + else + # triggered by a workflow dispatch (e.g: refs/heads/main) + # strip "refs/heads/" prefix and replace any remaining "/" with "-" to protect file paths + BRANCH_NAME="${GITHUB_REF#refs/heads/}" + VERSION_STRING=$(echo "$BRANCH_NAME" | tr '/' '-') + fi + echo "GIT_TAG_VERSION=${VERSION_STRING}" >> $GITHUB_ENV diff --git a/.github/workflows/build-companion-firmwares.yml b/.github/workflows/build-companion-firmwares.yml index 771fa6d5bc..8fd796f74b 100644 --- a/.github/workflows/build-companion-firmwares.yml +++ b/.github/workflows/build-companion-firmwares.yml @@ -10,33 +10,8 @@ on: - 'companion-*' jobs: - - build: - runs-on: ubuntu-latest - steps: - - - name: Clone Repo - uses: actions/checkout@v6 - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Firmwares - env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} - run: /usr/bin/env bash build.sh build-companion-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v7 - with: - name: companion-firmwares - path: out - - - name: Create Release - uses: softprops/action-gh-release@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - name: Companion Firmware ${{ env.GIT_TAG_VERSION }} - body: "" - draft: true - files: out/* \ No newline at end of file + build-companion-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'companion' + release_title_prefix: 'Companion Firmware' diff --git a/.github/workflows/build-repeater-firmwares.yml b/.github/workflows/build-repeater-firmwares.yml index 3185d4b21c..46b9076cc0 100644 --- a/.github/workflows/build-repeater-firmwares.yml +++ b/.github/workflows/build-repeater-firmwares.yml @@ -10,33 +10,8 @@ on: - 'repeater-*' jobs: - - build: - runs-on: ubuntu-latest - steps: - - - name: Clone Repo - uses: actions/checkout@v6 - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Firmwares - env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} - run: /usr/bin/env bash build.sh build-repeater-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v7 - with: - name: repeater-firmwares - path: out - - - name: Create Release - uses: softprops/action-gh-release@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - name: Repeater Firmware ${{ env.GIT_TAG_VERSION }} - body: "" - draft: true - files: out/* \ No newline at end of file + build-repeater-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'repeater' + release_title_prefix: 'Repeater Firmware' diff --git a/.github/workflows/build-room-server-firmwares.yml b/.github/workflows/build-room-server-firmwares.yml index 127095a8df..c8bd19f7f2 100644 --- a/.github/workflows/build-room-server-firmwares.yml +++ b/.github/workflows/build-room-server-firmwares.yml @@ -10,33 +10,8 @@ on: - 'room-server-*' jobs: - - build: - runs-on: ubuntu-latest - steps: - - - name: Clone Repo - uses: actions/checkout@v6 - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Firmwares - env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} - run: /usr/bin/env bash build.sh build-room-server-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v7 - with: - name: room-server-firmwares - path: out - - - name: Create Release - uses: softprops/action-gh-release@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - name: Room Server Firmware ${{ env.GIT_TAG_VERSION }} - body: "" - draft: true - files: out/* \ No newline at end of file + build-room-server-firmwares: + uses: ./.github/workflows/firmware-builder.yml + with: + firmware_type: 'room-server' + release_title_prefix: 'Room Server Firmware' diff --git a/.github/workflows/firmware-builder.yml b/.github/workflows/firmware-builder.yml new file mode 100644 index 0000000000..adc9e42488 --- /dev/null +++ b/.github/workflows/firmware-builder.yml @@ -0,0 +1,90 @@ +name: Firmware Builder + +on: + workflow_call: + inputs: + firmware_type: + required: true + type: string + release_title_prefix: + required: true + type: string + +jobs: + + generate-build-matrix: + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.get-build-targets.outputs.targets }} + steps: + + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Get Build Targets + id: get-build-targets + run: | + # get list of firmwares to build + TARGET_LIST=$(/usr/bin/env bash build.sh get-${{ inputs.firmware_type }}-firmwares-to-build) + + # convert targets separated by new line into a json array string + JSON_ARRAY=$(echo "$TARGET_LIST" | jq -R -s -c 'split("\n") | map(select(length > 0))') + + # use json array as targets result + echo "targets=$JSON_ARRAY" >> $GITHUB_OUTPUT + + build: + needs: generate-build-matrix + runs-on: ubuntu-latest + continue-on-error: true # don't fail entire build if one board fails to build + strategy: + matrix: + target: ${{ fromJson(needs.generate-build-matrix.outputs.targets) }} + fail-fast: false # don't cancel other builds if one board fails to build + steps: + + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Firmware + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-firmware ${{ matrix.target }} + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v7 + with: + name: "${{ matrix.target }}" + path: out + + create-release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') # only create release for tagged builds + steps: + + - name: Clone Repo + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Download All Artifacts + uses: actions/download-artifact@v8 + with: + merge-multiple: true + path: out + + - name: Create Release + uses: softprops/action-gh-release@v3 + with: + name: "${{ inputs.release_title_prefix }} ${{ env.GIT_TAG_VERSION }}" + body: "" + draft: true + files: out/* diff --git a/build.sh b/build.sh index 313c4c47a0..41719a44ab 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash +# exit when any command fails +set -e + global_usage() { cat - < Date: Thu, 9 Jul 2026 11:43:20 -0700 Subject: [PATCH 15/18] Update number_allocations.md Adds a small allocation for groupdata packets for the Meshcore firmware for the StreamSensor product. It was previously a LoRaWAN platform, and we've moved to Meshcore. --- docs/number_allocations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/number_allocations.md b/docs/number_allocations.md index 857525d468..84b9cba04d 100644 --- a/docs/number_allocations.md +++ b/docs/number_allocations.md @@ -17,6 +17,7 @@ Once you have a working app/project, you need to be able to demonstrate it exist | 0000 - 00FF | -reserved for internal use- | | | 0100 | MeshCore Open | zsylvester@monitormx.com — https://github.com/zjs81/meshcore-open | | 0110 - 011F | Ripple | ripple_biz@protonmail.com — https://buymeacoffee.com/ripplebiz | +| 0120 - 012F | StreamSensor | william@housedillon.com - https://housedillon.com/blog/lora-e5-with-seeed-fusion | | FF00 - FFFF | -reserved for testing/dev- | | (add rows, inside the range 0100 - FEFF for custom apps) From c242b4ec583178ceaf0671aa7d999a56591abcd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gjels=C3=B8?= <36234524+gjelsoe@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:52:27 +0200 Subject: [PATCH 16/18] Update Updated DOCs Updated Filter Test suite Updated Channel blocking to allow upto 4 IDs in the same line. --- examples/filter test suite/ChannelFilter.cpp | 772 +++++++++++-------- examples/filter test suite/ChannelFilter.h | 11 +- examples/filter test suite/FilterParser.cpp | 77 +- examples/filter test suite/FilterParser.h | 2 + examples/filter test suite/FilterRule.h | 7 + examples/filter test suite/Makefile | 2 +- examples/filter test suite/auto_test | Bin 116016 -> 122664 bytes examples/filter test suite/auto_test.cpp | 31 +- examples/filter test suite/mock_mesh.h | 22 +- examples/filter test suite/shell | Bin 0 -> 102136 bytes examples/simple_repeater/ChannelFilter.cpp | 41 +- examples/simple_repeater/ChannelFilter.h | 14 +- examples/simple_repeater/FILTER.md | 55 +- examples/simple_repeater/FilterParser.cpp | 77 +- examples/simple_repeater/FilterParser.h | 58 +- examples/simple_repeater/FilterRule.h | 7 + 16 files changed, 739 insertions(+), 437 deletions(-) create mode 100755 examples/filter test suite/shell diff --git a/examples/filter test suite/ChannelFilter.cpp b/examples/filter test suite/ChannelFilter.cpp index 6814eb148c..3069cb20db 100644 --- a/examples/filter test suite/ChannelFilter.cpp +++ b/examples/filter test suite/ChannelFilter.cpp @@ -5,77 +5,80 @@ // --------------------------------------------------------------------------- // Persistence layout (binary blob, fixed size): +// [uint8_t magic = FILTER_FILE_MAGIC] +// [uint8_t version = FILTER_FILE_VERSION] // [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 + memset(_rules, 0, sizeof(_rules)); + _mode = FilterMode::ALLOW; // safe default: pass all packets if no rules loaded } // --------------------------------------------------------------------------- // Load / Save // --------------------------------------------------------------------------- -// File format: [uint8_t magic=0xFC] [uint8_t version] [uint8_t mode] [FilterRule * MAX_FILTER_RULES] -// If magic is missing or version < FILTER_FILE_VERSION, file is discarded and -// a fresh default state is used. The file is not rewritten until the next 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; - // Validate magic byte - uint8_t magic; - if (f.read(&magic, 1) != 1 || magic != FILTER_FILE_MAGIC) { - f.close(); - return; // old or corrupt file — start fresh - } + // Validate magic byte — old or corrupt file starts fresh + uint8_t magic; + if (f.read(&magic, 1) != 1 || magic != FILTER_FILE_MAGIC) { + f.close(); + return; + } - // Validate version - uint8_t version; - if (f.read(&version, 1) != 1 || version < FILTER_FILE_VERSION) { - f.close(); - return; // outdated format — start fresh - } + // Validate version — discard files older than current version + uint8_t version; + if (f.read(&version, 1) != 1 || version < FILTER_FILE_VERSION) { + f.close(); + return; + } - // Read mode byte - uint8_t mode_byte; - if (f.read(&mode_byte, 1) != 1) { f.close(); return; } - _mode = (mode_byte <= (uint8_t)FilterMode::DROP) + // Read mode byte + 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; - // Read rules — 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 header[3] = { FILTER_FILE_MAGIC, FILTER_FILE_VERSION, (uint8_t)_mode }; - f.write(header, sizeof(header)); - f.write((const uint8_t*)_rules, sizeof(_rules)); - f.close(); + uint8_t header[3] = { FILTER_FILE_MAGIC, FILTER_FILE_VERSION, (uint8_t)_mode }; + f.write(header, sizeof(header)); + f.write((const uint8_t *)_rules, sizeof(_rules)); + f.close(); } // --------------------------------------------------------------------------- @@ -83,105 +86,144 @@ 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; -} - -// 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); + } + + // CHANNEL field has OR-list logic + if (rule.field == FilterField::CHANNEL) { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; // GRP_TXT / GRP_DATA only + if (pkt->payload_len < 1) return false; + + uint8_t ch = pkt->payload[0]; + bool found = false; + for (uint8_t i = 0; i < rule.channel_hash_count; i++) { + if (rule.channel_hashes[i] == ch) { + found = true; + break; + } + } - case FilterField::TYPE: - return applyOp(op, (int16_t)pkt->getPayloadType(), val); + bool primary_match; + if (rule.op == FilterOp::EQ) primary_match = found; + else if (rule.op == FilterOp::NEQ) primary_match = !found; + else return false; // GT/LT not meaningful for channel OR-list - case FilterField::HOPS: - return applyOp(op, (int16_t)pkt->getPathHashCount(), val); + if (!primary_match) return false; - case FilterField::PATHSIZE: - return applyOp(op, (int16_t)pkt->getPathHashSize(), val); + // AND condition for channel primary + 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; + } - 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); - } + // Scalar primary condition + if (!_evalScalar(rule.field, rule.op, rule.value, pkt, rssi)) return false; - case FilterField::SNR: - return applyOp(op, (int16_t)pkt->_snr, val); + // 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; + } - case FilterField::RSSI: - return applyOp(op, rssi, val); + return true; +} - default: - return false; - } +// 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; + // OR-match against channel_hashes list — find the rule via pointer arithmetic + // Note: val is unused for CHANNEL — match logic uses the rule struct directly. + // This case should not be reached via _evalScalar for channel primary field. + // It remains here as a safety fallback using val as single-value compare. + 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; +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; } // --------------------------------------------------------------------------- @@ -189,10 +231,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; } // --------------------------------------------------------------------------- @@ -200,84 +242,122 @@ 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) { + int pos = 0; + for (uint8_t i = 0; i < rule.channel_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + pos += snprintf(out + pos, outlen - pos, "0x%02X", rule.channel_hashes[i]); + } + out[pos] = '\0'; + } 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 @@ -287,194 +367,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/filter test suite/ChannelFilter.h b/examples/filter test suite/ChannelFilter.h index 4c02d30c42..73182867cc 100644 --- a/examples/filter test suite/ChannelFilter.h +++ b/examples/filter test suite/ChannelFilter.h @@ -15,7 +15,7 @@ // magic : 0xFC — identifies this as a valid filter rules file // version: 2 — current struct version; files with version < 2 are discarded #define FILTER_FILE_MAGIC 0xFC -#define FILTER_FILE_VERSION 2 +#define FILTER_FILE_VERSION 3 // --------------------------------------------------------------------------- // ChannelFilter @@ -44,7 +44,8 @@ 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); + // sender == nullptr means local Serial CLI; sender != nullptr means remote CLI (paginates output). + void handleCommand(const char* args, char* reply, FILESYSTEM& fs, ClientInfo* sender = nullptr); private: FilterRule _rules[MAX_FILTER_RULES]; @@ -52,15 +53,11 @@ class ChannelFilter { // --- 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; + void _listRules(char* reply, uint8_t page, bool is_remote) const; }; diff --git a/examples/filter test suite/FilterParser.cpp b/examples/filter test suite/FilterParser.cpp index 58aefeb5e0..c4d58b1c82 100644 --- a/examples/filter test suite/FilterParser.cpp +++ b/examples/filter test suite/FilterParser.cpp @@ -154,16 +154,10 @@ static bool parseScalarValue(const char* tok, FilterField field, int16_t& out) { 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::CHANNEL: + // Channel OR-list is handled separately in parseAddCommand. + // parseScalarValue is only called for AND conditions — channel not allowed there. + return false; case FilterField::SNR: { char* end; long v = strtol(tok, &end, 0); @@ -213,10 +207,8 @@ static FilterParseResult parseAddCommand(Tokenizer& tz) { 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 + nextToken(tz, tok); if (count >= MAX_PATH_HASHES_PER_RULE) { result.error = FilterParseError::TOO_MANY_HASHES; @@ -249,6 +241,47 @@ static FilterParseResult parseAddCommand(Tokenizer& tz) { result.rule.path_hash_len = hashlen; result.rule.path_hash_count = count; + } else if (result.rule.field == FilterField::CHANNEL) { + // CHANNEL: one or more space-separated hex byte tokens (OR-list) + // AND condition not supported when channel is primary field + uint8_t count = 0; + + while (peekToken(tz, tok)) { + // Stop consuming hashes when we see the "and" keyword + if (strcmp(tok, "and") == 0) break; + + nextToken(tz, tok); + + if (count >= MAX_CHANNEL_HASHES) { + result.error = FilterParseError::TOO_MANY_CHANNELS; + return result; + } + + // Accept 0xNN or NN (exactly 1 byte) + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 1) { + result.rule.channel_hashes[count++] = bytes[0]; + } else { + // Try plain decimal + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + result.rule.channel_hashes[count++] = (uint8_t)v; + } + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.channel_hash_count = count; + // Channel OR-list parsed — fall through to AND condition parsing below + } else { // Scalar field if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } @@ -267,15 +300,21 @@ static FilterParseResult parseAddCommand(Tokenizer& tz) { FilterField and_field; if (!parseField(tok, and_field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + // Duplicate field not allowed + if (and_field == result.rule.field) { + result.error = FilterParseError::AND_DUPLICATE_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; + // CHANNEL not supported as AND condition + if (and_field == FilterField::CHANNEL) { + result.error = FilterParseError::AND_CHANNEL_NOT_ALLOWED; return result; } @@ -401,8 +440,10 @@ const char* filterParseErrorStr(FilterParseError err) { 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"; + 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"; + case FilterParseError::AND_CHANNEL_NOT_ALLOWED: return "Err - channel field not supported as AND condition"; + case FilterParseError::TOO_MANY_CHANNELS: return "Err - too many channel hashes (max 4)"; default: return "Err - unknown error"; } } diff --git a/examples/filter test suite/FilterParser.h b/examples/filter test suite/FilterParser.h index fb14972d71..5904098c86 100644 --- a/examples/filter test suite/FilterParser.h +++ b/examples/filter test suite/FilterParser.h @@ -21,6 +21,8 @@ enum class FilterParseError : uint8_t { 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 + AND_CHANNEL_NOT_ALLOWED, // channel field not supported as AND condition + TOO_MANY_CHANNELS, // more channel hashes than MAX_CHANNEL_HASHES }; // --------------------------------------------------------------------------- diff --git a/examples/filter test suite/FilterRule.h b/examples/filter test suite/FilterRule.h index 1037269db4..728f9cdd40 100644 --- a/examples/filter test suite/FilterRule.h +++ b/examples/filter test suite/FilterRule.h @@ -9,6 +9,7 @@ #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 +#define MAX_CHANNEL_HASHES 4 // OR-list for channel field // --------------------------------------------------------------------------- // Enums @@ -76,6 +77,12 @@ struct FilterRule { 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) + // CHANNEL field only: OR-list of channel hash bytes. + // Unused slots are zero-filled. + // AND condition is not supported when field == CHANNEL. + uint8_t channel_hashes[MAX_CHANNEL_HASHES]; + uint8_t channel_hash_count; // number of valid entries (1..MAX_CHANNEL_HASHES) + bool enabled; // false = rule is defined but temporarily inactive bool in_use; // false = slot is empty }; diff --git a/examples/filter test suite/Makefile b/examples/filter test suite/Makefile index e047841b70..83aa473e6f 100644 --- a/examples/filter test suite/Makefile +++ b/examples/filter test suite/Makefile @@ -1,5 +1,5 @@ CXX = g++ -CXXFLAGS = -std=c++11 -Wall -Wextra -I. +CXXFLAGS = -std=c++17 -Wall -Wextra -I. SRCS_COMMON = FilterParser.cpp ChannelFilter.cpp diff --git a/examples/filter test suite/auto_test b/examples/filter test suite/auto_test index 85f6632a5a3fc8ac4617808204d65551b7e61001..6dd39d8c0c24fa5302981092931aff07716f141e 100755 GIT binary patch literal 122664 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 RKYibZAMFyX0YbOl{{xj`9jyQW literal 116016 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 W0YALLXKmx~QjD5i#n*bkkN*X^V6&qD diff --git a/examples/filter test suite/auto_test.cpp b/examples/filter test suite/auto_test.cpp index c1fb00c93b..c7e3dd1d0a 100644 --- a/examples/filter test suite/auto_test.cpp +++ b/examples/filter test suite/auto_test.cpp @@ -85,7 +85,18 @@ static void test_parser() { 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); + CHECK(" channel_hash_count == 1", r.rule.channel_hash_count == 1); + CHECK(" channel_hashes[0] == 0xAB", r.rule.channel_hashes[0] == 0xAB); + + r = parseFilterCommand("add drop channel eq 0x11 0x22 0x33"); + CHECK("channel OR-list 3 values -> OK", r.error == FilterParseError::OK); + CHECK(" channel_hash_count == 3", r.rule.channel_hash_count == 3); + CHECK(" channel_hashes[0] == 0x11", r.rule.channel_hashes[0] == 0x11); + CHECK(" channel_hashes[1] == 0x22", r.rule.channel_hashes[1] == 0x22); + CHECK(" channel_hashes[2] == 0x33", r.rule.channel_hashes[2] == 0x33); + + r = parseFilterCommand("add drop channel eq 0x11 0x22 0x33 0x44 0x55"); + CHECK("channel OR-list 5 values -> TOO_MANY_CHANNELS", r.error == FilterParseError::TOO_MANY_CHANNELS); r = parseFilterCommand("add drop path eq AB 12 CD"); CHECK("add drop path eq AB 12 CD -> OK", r.error == FilterParseError::OK); @@ -229,6 +240,14 @@ static void test_evaluate() { CHECK("channel 0xCD -> PASS", !f.evaluate(&chanCD, -80)); f.handleCommand("clear", reply, fs); + section("Evaluate — channel OR-list"); + addRule(f, "add drop channel eq 0xAB 0xCD 0xEF", fs); + mesh::Packet ch11 = makePacket(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_GRP_TXT, 0, 0x11); + CHECK("channel 0xAB in OR-list -> DROP", f.evaluate(&chanAB, -80)); + CHECK("channel 0xCD in OR-list -> DROP", f.evaluate(&chanCD, -80)); + CHECK("channel 0x11 not in OR-list -> PASS", !f.evaluate(&ch11, -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}; @@ -334,7 +353,7 @@ static void test_persistence() { f.read(&version, 1); f.close(); CHECK("saved file has correct magic", magic == 0xFC); - CHECK("saved file has correct version", version == 2); + CHECK("saved file has correct version", version == 3); } } @@ -348,7 +367,7 @@ static void test_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(" channel_hashes[0] == 0x11", r.rule.channel_hashes[0] == 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); @@ -393,13 +412,13 @@ static void test_and_condition() { 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); + f.handleCommand("add drop channel eq 0x11 and hops gt 8", reply, fs, nullptr); + f.handleCommand("list", reply, fs, (ClientInfo*)1); // remote = paginated 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); + f.handleCommand("clear", reply, fs, nullptr); } static void test_list_length() { diff --git a/examples/filter test suite/mock_mesh.h b/examples/filter test suite/mock_mesh.h index 74219ac1e0..110b866c07 100644 --- a/examples/filter test suite/mock_mesh.h +++ b/examples/filter test suite/mock_mesh.h @@ -5,6 +5,13 @@ // without Arduino/MeshCore headers. // --------------------------------------------------------------------------- +#include +#include +#include +#include +#include +// --------------------------------------------------------------------------- + #include #include #include @@ -139,7 +146,20 @@ class MockFS { void clear() { _files.clear(); } }; -// Alias so ChannelFilter.cpp sees FILESYSTEM +// Minimal Serial stub for local list output +struct MockSerial { + void printf(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + vprintf(fmt, args); + va_end(args); + } +}; +inline MockSerial Serial; + +// Minimal ClientInfo stub — only used as a pointer (nullptr = local, non-null = remote) +struct ClientInfo {}; + using FILESYSTEM = MockFS; // File type alias using File = MockFile; diff --git a/examples/filter test suite/shell b/examples/filter test suite/shell new file mode 100755 index 0000000000000000000000000000000000000000..651eae22b4af43c7b10c6370ef0ca5340cd7dc8b GIT binary patch literal 102136 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 D%s^05 literal 0 HcmV?d00001 diff --git a/examples/simple_repeater/ChannelFilter.cpp b/examples/simple_repeater/ChannelFilter.cpp index b18ce301ad..c0ee443d29 100644 --- a/examples/simple_repeater/ChannelFilter.cpp +++ b/examples/simple_repeater/ChannelFilter.cpp @@ -132,6 +132,36 @@ bool ChannelFilter::_ruleMatches(const FilterRule &rule, const mesh::Packet *pkt return true; } + // CHANNEL field has OR-list logic + if (rule.field == FilterField::CHANNEL) { + uint8_t pt = pkt->getPayloadType(); + if (pt != 0x05 && pt != 0x06) return false; // GRP_TXT / GRP_DATA only + if (pkt->payload_len < 1) return false; + + uint8_t ch = pkt->payload[0]; + bool found = false; + for (uint8_t i = 0; i < rule.channel_hash_count; i++) { + if (rule.channel_hashes[i] == ch) { + found = true; + break; + } + } + + bool primary_match; + if (rule.op == FilterOp::EQ) primary_match = found; + else if (rule.op == FilterOp::NEQ) primary_match = !found; + else return false; // GT/LT not meaningful for channel OR-list + + if (!primary_match) return false; + + // AND condition for channel primary + 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; @@ -164,6 +194,10 @@ bool ChannelFilter::_evalScalar(FilterField field, FilterOp op, int16_t val, uint8_t pt = pkt->getPayloadType(); if (pt != 0x05 && pt != 0x06) return false; if (pkt->payload_len < 1) return false; + // OR-match against channel_hashes list — find the rule via pointer arithmetic + // Note: val is unused for CHANNEL — match logic uses the rule struct directly. + // This case should not be reached via _evalScalar for channel primary field. + // It remains here as a safety fallback using val as single-value compare. return applyOp(op, (int16_t)pkt->payload[0], val); } @@ -312,7 +346,12 @@ static void formatRuleValue(const FilterRule &rule, char *out, int outlen) { } 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); + int pos = 0; + for (uint8_t i = 0; i < rule.channel_hash_count && pos < outlen - 1; i++) { + if (i > 0 && pos < outlen - 2) out[pos++] = ' '; + pos += snprintf(out + pos, outlen - pos, "0x%02X", rule.channel_hashes[i]); + } + out[pos] = '\0'; } else if (rule.field == FilterField::SNR) { // Convert stored quarter-dB back to whole dB for display snprintf(out, outlen, "%d", (int)(rule.value / 4)); diff --git a/examples/simple_repeater/ChannelFilter.h b/examples/simple_repeater/ChannelFilter.h index ef095fe248..56332ea563 100644 --- a/examples/simple_repeater/ChannelFilter.h +++ b/examples/simple_repeater/ChannelFilter.h @@ -9,12 +9,13 @@ #include // Persistence file path -#define FILTER_RULES_FILE "/filter_rules.bin" +#define FILTER_RULES_FILE "/filter_rules.bin" +// File format: [magic] [version] [mode] [FilterRule * MAX_FILTER_RULES] // magic : 0xFC — identifies this as a valid filter rules file // version: 2 — current struct version; files with version < 2 are discarded #define FILTER_FILE_MAGIC 0xFC -#define FILTER_FILE_VERSION 2 +#define FILTER_FILE_VERSION 3 // --------------------------------------------------------------------------- // ChannelFilter @@ -43,7 +44,8 @@ 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, ClientInfo* sender); + // sender == nullptr means local Serial CLI; sender != nullptr means remote CLI (paginates output). + void handleCommand(const char* args, char* reply, FILESYSTEM& fs, ClientInfo* sender = nullptr); private: FilterRule _rules[MAX_FILTER_RULES]; @@ -51,15 +53,11 @@ class ChannelFilter { // --- 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, bool remote) const; + void _listRules(char* reply, uint8_t page, bool is_remote) const; }; \ No newline at end of file diff --git a/examples/simple_repeater/FILTER.md b/examples/simple_repeater/FILTER.md index 536efe8d7f..04c641d30c 100644 --- a/examples/simple_repeater/FILTER.md +++ b/examples/simple_repeater/FILTER.md @@ -2,9 +2,7 @@ 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. +Rules survive reboot and are stored in `/filter_rules.bin`. The file includes a magic byte and version number — if the format changes between firmware versions, the file is automatically discarded and rules start fresh. --- @@ -43,7 +41,7 @@ All commands are prefixed with `filter`. | `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` | +| `channel` | Channel hash byte — OR-list (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` | @@ -73,6 +71,7 @@ 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) +- `channel` is not supported as the AND field (it can still be the primary field) - The AND field must be different from the primary field --- @@ -110,13 +109,37 @@ Numeric values (decimal or hex) are also accepted for `payload`, e.g. `5` or `0x ### `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). +One or more hex strings separated by spaces. Hash size is determined automatically from the number of hex characters: + +| Hex chars | Bytes | Example | +|---|---|---| +| 2 | 1 | `AB` | +| 4 | 2 | `ABBA` | +| 6 | 3 | `ABBA11` | + +All hashes in a single rule must be the same size — mixing sizes returns an error. 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). + +``` +filter add drop path eq AB ← single 1-byte hash +filter add drop path eq AB CD EF ← any of three 1-byte hashes +filter add drop path eq ABBA CDCD ← any of two 2-byte hashes +filter add drop path eq ABBA11 CDCD22 ← any of two 3-byte hashes +``` > `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. +One or more hex bytes separated by spaces, with or without `0x` prefix. The rule matches if the packet's channel hash equals **any** of the listed values (OR logic). Only applies to `grptxt` and `grpdata` packets. + +``` +filter add drop channel eq 0xAB ← single channel +filter add drop channel eq 0x81 0x11 0x22 ← any of these three channels +``` + +> `channel` cannot be used as an AND field. It can only be the primary field. ### `snr` values @@ -156,7 +179,7 @@ mode:allow rules:6/8 p1/2 ``` mode:allow rules:6/8 p2/2 3 allow path eq AB 12 -4 drop channel eq 0xAB and rssi lt -100 +4 drop channel eq 0x81 0x11 0x22 and hops gt 8 5 drop rssi lt -110 ``` @@ -208,7 +231,17 @@ filter add drop path eq AB 12 CD filter add drop channel eq 0xAB ``` -### Drop group messages on a specific channel only when more than 8 hops away +### Drop packets on any of several channels +``` +filter add drop channel eq 0x81 0x11 0x22 +``` + +### Drop group messages on specific channels only when more than 8 hops away +``` +filter add drop channel eq 0x81 0x11 0x22 and hops gt 8 +``` + +### Drop group messages on a single channel only when more than 8 hops away ``` filter add drop channel eq 0x11 and hops gt 8 ``` @@ -275,5 +308,7 @@ filter mode allow | 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 +| Maximum channel hashes per rule | 4 | +| Path hash size | 1, 2 or 3 bytes (2, 4 or 6 hex chars) — all hashes in a rule must be the same size | +| `path` as AND field | Not supported | +| `channel` 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 index 6c116fe2bb..5bf50d8dc3 100644 --- a/examples/simple_repeater/FilterParser.cpp +++ b/examples/simple_repeater/FilterParser.cpp @@ -154,16 +154,10 @@ static bool parseScalarValue(const char* tok, FilterField field, int16_t& out) { 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::CHANNEL: + // Channel OR-list is handled separately in parseAddCommand. + // parseScalarValue is only called for AND conditions — channel not allowed there. + return false; case FilterField::SNR: { char* end; long v = strtol(tok, &end, 0); @@ -213,10 +207,8 @@ static FilterParseResult parseAddCommand(Tokenizer& tz) { 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 + nextToken(tz, tok); if (count >= MAX_PATH_HASHES_PER_RULE) { result.error = FilterParseError::TOO_MANY_HASHES; @@ -249,6 +241,47 @@ static FilterParseResult parseAddCommand(Tokenizer& tz) { result.rule.path_hash_len = hashlen; result.rule.path_hash_count = count; + } else if (result.rule.field == FilterField::CHANNEL) { + // CHANNEL: one or more space-separated hex byte tokens (OR-list) + // AND condition not supported when channel is primary field + uint8_t count = 0; + + while (peekToken(tz, tok)) { + // Stop consuming hashes when we see the "and" keyword + if (strcmp(tok, "and") == 0) break; + + nextToken(tz, tok); + + if (count >= MAX_CHANNEL_HASHES) { + result.error = FilterParseError::TOO_MANY_CHANNELS; + return result; + } + + // Accept 0xNN or NN (exactly 1 byte) + uint8_t bytes[MAX_PATH_HASH_SIZE]; + uint8_t len = parseHexBytes(tok, bytes); + if (len == 1) { + result.rule.channel_hashes[count++] = bytes[0]; + } else { + // Try plain decimal + char* end; + long v = strtol(tok, &end, 0); + if (*end != '\0' || v < 0 || v > 255) { + result.error = FilterParseError::INVALID_HEX; + return result; + } + result.rule.channel_hashes[count++] = (uint8_t)v; + } + } + + if (count == 0) { + result.error = FilterParseError::MISSING_TOKEN; + return result; + } + + result.rule.channel_hash_count = count; + // Channel OR-list parsed — fall through to AND condition parsing below + } else { // Scalar field if (!nextToken(tz, tok)) { result.error = FilterParseError::MISSING_TOKEN; return result; } @@ -267,15 +300,21 @@ static FilterParseResult parseAddCommand(Tokenizer& tz) { FilterField and_field; if (!parseField(tok, and_field)) { result.error = FilterParseError::UNKNOWN_FIELD; return result; } + // Duplicate field not allowed + if (and_field == result.rule.field) { + result.error = FilterParseError::AND_DUPLICATE_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; + // CHANNEL not supported as AND condition + if (and_field == FilterField::CHANNEL) { + result.error = FilterParseError::AND_CHANNEL_NOT_ALLOWED; return result; } @@ -401,8 +440,10 @@ const char* filterParseErrorStr(FilterParseError err) { 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"; + 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"; + case FilterParseError::AND_CHANNEL_NOT_ALLOWED: return "Err - channel field not supported as AND condition"; + case FilterParseError::TOO_MANY_CHANNELS: return "Err - too many channel hashes (max 4)"; 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 index 993ed097b4..618e78bdb4 100644 --- a/examples/simple_repeater/FilterParser.h +++ b/examples/simple_repeater/FilterParser.h @@ -7,20 +7,22 @@ // --------------------------------------------------------------------------- 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 + 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 + AND_CHANNEL_NOT_ALLOWED, // channel field not supported as AND condition + TOO_MANY_CHANNELS, // more channel hashes than MAX_CHANNEL_HASHES }; // --------------------------------------------------------------------------- @@ -28,13 +30,13 @@ enum class FilterParseError : uint8_t { // --------------------------------------------------------------------------- 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 + 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 }; // --------------------------------------------------------------------------- @@ -42,11 +44,11 @@ enum class FilterCommand : uint8_t { // --------------------------------------------------------------------------- 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 + 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 }; // --------------------------------------------------------------------------- @@ -56,7 +58,7 @@ struct FilterParseResult { // 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); +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 +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 index bc4ff72a6d..c3aaf38e2b 100644 --- a/examples/simple_repeater/FilterRule.h +++ b/examples/simple_repeater/FilterRule.h @@ -9,6 +9,7 @@ #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 +#define MAX_CHANNEL_HASHES 4 // OR-list for channel field // --------------------------------------------------------------------------- // Enums @@ -76,6 +77,12 @@ struct FilterRule { 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) + // CHANNEL field only: OR-list of channel hash bytes. + // Unused slots are zero-filled. + // AND condition is not supported when field == CHANNEL. + uint8_t channel_hashes[MAX_CHANNEL_HASHES]; + uint8_t channel_hash_count; // number of valid entries (1..MAX_CHANNEL_HASHES) + bool enabled; // false = rule is defined but temporarily inactive bool in_use; // false = slot is empty }; \ No newline at end of file From d9cd3ea03a41f74a1869210b0c4330d8f92668f2 Mon Sep 17 00:00:00 2001 From: ripplebiz Date: Mon, 13 Jul 2026 19:30:11 +1000 Subject: [PATCH 17/18] Revert "Add StreamSensor allocation for GroupData" --- docs/number_allocations.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/number_allocations.md b/docs/number_allocations.md index 84b9cba04d..857525d468 100644 --- a/docs/number_allocations.md +++ b/docs/number_allocations.md @@ -17,7 +17,6 @@ Once you have a working app/project, you need to be able to demonstrate it exist | 0000 - 00FF | -reserved for internal use- | | | 0100 | MeshCore Open | zsylvester@monitormx.com — https://github.com/zjs81/meshcore-open | | 0110 - 011F | Ripple | ripple_biz@protonmail.com — https://buymeacoffee.com/ripplebiz | -| 0120 - 012F | StreamSensor | william@housedillon.com - https://housedillon.com/blog/lora-e5-with-seeed-fusion | | FF00 - FFFF | -reserved for testing/dev- | | (add rows, inside the range 0100 - FEFF for custom apps) From 84ceabfd0d2c534bbfebb53ae5f5ad4d8dc0eac4 Mon Sep 17 00:00:00 2001 From: DG1TAL Date: Mon, 27 Jul 2026 20:07:08 +0200 Subject: [PATCH 18/18] Clarify hashtag privacy and group sender identity --- docs/companion_protocol.md | 2 ++ docs/payloads.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 7cca7bc9a2..8c6b84b973 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -440,6 +440,8 @@ Byte 0: 0x14 - Uses a secret key derived from the channel name - It is the first 16 bytes of `sha256("#test")` - For example hashtag channel `#test` has the key: `9cd8fcf22a47333b591d96a2b848b73f` + - Traffic is encrypted on air, but anyone who knows or guesses the channel + name can derive the key. Hashtag channels should not be treated as private. - Used as a topic based public group chat, separate from the default public channel 3. **Private Channels** - Uses a randomly generated 16-byte secret key diff --git a/docs/payloads.md b/docs/payloads.md index 21cb94696c..9d2a8a113b 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -236,6 +236,9 @@ txt_type The plaintext contained in the ciphertext matches the format described in [plain text message](#plain-text-message). Specifically, it consists of a four byte timestamp, a flags byte, and the message. The flags byte will generally be `0x00` because it is a "plain text message". The message will be of the form `: ` (eg., `user123: I'm on my way`). +The sender name is unverified message text. Group messages contain no sender +signature, so any channel-key holder can choose any sender name. + # Group datagram | Field | Size (bytes) | Description |