From ff50589b6980efa1fcb6da8296884d2d421c2e47 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 23 Sep 2026 00:19:12 -0700 Subject: [PATCH] feat(cli): support relative minutes in radio schedules --- docs/cli_commands.md | 10 ++++- docs/radio_profiles.md | 12 ++++- src/helpers/CLICommandUtils.h | 17 +++++++ src/helpers/CommonCLI.cpp | 14 +++--- src/helpers/RadioProfileCLI.cpp | 8 ++-- test/fixtures/radio_profiles/cli_test.cpp | 28 ++++++++++++ test/test_radio_profile_cli.py | 3 +- .../test_repeater_radio_timing_integration.py | 45 +++++++++++++++++++ 8 files changed, 121 insertions(+), 16 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 2d88e6e5c..1826cdf42 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1285,11 +1285,17 @@ intervals and the 24-hour watchdog policy. An explicitly saved normal-mode - `bw`: Bandwidth in kHz (same allowed values as `set radio`) - `sf`: Spreading factor (5-12) - `cr`: Coding rate (5-8) -- `start_time`: Unix epoch time when the setting starts -- `end_time`: Unix epoch time when a temporary setting reverts +- `start_time`: UTC Unix epoch seconds, or `+N` for N whole minutes from now +- `end_time`: UTC Unix epoch seconds, or `+N` for N whole minutes from now - `n`: Scheduled entry number from `get radioat` or `get tempradioat` **Notes:** +- Both relative endpoints use the same command-time snapshot of the node clock. + For example, `set tempradioat 910.5,500,8,5,+5,+15` starts in 5 minutes + and reverts in 15 minutes (10 minutes active). `set radioat 910.5,500,8,5,+5` + schedules a saved change in 5 minutes. Bare numbers remain absolute timestamps; + absolute and relative endpoints can be mixed. Relative offsets must be positive. + The existing scheduler and clock behavior are unchanged. - `get radioat` and `get tempradioat` list all entries when `n` is omitted. - `del radioat` and `del tempradioat` delete all entries when `n` is omitted. - Each queue supports 3 entries. Scheduled entries are not saved across reboot. diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 536717abf..6d56df27e 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -427,7 +427,7 @@ See the [implementation and validation notes](radio_dwell_policy_validation.md). ## Schedule the second profile -Use UTC Unix timestamps (seconds): +Use UTC Unix timestamps (seconds), or `+N` for N whole minutes from now: ```text set radioat2 910.5,500,8,5,rxtx,START[,PREAMBLE] @@ -440,12 +440,20 @@ del radioat2 1 del tempradioat2 all ``` -Replace `START`, `END` and the optional `PREAMBLE` with numbers; omit the square +Replace `START` and `END` with timestamps or `+N`, and the optional `PREAMBLE` +with a number; omit the square brackets. Each schedule family has four slots. Times must be in the future and within 24 days. Overlapping temporary second-profile sessions are rejected. `radioat2` changes the saved second profile when it starts; `tempradioat2` restores the saved profile at its end. Pending schedules do not survive reboot. +For example, `set tempradioat2 910.5,500,8,5,rxtx,+5,+15` starts in 5 minutes +and restores in 15 minutes (10 minutes active). Both offsets are measured from +the same node-clock snapshot when accepting the command, not from each other. +`set radioat2 910.5,500,8,5,rxtx,+5` schedules a saved change in 5 minutes. +Offsets must be positive whole minutes. Absolute and relative endpoints can be +mixed; bare numbers still mean UTC seconds. Existing clock requirements apply. + ## Return to one profile ```text diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index 142677b95..82912879f 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -675,6 +675,23 @@ inline bool parseUnsignedIntegerStrict(const char* text, uint32_t& result) { return true; } +// Bare values are UTC seconds; +N is N minutes from one command-time snapshot. +// Resolve both endpoints against the same snapshot, never end against start. +inline bool parseRadioScheduleTime(const char* text, uint32_t now, uint32_t& result) { + if (text == nullptr) return false; + while (*text == ' ' || *text == '\t') ++text; + const bool relative = *text == '+'; + if (relative && (text[1] < '0' || text[1] > '9')) return false; + uint32_t value; + if (!parseUnsignedIntegerStrict(relative ? text + 1 : text, value)) return false; + if (relative) { + if (!value || value > (UINT32_MAX - now) / 60U) return false; + value = now + value * 60U; + } + result = value; + return true; +} + // Zero clears a custom port override; a full broker URI supplies its default. inline bool parseMqttPort(const char* text, uint16_t& result) { uint32_t value; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b4dd951fb..fd262e468 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -364,7 +364,7 @@ static int countSeparatedParts(const char* s, char separator) { static bool parseScheduledRadioArgs(const char* args, bool temporary, float& freq, float& bw, uint8_t& sf, uint8_t& cr, uint32_t& start_time, - uint32_t& end_time) { + uint32_t& end_time, uint32_t now) { const int expected_parts = temporary ? 6 : 5; args = skipSpacesConst(args); if (countSeparatedParts(args, ',') != expected_parts) { @@ -387,7 +387,7 @@ static bool parseScheduledRadioArgs(const char* args, bool temporary, float& fre || !mesh::cli::parseDecimalStrict(parts[1], bw) || !parseUint32Strict(parts[2], sf_u32) || !parseUint32Strict(parts[3], cr_u32) - || !parseUint32Strict(parts[4], start_time)) { + || !mesh::cli::parseRadioScheduleTime(parts[4], now, start_time)) { return false; } if (sf_u32 > 255 || cr_u32 > 255) { @@ -396,7 +396,7 @@ static bool parseScheduledRadioArgs(const char* args, bool temporary, float& fre sf = (uint8_t)sf_u32; cr = (uint8_t)cr_u32; - if (temporary && !parseUint32Strict(parts[5], end_time)) { + if (temporary && !mesh::cli::parseRadioScheduleTime(parts[5], now, end_time)) { return false; } if (!temporary) { @@ -3854,8 +3854,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint16_t preamble = 0; char legacy[120]; if (!mesh::RadioProfileCLI::parseSuffix(&config[8], 5, legacy, sizeof(legacy), preamble) - || !parseScheduledRadioArgs(legacy, false, freq, bw, sf, cr, start_time, end_time)) { - strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start"); + || !parseScheduledRadioArgs(legacy, false, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) { + strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start (UTC or +minutes)"); } else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw) || !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) { strcpy(reply, "Error, invalid radio params"); @@ -3873,8 +3873,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint16_t preamble = 0; char legacy[120]; if (!mesh::RadioProfileCLI::parseSuffix(&config[12], 6, legacy, sizeof(legacy), preamble) - || !parseScheduledRadioArgs(legacy, true, freq, bw, sf, cr, start_time, end_time)) { - strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end"); + || !parseScheduledRadioArgs(legacy, true, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) { + strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)"); } else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw) || !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) { strcpy(reply, "Error, invalid radio params"); diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 2891c7901..afe9a8cbc 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -665,17 +665,17 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, && cli::parseUnsignedIntegerStrict(parts[3], cr) && cr <= 255 && parseMode(parts[4], config.mode); config.params.sf = sf; config.params.cr = cr; + const uint32_t epoch = scheduled && rtc_ ? rtc_->getCurrentTime() : 0; if (valid && count > expected) valid = parsePreamble(parts[expected], config.params.preamble); if (valid && temporary) valid = cli::parseUnsignedIntegerStrict(parts[5], minutes) && minutes >= 1 && minutes <= 10080; - if (valid && scheduled) valid = cli::parseUnsignedIntegerStrict(parts[5], start); - if (valid && scheduled_temp) valid = cli::parseUnsignedIntegerStrict(parts[6], end); + if (valid && scheduled) valid = cli::parseRadioScheduleTime(parts[5], epoch, start); + if (valid && scheduled_temp) valid = cli::parseRadioScheduleTime(parts[6], epoch, end); if (valid) valid = radio_->validateProfile(config.params); RadioProfiles preview = *radio_->profiles(); preview.setSecondary(config, false); if (valid) valid = preview.automaticPreambleFits(); if (!valid) { snprintf(reply, capacity, "Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", key, - scheduled_temp ? ",start,end" : scheduled ? ",start" : temporary ? ",minutes" : ""); return true; } + scheduled_temp ? ",start,end (UTC or +minutes)" : scheduled ? ",start (UTC or +minutes)" : temporary ? ",minutes" : ""); return true; } if (scheduled) { - const uint32_t epoch = rtc_ ? rtc_->getCurrentTime() : 0; if (!epoch || start <= epoch || (uint64_t)(start-epoch) * 1000 > 0x7fffffffULL || (scheduled_temp && (end <= start || (uint64_t)(end-epoch) * 1000 > 0x7fffffffULL))) { snprintf(reply, capacity, "Error: future UTC start/end required, within 24 days"); return true; diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 3bd39715a..a02a37e34 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -187,6 +187,33 @@ static void scheduledSaveFailureKeepsOriginalTimes() { assert(f.radio.p.secondary.params.freq == 910.5f); } +static void relativeSchedules() { + Fixture f; + f.cmd("set tempradioat2 910.5,500,8,5,rxtx,+1,+3,80"); + f.advance(59000); + assert(!f.radio.p.enabled()); + f.advance(1000); + assert(f.radio.p.secondary_temporary); + f.advance(119000); + assert(f.radio.p.secondary_temporary); + f.advance(1000); + assert(!f.radio.p.enabled()); + char command[160]; + snprintf(command, sizeof(command), "set tempradioat2 911.5,500,8,5,rxtx,+1,%lu,80", + (unsigned long)(f.clock.epoch + 120)); + f.cmd(command); + f.advance(60000); + assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 911.5f); + f.advance(60000); + assert(!f.radio.p.enabled()); + f.cmd("set radioat2 912.5,500,8,5,rx,+1,80"); + f.advance(60000); + assert(!f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq == 912.5f); + f.cmd("set radioat2 912.5,500,8,5,rx,+0,80", false); + f.cmd("set radioat2 912.5,500,8,5,rx,+4294967295,80", false); + f.cmd("set tempradioat2 912.5,500,8,5,rx,+3,+1,80", false); +} + int main(int argc, char** argv) { if (argc == 2) { if (!strcmp(argv[1], "permanent_order")) permanentScheduleOrder(); @@ -195,6 +222,7 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "save_failure")) scheduledSaveFailureKeepsOriginalTimes(); else if (!strcmp(argv[1], "schedule_permutations")) schedulePermutations(); else if (!strcmp(argv[1], "storage_isolation")) storageBackoffDoesNotDelayTemporaryWindows(); + else if (!strcmp(argv[1], "relative_schedule")) relativeSchedules(); else return 2; return 0; } diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py index 0204e0edc..1ad6306d7 100644 --- a/test/test_radio_profile_cli.py +++ b/test/test_radio_profile_cli.py @@ -20,7 +20,8 @@ class RadioProfileCLITest(unittest.TestCase): str(ROOT/'test/fixtures/radio_profiles/cli_test.cpp'), '-o', str(exe)], check=True, capture_output=True, text=True) for scenario in (None, 'permanent_order', 'temporary_boundary', 'lease_creation', - 'save_failure', 'schedule_permutations', 'storage_isolation'): + 'save_failure', 'schedule_permutations', 'storage_isolation', + 'relative_schedule'): with self.subTest(scenario=scenario or 'commands_persistence_and_timers'): subprocess.run([str(exe)] + ([scenario] if scenario else []), check=True) diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 3819c8b49..6c66ffab8 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -557,6 +557,51 @@ class RepeaterRadioTimingIntegrationTest(unittest.TestCase): defines = "#define USE_LR2021 1\n" if lr2021 else "" self.compile_and_run(defines + HARNESS.replace("@METHODS@", methods)) + def test_scheduled_radio_relative_and_absolute_times(self): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8") + parsers = "\n".join(method(source, signature) for signature in [ + "static bool looksUnsignedInteger(", "static const char* skipSpacesConst(", + "static bool parseUint32Strict(", "static int countSeparatedParts(", + "static bool parseScheduledRadioArgs(", + ]) + utils = (ROOT / "src/Utils.cpp").read_text(encoding="utf-8") + parts = method(utils, "int Utils::parseTextParts(") + self.compile_and_run(r''' +#include +#include +#include +#include +#include +struct StrHelper { + static void strncpy(char* dst, const char* src, size_t n) { snprintf(dst, n, "%s", src); } +}; +namespace mesh { +struct Utils { static int parseTextParts(char*, const char*[], int, char); }; +@PARTS@ +} +@PARSERS@ +int main() { + const uint32_t now = 1700000000; + float freq, bw; uint8_t sf, cr; uint32_t start, end; + assert(parseScheduledRadioArgs("910.5,500,8,5,+5,+15", true, freq,bw,sf,cr,start,end,now)); + assert(start == now + 300 && end == now + 900); + assert(parseScheduledRadioArgs("910.5,500,8,5,1700000300,1700000900", true, freq,bw,sf,cr,start,end,now)); + assert(start == now + 300 && end == now + 900); + assert(parseScheduledRadioArgs("910.5,500,8,5,1700000300,+15", true, freq,bw,sf,cr,start,end,now)); + assert(start == now + 300 && end == now + 900); + assert(parseScheduledRadioArgs("910.5,500,8,5,+5", false, freq,bw,sf,cr,start,end,now)); + assert(start == now + 300 && end == 0); + for (const char* invalid : {"+0", "+", "++1", "+ 1", "-1", "+1.5", "+1x", "+4294967295", "4294967296"}) { + start = 42; + assert(!mesh::cli::parseRadioScheduleTime(invalid, now, start)); + assert(start == 42); + } + assert(mesh::cli::parseRadioScheduleTime(" +1 ", now, start) && start == now + 60); + assert(mesh::cli::parseRadioScheduleTime("+1", UINT32_MAX - 60, start) && start == UINT32_MAX); + assert(!mesh::cli::parseRadioScheduleTime("+1", UINT32_MAX - 59, start)); +} +'''.replace("@PARTS@", parts).replace("@PARSERS@", parsers)) + def test_production_cli_replies_and_duration_validation(self): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8") parsers = "\n".join(method(source, signature) for signature in [