diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1b0ca1916c..ad9ac04857 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,5 +1,6 @@ #include "MyMesh.h" #include +#include "ReportEngine.h" /* ------------------------------ Config -------------------------------- */ @@ -344,7 +345,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)){ @@ -975,6 +976,9 @@ void MyMesh::begin(FILESYSTEM *fs) { board.setAdcMultiplier(_prefs.adc_multiplier); + _scriptLoad(); + _nextScriptEval = futureMillis(60000UL); // first evaluation after 60s + #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); #endif @@ -1152,7 +1156,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,6 +1394,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } + } else if (memcmp(command, "report", 6) == 0) { + const char* arg = command + 6; + while (*arg == ' ') arg++; + scriptHandleCommand(arg, reply, sender); } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } @@ -1434,6 +1442,18 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // script engine: evaluate rules every minute + if (_nextScriptEval && millisHasNowPassed(_nextScriptEval)) { + _nextScriptEval = futureMillis(60000UL); + _scriptEvaluate(); + } + + // script engine: deferred SPIFFS save (avoids blocking CLI on slow filesystems e.g. nRF52) + if (_scriptRulesDirty) { + _scriptRulesDirty = false; + _scriptSave(); + } + // update uptime uint32_t now = millis(); uptime_millis += now - last_millis; @@ -1446,4 +1466,4 @@ bool MyMesh::hasPendingWork() const { if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif return _mgr->getOutboundTotal() > 0; -} +} \ No newline at end of file diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index fbc756f471..73d74511f4 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -34,6 +34,7 @@ #include #include #include "RateLimiter.h" +#include "ReportEngine.h" #ifdef WITH_BRIDGE extern AbstractBridge* bridge; @@ -130,6 +131,17 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); + // ── Script engine ────────────────────────────────────────────────────────── + ScriptRule _scriptRules[MAX_SCRIPT_RULES]; + uint8_t _scriptRuleCount = 0; + unsigned long _nextScriptEval = 0; + bool _scriptRulesDirty = false; // deferred SPIFFS save + + void _scriptLoad(); + void _scriptSave(); + void _scriptSendMessage(const ScriptRule& rule, const char* text); + void _scriptEvaluate(); + protected: float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; @@ -225,6 +237,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char* command, char* reply); void loop(); + void scriptHandleCommand(const char* arg, char* reply, ClientInfo* sender); #if defined(WITH_BRIDGE) void setBridgeState(bool enable) override { @@ -233,7 +246,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { { bridge.begin(); } - else + else { bridge.end(); } @@ -252,4 +265,4 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #if defined(USE_SX1262) || defined(USE_SX1268) void setRxBoostedGain(bool enable) override; #endif -}; +}; \ No newline at end of file diff --git a/examples/simple_repeater/REPORT.md b/examples/simple_repeater/REPORT.md new file mode 100644 index 0000000000..c452ba3c61 --- /dev/null +++ b/examples/simple_repeater/REPORT.md @@ -0,0 +1,266 @@ +# MeshCore Repeater Automated Messages + +This allows a repeater node to automatically send messages to a channel based on a condition (trigger) or on a fixed time interval. Rules are stored in SPIFFS and survive reboots. All commands are sent via the standard CLI interface. + +--- + +## Command Reference + +### `report add` + +Adds a new rule. Rules are evaluated every 60 seconds. + +``` +report add / @ [at:HH:MM] [@scope:] [message] +``` + +| Field | Description | +|---|-------------------------------------------------------------------------------------------------------------| +| `` | What causes the report to fire — see [Triggers](#triggers) | +| `/` | Minimum seconds between sends — minimum is `1800` (30 min) | +| `@` | Destination channel — see [Channels](#channels) | +| `at:HH:MM` | Optional time of day to fire (UTC) — see [Time of Day](#time-of-day) | +| `@scope:` | Optional region scope name (e.g. `diag`) | +| `[message]` | Optional message template — see [Template Tokens](#template-tokens). Defaults to `{value}{unit}` if omitted | + +The `at:` and `@scope:` tokens are optional and can appear in any order after the channel. + +--- + +### `report list` + +Lists all configured reports with their index, state, and parameters. + +``` +report list +``` + +Example output: +``` +1:on report:bat/3600s at:08:00 @hash:alertchan "battery {value}{unit}" +2:on bat<3400/1800s @private:b8da... "battery low {value}{unit}" +``` + +--- + +### `report del ` + +Deletes the report at index `n` (1-based). Subsequent reports are renumbered. + +``` +report del 1 +``` + +--- + +### `report clear` + +Deletes all reports. + +``` +report clear +``` + +--- + +### `report test ` + +Forces report `n` to fire immediately, regardless of trigger condition, interval, or `at:` time. Useful for verifying channel setup and message formatting. + +``` +report test 1 +``` + +--- + +### `report enable ` / `report disable ` + +Enables or disables report `n` without deleting it. + +``` +report enable 2 +report disable 2 +``` + +--- + +## Triggers + +### Condition triggers + +Fire only when the measured value crosses the threshold. + +| Trigger | Description | Unit | +|------------|---------------------------------|---| +| `batXXXX` | Battery voltage above threshold | mV | +| `tempXX` | Temperature above threshold | °C (integer) | +| `noise>XX` | Noise floor above threshold | dBm (negative integer) | +| `noise4250 fires if battery is above 4250 mV +temp<-10 fires if temperature is below -10°C +temp>60 fires if temperature is above 60°C +noise>-90 fires if noise floor is above -90 dBm +noise<-80 fires if noise floor is below -80 dBm +``` + +### Periodic triggers + +Fire unconditionally on every interval, regardless of value. + +| Trigger | Description | +|---|---| +| `report:bat` | Report current battery voltage | +| `report:temp` | Report current temperature | +| `report:noise` | Report current noise floor | + +Examples: +``` +report:bat send battery voltage every interval +report:temp send temperature every interval +report:noise send noise floor every interval +``` + +--- + +## Time of Day + +The optional `at:HH:MM` token restricts a report to fire only at a specific time of day. Without it, the report fires whenever the trigger condition is met and the interval has elapsed. + +``` +at:08:00 fire at 08:00 UTC +at:23:30 fire at 23:30 UTC +``` + +> **Note:** All times are UTC. Account for your local timezone offset when setting the time. + +When using `at:`, set the interval to `86400` (24 hours) to fire once per day: + +``` +report add report:bat /86400 at:08:00 @hash:alertchan "Battery {value}{unit}" +``` + +The report engine evaluates every 60 seconds, so the fire window is within ±1 minute of the specified time. + +`at:` can be combined with condition triggers to create a daily check at a specific time: + +``` +report add bat<3400 /86400 at:06:00 @hash:alertchan "battery low {value}{unit}" +``` + +--- + +## Channels + +Two channel types are supported. + +### Hash channel (`@hash:name`) + +Sends to a public hashtag channel. The channel name must be entered **without** the `#` prefix — the `#` is added internally when deriving the channel key. + +``` +@hash:alertchan sends to #alertchan +@hash:open_diag sends to #open_diag +``` + +### Private channel (`@private:hexkey`) + +Sends to a key-protected private channel. The key is a 32-character hex string (16 raw bytes), as shown in the companion app channel settings. + +``` +@private:b8fe52900c881c97afe2ca8327681911 +``` + +Since CLI commands are encrypted peer-to-peer, the key can be safely sent over LoRa via the CLI. + +--- + +## Scope (optional) + +If a region scope is specified, the packet is sent with a transport code matching that region. If omitted, `default_scope` is used as-is (which may be null, resulting in a plain unscoped flood). + +``` +@scope:diag +@scope:world +``` + +The scope name must match a region name configured in the node's region map. + +--- + +## Template Tokens + +The message field supports the following tokens, which are replaced at send time: + +| Token | Replaced with | Example | +|---|---|---| +| `{value}` | The measured value as an integer | `3742` or `-103` | +| `{unit}` | The unit for the measured value | `mV`, `C`, or `dBm` | + +Temperature is always reported in °C. + +Tokens can be combined freely. If no message is provided, the default template `{value}{unit}` is used. + +Example messages: +``` +"{value}{unit}" → "3742mV" +"battery low {value}{unit}" → "battery low 3312mV" +"Noise floor: {value}{unit}" → "Noise floor: -103dBm" +"Temp alert: {value}{unit}" → "Temp alert: 62C" +``` + +--- + +## Limits + +| Parameter | Value | +|---|---| +| Maximum reports | 8 | +| Minimum interval | 1800 seconds (30 minutes) | +| Message template length | 64 characters | +| Channel name / key length | 36 characters | +| Scope name length | 32 characters | + +--- + +## Examples + +``` +# Report battery every hour on #alertchan +report add report:bat /3600 @hash:alertchan "battery {value}{unit}" + +# Report battery every day at 08:00 UTC on #alertchan +report add report:bat /86400 at:08:00 @hash:alertchan "battery {value}{unit}" + +# Report noise floor every day at 06:00 UTC with region scope +report add report:noise /86400 at:06:00 @hash:alertchan @scope:diag "noise floor {value}{unit}" + +# Alert if battery drops below 3.4V (check every 30 min) +report add bat<3400 /1800 @hash:alertchan "battery low {value}{unit}" + +# Daily battery check at 07:00 UTC — only alert if low +report add bat<3400 /86400 at:07:00 @hash:alertchan "battery low {value}{unit}" + +# Alert if temperature exceeds 60°C +report add temp>60 /1800 @hash:alertchan "temp high {value}{unit}" + +# Report noise floor every 2 hours on a private channel +report add report:noise /7200 @private:b8fe52900c881c97afe2ca8327681911 "noise floor {value}{unit}" + +# List all reports +report list + +# Force report 1 to fire immediately for testing +report test 1 + +# Disable report 2 temporarily +report disable 2 + +# Delete report 1 +report del 1 +``` \ No newline at end of file diff --git a/examples/simple_repeater/ReportEngine.cpp b/examples/simple_repeater/ReportEngine.cpp new file mode 100644 index 0000000000..cd15fa64d2 --- /dev/null +++ b/examples/simple_repeater/ReportEngine.cpp @@ -0,0 +1,627 @@ +// ReportEngine.cpp +// +// Implements load/save, CLI parser and evaluation of script rules. +// Included directly from MyMesh.cpp — call the four public methods: +// +// _scriptLoad() — from begin() +// scriptHandleCommand(arg, reply) — from handleCommand() +// _scriptEvaluate() — from loop() on timer +// _scriptSendMessage(rule, text) — internal, called by _scriptEvaluate() + +#include "MyMesh.h" +#include "ReportEngine.h" + +// ═══════════════════════════════════════════════════════════════════════════════ +// LOAD / SAVE +// ═══════════════════════════════════════════════════════════════════════════════ + +void MyMesh::_scriptLoad() { + memset(_scriptRules, 0, sizeof(_scriptRules)); + _scriptRuleCount = 0; + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File f = _fs->open(SCRIPT_RULES_FILE, FILE_O_READ); +#elif defined(RP2040_PLATFORM) + File f = _fs->open(SCRIPT_RULES_FILE, "r"); +#else + File f = _fs->open(SCRIPT_RULES_FILE); +#endif + if (!f) return; + + ScriptRulesFile hdr; + if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr)) { f.close(); return; } + + if (hdr.version != SCRIPT_ENGINE_VERSION) { + MESH_DEBUG_PRINTLN("ScriptEngine: version mismatch, discarding rules"); + f.close(); return; + } + if (hdr.count > MAX_SCRIPT_RULES) hdr.count = MAX_SCRIPT_RULES; + + memcpy(_scriptRules, hdr.rules, hdr.count * sizeof(ScriptRule)); + _scriptRuleCount = hdr.count; + f.close(); + MESH_DEBUG_PRINTLN("ScriptEngine: loaded %d rules", (int)_scriptRuleCount); +} + +void MyMesh::_scriptSave() { + ScriptRulesFile hdr; + hdr.version = SCRIPT_ENGINE_VERSION; + hdr.count = _scriptRuleCount; + memcpy(hdr.rules, _scriptRules, _scriptRuleCount * sizeof(ScriptRule)); + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _fs->remove(SCRIPT_RULES_FILE); + File f = _fs->open(SCRIPT_RULES_FILE, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = _fs->open(SCRIPT_RULES_FILE, "w"); +#else + if (_fs->exists(SCRIPT_RULES_FILE)) _fs->remove(SCRIPT_RULES_FILE); + File f = _fs->open(SCRIPT_RULES_FILE, "w"); +#endif + if (!f) { MESH_DEBUG_PRINTLN("ScriptEngine: ERROR saving rules"); return; } + f.write((uint8_t*)&hdr, sizeof(hdr)); + f.close(); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SEND HELPER +// ═══════════════════════════════════════════════════════════════════════════════ + +// Initialise a GroupChannel from a report. +// +// Both channel types use the same 16-byte key and the same hash derivation: +// secret[] = 16-byte key, zero-padded to PUB_KEY_SIZE +// hash[] = first PATH_HASH_SIZE bytes of SHA256(key[0..15]) +// +// Hash channel (@hash:name): +// key = SHA256("#" + name)[0..15] +// +// Private channel (@private:hexkey): +// key = fromHex(hexkey) — exactly 32 hex chars (16 bytes) +static void _scriptInitChannel(mesh::GroupChannel& ch, const ScriptRule& rule) { + memset(&ch, 0, sizeof(ch)); + + uint8_t key16[16]; + memset(key16, 0, sizeof(key16)); + + if (rule.chan_is_hash) { + char prefixed[40]; + snprintf(prefixed, sizeof(prefixed), "#%s", rule.chan_name); + uint8_t digest[32]; + mesh::Utils::sha256(digest, sizeof(digest), + (const uint8_t*)prefixed, strlen(prefixed)); + memcpy(key16, digest, 16); + } else { + // fromHex() requires strlen(src) == dest_size*2 exactly (16*2 = 32 hex chars) + mesh::Utils::fromHex(key16, 16, rule.chan_name); + } + + // secret[] = 16-byte key, rest zero-padded + memcpy(ch.secret, key16, 16); + + // hash[] = first PATH_HASH_SIZE bytes of SHA256(key[0..15]) + uint8_t hash_digest[32]; + mesh::Utils::sha256(hash_digest, sizeof(hash_digest), key16, 16); + memcpy(ch.hash, hash_digest, sizeof(ch.hash)); +} + +// Sends a message on a GroupChannel — built exactly like periodic_msg. +void MyMesh::_scriptSendMessage(const ScriptRule& rule, const char* text) { + mesh::GroupChannel ch; + _scriptInitChannel(ch, rule); + + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + uint8_t temp[128]; + memcpy(temp, ×tamp, 4); + temp[4] = 0; // TXT_TYPE_PLAIN + snprintf((char*)&temp[5], sizeof(temp) - 5, "%s: %s", _prefs.node_name, text); + int content_len = strlen((char*)&temp[5]); + + auto pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, ch, temp, 5 + content_len); + if (pkt) { + // Use named scope if set, otherwise fall back to default_scope as-is. + // A local TransportKey is used so default_scope is never modified. + if (rule.scope_name[0] != 0) { + TransportKey scope; + memset(scope.key, 0, sizeof(scope.key)); + RegionEntry* region = region_map.findByName(rule.scope_name); + if (region) region_map.getTransportKeysFor(*region, &scope, 1); + sendFloodScoped(scope, pkt, 0, _prefs.path_hash_mode + 1); + } else { + sendFloodScoped(default_scope, pkt, 0, _prefs.path_hash_mode + 1); + } + MESH_DEBUG_PRINTLN("ScriptEngine: sent '%s' on '%s'", text, rule.chan_name); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// EVALUATE RULES (called from loop() every minute) +// ═══════════════════════════════════════════════════════════════════════════════ + +void MyMesh::_scriptEvaluate() { + if (_scriptRuleCount == 0) return; + + uint32_t now = getRTCClock()->getCurrentTime(); + uint16_t bat_mv = board.getBattMilliVolts(); + float temp_f = board.getMCUTemperature(); // NaN if not supported by target + int16_t noise_dbm = (int16_t)_radio->getNoiseFloor(); + + for (uint8_t i = 0; i < _scriptRuleCount; i++) { + ScriptRule& r = _scriptRules[i]; + if (!r.enabled) continue; + + // Enforce minimum interval + uint32_t effective_interval = max((uint32_t)SCRIPT_MIN_INTERVAL_SECS, r.interval_secs); + if (r.last_fired != 0 && (now - r.last_fired) < effective_interval) continue; + + // If at_hour is set, only fire within the correct minute window. + // Reuse already-fetched timestamp (now) to avoid a second RTC call. + if (r.at_hour != -1) { + uint8_t cur_hour = (now % 86400) / 3600; + uint8_t cur_minute = (now % 3600) / 60; + if (cur_hour != (uint8_t)r.at_hour || cur_minute != (uint8_t)r.at_minute) continue; + } + + // Evaluate trigger + bool fire = false; + int16_t actual_value = 0; + const char* unit = ""; + + switch (r.trigger) { + case TRIGGER_BAT_BELOW: + actual_value = (int16_t)bat_mv; + unit = "mV"; + fire = (bat_mv < (uint16_t)r.trigger_value); + break; + + case TRIGGER_BAT_ABOVE: + actual_value = (int16_t)bat_mv; + unit = "mV"; + fire = (bat_mv > (uint16_t)r.trigger_value); + break; + + case TRIGGER_TEMP_BELOW: + if (isnan(temp_f)) break; + actual_value = (int16_t)temp_f; + unit = "C"; + fire = (actual_value < r.trigger_value); + break; + + case TRIGGER_TEMP_ABOVE: + if (isnan(temp_f)) break; + actual_value = (int16_t)temp_f; + unit = "C"; + fire = (actual_value > r.trigger_value); + break; + + case TRIGGER_NOISE_ABOVE: + actual_value = noise_dbm; + unit = "dBm"; + fire = (noise_dbm > r.trigger_value); + break; + + case TRIGGER_NOISE_BELOW: + actual_value = noise_dbm; + unit = "dBm"; + fire = (noise_dbm < r.trigger_value); + break; + + case TRIGGER_PERIODIC: + switch (r.report_var) { + case REPORT_BAT: + actual_value = (int16_t)bat_mv; + unit = "mV"; + fire = true; + break; + case REPORT_TEMP: + if (!isnan(temp_f)) { // only fire if sensor is available + actual_value = (int16_t)temp_f; + unit = "C"; + fire = true; + } + break; + case REPORT_NOISE: + actual_value = noise_dbm; + unit = "dBm"; + fire = true; + break; + } + break; + } + + if (!fire) continue; + + // Build message — replace {value} and {unit} in the template + const char* tmpl = r.message[0] ? r.message : "{value}{unit}"; + char val_str[16]; + snprintf(val_str, sizeof(val_str), "%d", (int)actual_value); + + // Manual template substitution without dynamic allocation + char buf[128]; + const char* src = tmpl; + char* dst = buf; + char* end = buf + sizeof(buf) - 1; + while (*src && dst < end) { + if (strncmp(src, "{value}", 7) == 0) { + size_t vlen = strlen(val_str); + if (dst + vlen < end) { memcpy(dst, val_str, vlen); dst += vlen; } + src += 7; + } else if (strncmp(src, "{unit}", 6) == 0) { + size_t ulen = strlen(unit); + if (dst + ulen < end) { memcpy(dst, unit, ulen); dst += ulen; } + src += 6; + } else { + *dst++ = *src++; + } + } + *dst = 0; + + _scriptSendMessage(r, buf); + r.last_fired = now; + // last_fired is not persisted to SPIFFS on every fire — it resets on reboot. + // Worst case: one extra message sent after an unexpected reboot, which is acceptable. + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CLI PARSER +// ═══════════════════════════════════════════════════════════════════════════════ +// +// Command format: +// report add bat<3400 /3600 @private:MyKey "Battery low: {value}{unit}" +// report add bat>4300 /3600 @hash:AlertChan "Battery high: {value}{unit}" +// report add temp<-10 /3600 @private:MyKey "Temp low: {value}{unit}" +// report add temp>60 /3600 @hash:Chan "Temp high: {value}{unit}" +// report add noise>-90 /3600 @hash:Chan "Noise: {value}{unit}" +// report add noise<-80 /3600 @hash:Chan "Noise: {value}{unit}" +// report add report:bat /3600 @hash:Chan "Battery: {value}{unit}" +// report add report:temp /1800 @hash:Chan "Temp: {value}{unit}" +// report add report:noise /7200 @hash:Chan "Noise floor: {value}{unit}" +// report list +// report del (1-based index) +// report clear +// report test (force immediate fire) +// report enable +// report disable + +static const char* _triggerName(ScriptTriggerType t, ScriptReportVar rv) { + switch (t) { + case TRIGGER_BAT_BELOW: return "bat<"; + case TRIGGER_BAT_ABOVE: return "bat>"; + case TRIGGER_TEMP_BELOW: return "temp<"; + case TRIGGER_TEMP_ABOVE: return "temp>"; + case TRIGGER_NOISE_ABOVE: return "noise>"; + case TRIGGER_NOISE_BELOW: return "noise<"; + case TRIGGER_PERIODIC: + switch (rv) { + case REPORT_BAT: return "report:bat"; + case REPORT_TEMP: return "report:temp"; + case REPORT_NOISE: return "report:noise"; + } + } + return "?"; +} + +void MyMesh::scriptHandleCommand(const char* arg, char* reply, ClientInfo* sender) { + bool is_remote = (sender != NULL); // Check if commands are from Serial (ClientInfo = NULL). + while (*arg == ' ') arg++; // skip leading spaces + + // ── report list [page] / report list d ──────────────────────────────────── + // "report list" — compact paged view, page 1 + // "report list 2" — compact paged view, page 2 + // "report list d1" — full detail for report n + if (strncmp(arg, "list", 4) == 0 && (arg[4] == 0 || arg[4] == ' ')) { + if (_scriptRuleCount == 0) { strcpy(reply, "No reports"); return; } + if (!is_remote) { + // Serial CLI full output — no size limit + for (uint8_t i = 0; i < _scriptRuleCount; i++) { + const ScriptRule& r = _scriptRules[i]; + char at_buf[12] = ""; + if (r.at_hour != -1) snprintf(at_buf, sizeof(at_buf), " at:%02d:%02d", r.at_hour, r.at_minute); + Serial.printf("%d:%s %s /%us%s @%s:%s \"%s\"\n", + i + 1, + r.enabled ? "on" : "off", + _triggerName(r.trigger, r.report_var), + r.interval_secs, + at_buf, + r.chan_is_hash ? "hash" : "private", + r.chan_name, + r.message); + } + reply[0] = '\0'; + return; + } else { + const char* list_arg = arg + 4; + while (*list_arg == ' ') list_arg++; + + // ── Detail view: report list d ────────────────────────────────────── + if (list_arg[0] == 'd') { + int idx = atoi(list_arg + 1) - 1; + if (idx < 0 || idx >= (int)_scriptRuleCount) { + snprintf(reply, 160, "Err - invalid index (1..%d)", (int)_scriptRuleCount); + return; + } + const ScriptRule& r = _scriptRules[idx]; + char at_buf[12] = ""; + if (r.at_hour != -1) snprintf(at_buf, sizeof(at_buf), " at:%02d:%02d", r.at_hour, r.at_minute); + snprintf(reply, 160, + "%d:%s %s/%us%s @%s:%s%s%s \"%s\"", + idx + 1, + r.enabled ? "on" : "off", + _triggerName(r.trigger, r.report_var), + r.interval_secs, + at_buf, + r.chan_is_hash ? "hash" : "private", + r.chan_name, + r.scope_name[0] ? " @scope:" : "", + r.scope_name[0] ? r.scope_name : "", + r.message); + return; + } + + // ── Compact paged view ──────────────────────────────────────────────── + // Compact format per report (~45 bytes): + // "1:on report:bat/3600 @h:alertchan at:08:00" + // "2:off bat<3400/1800 @p:b8da.." + // @h: = hash channel, @p: = first 4 chars of private key + ".." + int page = list_arg[0] ? atoi(list_arg) : 1; + if (page < 1) page = 1; + + // Determine reports per page by fitting into 150 bytes + // Worst compact line: "8:off report:noise/86400 at:23:59 @p:b8da.. @s:world" = ~55 bytes + // 150 / 57 = 2 reports per page safe minimum, use 3 as target + const int RULES_PER_PAGE = 3; + int start = (page - 1) * RULES_PER_PAGE; + int total_pages = ((int)_scriptRuleCount + RULES_PER_PAGE - 1) / RULES_PER_PAGE; + + if (start >= (int)_scriptRuleCount) { + snprintf(reply, 160, "Err - page %d of %d", page, total_pages); + return; + } + + int len = 0; + // Page header: "p1/2: " to show navigation + len += snprintf(reply + len, 160 - len, "p%d/%d:\n", page, total_pages); + + int end = start + RULES_PER_PAGE; + if (end > (int)_scriptRuleCount) end = (int)_scriptRuleCount; + + for (int i = start; i < end; i++) { + const ScriptRule& r = _scriptRules[i]; + + // Channel: @h:name or @p:first4.. + char chan_buf[12]; + if (r.chan_is_hash) { + snprintf(chan_buf, sizeof(chan_buf), "@h:%.7s", r.chan_name); + } else { + snprintf(chan_buf, sizeof(chan_buf), "@p:%.4s..", r.chan_name); + } + + // at: suffix + char at_buf[10] = ""; + if (r.at_hour != -1) snprintf(at_buf, sizeof(at_buf), " %02d:%02d", r.at_hour, r.at_minute); + + // scope suffix: first 6 chars + char sc_buf[10] = ""; + if (r.scope_name[0]) snprintf(sc_buf, sizeof(sc_buf), " @s:%.6s", r.scope_name); + + len += snprintf(reply + len, 160 - len, + "%d:%s %s/%u %s%s%s\n", + i + 1, + r.enabled ? "on" : "off", + _triggerName(r.trigger, r.report_var), + r.interval_secs, + chan_buf, + at_buf, + sc_buf); + + if (len >= 155) break; + } + return; + } + } + + // ── report clear ──────────────────────────────────────────────────────────── + if (strcmp(arg, "clear") == 0) { + _scriptRuleCount = 0; + memset(_scriptRules, 0, sizeof(_scriptRules)); + _scriptRulesDirty = true; // deferred save — avoids blocking CLI on nRF52 + strcpy(reply, "OK - all rules cleared"); + return; + } + + // ── report del ────────────────────────────────────────────────────────── + if (strncmp(arg, "del ", 4) == 0) { + int idx = atoi(arg + 4) - 1; // convert 1-based to 0-based + if (idx < 0 || idx >= (int)_scriptRuleCount) { + snprintf(reply, 160, "Err - invalid index (1..%d)", (int)_scriptRuleCount); + return; + } + // Shift subsequent rules down one slot + for (int j = idx; j < (int)_scriptRuleCount - 1; j++) { + _scriptRules[j] = _scriptRules[j + 1]; + } + _scriptRuleCount--; + _scriptRulesDirty = true; // deferred save — avoids blocking CLI on nRF52 + snprintf(reply, 160, "OK - rule %d deleted", idx + 1); + return; + } + + // ── report enable / disable ───────────────────────────────────────────── + if (strncmp(arg, "enable ", 7) == 0 || strncmp(arg, "disable ", 8) == 0) { + bool en = (arg[0] == 'e'); + int idx = atoi(arg + (en ? 7 : 8)) - 1; + if (idx < 0 || idx >= (int)_scriptRuleCount) { + snprintf(reply, 160, "Err - invalid index (1..%d)", (int)_scriptRuleCount); + return; + } + _scriptRules[idx].enabled = en; + _scriptRulesDirty = true; // deferred save — avoids blocking CLI on nRF52 + snprintf(reply, 160, "OK - rule %d %s", idx + 1, en ? "enabled" : "disabled"); + return; + } + + // ── report test ───────────────────────────────────────────────────────── + if (strncmp(arg, "test ", 5) == 0) { + int idx = atoi(arg + 5) - 1; + if (idx < 0 || idx >= (int)_scriptRuleCount) { + snprintf(reply, 160, "Err - invalid index (1..%d)", (int)_scriptRuleCount); + return; + } + // Reset last_fired so _scriptEvaluate() fires immediately + _scriptRules[idx].last_fired = 0; + _scriptEvaluate(); + snprintf(reply, 160, "OK - rule %d tested", idx + 1); + return; + } + + // ── report add ────────────────────────────────────────────────────────────── + if (strncmp(arg, "add ", 4) == 0) { + if (_scriptRuleCount >= MAX_SCRIPT_RULES) { + snprintf(reply, 160, "Err - max %d rules reached", MAX_SCRIPT_RULES); + return; + } + + const char* p = arg + 4; + ScriptRule r; + memset(&r, 0, sizeof(r)); + r.enabled = true; + r.at_hour = -1; + r.at_minute = 0; + + // Parse trigger: bat<, bat>, temp<, temp>, noise>, noise< or report:bat/temp/noise + if (strncmp(p, "report:", 7) == 0) { + r.trigger = TRIGGER_PERIODIC; + p += 7; + if (strncmp(p, "bat", 3) == 0) { r.report_var = REPORT_BAT; p += 3; } + else if (strncmp(p, "temp", 4) == 0) { r.report_var = REPORT_TEMP; p += 4; } + else if (strncmp(p, "noise", 5) == 0) { r.report_var = REPORT_NOISE; p += 5; } + else { strcpy(reply, "Err - unknown report var (bat/temp/noise)"); return; } + } else { + // Detect variable and operator + const char* ops[] = { "bat<", "bat>", "temp<", "temp>", "noise>", "noise<" }; + ScriptTriggerType types[] = { + TRIGGER_BAT_BELOW, TRIGGER_BAT_ABOVE, + TRIGGER_TEMP_BELOW, TRIGGER_TEMP_ABOVE, + TRIGGER_NOISE_ABOVE, TRIGGER_NOISE_BELOW + }; + bool found = false; + for (int k = 0; k < 6; k++) { + size_t olen = strlen(ops[k]); + if (strncmp(p, ops[k], olen) == 0) { + r.trigger = types[k]; + r.trigger_value = (int16_t)atoi(p + olen); + p += olen; + while (*p && *p != ' ') p++; // skip past the number + found = true; break; + } + } + if (!found) { + strcpy(reply, "Err - unknown trigger (bat<, bat>, temp<, temp>, noise>, noise<, report:X)"); + return; + } + } + + // Parse interval: /NNNN (seconds) + while (*p == ' ') p++; + if (*p != '/') { strcpy(reply, "Err - expected /interval_secs"); return; } + p++; + r.interval_secs = (uint32_t)atoi(p); + if (r.interval_secs < SCRIPT_MIN_INTERVAL_SECS) { + snprintf(reply, 160, "Err - interval min %d secs (%d min)", + SCRIPT_MIN_INTERVAL_SECS, SCRIPT_MIN_INTERVAL_SECS / 60); + return; + } + while (*p && *p != ' ') p++; // skip past the number + + // Parse channel: @private:KEY or @hash:NAME + // Skip optional at:HH:MM token if it appears before the channel + while (*p == ' ') p++; + if (strncmp(p, "at:", 3) == 0) { + while (*p && *p != ' ') p++; // skip past at:HH:MM + while (*p == ' ') p++; + } + if (*p != '@') { strcpy(reply, "Err - expected @private:KEY or @hash:NAME"); return; } + p++; + if (strncmp(p, "private:", 8) == 0) { + r.chan_is_hash = false; + p += 8; + } else if (strncmp(p, "hash:", 5) == 0) { + r.chan_is_hash = true; + p += 5; + } else { + strcpy(reply, "Err - expected @private:KEY or @hash:NAME"); + return; + } + // Channel name/key runs until next space + int ci = 0; + while (*p && *p != ' ' && ci < (int)sizeof(r.chan_name) - 1) { + r.chan_name[ci++] = *p++; + } + r.chan_name[ci] = 0; + if (ci == 0) { strcpy(reply, "Err - empty channel name/key"); return; } + + // Parse optional @scope:NAME token anywhere in remaining input + { + const char* scope_tok = strstr(p, "@scope:"); + if (scope_tok) { + scope_tok += 7; // skip "@scope:" + int si = 0; + while (*scope_tok && *scope_tok != ' ' && *scope_tok != '"' && si < (int)sizeof(r.scope_name) - 1) { + r.scope_name[si++] = *scope_tok++; + } + r.scope_name[si] = 0; + } + } + + // Parse optional at:HH:MM — search in the full remaining arg string + // before parsing the message, so p still covers the whole tail. + // Use " at:" prefix to avoid false matches inside channel names. + { + const char* at_tok = strstr(p, " at:"); + if (at_tok) { + at_tok += 4; // skip " at:" + int hh = atoi(at_tok); + const char* colon = strchr(at_tok, ':'); + int mm = colon ? atoi(colon + 1) : 0; + if (hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) { + r.at_hour = (int8_t)hh; + r.at_minute = (int8_t)mm; + } + } + } + + // Parse optional message template (quoted) + const char* msg = strchr(p, '"'); + if (msg) { + msg++; // skip opening quote + int mi = 0; + while (*msg && *msg != '"' && mi < (int)sizeof(r.message) - 1) { + r.message[mi++] = *msg++; + } + r.message[mi] = 0; + } else { + // Default template if none provided + strcpy(r.message, "{value}{unit}"); + } + + _scriptRules[_scriptRuleCount++] = r; + _scriptRulesDirty = true; // deferred save — avoids blocking CLI on nRF52 + char at_reply[12] = ""; + if (r.at_hour != -1) snprintf(at_reply, sizeof(at_reply), " at:%02d:%02d", r.at_hour, r.at_minute); + snprintf(reply, 160, "OK - rule %d added: %s/%us%s @%s:%s%s%s", + (int)_scriptRuleCount, + _triggerName(r.trigger, r.report_var), + r.interval_secs, + at_reply, + r.chan_is_hash ? "hash" : "private", + r.chan_name, + r.scope_name[0] ? " @scope:" : "", + r.scope_name[0] ? r.scope_name : ""); + return; + } + + // ── Unknown sub-command ─────────────────────────────────────────────────── + strcpy(reply, + "Err - usage: report |clear|test |enable |disable >"); +} \ No newline at end of file diff --git a/examples/simple_repeater/ReportEngine.h b/examples/simple_repeater/ReportEngine.h new file mode 100644 index 0000000000..198dc4a0eb --- /dev/null +++ b/examples/simple_repeater/ReportEngine.h @@ -0,0 +1,51 @@ +#pragma once + +#include + + +// ── Limits ──────────────────────────────────────────────────────────────────── +#define MAX_SCRIPT_RULES 8 +#define SCRIPT_MIN_INTERVAL_SECS 1800 // 30 min absolute minimum +#define SCRIPT_RULES_FILE "/report_rules" +#define SCRIPT_ENGINE_VERSION 1 // bump on struct changes + +// ── Trigger types ───────────────────────────────────────────────────────────── +enum ScriptTriggerType : uint8_t { + TRIGGER_BAT_BELOW = 0, // batXXXX (mV) + TRIGGER_TEMP_BELOW = 2, // tempXX (°C, int16) + TRIGGER_NOISE_ABOVE = 4, // noise>XX (dBm, int16 — typically negative) + TRIGGER_NOISE_BELOW = 5, // noise= SCRIPT_MIN_INTERVAL_SECS) + bool chan_is_hash; // false = private/key channel, true = hash channel + char chan_name[36]; // channel key (private, 32 hex chars) or channel name (hash) + char message[64]; // template with {value} and {unit} placeholders + char scope_name[32]; // optional region scope name (empty = use default_scope) + int8_t at_hour; // -1 = not set, 0-23 = fire at this hour (UTC) + int8_t at_minute; // 0-59, only used when at_hour != -1 + uint32_t last_fired; // RTC timestamp of last send (0 = never) +}; + +// ── Persistent file header ──────────────────────────────────────────────────── +struct ScriptRulesFile { + uint8_t version; + uint8_t count; + ScriptRule rules[MAX_SCRIPT_RULES]; +}; \ No newline at end of file