diff --git a/boards/nrf52840_s140_v7_extrafs.ld b/boards/nrf52840_s140_v7_extrafs.ld index 48348188..ef6ebba1 100644 --- a/boards/nrf52840_s140_v7_extrafs.ld +++ b/boards/nrf52840_s140_v7_extrafs.ld @@ -7,6 +7,9 @@ MEMORY { FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xD4000 - 0x27000 + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) * - Vendor UUID count diff --git a/build.sh b/build.sh index 742c40ef..6a01595d 100755 --- a/build.sh +++ b/build.sh @@ -15,6 +15,7 @@ RADIO_FREQ_OVERRIDE="" RADIO_BW_OVERRIDE="" RADIO_SF_OVERRIDE="" RADIO_CR_OVERRIDE="" +FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-}" BATCH_BUILD_MODE=0 RESOLVED_BUILD_TARGETS=() @@ -60,7 +61,7 @@ Examples: Build firmware for the "RAK_4631_repeater" device target $ bash build.sh build-firmware RAK_4631_repeater -Run without arguments to choose an interactive build action/target, debug options, radio settings, and firmware version +Run without arguments to choose an interactive build action/target, debug options, radio settings, firmware profile, and firmware version $ bash build.sh Build all firmwares for device targets containing the string "RAK_4631" @@ -315,6 +316,10 @@ clear_radio_overrides() { RADIO_CR_OVERRIDE="" } +clear_firmware_profile_overrides() { + FIRMWARE_PROFILE_OVERRIDE="" +} + set_radio_overrides() { RADIO_SETTING_TITLE=$1 RADIO_FREQ_OVERRIDE=$2 @@ -323,6 +328,10 @@ set_radio_overrides() { RADIO_CR_OVERRIDE=$5 } +set_firmware_profile_override() { + FIRMWARE_PROFILE_OVERRIDE=$1 +} + fetch_suggested_radio_settings() { python3 - "$RADIO_SETTINGS_API_URL" <<'PY' import json @@ -367,7 +376,7 @@ is_valid_custom_radio_bandwidth() { python3 - "$1" <<'PY' import sys -allowed = [7.81, 10.42, 15.63, 20.83, 31.25, 41.67, 62.5, 125.0, 250.0, 500.0] +allowed = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125.0, 250.0, 500.0] try: value = float(sys.argv[1]) except Exception: @@ -403,13 +412,13 @@ prompt_for_custom_radio_setting() { echo "Please enter 5, 6, 7, 8, 9, 10, 11, or 12." done - echo "Bandwidth options (kHz): 7.81 10.42 15.63 20.83 31.25 41.67 62.5 125 250 500" + echo "Bandwidth options (kHz): 7.8 10.4 15.6 20.8 31.25 41.7 62.5 125 250 500" while true; do read -r -p "BW (kHz): " bw if [[ "$bw" =~ ^[0-9]+([.][0-9]+)?$ ]] && is_valid_custom_radio_bandwidth "$bw"; then break fi - echo "Please enter one of: 7.81 10.42 15.63 20.83 31.25 41.67 62.5 125 250 500." + echo "Please enter one of: 7.8 10.4 15.6 20.8 31.25 41.7 62.5 125 250 500." done echo "Coding rate options: CR5, CR6, CR7, CR8" @@ -426,6 +435,7 @@ prompt_for_custom_radio_setting() { prompt_for_radio_build_settings() { local -a preset_rows=() + local -a fetched_preset_rows=() local -a options=("Keep target defaults (no radio override)") local row local title @@ -443,20 +453,26 @@ prompt_for_radio_build_settings() { if preset_output=$(fetch_suggested_radio_settings); then if [ -n "$preset_output" ]; then - mapfile -t preset_rows <<< "$preset_output" + mapfile -t fetched_preset_rows <<< "$preset_output" fi - for row in "${preset_rows[@]}"; do + for row in "${fetched_preset_rows[@]}"; do if [ -z "$row" ]; then continue fi - IFS=$'\t' read -r title description freq bw sf cr <<< "$row" - options+=("${title}: ${description}") + preset_rows+=("$row") done else echo "Could not fetch radio presets from ${RADIO_SETTINGS_API_URL}." - preset_rows=() fi + for row in "${preset_rows[@]}"; do + if [ -z "$row" ]; then + continue + fi + IFS=$'\t' read -r title description freq bw sf cr <<< "$row" + options+=("${title}: ${description}") + done + options+=("Custom") custom_index=${#options[@]} @@ -491,6 +507,37 @@ prompt_for_radio_build_settings() { done } +prompt_for_firmware_profile_settings() { + local -a options=( + "Keep target defaults" + "Cascade: path.hash.mode=2 / loop.detect=minimal / rxdelay=2 / agc.reset.interval=8 / advert.interval=0 / flood.advert.interval=83 / multi.acks=1" + ) + + clear_firmware_profile_overrides + + echo "Set firmware profile options:" + while true; do + print_numbered_menu "${options[@]}" + prompt_menu_choice "Firmware profile" "${#options[@]}" + if [ "$MENU_CHOICE" == "QUIT" ]; then + echo "Cancelled." + exit 1 + fi + + case "$MENU_CHOICE" in + 1) + echo "Using target default firmware profile settings." + return 0 + ;; + 2) + set_firmware_profile_override "cascade" + echo "Using firmware profile: Cascade" + return 0 + ;; + esac + done +} + get_env_metadata() { local env_name=$1 local trimmed_env_name @@ -966,6 +1013,14 @@ apply_radio_overrides() { fi } +apply_firmware_profile_overrides() { + case "${FIRMWARE_PROFILE_OVERRIDE,,}" in + cascade) + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCASCADE_PROFILE=1 -DDEFAULT_PATH_HASH_MODE=2 -DDEFAULT_LOOP_DETECT=1 -DDEFAULT_RX_DELAY_BASE=2.0f -DDEFAULT_AGC_RESET_INTERVAL_SECONDS=8 -DDEFAULT_ADVERT_INTERVAL_MINUTES=0 -DDEFAULT_FLOOD_ADVERT_INTERVAL_HOURS=83 -DDEFAULT_MULTI_ACKS=1" + ;; + esac +} + print_build_flags() { local env_name=$1 @@ -1136,6 +1191,7 @@ build_firmware() { disable_debug_flags apply_debug_overrides apply_radio_overrides + apply_firmware_profile_overrides print_build_flags "$env_name" pio run -e "$env_name" @@ -1363,6 +1419,7 @@ main() { prompt_for_build_mode prompt_for_debug_build_settings prompt_for_radio_build_settings + prompt_for_firmware_profile_settings set -- "${SELECTED_COMMAND_ARGS[@]}" validate_command "$@" fi diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ba92a2b1..fe3464c3 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -92,9 +92,10 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -### Start an Over-The-Air (OTA) firmware update +### Start or stop an Over-The-Air (OTA) firmware update **Usage:** - `start ota` +- `stop ota` --- @@ -214,7 +215,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `freq`: Frequency in MHz -- `bw`: Bandwidth in kHz +- `bw`: Bandwidth in kHz. Most targets allow `7.8`, `10.4`, `15.6`, `20.8`, `31.25`, `41.7`, `62.5`, `125`, `250`, `500`. LR1110 targets allow `62.5`, `125`, `250`, `500`. - `sf`: Spreading factor (5-12) - `cr`: Coding rate (5-8) @@ -247,8 +248,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `tempradio ,,,,` **Parameters:** -- `freq`: Frequency in MHz (300-2500) -- `bw`: Bandwidth in kHz (7.8-500) +- `freq`: Frequency in MHz (150-2500) +- `bw`: Bandwidth in kHz (same allowed values as `set radio`) - `sf`: Spreading factor (5-12) - `cr`: Coding rate (5-8) - `timeout_mins`: Duration in minutes (must be > 0) @@ -257,6 +258,32 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### Schedule radio parameter changes +**Usage:** +- `set radioat ,,,,` +- `get radioat [n|all]` +- `del radioat [n|all]` +- `set tempradioat ,,,,,` +- `get tempradioat [n|all]` +- `del tempradioat [n|all]` + +**Parameters:** +- `freq`: Frequency in MHz (150-2500) +- `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 +- `n`: Scheduled entry number from `get radioat` or `get tempradioat` + +**Notes:** +- `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. +- `radioat` saves the new radio preferences when it fires. `tempradioat` applies temporarily, then reverts to the saved radio preferences. + +--- + #### View or change this node's frequency **Usage:** - `get freq` @@ -423,6 +450,46 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### Send a repeater flood text +**Usage:** +- `send text.flood ` + +**Parameters:** +- `message`: Text to send to the shared `#repeaters` flood channel, prefixed with this node's name. + +**Example:** +``` +send text.flood checking ridge link +``` + +--- + +#### View or change battery alert state +**Usage:** +- `get battery.alert` +- `set battery.alert ` + +**Default:** `off` + +**Note:** When enabled, the repeater checks battery level once per minute and sends low-battery warnings to the `#repeaters` flood channel. + +--- + +#### View or change battery alert thresholds +**Usage:** +- `get battery.alert.low` +- `set battery.alert.low <1-100>` +- `get battery.alert.critical` +- `set battery.alert.critical <0-99>` + +**Defaults:** +- `battery.alert.low`: `20` +- `battery.alert.critical`: `10` + +**Note:** The low threshold must be greater than the critical threshold. + +--- + #### View this node's public key **Usage:** `get public.key` @@ -450,7 +517,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Default:** `off` -**Note:** When enabled, device enters sleep mode between radio transmissions +**Note:** When enabled, device enters sleep mode between radio transmissions. Enabling is refused from the local serial console or while an active USB serial data connection is detected; USB power alone does not block power saving. --- @@ -1011,7 +1078,30 @@ set direct.retry off --- -#### View or apply a direct retry preset +#### View or change direct retry heard-table gate +**Usage:** +- `get direct.retry.heard` +- `set direct.retry.heard ` + +**Parameters:** +- `state`: `on`|`off` + +**Default:** `on` + +**Note:** When enabled, the recent repeater table is the direct retry eligibility +gate. Prefixes missing from the table are assumed reachable; prefixes in the +table below the active SNR gate are blocked. + +**Examples:** +``` +get direct.retry.heard +set direct.retry.heard on +set direct.retry.heard off +``` + +--- + +#### View or apply a retry preset **Usage:** - `get retry.preset` - `set retry.preset ` @@ -1020,10 +1110,11 @@ set direct.retry off - `preset`: `infra`|`rooftop`|`mobile` **Notes:** +- Applies shared direct retry and flood retry defaults. - `infra`: fewer, slower retries for stable fixed infrastructure. - `rooftop`: default long retry window for weak rooftop links. -- `mobile`: long retry count with shorter spacing for moving or changing links. -- Changing `direct.retry.count`, `direct.retry.base`, `direct.retry.step`, or `direct.retry.margin` makes the preset report as `custom`. +- `mobile`: long retry count with shorter spacing for moving or changing links; flood retry count is `15`. +- Changing `direct.retry.count`, `direct.retry.base`, `direct.retry.step`, `direct.retry.margin`, `flood.retry.count`, or `flood.retry.path` makes the preset report as `custom`. **Examples:** ``` @@ -1035,6 +1126,155 @@ set retry.preset mobile --- +### Flood Retry + +Flood retry resends flood-routed packets when the same packet is not heard from +another qualifying repeater. + +#### View or change flood retry count +**Usage:** +- `get flood.retry.count` +- `set flood.retry.count ` + +**Parameters:** +- `count`: Base retry attempts after the original send, from `0` to `15`. `0` disables flood retry. + +**Note:** Actual attempts are capped at `15`. Hop 1 flood retries use `count * 2`; hop 2 flood retries use `count * 1.5`, rounded up. + +**Defaults:** +- `infra`: `1` +- `rooftop`: `3` +- `mobile`: `15` + +**Examples:** +``` +get flood.retry.count +set flood.retry.count 0 +set flood.retry.count 15 +``` + +--- + +#### View or change flood retry path gate +**Usage:** +- `get flood.retry.path` +- `set flood.retry.path ` + +**Parameters:** +- `count`: Maximum flood path hash count eligible for retry, from `0` to `63`. +- `off`: Disable the path-length gate. + +**Defaults:** +- `infra`: `1` +- `rooftop`: `2` +- `mobile`: `1` + +**Examples:** +``` +get flood.retry.path +set flood.retry.path 1 +set flood.retry.path off +``` + +--- + +#### View or change flood retry advert handling +**Usage:** +- `get flood.retry.advert` +- `set flood.retry.advert ` + +**Parameters:** +- `on`: Retry node advert floods. +- `off`: Do not retry node advert floods. + +**Default:** `off` + +**Examples:** +``` +get flood.retry.advert +set flood.retry.advert off +``` + +--- + +#### View or change flood retry target prefixes +**Usage:** +- `get flood.retry.prefixes` +- `set flood.retry.prefixes ` + +**Parameters:** +- `prefixes`: Comma-separated 3-byte path hash prefixes, up to 8 entries. +- `none` or `off`: Clear the list. + +**Note:** When set, non-bridge flood retry only accepts same-packet echoes whose +last hop matches one of these prefixes. When unset, any non-ignored last hop can +cancel the retry. + +**Examples:** +``` +get flood.retry.prefixes +set flood.retry.prefixes A58296,860CCA,425E5C +set flood.retry.prefixes none +``` + +--- + +#### View or change flood retry ignored prefixes +**Usage:** +- `get flood.retry.ignore` +- `set flood.retry.ignore ` + +**Parameters:** +- `prefixes`: Comma-separated 3-byte path hash prefixes, up to 8 entries. +- `none` or `off`: Clear the list. + +**Note:** Non-bridge flood retry does not cancel on same-packet echoes whose +last hop matches this list. Bridge mode also excludes these prefixes from bucket +and `other` hits. + +**Examples:** +``` +get flood.retry.ignore +set flood.retry.ignore 71CE82,C7618C +set flood.retry.ignore none +``` + +--- + +#### View or change flood retry bridge mode +**Usage:** +- `get flood.retry.bridge` +- `set flood.retry.bridge ` + +**Note:** Bridge mode retries until each configured fresh bucket, plus the non-source `other` bucket, has been heard or the retry count is exhausted. + +**Examples:** +``` +get flood.retry.bridge +set flood.retry.bridge on +``` + +--- + +#### View or change flood retry bridge buckets +**Usage:** +- `get flood.retry.bucket.` +- `set flood.retry.bucket ` + +**Parameters:** +- `n`: Bucket number from `1` to `6`. +- `prefixes`: Comma-separated 3-byte path hash prefixes, up to 17 entries per bucket. +- `none` or `off`: Clear the bucket. + +**Examples:** +``` +get flood.retry.bucket.1 +set flood.retry.bucket 1 71CE82,C7618C +set flood.retry.bucket 2 none +``` + +--- + #### View or change direct retry count **Usage:** - `get direct.retry.count` @@ -1067,6 +1307,9 @@ set direct.retry.count 15 **Explanation:** - The first retry waits `base` milliseconds after the failed echo window. +- For non-TRACE direct paths shorter than 6 remaining hops, the effective wait is scaled by `hops / 6`. +- Non-TRACE direct paths with 6 or more remaining hops use the configured value unchanged. +- TRACE retries shorter than 16 remaining hops use `hops / 16`; 16 or more remaining hops use the configured value unchanged. - Larger values reduce channel pressure and give slow repeaters more time. - Smaller values recover faster but create tighter retry bursts. @@ -1092,7 +1335,10 @@ set direct.retry.base 500 **Explanation:** - Retry delay is `base + attempt_index * step`. -- With `base=175` and `step=100`, retries wait about `175`, `275`, `375`, `475` ms, and so on. +- For non-TRACE direct paths shorter than 6 remaining hops, that computed delay is scaled by `hops / 6`. +- Non-TRACE direct paths with 6 or more remaining hops use the computed delay unchanged. +- TRACE retries shorter than 16 remaining hops use `hops / 16`; 16 or more remaining hops use the computed delay unchanged. +- With `base=175` and `step=100`, non-TRACE paths with 6 or more remaining hops wait about `175`, `275`, `375`, `475` ms, and so on. - `step=0` keeps every retry at the same delay. - Larger steps spread retries over time and are safer on busy channels. diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index ca2c0803..62bc4779 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -78,7 +78,6 @@ set flood.retry.ignore none | `battery.alert.critical` | Critical threshold percentage. Critical warnings repeat more often. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` | | `recent.repeater` | Shows, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. | `get recent.repeater`, `get recent.repeater `, `set recent.repeater `, `clear recent.repeater` | `set recent.repeater A1B2C3 -8.5` | | `outpath` | Overrides the primary direct route used for replies to the current remote client. | `get outpath`, `set outpath `, `set outpath direct`, `set outpath clear`, `set outpath flood` | `set outpath A1B2C3,D4E5F6` | -| `altpath` | Optional second direct route used for duplicate response attempts to the current remote client. | `get altpath`, `set altpath `, `set altpath clear` | `set altpath A1B2C3,D4E5F6` | ## Other Keymind Commands @@ -146,8 +145,8 @@ Serial CLI pages contain up to `128` rows. Remote LoRa CLI pages contain up to ## Direct Path Overrides -`outpath` and `altpath` apply to the current remote client ACL entry. They need -remote client context, so they are not useful from the local serial CLI. +`outpath` applies to the current remote client ACL entry. It needs remote +client context, so it is not useful from the local serial CLI. Set paths with comma-separated hop hashes. Each hop must be `2`, `4`, or `6` hex characters, and all hops in one path must use the same width. @@ -158,17 +157,12 @@ set outpath A1B2C3,D4E5F6 set outpath direct set outpath clear set outpath flood - -get altpath -set altpath A1B2C3,D4E5F6 -set altpath clear ``` `set outpath direct` sets a zero-hop direct route for a client reachable without repeaters. `set outpath clear` forgets the override and lets normal path discovery fill it again. `set outpath flood` forces replies to use flood packets -until the client logs in again. `altpath` sends a duplicate reply over a second -direct route; clearing it returns replies to a single route. +until the client logs in again. ## Direct Retry Settings @@ -180,8 +174,8 @@ Direct retry applies to direct-routed packets. A queued resend is canceled when | `direct.retry.heard` | Uses the recent repeater table as the direct retry eligibility gate. | `get direct.retry.heard`, `set direct.retry.heard on/off` | `set direct.retry.heard on` | | `direct.retry.margin` | SNR margin in dB above the SF-specific receive floor. | `get direct.retry.margin`, `set direct.retry.margin <0-40>` | `set direct.retry.margin 5` | | `direct.retry.count` | Maximum direct retry attempts after initial TX. | `get direct.retry.count`, `set direct.retry.count <1-15>` | `set direct.retry.count 15` | -| `direct.retry.base` | Base wait in milliseconds before retry. | `get direct.retry.base`, `set direct.retry.base <10-5000>` | `set direct.retry.base 175` | -| `direct.retry.step` | Milliseconds added per retry attempt. | `get direct.retry.step`, `set direct.retry.step <0-5000>` | `set direct.retry.step 100` | +| `direct.retry.base` | Base wait in milliseconds before retry; non-TRACE paths under 6 remaining hops scale by `hops / 6`, TRACE paths under 16 by `hops / 16`. | `get direct.retry.base`, `set direct.retry.base <10-5000>` | `set direct.retry.base 175` | +| `direct.retry.step` | Milliseconds added per retry attempt before the same short-path scaling. | `get direct.retry.step`, `set direct.retry.step <0-5000>` | `set direct.retry.step 100` | | `direct.retry.cr` | Adaptive coding-rate thresholds for direct retry packets. Uses `CR4`, `CR5`, `CR7`, or `CR8`; `CR6` is never selected. | `get direct.retry.cr`, `set direct.retry.cr ,,,`, `set direct.retry.cr off` | `set direct.retry.cr 10.0,7.5,2.5,0` | The default adaptive coding-rate profile is `10.0,7.5,2.5,2.5`. @@ -217,14 +211,16 @@ set direct.retry.margin 0 ## Flood And Advert Settings -Flood retry applies to flood-routed packets. A queued retry is canceled when a qualifying downstream echo is heard. +Flood retry applies to flood-routed packets. A queued retry is canceled when the +same packet is heard from a qualifying, non-ignored repeater. Bridge mode uses +the bucket rules below instead. | Setting | What it does | How to use | Example | | --- | --- | --- | --- | -| `flood.retry.count` | Maximum flood retry attempts after initial TX. `0` disables flood retry. | `get flood.retry.count`, `set flood.retry.count <0-15>` | `set flood.retry.count 7` | +| `flood.retry.count` | Base flood retry attempts after initial TX. Hop 1 doubles it, hop 2 uses 1.5x rounded up, and actual attempts cap at `15`; `0` disables flood retry. | `get flood.retry.count`, `set flood.retry.count <0-15>` | `set flood.retry.count 7` | | `flood.retry.path` | Maximum path hash count eligible for flood retry, or `off` to disable the gate. | `get flood.retry.path`, `set flood.retry.path <0-63/off>` | `set flood.retry.path 1` | | `flood.retry.advert` | Allows or blocks retry for node advert packets (`type=4`). Default is `off`. | `get flood.retry.advert`, `set flood.retry.advert on/off` | `set flood.retry.advert off` | -| `flood.retry.prefixes` | Target prefixes. If set, only matching downstream echoes cancel a retry. | `get flood.retry.prefixes`, `set flood.retry.prefixes ` | `set flood.retry.prefixes BEEBB0,425E5C` | +| `flood.retry.prefixes` | Target prefixes. If set, only same-packet echoes from matching last-hop prefixes cancel a retry. | `get flood.retry.prefixes`, `set flood.retry.prefixes ` | `set flood.retry.prefixes BEEBB0,425E5C` | | `flood.retry.ignore` | Ignored prefixes. In non-bridge retry, ignored last-hop echoes do not cancel retry. | `get flood.retry.ignore`, `set flood.retry.ignore ` | `set flood.retry.ignore 71CE82,C7618C` | | `flood.retry.bridge` | Enables bucket-based bridge retry logic. | `get flood.retry.bridge`, `set flood.retry.bridge on/off` | `set flood.retry.bridge on` | | `flood.retry.bucket.` | Shows one bridge bucket. Buckets are numbered `1`-`6`. | `get flood.retry.bucket.` | `get flood.retry.bucket.1` | @@ -236,7 +232,7 @@ The shared retry preset sets these flood defaults: | --- | ---: | ---: | | `infra` | `1` | `1` | | `rooftop` | `3` | `2` | -| `mobile` | `3` | `1` | +| `mobile` | `15` | `1` | Example for path-gated retry: diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2641b20d..8f301412 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -19,6 +19,28 @@ #define LORA_TX_POWER 20 #endif +#ifndef DEFAULT_ADVERT_INTERVAL_MINUTES + #define DEFAULT_ADVERT_INTERVAL_MINUTES 2 +#endif +#ifndef DEFAULT_FLOOD_ADVERT_INTERVAL_HOURS + #define DEFAULT_FLOOD_ADVERT_INTERVAL_HOURS 47 +#endif +#ifndef DEFAULT_AGC_RESET_INTERVAL_SECONDS + #define DEFAULT_AGC_RESET_INTERVAL_SECONDS 0 +#endif +#ifndef DEFAULT_RX_DELAY_BASE + #define DEFAULT_RX_DELAY_BASE 0.0f +#endif +#ifndef DEFAULT_MULTI_ACKS + #define DEFAULT_MULTI_ACKS 0 +#endif +#ifndef DEFAULT_PATH_HASH_MODE + #define DEFAULT_PATH_HASH_MODE 0 +#endif +#ifndef DEFAULT_LOOP_DETECT + #define DEFAULT_LOOP_DETECT LOOP_DETECT_OFF +#endif + #ifndef ADVERT_NAME #define ADVERT_NAME "repeater" #endif @@ -60,6 +82,165 @@ #define LAZY_CONTACTS_WRITE_DELAY 5000 +#ifndef REPEATERS_CHANNEL_KEY_HEX + #define REPEATERS_CHANNEL_KEY_HEX "89db441e2814dccf0dbd2e8cc5f501a3" +#endif +#ifndef BATT_MIN_MILLIVOLTS + #define BATT_MIN_MILLIVOLTS 3000 +#endif +#ifndef BATT_MAX_MILLIVOLTS + #define BATT_MAX_MILLIVOLTS 4200 +#endif + +#define LOW_BATTERY_MIN_VALID_MV 1000 +#define LOW_BATTERY_CHECK_INTERVAL (60UL * 1000UL) +#define LOW_BATTERY_WARN_INTERVAL (24UL * 60UL * 60UL * 1000UL) +#define LOW_BATTERY_CRITICAL_INTERVAL (12UL * 60UL * 60UL * 1000UL) + +#ifndef DIRECT_RETRY_TRACE_SCALE_HOPS + #define DIRECT_RETRY_TRACE_SCALE_HOPS 32U +#endif + +static const char* skipLocalSpaces(const char* text) { + while (text != NULL && *text == ' ') text++; + return text; +} + +static bool selectorIsEmpty(const char* text) { + text = skipLocalSpaces(text); + return text == NULL || *text == 0; +} + +static bool selectorIsAll(const char* text) { + text = skipLocalSpaces(text); + if (text == NULL || memcmp(text, "all", 3) != 0) { + return false; + } + text += 3; + while (*text == ' ') text++; + return *text == 0; +} + +static bool parsePositiveSelector(const char* text, int& value) { + text = skipLocalSpaces(text); + if (text == NULL || *text == 0) { + return false; + } + + uint32_t n = 0; + bool saw_digit = false; + while (*text >= '0' && *text <= '9') { + saw_digit = true; + n = (n * 10) + (uint32_t)(*text - '0'); + if (n > 32767) { + return false; + } + text++; + } + while (*text == ' ') text++; + if (!saw_digit || n == 0 || *text != 0) { + return false; + } + value = (int)n; + return true; +} + +static bool bwMatches(float bw, float allowed) { + float diff = bw - allowed; + if (diff < 0.0f) diff = -diff; + return diff <= 0.001f; +} + +static bool isValidLoRaBandwidth(float bw) { +#if defined(USE_LR1110) + return bwMatches(bw, 62.5f) + || bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#elif defined(USE_LLCC68) || defined(USE_SX1272) + return bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#else + return bwMatches(bw, 7.8f) + || bwMatches(bw, 10.4f) + || bwMatches(bw, 15.6f) + || bwMatches(bw, 20.8f) + || bwMatches(bw, 31.25f) + || bwMatches(bw, 41.7f) + || bwMatches(bw, 62.5f) + || bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#endif +} + +static bool isValidScheduledRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { + return freq >= 150.0f && freq <= 2500.0f + && isValidLoRaBandwidth(bw) + && sf >= 5 && sf <= 12 + && cr >= 5 && cr <= 8; +} + +static bool buildRepeatersChannel(mesh::GroupChannel& channel) { + const char* hex = REPEATERS_CHANNEL_KEY_HEX; + size_t hex_len = strlen(hex); + if (!(hex_len == 32 || hex_len == 64)) return false; + for (size_t i = 0; i < hex_len; i++) { + if (!mesh::Utils::isHexChar(hex[i])) return false; + } + + memset(channel.secret, 0, sizeof(channel.secret)); + size_t key_len = hex_len / 2; + if (!mesh::Utils::fromHex(channel.secret, key_len, hex)) return false; + + mesh::Utils::sha256(channel.hash, sizeof(channel.hash), channel.secret, key_len); + return true; +} + +static uint8_t batteryPercentFromMilliVolts(uint16_t batt_mv) { + const int min_mv = BATT_MIN_MILLIVOLTS; + const int max_mv = BATT_MAX_MILLIVOLTS; + if (max_mv <= min_mv) return 100; + + int pct = (((int)batt_mv - min_mv) * 100) / (max_mv - min_mv); + if (pct < 0) return 0; + if (pct > 100) return 100; + return (uint8_t)pct; +} + +static bool parseBatteryAlertPercent(const char* value, uint8_t min_value, uint8_t max_value, uint8_t& result) { + if (value == NULL || *value == 0) { + return false; + } + + uint16_t parsed = 0; + while (*value) { + if (*value < '0' || *value > '9') { + return false; + } + parsed = (uint16_t)(parsed * 10 + (*value - '0')); + if (parsed > max_value) { + return false; + } + value++; + } + if (parsed < min_value) { + return false; + } + + result = (uint8_t)parsed; + return true; +} + +static void formatFixed3(char* dest, size_t dest_len, float value) { + long scaled = (long)(value * 1000.0f + (value >= 0.0f ? 0.5f : -0.5f)); + long whole = scaled / 1000; + long decimals = scaled % 1000; + if (decimals < 0) decimals = -decimals; + snprintf(dest, dest_len, "%ld.%03ld", whole, decimals); +} + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -558,6 +739,66 @@ bool MyMesh::extractDirectRetryPrefix(const mesh::Packet* packet, uint8_t* prefi return true; } +static bool isDirectShortcutPayload(const mesh::Packet* packet) { + if (packet == NULL || !packet->isRouteDirect()) { + return false; + } + + switch (packet->getPayloadType()) { + case PAYLOAD_TYPE_PATH: + case PAYLOAD_TYPE_REQ: + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_TXT_MSG: + case PAYLOAD_TYPE_ANON_REQ: + return true; + default: + return false; + } +} + +bool MyMesh::maybeShortCircuitDirect(mesh::Packet* packet) { + if (!isDirectShortcutPayload(packet)) { + return false; + } + + uint8_t hash_size = packet->getPathHashSize(); + uint8_t hash_count = packet->getPathHashCount(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES || hash_count < 3) { + return false; + } + + int self_idx = -1; + for (uint8_t i = 1; i + 1 < hash_count; i++) { + if (self_id.isHashMatch(&packet->path[i * hash_size], hash_size)) { + self_idx = i; + break; + } + } + if (self_idx < 1) { + return false; + } + + const SimpleMeshTables* tables = static_cast(getTables()); + if (tables == NULL) { + return false; + } + + const uint8_t* previous_hop = &packet->path[(self_idx - 1) * hash_size]; + const uint8_t* next_hop = &packet->path[(self_idx + 1) * hash_size]; + if (tables->findRecentRepeaterByHash(previous_hop, hash_size) == NULL + || tables->findRecentRepeaterByHash(next_hop, hash_size) == NULL) { + return false; + } + + uint8_t remaining_count = hash_count - (uint8_t)self_idx; + memmove(packet->path, &packet->path[self_idx * hash_size], remaining_count * hash_size); + packet->setPathHashCount(remaining_count); + MESH_DEBUG_PRINTLN("direct shortcut: skipped %u planned hop(s), remaining=%u", + (uint32_t)self_idx, + (uint32_t)remaining_count); + return true; +} + int8_t MyMesh::getDirectRetryMinSNRX4() const { switch (active_sf) { case 7: return -30; @@ -587,11 +828,73 @@ uint32_t MyMesh::getDirectRetryAttemptStepMillis() const { return _prefs.direct_retry_step_ms; } +static uint8_t decodeRetryTraceHashSize(uint8_t flags, uint8_t route_bytes) { + uint8_t code = flags & 0x03; + uint8_t size_pow2 = (uint8_t)(1U << code); + uint8_t size_linear = (uint8_t)(code + 1U); + + bool pow2_ok = size_pow2 > 0 && (route_bytes % size_pow2) == 0; + bool linear_ok = size_linear > 0 && (route_bytes % size_linear) == 0; + + if (pow2_ok && !linear_ok) return size_pow2; + if (linear_ok && !pow2_ok) return size_linear; + if (pow2_ok) return size_pow2; + return size_linear; +} + +static uint8_t getDirectRetryRemainingHops(const mesh::Packet* packet) { + if (packet == NULL) { + return 0; + } + if (packet->getPayloadType() != PAYLOAD_TYPE_TRACE) { + return packet->getPathHashCount(); + } + if (packet->payload_len < 9) { + return 0; + } + + uint8_t route_bytes = packet->payload_len - 9; + uint8_t hash_size = decodeRetryTraceHashSize(packet->payload[8], route_bytes); + if (hash_size == 0) { + return 0; + } + + uint8_t route_hops = route_bytes / hash_size; + if (packet->path_len >= route_hops) { + return 0; + } + return route_hops - packet->path_len; +} + +static uint32_t scaleDirectRetryDelayForPath(const mesh::Packet* packet, uint32_t delay_ms) { + uint8_t hops = getDirectRetryRemainingHops(packet); + if (hops == 0) { + return delay_ms; + } + + if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { + if (hops >= DIRECT_RETRY_TRACE_SCALE_HOPS) { + return delay_ms; + } + uint32_t scaled = ((delay_ms * hops) + (DIRECT_RETRY_TRACE_SCALE_HOPS - 1U)) / DIRECT_RETRY_TRACE_SCALE_HOPS; + return scaled > 0 ? scaled : 1; + } + + if (hops >= 6) { + return delay_ms; + } + uint32_t scaled = ((delay_ms * hops) + 5U) / 6U; + return scaled > 0 ? scaled : 1; +} + bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { (void)packet; if (!_prefs.direct_retry_enabled) { return false; } + if (!_prefs.direct_retry_recent_enabled) { + return true; + } if (next_hop_hash == NULL || next_hop_hash_len == 0) { return true; } @@ -638,29 +941,63 @@ uint8_t MyMesh::getDirectRetryMaxAttempts(const mesh::Packet* packet) const { } uint32_t MyMesh::getDirectRetryAttemptDelay(const mesh::Packet* packet, uint8_t attempt_idx) { - (void)packet; - return _prefs.direct_retry_base_ms + ((uint32_t)attempt_idx * getDirectRetryAttemptStepMillis()); + uint32_t delay_ms = _prefs.direct_retry_base_ms + ((uint32_t)attempt_idx * getDirectRetryAttemptStepMillis()); + return scaleDirectRetryDelayForPath(packet, delay_ms); } -void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) { +static void formatDirectRetryTarget(char* dest, size_t dest_len, const uint8_t* target_hash, uint8_t target_hash_len) { + if (dest == NULL || dest_len == 0) { + return; + } + if (target_hash == NULL || target_hash_len == 0 || target_hash_len > MAX_HASH_SIZE) { + StrHelper::strncpy(dest, "-", dest_len); + return; + } + + size_t hex_len = (size_t)target_hash_len * 2; + if (dest_len <= hex_len) { + StrHelper::strncpy(dest, "-", dest_len); + return; + } + + mesh::Utils::toHex(dest, target_hash, target_hash_len); + dest[hex_len] = 0; +} + +void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt, + const uint8_t* target_hash, uint8_t target_hash_len, int16_t payload_type) { + char type_label[8]; + char target_label[(MAX_HASH_SIZE * 2) + 1]; + const char* route_label = packet != NULL ? (packet->isRouteDirect() ? "D" : "F") : "D"; + if (packet != NULL) { + snprintf(type_label, sizeof(type_label), "%u", (uint32_t)packet->getPayloadType()); + } else if (payload_type >= 0) { + snprintf(type_label, sizeof(type_label), "%u", (uint32_t)payload_type); + } else { + strcpy(type_label, "?"); + } + formatDirectRetryTarget(target_label, sizeof(target_label), target_hash, target_hash_len); + #if MESH_DEBUG - MESH_DEBUG_PRINTLN("direct retry %s attempt=%u delay=%lu type=%u route=%s", + MESH_DEBUG_PRINTLN("direct retry %s attempt=%u delay=%lu type=%s route=%s target=%s", event ? event : "?", (uint32_t)retry_attempt, (unsigned long)delay_millis, - packet ? (uint32_t)packet->getPayloadType() : 0, - packet && packet->isRouteDirect() ? "D" : "F"); + type_label, + route_label, + target_label); #endif if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": direct retry %s attempt=%u delay=%lu type=%u route=%s\n", + f.printf(": direct retry %s attempt=%u delay=%lu type=%s route=%s target=%s\n", event ? event : "?", (uint32_t)retry_attempt, (unsigned long)delay_millis, - packet ? (uint32_t)packet->getPayloadType() : 0, - packet && packet->isRouteDirect() ? "D" : "F"); + type_label, + route_label, + target_label); f.close(); } } @@ -690,6 +1027,582 @@ void MyMesh::onDirectRetrySucceeded(const uint8_t* next_hop_hash, uint8_t next_h } } +bool MyMesh::hasFloodRetryPrefixes() const { + for (int i = 0; i < FLOOD_RETRY_PREFIX_SLOTS; i++) { + const uint8_t* configured = _prefs.flood_retry_prefixes[i]; + if (configured[0] != 0 || configured[1] != 0 || configured[2] != 0) { + return true; + } + } + return false; +} + +bool MyMesh::floodRetryLastHopMatches(const mesh::Packet* packet) const { + if (packet == NULL || packet->getPathHashCount() == 0) { + return false; + } + + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return false; + } + + const uint8_t* heard_prefix = &packet->path[(packet->getPathHashCount() - 1) * hash_size]; + for (int i = 0; i < FLOOD_RETRY_PREFIX_SLOTS; i++) { + const uint8_t* configured = _prefs.flood_retry_prefixes[i]; + if ((configured[0] != 0 || configured[1] != 0 || configured[2] != 0) + && memcmp(configured, heard_prefix, hash_size) == 0) { + return true; + } + } + + return false; +} + +bool MyMesh::floodRetryPrefixMatches(const mesh::Packet* packet) const { + if (packet == NULL || packet->getPathHashCount() == 0) { + return false; + } + + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return false; + } + + const uint8_t* path = packet->path; + for (int hop = 0; hop < packet->getPathHashCount(); hop++) { + for (int i = 0; i < FLOOD_RETRY_PREFIX_SLOTS; i++) { + const uint8_t* configured = _prefs.flood_retry_prefixes[i]; + if ((configured[0] != 0 || configured[1] != 0 || configured[2] != 0) + && memcmp(configured, path, hash_size) == 0) { + return true; + } + } + path += hash_size; + } + + return false; +} + +bool MyMesh::floodRetryPrefixIgnored(const uint8_t* prefix, uint8_t prefix_len) const { + if (prefix == NULL || prefix_len == 0 || prefix_len > MAX_ROUTE_HASH_BYTES) { + return false; + } + for (int i = 0; i < FLOOD_RETRY_IGNORE_PREFIXES; i++) { + const uint8_t* ignored = _prefs.flood_retry_ignore_prefixes[i]; + if ((ignored[0] != 0 || ignored[1] != 0 || ignored[2] != 0) + && memcmp(ignored, prefix, prefix_len) == 0) { + return true; + } + } + return false; +} + +uint8_t MyMesh::floodRetryEffectivePathLength(const mesh::Packet* packet, uint8_t max_hops) const { + if (packet == NULL || !packet->isRouteFlood() || packet->getPathHashCount() == 0) { + return 0; + } + + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return packet->getPathHashCount(); + } + + uint8_t hop_count = packet->getPathHashCount(); + if (max_hops < hop_count) { + hop_count = max_hops; + } + + uint8_t effective_len = 0; + const uint8_t* path = packet->path; + for (uint8_t hop = 0; hop < hop_count; hop++) { + if (!floodRetryPrefixIgnored(path, hash_size)) { + effective_len++; + } + path += hash_size; + } + return effective_len; +} + +bool MyMesh::floodRetryPrefixFresh(const uint8_t* prefix, uint8_t prefix_len) const { + const SimpleMeshTables* tables = static_cast(getTables()); + if (tables == NULL) { + return false; + } + const auto* recent = tables->findRecentRepeaterByHash(prefix, prefix_len); + if (recent == NULL || recent->last_heard_millis == 0) { + return false; + } + return (uint32_t)(millis() - recent->last_heard_millis) <= 3600000UL; +} + +static const uint8_t FLOOD_RETRY_BRIDGE_OTHER_BUCKET = FLOOD_RETRY_BRIDGE_BUCKETS; + +static uint8_t floodRetryBucketMask(uint8_t bucket) { + if (bucket >= 8) { + return 0; + } + return (uint8_t)(1U << bucket); +} + +int MyMesh::floodRetryBucketForPrefix(const uint8_t* prefix, uint8_t prefix_len, bool require_fresh, + bool include_other) const { + if (prefix == NULL || prefix_len == 0 || prefix_len > MAX_ROUTE_HASH_BYTES) { + return -1; + } + if (floodRetryPrefixIgnored(prefix, prefix_len)) { + return -1; + } + if (require_fresh && !floodRetryPrefixFresh(prefix, prefix_len)) { + return -1; + } + for (int bucket = 0; bucket < FLOOD_RETRY_BRIDGE_BUCKETS; bucket++) { + for (int i = 0; i < FLOOD_RETRY_BUCKET_PREFIXES; i++) { + const uint8_t* configured = _prefs.flood_retry_bridge_buckets[bucket][i]; + if ((configured[0] != 0 || configured[1] != 0 || configured[2] != 0) + && memcmp(configured, prefix, prefix_len) == 0) { + return bucket; + } + } + } + if (include_other) { + return FLOOD_RETRY_BRIDGE_OTHER_BUCKET; + } + return -1; +} + +int MyMesh::floodRetryBucketForPathHop(const uint8_t* prefix, uint8_t prefix_len, uint8_t hop, + uint8_t progress_marker) const { + return floodRetryBucketForPrefix(prefix, prefix_len, hop < progress_marker, true); +} + +int MyMesh::floodRetrySourceBucket(const mesh::Packet* packet) const { + if (packet == NULL) { + return -1; + } + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return -1; + } + if (packet->getPathHashCount() < 2) { + return FLOOD_RETRY_BRIDGE_OTHER_BUCKET; + } + const uint8_t* source_prefix = &packet->path[(packet->getPathHashCount() - 2) * hash_size]; + return floodRetryBucketForPrefix(source_prefix, hash_size, true, true); +} + +uint8_t MyMesh::floodRetryBridgeTargetMask(uint8_t source_bucket) const { + uint8_t mask = 0; + for (int bucket = 0; bucket < FLOOD_RETRY_BRIDGE_BUCKETS; bucket++) { + if (bucket == source_bucket) { + continue; + } + for (int i = 0; i < FLOOD_RETRY_BUCKET_PREFIXES; i++) { + const uint8_t* configured = _prefs.flood_retry_bridge_buckets[bucket][i]; + if ((configured[0] != 0 || configured[1] != 0 || configured[2] != 0) + && !floodRetryPrefixIgnored(configured, FLOOD_RETRY_PREFIX_LEN) + && floodRetryPrefixFresh(configured, FLOOD_RETRY_PREFIX_LEN)) { + mask |= floodRetryBucketMask((uint8_t)bucket); + break; + } + } + } + if (source_bucket != FLOOD_RETRY_BRIDGE_OTHER_BUCKET) { + mask |= floodRetryBucketMask(FLOOD_RETRY_BRIDGE_OTHER_BUCKET); + } + return mask; +} + +uint8_t MyMesh::floodRetryBridgeHeardMask(const mesh::Packet* packet, uint8_t source_bucket, + uint8_t progress_marker) const { + if (packet == NULL || packet->getPathHashCount() == 0) { + return 0; + } + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return 0; + } + + uint8_t mask = 0; + const uint8_t* path = packet->path; + for (int hop = 0; hop < packet->getPathHashCount(); hop++) { + if (progress_marker > 0 && hop == progress_marker - 1) { + path += hash_size; + continue; + } + int bucket = floodRetryBucketForPathHop(path, hash_size, (uint8_t)hop, progress_marker); + if (bucket >= 0 && bucket != source_bucket) { + mask |= floodRetryBucketMask((uint8_t)bucket); + } + path += hash_size; + } + return mask; +} + +MyMesh::FloodRetryBridgeState* MyMesh::floodRetryBridgeStateFor(const mesh::Packet* packet, bool create) const { + if (packet == NULL) { + return NULL; + } + + uint8_t key[MAX_HASH_SIZE]; + packet->calculatePacketHash(key); + FloodRetryBridgeState* free_slot = NULL; + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (flood_retry_bridge_states[i].active + && memcmp(flood_retry_bridge_states[i].key, key, MAX_HASH_SIZE) == 0) { + return &flood_retry_bridge_states[i]; + } + if (!flood_retry_bridge_states[i].active && free_slot == NULL) { + free_slot = &flood_retry_bridge_states[i]; + } + } + if (!create || free_slot == NULL) { + return NULL; + } + + int source_bucket = floodRetrySourceBucket(packet); + if (source_bucket < 0) { + return NULL; + } + + uint8_t target_mask = floodRetryBridgeTargetMask((uint8_t)source_bucket); + if (target_mask == 0) { + return NULL; + } + + uint8_t progress_marker = packet->getPathHashCount(); + uint8_t heard_mask = floodRetryBridgeHeardMask(packet, (uint8_t)source_bucket, progress_marker) & target_mask; + if ((heard_mask & target_mask) == target_mask) { + return NULL; + } + + memset(free_slot, 0, sizeof(*free_slot)); + memcpy(free_slot->key, key, sizeof(free_slot->key)); + free_slot->source_bucket = (uint8_t)source_bucket; + free_slot->target_mask = target_mask; + free_slot->heard_mask = heard_mask; + free_slot->progress_marker = progress_marker; + free_slot->active = true; + return free_slot; +} + +bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { + if (_prefs.disable_fwd || constrain(_prefs.flood_retry_attempts, 0, 15) == 0) { + return false; + } + if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && !_prefs.flood_retry_advert_enabled) { + return false; + } + if (!_prefs.flood_retry_bridge_enabled) { + return true; + } + FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, true); + if (state == NULL) { + return false; + } + if ((state->heard_mask & state->target_mask) == state->target_mask) { + state->active = false; + return false; + } + return true; +} + +void MyMesh::clearFloodRetryBridgeState(const mesh::Packet* packet) { + FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, false); + if (state != NULL) { + state->active = false; + } +} + +void MyMesh::refreshFloodRetryHeardRecent(const mesh::Packet* packet) { + if (packet == NULL || !packet->isRouteFlood() || packet->getPathHashCount() == 0) { + return; + } + + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return; + } + + SimpleMeshTables* tables = static_cast(getTables()); + if (tables == NULL) { + return; + } + const uint8_t* path = packet->path; + if (_prefs.flood_retry_bridge_enabled) { + FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, false); + if (state != NULL) { + for (int hop = 0; hop < packet->getPathHashCount(); hop++) { + if (state->progress_marker > 0 && hop == state->progress_marker - 1) { + path += hash_size; + continue; + } + int bucket = floodRetryBucketForPathHop(path, hash_size, (uint8_t)hop, state->progress_marker); + uint8_t bucket_mask = bucket >= 0 ? floodRetryBucketMask((uint8_t)bucket) : 0; + if (bucket >= 0 && bucket != state->source_bucket && (state->target_mask & bucket_mask)) { + tables->setRecentRepeater(path, hash_size, packet->_snr, false, true); + } + path += hash_size; + } + return; + } + } + + const uint8_t* heard_prefix = &packet->path[(packet->getPathHashCount() - 1) * hash_size]; + tables->setRecentRepeater(heard_prefix, hash_size, packet->_snr, false, true); +} + +void MyMesh::formatFloodRetryPath(char* dest, size_t dest_len, const mesh::Packet* packet) const { + if (dest == NULL || dest_len == 0) { + return; + } + dest[0] = 0; + + if (packet == NULL || packet->getPathHashCount() == 0) { + StrHelper::strncpy(dest, "-", dest_len); + return; + } + + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + StrHelper::strncpy(dest, "invalid", dest_len); + return; + } + + char* out = dest; + size_t remaining = dest_len; + const uint8_t* path = packet->path; + for (int hop = 0; hop < packet->getPathHashCount(); hop++) { + size_t needed = (hop > 0 ? 1 : 0) + ((size_t)hash_size * 2) + 1; + if (remaining < needed) { + if (remaining > 4) { + strcpy(out, "..."); + } + return; + } + if (hop > 0) { + *out++ = '>'; + remaining--; + } + mesh::Utils::toHex(out, path, hash_size); + out += (size_t)hash_size * 2; + remaining -= (size_t)hash_size * 2; + path += hash_size; + } +} + +bool MyMesh::formatFloodRetryHeard(char* dest, size_t dest_len, const mesh::Packet* packet) const { + if (dest == NULL || dest_len == 0 || packet == NULL || packet->getPathHashCount() == 0) { + return false; + } + dest[0] = 0; + + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return false; + } + + char* out = dest; + size_t remaining = dest_len; + bool first = true; + + if (_prefs.flood_retry_bridge_enabled) { + FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, false); + if (state == NULL) { + return false; + } + const uint8_t* path = packet->path; + for (int hop = 0; hop < packet->getPathHashCount(); hop++) { + if (state->progress_marker > 0 && hop == state->progress_marker - 1) { + path += hash_size; + continue; + } + int bucket = floodRetryBucketForPathHop(path, hash_size, (uint8_t)hop, state->progress_marker); + uint8_t bucket_mask = bucket >= 0 ? floodRetryBucketMask((uint8_t)bucket) : 0; + if (bucket >= 0 && bucket != state->source_bucket && (state->target_mask & bucket_mask)) { + char bucket_label[8]; + if ((uint8_t)bucket == FLOOD_RETRY_BRIDGE_OTHER_BUCKET) { + strcpy(bucket_label, "other"); + } else { + snprintf(bucket_label, sizeof(bucket_label), "b%d", bucket + 1); + } + size_t needed = (first ? 0 : 1) + strlen(bucket_label) + 1 + ((size_t)hash_size * 2) + 1; + if (remaining < needed) { + if (remaining > 4) { + strcpy(out, "..."); + } + return dest[0] != 0; + } + if (!first) { + *out++ = ','; + remaining--; + } + int n = snprintf(out, remaining, "%s:", bucket_label); + if (n < 0 || (size_t)n >= remaining) { + return dest[0] != 0; + } + out += n; + remaining -= n; + mesh::Utils::toHex(out, path, hash_size); + out += (size_t)hash_size * 2; + remaining -= (size_t)hash_size * 2; + first = false; + } + path += hash_size; + } + return dest[0] != 0; + } + + const uint8_t* heard_prefix = &packet->path[(packet->getPathHashCount() - 1) * hash_size]; + if (remaining < ((size_t)hash_size * 2) + 1) { + return false; + } + mesh::Utils::toHex(out, heard_prefix, hash_size); + return true; +} + +void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) { + if (event == NULL || packet == NULL) { + return; + } + + bool clear_bridge_state = _prefs.flood_retry_bridge_enabled + && (strcmp(event, "good") == 0 || strcmp(event, "failure") == 0 || strcmp(event, "failed_all_tries") == 0 + || strncmp(event, "dropped_", 8) == 0); + + if (clear_bridge_state && strcmp(event, "failure") == 0) { + clearFloodRetryBridgeState(packet); + } + + if (strcmp(event, "failure") == 0) { + return; + } + + const char* time_label = "time_ms"; + if (strcmp(event, "queued") == 0 || strcmp(event, "dropped_queue_full") == 0) { + time_label = "wait_ms"; + } else if (strcmp(event, "resent") == 0 || strcmp(event, "failed_all_tries") == 0 + || strcmp(event, "failure") == 0 || strncmp(event, "dropped_", 8) == 0) { + time_label = "elapsed_ms"; + } else if (strcmp(event, "good") == 0) { + time_label = "echo_ms"; + } + + char path_log[208]; + char heard_log[96]; + char heard_suffix[112]; + formatFloodRetryPath(path_log, sizeof(path_log), packet); + heard_suffix[0] = 0; + if (strcmp(event, "good") == 0 && formatFloodRetryHeard(heard_log, sizeof(heard_log), packet)) { + refreshFloodRetryHeardRecent(packet); + snprintf(heard_suffix, sizeof(heard_suffix), ", heard=%s", heard_log); + } + + MESH_DEBUG_PRINTLN("%s flood retry %s (retry=%u, type=%d, route=%s, payload_len=%d, hop=%u, path=%s%s, %s=%lu)", + getLogDateTime(), + event, + (unsigned int)retry_attempt, + (uint32_t)packet->getPayloadType(), + packet->isRouteDirect() ? "D" : "F", + (uint32_t)packet->payload_len, + (unsigned int)packet->getPathHashCount(), + path_log, + heard_suffix, + time_label, + (unsigned long)delay_millis); + + if (_logging) { + File f = openAppend(PACKET_LOG_FILE); + if (f) { + f.print(getLogDateTime()); + f.printf(": FLOOD RETRY %s (retry=%u, type=%d, route=%s, payload_len=%d, hop=%u, path=%s%s, %s=%lu)\n", + event, + (unsigned int)retry_attempt, + (uint32_t)packet->getPayloadType(), + packet->isRouteDirect() ? "D" : "F", + (uint32_t)packet->payload_len, + (unsigned int)packet->getPathHashCount(), + path_log, + heard_suffix, + time_label, + (unsigned long)delay_millis); + f.close(); + } + } + + if (clear_bridge_state) { + clearFloodRetryBridgeState(packet); + } +} + +bool MyMesh::hasFloodRetryTargetPrefix(const mesh::Packet* packet) const { + if (_prefs.flood_retry_bridge_enabled) { + return false; + } + return floodRetryPrefixMatches(packet); +} + +uint8_t MyMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const { + uint8_t gate = _prefs.flood_retry_max_path; + if (gate == FLOOD_RETRY_PATH_GATE_DISABLED) { + return FLOOD_RETRY_PATH_GATE_DISABLED; + } + if (gate > 63) { + gate = FLOOD_RETRY_ROOFTOP_MAX_PATH; + } + + uint8_t raw_hops = packet != NULL ? packet->getPathHashCount() : 0; + uint8_t effective_hops = floodRetryEffectivePathLength(packet); + uint8_t ignored_hops = raw_hops > effective_hops ? raw_hops - effective_hops : 0; + uint16_t adjusted_gate = (uint16_t)gate + ignored_hops; + return adjusted_gate > 63 ? 63 : (uint8_t)adjusted_gate; +} + +uint8_t MyMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const { + if (_prefs.disable_fwd) { + return 0; + } + + uint8_t attempts = constrain(_prefs.flood_retry_attempts, 0, 15); + uint16_t scaled_attempts = attempts; + uint8_t hops = packet != NULL ? packet->getPathHashCount() : 0; + if (hops == 1) { + scaled_attempts = (uint16_t)attempts * 2U; + } else if (hops == 2) { + scaled_attempts = (((uint16_t)attempts * 3U) + 1U) / 2U; + } + return scaled_attempts > 15 ? 15 : (uint8_t)scaled_attempts; +} + +bool MyMesh::isFloodRetryEchoTarget(const mesh::Packet* packet, uint8_t progress_marker) const { + if (packet == NULL || !packet->isRouteFlood()) { + return false; + } + if (_prefs.flood_retry_bridge_enabled) { + FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, false); + if (state == NULL) { + return false; + } + state->heard_mask |= floodRetryBridgeHeardMask(packet, state->source_bucket, state->progress_marker) & state->target_mask; + return (state->heard_mask & state->target_mask) == state->target_mask; + } + if (packet->getPathHashCount() == 0) { + return false; + } + uint8_t hash_size = packet->getPathHashSize(); + if (hash_size == 0 || hash_size > MAX_ROUTE_HASH_BYTES) { + return false; + } + const uint8_t* heard_prefix = &packet->path[(packet->getPathHashCount() - 1) * hash_size]; + if (floodRetryPrefixIgnored(heard_prefix, hash_size)) { + return false; + } + if (hasFloodRetryPrefixes()) { + return floodRetryLastHopMatches(packet); + } + return true; +} + static void formatLocalSnrX4(char* dest, size_t dest_len, int16_t snr_x4) { int16_t v = snr_x4; const char* sign = ""; @@ -698,6 +1611,10 @@ static void formatLocalSnrX4(char* dest, size_t dest_len, int16_t snr_x4) { v = -v; } snprintf(dest, dest_len, "%s%d.%02d", sign, v / 4, (v % 4) * 25); + size_t len = strlen(dest); + if (len > 3 && dest[len - 1] == '0') { + dest[len - 1] = 0; + } } void MyMesh::formatRecentRepeatersReply(char *reply, int page) { @@ -712,12 +1629,12 @@ void MyMesh::formatRecentRepeatersReply(char *reply, int page) { return; } - const int page_size = 4; + const int page_size = 10; int pages = (count + page_size - 1) / page_size; if (page < 1) page = 1; if (page > pages) page = pages; - int len = snprintf(reply, 160, "> %d/%d ", page, pages); + int len = snprintf(reply, 160, "> %d/%d", page, pages); int start = (page - 1) * page_size; for (int i = 0; i < page_size && len < 150; i++) { const SimpleMeshTables::RecentRepeaterInfo* info = tables->getRecentRepeaterBySortedIdx(start + i); @@ -727,13 +1644,39 @@ void MyMesh::formatRecentRepeatersReply(char *reply, int page) { mesh::Utils::toHex(prefix, info->prefix, info->prefix_len); prefix[info->prefix_len * 2] = 0; formatLocalSnrX4(snr, sizeof(snr), info->snr_x4); - len += snprintf(&reply[len], 160 - len, "%s%s,%s", - i == 0 ? "" : " ", + len += snprintf(&reply[len], 160 - len, "\n%s,%s%s", prefix, + snr[0] == '-' ? "" : " ", snr); } } +void MyMesh::printRecentRepeatersSerial() { + const SimpleMeshTables* tables = static_cast(getTables()); + if (tables == NULL) { + Serial.println("Error: unsupported"); + return; + } + + int count = tables->getRecentRepeaterCount(); + Serial.printf("Recent repeaters (%d):\n", count); + if (count <= 0) { + Serial.println("-none-"); + return; + } + + for (int i = 0; i < count; i++) { + const SimpleMeshTables::RecentRepeaterInfo* info = tables->getRecentRepeaterBySortedIdx(i); + if (info == NULL) break; + char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1]; + char snr[12]; + mesh::Utils::toHex(prefix, info->prefix, info->prefix_len); + prefix[info->prefix_len * 2] = 0; + formatLocalSnrX4(snr, sizeof(snr), info->snr_x4); + Serial.printf("%s,%s%s\n", prefix, snr[0] == '-' ? "" : " ", snr); + } +} + bool MyMesh::setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4) { SimpleMeshTables* tables = static_cast(getTables()); return tables != NULL && tables->setRecentRepeater(prefix, prefix_len, snr_x4); @@ -1062,12 +2005,16 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc last_millis = 0; uptime_millis = 0; next_local_advert = next_flood_advert = 0; + next_battery_alert_check = 0; + last_battery_alert_sent = 0; + battery_alert_sent = false; dirty_contacts_expiry = 0; - set_radio_at = revert_radio_at = 0; active_sf = 0; active_cr = 0; + memset(scheduled_radio_settings, 0, sizeof(scheduled_radio_settings)); _logging = false; region_load_active = false; + memset(flood_retry_bridge_states, 0, sizeof(flood_retry_bridge_states)); #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); @@ -1076,7 +2023,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // defaults memset(&_prefs, 0, sizeof(_prefs)); _prefs.airtime_factor = 1.0; - _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; + _prefs.rx_delay_base = DEFAULT_RX_DELAY_BASE; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); @@ -1088,13 +2035,17 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.bw = LORA_BW; _prefs.cr = LORA_CR; _prefs.tx_power_dbm = LORA_TX_POWER; - _prefs.advert_interval = 1; // default to 2 minutes for NEW installs - _prefs.flood_advert_interval = 47; // 47 hours + _prefs.advert_interval = DEFAULT_ADVERT_INTERVAL_MINUTES / 2; + _prefs.flood_advert_interval = DEFAULT_FLOOD_ADVERT_INTERVAL_HOURS; _prefs.flood_max = 64; _prefs.flood_max_unscoped = 64; _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') + _prefs.agc_reset_interval = DEFAULT_AGC_RESET_INTERVAL_SECONDS / 4; + _prefs.multi_acks = DEFAULT_MULTI_ACKS; + _prefs.path_hash_mode = DEFAULT_PATH_HASH_MODE; + _prefs.loop_detect = DEFAULT_LOOP_DETECT; _prefs.retry_preset = RETRY_PRESET_ROOFTOP; _prefs.direct_retry_attempts = DIRECT_RETRY_ROOFTOP_COUNT; _prefs.direct_retry_base_ms = DIRECT_RETRY_ROOFTOP_BASE_MS; @@ -1108,6 +2059,14 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.direct_retry_cr_enabled = 1; _prefs.direct_retry_prefs_magic[0] = DIRECT_RETRY_PREFS_MAGIC_0; _prefs.direct_retry_prefs_magic[1] = DIRECT_RETRY_PREFS_MAGIC_1; + _prefs.direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; + _prefs.flood_retry_attempts = FLOOD_RETRY_ROOFTOP_COUNT; + _prefs.flood_retry_max_path = FLOOD_RETRY_ROOFTOP_MAX_PATH; + _prefs.flood_retry_bridge_enabled = 0; + _prefs.flood_retry_advert_enabled = FLOOD_RETRY_ADVERT_DEFAULT; + _prefs.battery_alert_enabled = 0; + _prefs.battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT; + _prefs.battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT; // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -1175,9 +2134,7 @@ void MyMesh::begin(FILESYSTEM *fs) { } #endif - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - active_sf = _prefs.sf; - active_cr = _prefs.cr; + applySavedRadioParams(); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1206,14 +2163,574 @@ void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint3 } } -void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; +bool MyMesh::sendRepeatersFloodText(const char* text) { + if (text == NULL || *text == 0) return false; - revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params + mesh::GroupChannel channel; + if (!buildRepeatersChannel(channel)) { + return false; + } + + uint8_t temp[MAX_PACKET_PAYLOAD]; + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + memcpy(temp, ×tamp, 4); + temp[4] = (TXT_TYPE_PLAIN << 2); + + const size_t max_data_len = MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE; + const size_t prefix_cap = max_data_len > 5 ? max_data_len - 5 + 1 : 0; + int prefix_written = prefix_cap > 0 + ? snprintf((char*)&temp[5], prefix_cap, "%s: ", _prefs.node_name) + : -1; + if (prefix_written < 0) { + return false; + } + + size_t prefix_len = (size_t)prefix_written; + if (prefix_len >= prefix_cap) { + prefix_len = prefix_cap - 1; + } + + size_t text_len = strlen(text); + size_t max_text_len = max_data_len - 5 - prefix_len; + if (text_len > max_text_len) { + text_len = max_text_len; + } + memcpy(&temp[5 + prefix_len], text, text_len); + + auto pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel, temp, 5 + prefix_len + text_len); + if (pkt == NULL) { + return false; + } + + sendFloodScoped(default_scope, pkt, 0, _prefs.path_hash_mode + 1); + return true; +} + +void MyMesh::checkBatteryAlert() { + if (!_prefs.battery_alert_enabled) { + battery_alert_sent = false; + return; + } + + if (next_battery_alert_check && !millisHasNowPassed(next_battery_alert_check)) { + return; + } + next_battery_alert_check = futureMillis(LOW_BATTERY_CHECK_INTERVAL); + + uint16_t batt_mv = board.getBattMilliVolts(); + uint8_t batt_pct = batteryPercentFromMilliVolts(batt_mv); + if (batt_mv <= LOW_BATTERY_MIN_VALID_MV || batt_pct >= _prefs.battery_alert_low_percent) { + battery_alert_sent = false; + return; + } + + unsigned long interval = batt_pct <= _prefs.battery_alert_critical_percent + ? LOW_BATTERY_CRITICAL_INTERVAL + : LOW_BATTERY_WARN_INTERVAL; + if (battery_alert_sent && !millisHasNowPassed(last_battery_alert_sent + interval)) { + return; + } + + char text[96]; + snprintf(text, sizeof(text), "LOW BATTERY %u%% (%u mV)", (uint32_t)batt_pct, (uint32_t)batt_mv); + if (sendRepeatersFloodText(text)) { + battery_alert_sent = true; + last_battery_alert_sent = millis(); + } +} + +void MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { + radio_driver.setParams(freq, bw, sf, cr); + active_sf = sf; + active_cr = cr; +} + +void MyMesh::applySavedRadioParams() { + applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); +} + +bool MyMesh::hasStartedScheduledTempRadio() const { + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary && setting.started) { + return true; + } + } + return false; +} + +int MyMesh::findFreeScheduledRadioSlot() const { + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + if (!scheduled_radio_settings[i].active) { + return i; + } + } + return -1; +} + +int MyMesh::countScheduledRadioSettings(bool temporary) const { + int count = 0; + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary == temporary) { + count++; + } + } + return count; +} + +int MyMesh::findScheduledRadioSettingByIndex(bool temporary, int wanted) const { + bool used[MAX_SCHEDULED_RADIO_SETTINGS] = {}; + for (int rank = 1; rank <= wanted; rank++) { + int best = -1; + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (!setting.active || setting.temporary != temporary || used[i]) { + continue; + } + if (best < 0 || setting.start_time < scheduled_radio_settings[best].start_time + || (setting.start_time == scheduled_radio_settings[best].start_time && i < best)) { + best = i; + } + } + if (best < 0) { + return -1; + } + used[best] = true; + if (rank == wanted) { + return best; + } + } + return -1; +} + +int MyMesh::getScheduledRadioSettingIndex(bool temporary, int slot_idx) const { + int count = countScheduledRadioSettings(temporary); + for (int i = 1; i <= count; i++) { + if (findScheduledRadioSettingByIndex(temporary, i) == slot_idx) { + return i; + } + } + return -1; +} + +bool MyMesh::scheduledRadioConflicts(bool temporary, uint32_t start_time, uint32_t end_time) const { + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (!setting.active) { + continue; + } + if (temporary) { + if (setting.temporary && start_time < setting.end_time && end_time > setting.start_time) { + return true; + } + if (!setting.temporary && setting.start_time >= start_time && setting.start_time < end_time) { + return true; + } + } else { + if (!setting.temporary && setting.start_time == start_time) { + return true; + } + if (setting.temporary && start_time >= setting.start_time && start_time < setting.end_time) { + return true; + } + } + } + return false; +} + +void MyMesh::clearScheduledRadioSetting(int idx, bool restore_if_started) { + if (idx < 0 || idx >= MAX_SCHEDULED_RADIO_SETTINGS) { + return; + } + bool restore_radio = restore_if_started + && scheduled_radio_settings[idx].active + && scheduled_radio_settings[idx].temporary + && scheduled_radio_settings[idx].started; + scheduled_radio_settings[idx].active = false; + scheduled_radio_settings[idx].started = false; + if (restore_radio && !hasStartedScheduledTempRadio()) { + applySavedRadioParams(); + } +} + +void MyMesh::formatScheduledRadioDuration(char* dest, size_t dest_len, uint32_t target_time) const { + uint32_t now = getRTCClock()->getCurrentTime(); + if (target_time <= now) { + StrHelper::strncpy(dest, "now", dest_len); + return; + } + + uint32_t seconds = target_time - now; + uint32_t days = seconds / 86400; + seconds %= 86400; + uint32_t hours = seconds / 3600; + seconds %= 3600; + uint32_t minutes = seconds / 60; + seconds %= 60; + + if (days > 0) { + snprintf(dest, dest_len, "%lud%luh", (unsigned long)days, (unsigned long)hours); + } else if (hours > 0) { + snprintf(dest, dest_len, "%luh%lum", (unsigned long)hours, (unsigned long)minutes); + } else if (minutes > 0) { + snprintf(dest, dest_len, "%lum%lus", (unsigned long)minutes, (unsigned long)seconds); + } else { + snprintf(dest, dest_len, "%lus", (unsigned long)seconds); + } +} + +void MyMesh::formatRadioParamTuple(char* dest, size_t dest_len, const ScheduledRadioSetting& setting) const { + char freq[16]; + char bw[16]; + formatFixed3(freq, sizeof(freq), setting.freq); + StrHelper::strncpy(bw, StrHelper::ftoa3(setting.bw), sizeof(bw)); + snprintf(dest, dest_len, "%s,%s,%u,%u", freq, bw, (uint32_t)setting.sf, (uint32_t)setting.cr); +} + +void MyMesh::formatScheduledRadioSetting(char* reply, int setting_idx, int display_idx) const { + const ScheduledRadioSetting& setting = scheduled_radio_settings[setting_idx]; + char params[40]; + char delay[16]; + formatRadioParamTuple(params, sizeof(params), setting); + + if (setting.temporary) { + if (setting.started) { + formatScheduledRadioDuration(delay, sizeof(delay), setting.end_time); + snprintf(reply, 160, "> %d:%s@%lu-%lu active ends in %s", + display_idx, + params, + (unsigned long)setting.start_time, + (unsigned long)setting.end_time, + delay); + } else { + formatScheduledRadioDuration(delay, sizeof(delay), setting.start_time); + snprintf(reply, 160, "> %d:%s@%lu-%lu starts in %s", + display_idx, + params, + (unsigned long)setting.start_time, + (unsigned long)setting.end_time, + delay); + } + } else { + formatScheduledRadioDuration(delay, sizeof(delay), setting.start_time); + snprintf(reply, 160, "> %d:%s@%lu in %s", + display_idx, + params, + (unsigned long)setting.start_time, + delay); + } +} + +void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8_t sf, uint8_t cr, + uint32_t start_time, uint32_t end_time, char* reply) { + uint32_t now = getRTCClock()->getCurrentTime(); + if (!isValidScheduledRadioParams(freq, bw, sf, cr)) { + strcpy(reply, "Error, invalid radio params"); + return; + } + if (start_time <= now) { + strcpy(reply, "Error: start is in the past"); + return; + } + if (temporary && end_time <= now) { + strcpy(reply, "Error: end is in the past"); + return; + } + if (temporary && end_time <= start_time) { + strcpy(reply, "Error: end must be after start"); + return; + } + if (countScheduledRadioSettings(temporary) >= MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE) { + snprintf(reply, 160, "Error: max %d queued", MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE); + return; + } + if (scheduledRadioConflicts(temporary, start_time, end_time)) { + strcpy(reply, "Error: schedule conflict"); + return; + } + + int slot = findFreeScheduledRadioSlot(); + if (slot < 0) { + strcpy(reply, "Error: queue full"); + return; + } + + scheduled_radio_settings[slot].active = true; + scheduled_radio_settings[slot].temporary = temporary; + scheduled_radio_settings[slot].started = false; + scheduled_radio_settings[slot].freq = freq; + scheduled_radio_settings[slot].bw = bw; + scheduled_radio_settings[slot].sf = sf; + scheduled_radio_settings[slot].cr = cr; + scheduled_radio_settings[slot].start_time = start_time; + scheduled_radio_settings[slot].end_time = temporary ? end_time : 0; + + char delay[16]; + formatScheduledRadioDuration(delay, sizeof(delay), start_time); + snprintf(reply, 160, "OK - %s %d in %s", + temporary ? "tempradioat" : "radioat", + getScheduledRadioSettingIndex(temporary, slot), + delay); +} + +void MyMesh::formatScheduledRadioParams(bool temporary, const char* selector, char* reply) { + if (selectorIsEmpty(selector) || selectorIsAll(selector)) { + int count = countScheduledRadioSettings(temporary); + if (count == 0) { + strcpy(reply, "> -none-"); + return; + } + + int len = snprintf(reply, 160, "> "); + for (int display_idx = 1; display_idx <= count && len < 159; display_idx++) { + int idx = findScheduledRadioSettingByIndex(temporary, display_idx); + if (idx < 0) { + break; + } + char params[40]; + formatRadioParamTuple(params, sizeof(params), scheduled_radio_settings[idx]); + int written; + if (temporary) { + written = snprintf(&reply[len], 160 - len, "%s%d:%s@%lu-%lu", + display_idx == 1 ? "" : " ", + display_idx, + params, + (unsigned long)scheduled_radio_settings[idx].start_time, + (unsigned long)scheduled_radio_settings[idx].end_time); + } else { + written = snprintf(&reply[len], 160 - len, "%s%d:%s@%lu", + display_idx == 1 ? "" : " ", + display_idx, + params, + (unsigned long)scheduled_radio_settings[idx].start_time); + } + if (written < 0 || written >= 160 - len) { + reply[159] = 0; + break; + } + len += written; + } + return; + } + + int wanted = 0; + if (!parsePositiveSelector(selector, wanted)) { + strcpy(reply, temporary ? "Error, use: get tempradioat [n]" : "Error, use: get radioat [n]"); + return; + } + + int idx = findScheduledRadioSettingByIndex(temporary, wanted); + if (idx < 0) { + strcpy(reply, "Error: not found"); + return; + } + formatScheduledRadioSetting(reply, idx, wanted); +} + +void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, char* reply) { + if (selectorIsEmpty(selector) || selectorIsAll(selector)) { + int deleted = 0; + bool restore_radio = false; + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary == temporary) { + restore_radio = restore_radio || (setting.temporary && setting.started); + setting.active = false; + setting.started = false; + deleted++; + } + } + if (restore_radio && !hasStartedScheduledTempRadio()) { + applySavedRadioParams(); + } + snprintf(reply, 160, "OK - deleted %d", deleted); + return; + } + + int wanted = 0; + if (!parsePositiveSelector(selector, wanted)) { + strcpy(reply, temporary ? "Error, use: del tempradioat [n]" : "Error, use: del radioat [n]"); + return; + } + + int idx = findScheduledRadioSettingByIndex(temporary, wanted); + if (idx < 0) { + strcpy(reply, "Error: not found"); + return; + } + clearScheduledRadioSetting(idx, true); + strcpy(reply, "OK"); +} + +void MyMesh::processScheduledRadioSettings() { + uint32_t now = getRTCClock()->getCurrentTime(); + bool saved_params_changed = false; + bool temp_ended = false; + + while (true) { + int due_idx = -1; + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (!setting.active || setting.temporary || now < setting.start_time) { + continue; + } + if (due_idx < 0 || setting.start_time < scheduled_radio_settings[due_idx].start_time + || (setting.start_time == scheduled_radio_settings[due_idx].start_time && i < due_idx)) { + due_idx = i; + } + } + if (due_idx < 0) { + break; + } + + ScheduledRadioSetting& setting = scheduled_radio_settings[due_idx]; + _prefs.freq = setting.freq; + _prefs.bw = setting.bw; + _prefs.sf = setting.sf; + _prefs.cr = setting.cr; + savePrefs(); + setting.active = false; + setting.started = false; + saved_params_changed = true; + } + + if (saved_params_changed && !hasStartedScheduledTempRadio()) { + applySavedRadioParams(); + } + + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary && setting.started && now >= setting.end_time) { + setting.active = false; + setting.started = false; + temp_ended = true; + } + } + + if (temp_ended && !hasStartedScheduledTempRadio()) { + applySavedRadioParams(); + } + + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary && !setting.started && now >= setting.start_time) { + if (now >= setting.end_time) { + setting.active = false; + } else { + applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr); + setting.started = true; + } + } + } +} + +bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { + return timestamp && millisHasNowPassed(timestamp); +} + +bool MyMesh::hasScheduledRadioWorkDue() const { + uint32_t now = getRTCClock()->getCurrentTime(); + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (!setting.active) { + continue; + } + if (!setting.temporary && now >= setting.start_time) { + return true; + } + if (setting.temporary) { + if (!setting.started && now >= setting.start_time) { + return true; + } + if (setting.started && now >= setting.end_time) { + return true; + } + } + } + return false; +} + +uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const { + if (!timestamp || sleep_secs == 0) { + return sleep_secs; + } + unsigned long now = millis(); + if ((long)(now - timestamp) >= 0) { + return 0; + } + unsigned long remaining_ms = timestamp - now; + uint32_t remaining_secs = (remaining_ms + 999UL) / 1000UL; + return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; +} + +uint32_t MyMesh::limitSleepToRtcTime(uint32_t timestamp, uint32_t sleep_secs) const { + if (!timestamp || sleep_secs == 0) { + return sleep_secs; + } + uint32_t now = getRTCClock()->getCurrentTime(); + if (now >= timestamp) { + return 0; + } + uint32_t remaining_secs = timestamp - now; + return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; +} + +uint32_t MyMesh::limitSleepToScheduledRadioWork(uint32_t sleep_secs) const { + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (!setting.active) { + continue; + } + if (!setting.temporary || !setting.started) { + sleep_secs = limitSleepToRtcTime(setting.start_time, sleep_secs); + } + if (setting.temporary && setting.started) { + sleep_secs = limitSleepToRtcTime(setting.end_time, sleep_secs); + } + } + return sleep_secs; +} + +uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { + if (max_secs == 0 || hasPendingWork()) { + return 0; + } + + uint32_t sleep_secs = max_secs; + sleep_secs = limitSleepToMillisTimer(next_flood_advert, sleep_secs); + sleep_secs = limitSleepToMillisTimer(next_local_advert, sleep_secs); + sleep_secs = limitSleepToMillisTimer(dirty_contacts_expiry, sleep_secs); + if (_prefs.battery_alert_enabled) { + sleep_secs = limitSleepToMillisTimer(next_battery_alert_check, sleep_secs); + } + sleep_secs = limitSleepToScheduledRadioWork(sleep_secs); + return sleep_secs; +} + +void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + if (scheduled_radio_settings[i].active && scheduled_radio_settings[i].temporary) { + scheduled_radio_settings[i].active = false; + scheduled_radio_settings[i].started = false; + } + } + + int slot = findFreeScheduledRadioSlot(); + if (slot < 0) { + return; + } + + uint32_t start_time = getRTCClock()->getCurrentTime() + 2; // give CLI reply time to be sent first + scheduled_radio_settings[slot].active = true; + scheduled_radio_settings[slot].temporary = true; + scheduled_radio_settings[slot].started = false; + scheduled_radio_settings[slot].freq = freq; + scheduled_radio_settings[slot].bw = bw; + scheduled_radio_settings[slot].sf = sf; + scheduled_radio_settings[slot].cr = cr; + scheduled_radio_settings[slot].start_time = start_time; + scheduled_radio_settings[slot].end_time = start_time + ((uint32_t)timeout_mins * 60); } bool MyMesh::formatFileSystem() { @@ -1492,6 +3009,7 @@ static void formatPathReply(const uint8_t* path, uint8_t path_len, char* out, si } void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *command, char *reply) { + char* reply_start = reply; if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_map = temp_map; // copy over the temp instance as new current map @@ -1557,6 +3075,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * strcpy(reply, "Err - bad pubkey"); } } + } else if (sender_timestamp == 0 && sender == NULL + && memcmp(command, "get recent.repeater", 19) == 0 + && (command[19] == 0 || command[19] == ' ')) { + printRecentRepeatersSerial(); + reply_start[0] = 0; } else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) { Serial.println("ACL:"); for (int i = 0; i < acl.getNumClients(); i++) { @@ -1596,6 +3119,60 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * formatPathReply(sender->out_path, sender->out_path_len, reply, 160); } } + } else if (strncmp(command, "send text.flood ", 16) == 0) { + char* text = trimSpaces(command + 16); + if (*text == 0) { + strcpy(reply, "Err - usage: send text.flood "); + } else if (sendRepeatersFloodText(text)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - unable to create packet"); + } + } else if (strcmp(command, "get battery.alert") == 0) { + sprintf(reply, "> %s", _prefs.battery_alert_enabled ? "on" : "off"); + } else if (strcmp(command, "get battery.alert.low") == 0) { + sprintf(reply, "> %u", (uint32_t)_prefs.battery_alert_low_percent); + } else if (strcmp(command, "get battery.alert.critical") == 0) { + sprintf(reply, "> %u", (uint32_t)_prefs.battery_alert_critical_percent); + } else if (strncmp(command, "set battery.alert ", 18) == 0) { + const char* value = command + 18; + if (strcmp(value, "on") == 0) { + _prefs.battery_alert_enabled = 1; + next_battery_alert_check = 0; + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(value, "off") == 0) { + _prefs.battery_alert_enabled = 0; + battery_alert_sent = false; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - usage: set battery.alert "); + } + } else if (strncmp(command, "set battery.alert.low ", 22) == 0) { + uint8_t percent; + if (!parseBatteryAlertPercent(command + 22, 1, 100, percent)) { + strcpy(reply, "Err - usage: set battery.alert.low <1-100>"); + } else if (percent <= _prefs.battery_alert_critical_percent) { + strcpy(reply, "Err - low must be greater than critical"); + } else { + _prefs.battery_alert_low_percent = percent; + next_battery_alert_check = 0; + savePrefs(); + strcpy(reply, "OK"); + } + } else if (strncmp(command, "set battery.alert.critical ", 27) == 0) { + uint8_t percent; + if (!parseBatteryAlertPercent(command + 27, 0, 99, percent)) { + strcpy(reply, "Err - usage: set battery.alert.critical <0-99>"); + } else if (percent >= _prefs.battery_alert_low_percent) { + strcpy(reply, "Err - critical must be less than low"); + } else { + _prefs.battery_alert_critical_percent = percent; + next_battery_alert_check = 0; + savePrefs(); + strcpy(reply, "OK"); + } } else if (memcmp(command, "discover.neighbors", 18) == 0) { const char* sub = command + 18; while (*sub == ' ') sub++; @@ -1616,6 +3193,7 @@ void MyMesh::loop() { #endif mesh::Mesh::loop(); + checkBatteryAlert(); if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { mesh::Packet *pkt = createSelfAdvert(); @@ -1631,21 +3209,7 @@ void MyMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - active_sf = pending_sf; - active_cr = pending_cr; - MESH_DEBUG_PRINTLN("Temp radio params"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - active_sf = _prefs.sf; - active_cr = _prefs.cr; - MESH_DEBUG_PRINTLN("Radio params restored"); - } + processScheduledRadioSettings(); // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { @@ -1664,5 +3228,9 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif - return _mgr->getOutboundTotal() > 0; + if (_mgr->getOutboundTotal() > 0) return true; + if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; + if (isMillisTimerDue(dirty_contacts_expiry)) return true; + if (_prefs.battery_alert_enabled && isMillisTimerDue(next_battery_alert_check)) return true; + return hasScheduledRadioWorkDue(); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 467a4186..dc0580d5 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -80,11 +80,32 @@ struct NeighbourInfo { #define PACKET_LOG_FILE "/packet_log" +#ifndef MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE + #define MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE 3 +#endif + +#define MAX_SCHEDULED_RADIO_SETTINGS (MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE * 2) + class MyMesh : public mesh::Mesh, public CommonCLICallbacks { + struct ScheduledRadioSetting { + bool active; + bool temporary; + bool started; + float freq; + float bw; + uint8_t sf; + uint8_t cr; + uint32_t start_time; + uint32_t end_time; + }; + FILESYSTEM* _fs; uint32_t last_millis; uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; + unsigned long next_battery_alert_check; + unsigned long last_battery_alert_sent; + bool battery_alert_sent; bool _logging; NodePrefs _prefs; ClientACL acl; @@ -99,6 +120,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { RegionEntry* recv_pkt_region; TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; + struct FloodRetryBridgeState { + uint8_t key[MAX_HASH_SIZE]; + uint8_t source_bucket; + uint8_t target_mask; + uint8_t heard_mask; + uint8_t progress_marker; + bool active; + }; + mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS]; uint32_t pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; @@ -107,13 +137,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif CayenneLPP telemetry; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; uint8_t active_sf; // live SF, including temporary radio overrides - uint8_t pending_cr; uint8_t active_cr; // live CR, including temporary radio overrides + ScheduledRadioSetting scheduled_radio_settings[MAX_SCHEDULED_RADIO_SETTINGS]; int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_RS232_BRIDGE) RS232Bridge bridge; @@ -126,6 +152,25 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getDirectRetryCodingRateForSNR(int8_t snr_x4) const; uint8_t getDirectRetryConfiguredMaxAttempts() const; uint32_t getDirectRetryAttemptStepMillis() const; + bool hasFloodRetryPrefixes() const; + bool floodRetryPrefixMatches(const mesh::Packet* packet) const; + bool floodRetryLastHopMatches(const mesh::Packet* packet) const; + bool floodRetryPrefixIgnored(const uint8_t* prefix, uint8_t prefix_len) const; + uint8_t floodRetryEffectivePathLength(const mesh::Packet* packet, uint8_t max_hops = 0xFF) const; + bool floodRetryPrefixFresh(const uint8_t* prefix, uint8_t prefix_len) const; + int floodRetryBucketForPrefix(const uint8_t* prefix, uint8_t prefix_len, bool require_fresh, + bool include_other) const; + int floodRetryBucketForPathHop(const uint8_t* prefix, uint8_t prefix_len, uint8_t hop, + uint8_t progress_marker) const; + int floodRetrySourceBucket(const mesh::Packet* packet) const; + uint8_t floodRetryBridgeTargetMask(uint8_t source_bucket) const; + uint8_t floodRetryBridgeHeardMask(const mesh::Packet* packet, uint8_t source_bucket, + uint8_t progress_marker) const; + FloodRetryBridgeState* floodRetryBridgeStateFor(const mesh::Packet* packet, bool create) const; + void clearFloodRetryBridgeState(const mesh::Packet* packet); + void refreshFloodRetryHeardRecent(const mesh::Packet* packet); + void formatFloodRetryPath(char* dest, size_t dest_len, const mesh::Packet* packet) const; + bool formatFloodRetryHeard(char* dest, size_t dest_len, const mesh::Packet* packet) const; void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); @@ -133,9 +178,30 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); mesh::Packet* createSelfAdvert(); + bool sendRepeatersFloodText(const char* text); + void checkBatteryAlert(); + void printRecentRepeatersSerial(); File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); + void applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr); + void applySavedRadioParams(); + void processScheduledRadioSettings(); + bool isMillisTimerDue(unsigned long timestamp) const; + bool hasScheduledRadioWorkDue() const; + uint32_t limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const; + uint32_t limitSleepToRtcTime(uint32_t timestamp, uint32_t sleep_secs) const; + uint32_t limitSleepToScheduledRadioWork(uint32_t sleep_secs) const; + bool hasStartedScheduledTempRadio() const; + int findFreeScheduledRadioSlot() const; + int countScheduledRadioSettings(bool temporary) const; + int findScheduledRadioSettingByIndex(bool temporary, int wanted) const; + int getScheduledRadioSettingIndex(bool temporary, int slot_idx) const; + bool scheduledRadioConflicts(bool temporary, uint32_t start_time, uint32_t end_time) const; + void clearScheduledRadioSetting(int idx, bool restore_if_started); + void formatScheduledRadioDuration(char* dest, size_t dest_len, uint32_t target_time) const; + void formatRadioParamTuple(char* dest, size_t dest_len, const ScheduledRadioSetting& setting) const; + void formatScheduledRadioSetting(char* reply, int setting_idx, int display_idx) const; protected: float getAirtimeBudgetFactor() const override { @@ -155,13 +221,22 @@ protected: uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; uint8_t getDefaultTxCodingRate() const override { return active_cr; } bool allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const override; + bool maybeShortCircuitDirect(mesh::Packet* packet) override; void configureDirectRetryPacket(mesh::Packet* retry, const mesh::Packet* original, uint8_t retry_attempt) override; uint32_t getDirectRetryEchoDelay(const mesh::Packet* packet) const override; uint8_t getDirectRetryMaxAttempts(const mesh::Packet* packet) const override; uint32_t getDirectRetryAttemptDelay(const mesh::Packet* packet, uint8_t attempt_idx) override; - void onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) override; + void onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt, + const uint8_t* target_hash = NULL, uint8_t target_hash_len = 0, + int16_t payload_type = -1) override; void onDirectRetryFailed(const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) override; void onDirectRetrySucceeded(const uint8_t* next_hop_hash, uint8_t next_hop_hash_len, int8_t snr_x4) override; + bool allowFloodRetry(const mesh::Packet* packet) const override; + void onFloodRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) override; + bool hasFloodRetryTargetPrefix(const mesh::Packet* packet) const override; + uint8_t getFloodRetryMaxPathLength(const mesh::Packet* packet) const override; + uint8_t getFloodRetryMaxAttempts(const mesh::Packet* packet) const override; + bool isFloodRetryEchoTarget(const mesh::Packet* packet, uint8_t progress_marker) const override; int getInterferenceThreshold() const override { return _prefs.interference_threshold; @@ -215,6 +290,10 @@ public: // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; + void addScheduledRadioParams(bool temporary, float freq, float bw, uint8_t sf, uint8_t cr, + uint32_t start_time, uint32_t end_time, char* reply) override; + void formatScheduledRadioParams(bool temporary, const char* selector, char* reply) override; + void deleteScheduledRadioParams(bool temporary, const char* selector, char* reply) override; bool formatFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; @@ -250,6 +329,7 @@ public: handleCommand(sender_timestamp, NULL, command, reply); } void loop(); + uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const; #if defined(WITH_BRIDGE) void setBridgeState(bool enable) override { diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 82e2a212..e4f742e8 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -152,12 +152,15 @@ void loop() { #endif rtc_clock.tick(); - if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { + if (the_mesh.getNodePrefs()->powersaving_enabled && !board.isUsbDataConnected()) { + uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30); #if defined(NRF52_PLATFORM) - board.sleep(0); // nrf ignores seconds param, sleeps whenever possible + if (sleep_secs > 0) { + board.sleep(0); // nrf ignores seconds param, sleeps whenever possible + } #else - if (the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep - board.sleep(30); // Sleep. Wake up after a while or when receiving a LoRa packet + if (sleep_secs > 0 && the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep + board.sleep(sleep_secs); // Sleep. Wake up for scheduled jobs or when receiving a LoRa packet } #endif } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index c311c941..637f1eb0 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1032,10 +1032,48 @@ void MyMesh::loop() { last_millis = now; } +bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { + return timestamp && millisHasNowPassed(timestamp); +} + +uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const { + if (!timestamp || sleep_secs == 0) { + return sleep_secs; + } + unsigned long now = millis(); + if ((long)(now - timestamp) >= 0) { + return 0; + } + unsigned long remaining_ms = timestamp - now; + uint32_t remaining_secs = (remaining_ms + 999UL) / 1000UL; + return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; +} + +uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { + if (max_secs == 0 || hasPendingWork()) { + return 0; + } + + uint32_t sleep_secs = max_secs; + if (acl.getNumClients() > 0) { + sleep_secs = limitSleepToMillisTimer(next_push, sleep_secs); + } + sleep_secs = limitSleepToMillisTimer(next_flood_advert, sleep_secs); + sleep_secs = limitSleepToMillisTimer(next_local_advert, sleep_secs); + sleep_secs = limitSleepToMillisTimer(set_radio_at, sleep_secs); + sleep_secs = limitSleepToMillisTimer(revert_radio_at, sleep_secs); + sleep_secs = limitSleepToMillisTimer(dirty_contacts_expiry, sleep_secs); + return sleep_secs; +} + // To check if there is pending work bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif - return _mgr->getOutboundTotal() > 0; + if (_mgr->getOutboundTotal() > 0) return true; + if (acl.getNumClients() > 0 && isMillisTimerDue(next_push)) return true; + if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; + if (isMillisTimerDue(set_radio_at) || isMillisTimerDue(revert_radio_at)) return true; + return isMillisTimerDue(dirty_contacts_expiry); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 380e54da..98a79bb8 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -225,7 +225,12 @@ public: void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); + uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const; // To check if there is pending work bool hasPendingWork() const; + +private: + bool isMillisTimerDue(unsigned long timestamp) const; + uint32_t limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const; }; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index ad8aa914..a3872684 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -119,12 +119,15 @@ void loop() { #endif rtc_clock.tick(); - if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { + if (the_mesh.getNodePrefs()->powersaving_enabled && !board.isUsbDataConnected()) { + uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30); #if defined(NRF52_PLATFORM) - board.sleep(0); // nrf ignores seconds param, sleeps whenever possible + if (sleep_secs > 0) { + board.sleep(0); // nrf ignores seconds param, sleeps whenever possible + } #else - if (the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep - board.sleep(30); // Sleep. Wake up after a while or when receiving a LoRa packet + if (sleep_secs > 0 && the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep + board.sleep(sleep_secs); // Sleep. Wake up for scheduled jobs or when receiving a LoRa packet } #endif } diff --git a/src/Mesh.cpp b/src/Mesh.cpp index b4977125..45eb031f 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -28,6 +28,41 @@ static uint8_t decodeTraceHashSize(uint8_t flags, uint8_t route_bytes) { return size_linear; } +static uint8_t getTraceRemainingHops(const Packet* packet) { + if (packet == NULL || packet->payload_len < 9) { + return 0; + } + + uint8_t route_bytes = packet->payload_len - 9; + uint8_t hash_size = decodeTraceHashSize(packet->payload[8], route_bytes); + if (hash_size == 0) { + return 0; + } + + uint8_t route_hops = route_bytes / hash_size; + if (packet->path_len >= route_hops) { + return 0; + } + return route_hops - packet->path_len; +} + +static uint8_t getTraceDirectPriority(const Packet* packet) { + uint8_t remaining_hops = getTraceRemainingHops(packet); + if (remaining_hops == 0) { + return 5; + } + if (remaining_hops <= 4) { + return 1; + } + if (remaining_hops <= 8) { + return 2; + } + if (remaining_hops <= 12) { + return 3; + } + return 5; +} + void Mesh::begin() { for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { _direct_retries[i].packet = NULL; @@ -39,6 +74,7 @@ void Mesh::begin() { _direct_retries[i].retry_attempts_sent = 0; memset(_direct_retries[i].next_hop_hash, 0, sizeof(_direct_retries[i].next_hop_hash)); _direct_retries[i].next_hop_hash_len = 0; + _direct_retries[i].payload_type = 0; _direct_retries[i].priority = 0; _direct_retries[i].progress_marker = 0; _direct_retries[i].expect_path_growth = false; @@ -78,8 +114,12 @@ void Mesh::loop() { uint32_t elapsed_millis = _direct_retries[i].retry_started_at == 0 ? 0 : (uint32_t)(_ms->getMillis() - _direct_retries[i].retry_started_at); - onDirectRetryEvent("failed_all_tries", _direct_retries[i].packet, elapsed_millis, _direct_retries[i].retry_attempts_sent); - onDirectRetryEvent("failure", _direct_retries[i].packet, elapsed_millis, _direct_retries[i].retry_attempts_sent); + onDirectRetryEvent("failed_all_tries", _direct_retries[i].packet, elapsed_millis, _direct_retries[i].retry_attempts_sent, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, + _direct_retries[i].payload_type); + onDirectRetryEvent("failure", _direct_retries[i].packet, elapsed_millis, _direct_retries[i].retry_attempts_sent, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, + _direct_retries[i].payload_type); onDirectRetryFailed(_direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); continue; @@ -230,9 +270,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // append SNR (Not hash!) pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); + uint8_t pri = getTraceDirectPriority(pkt); uint32_t d = getDirectRetransmitDelay(pkt); - maybeScheduleDirectRetry(pkt, 5); - return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable? + maybeScheduleDirectRetry(pkt, pri); + return ACTION_RETRANSMIT_DELAYED(pri, d); } } return ACTION_RELEASE; @@ -589,6 +630,7 @@ void Mesh::clearDirectRetrySlot(int idx) { _direct_retries[idx].retry_attempts_sent = 0; memset(_direct_retries[idx].next_hop_hash, 0, sizeof(_direct_retries[idx].next_hop_hash)); _direct_retries[idx].next_hop_hash_len = 0; + _direct_retries[idx].payload_type = 0; _direct_retries[idx].priority = 0; _direct_retries[idx].progress_marker = 0; _direct_retries[idx].expect_path_growth = false; @@ -641,7 +683,9 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { uint8_t retry_attempt = _direct_retries[i].waiting_final_echo ? _direct_retries[i].retry_attempts_sent : _direct_retries[i].retry_attempts_sent + 1; - onDirectRetryEvent("good", _direct_retries[i].packet, echo_millis, retry_attempt); + onDirectRetryEvent("good", _direct_retries[i].packet, echo_millis, retry_attempt, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, + _direct_retries[i].payload_type); if (_direct_retries[i].queued) { for (int j = 0; j < _mgr->getOutboundTotal(); j++) { if (_mgr->getOutboundByIdx(j) == _direct_retries[i].packet) { @@ -661,7 +705,8 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { uint32_t echo_millis = _direct_retries[i].echo_wait_started_at == 0 ? 0 : (uint32_t)(_ms->getMillis() - _direct_retries[i].echo_wait_started_at); - onDirectRetryEvent("good", _direct_retries[i].trigger_packet, echo_millis, _direct_retries[i].retry_attempts_sent + 1); + onDirectRetryEvent("good", _direct_retries[i].trigger_packet, echo_millis, _direct_retries[i].retry_attempts_sent + 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); } cleared = true; @@ -682,7 +727,8 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { uint32_t elapsed_millis = _direct_retries[i].retry_started_at == 0 ? 0 : (uint32_t)(_ms->getMillis() - _direct_retries[i].retry_started_at); - onDirectRetryEvent("resent", packet, elapsed_millis, _direct_retries[i].retry_attempts_sent + 1); + onDirectRetryEvent("resent", packet, elapsed_millis, _direct_retries[i].retry_attempts_sent + 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); _direct_retries[i].echo_wait_started_at = _ms->getMillis(); _direct_retries[i].retry_attempts_sent++; uint8_t max_attempts = getDirectRetryMaxAttempts(packet); @@ -703,8 +749,10 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { Packet* retry = obtainNewPacket(); if (retry == NULL) { - onDirectRetryEvent("dropped_no_packet", packet, elapsed_millis, _direct_retries[i].retry_attempts_sent + 1); - onDirectRetryEvent("failure", packet, elapsed_millis, _direct_retries[i].retry_attempts_sent + 1); + onDirectRetryEvent("dropped_no_packet", packet, elapsed_millis, _direct_retries[i].retry_attempts_sent + 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); + onDirectRetryEvent("failure", packet, elapsed_millis, _direct_retries[i].retry_attempts_sent + 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); continue; } @@ -719,10 +767,13 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { _direct_retries[i].retry_delay = retry_delay; _direct_retries[i].retry_at = futureMillis(retry_delay); _direct_retries[i].waiting_final_echo = false; - onDirectRetryEvent("queued", retry, retry_delay, retry_attempt); + onDirectRetryEvent("queued", retry, retry_delay, retry_attempt, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); } else { - onDirectRetryEvent("dropped_queue_full", retry, retry_delay, retry_attempt); - onDirectRetryEvent("failure", retry, elapsed_millis, retry_attempt); + onDirectRetryEvent("dropped_queue_full", retry, retry_delay, retry_attempt, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); + onDirectRetryEvent("failure", retry, elapsed_millis, retry_attempt, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); releasePacket(retry); clearDirectRetrySlot(i); } @@ -737,8 +788,10 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { // Allocate the retry packet only after TX-complete so busy repeaters do not reserve pool slots early. Packet* retry = obtainNewPacket(); if (retry == NULL) { - onDirectRetryEvent("dropped_no_packet", packet, _direct_retries[i].retry_delay, 1); - onDirectRetryEvent("failure", packet, 0, 1); + onDirectRetryEvent("dropped_no_packet", packet, _direct_retries[i].retry_delay, 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); + onDirectRetryEvent("failure", packet, 0, 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); continue; } @@ -757,10 +810,13 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { _direct_retries[i].retry_at = futureMillis(_direct_retries[i].retry_delay); _direct_retries[i].retry_started_at = now; _direct_retries[i].echo_wait_started_at = now; - onDirectRetryEvent("queued", retry, _direct_retries[i].retry_delay, 1); + onDirectRetryEvent("queued", retry, _direct_retries[i].retry_delay, 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); } else { - onDirectRetryEvent("dropped_queue_full", retry, _direct_retries[i].retry_delay, 1); - onDirectRetryEvent("failure", retry, 0, 1); + onDirectRetryEvent("dropped_queue_full", retry, _direct_retries[i].retry_delay, 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); + onDirectRetryEvent("failure", retry, 0, 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); releasePacket(retry); clearDirectRetrySlot(i); } @@ -776,16 +832,20 @@ void Mesh::clearPendingDirectRetryOnSendFail(const Packet* packet) { if (_direct_retries[i].queued) { if (_direct_retries[i].packet == packet) { // The queued retry itself failed; Dispatcher will release it after this hook. - onDirectRetryEvent("dropped_send_fail", packet, 0, _direct_retries[i].retry_attempts_sent + 1); - onDirectRetryEvent("failure", packet, 0, _direct_retries[i].retry_attempts_sent + 1); + onDirectRetryEvent("dropped_send_fail", packet, 0, _direct_retries[i].retry_attempts_sent + 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); + onDirectRetryEvent("failure", packet, 0, _direct_retries[i].retry_attempts_sent + 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); } continue; } if (_direct_retries[i].trigger_packet == packet) { - onDirectRetryEvent("dropped_send_fail", packet, 0, 1); - onDirectRetryEvent("failure", packet, 0, 1); + onDirectRetryEvent("dropped_send_fail", packet, 0, 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); + onDirectRetryEvent("failure", packet, 0, 1, + _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); } } @@ -922,8 +982,8 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { } } if (slot_idx < 0) { - onDirectRetryEvent("dropped_no_slot", packet, 0, 0); - onDirectRetryEvent("failure", packet, 0, 0); + onDirectRetryEvent("dropped_no_slot", packet, 0, 0, next_hop_hash, next_hop_hash_len); + onDirectRetryEvent("failure", packet, 0, 0, next_hop_hash, next_hop_hash_len); return; } @@ -940,6 +1000,7 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { memset(_direct_retries[slot_idx].next_hop_hash, 0, sizeof(_direct_retries[slot_idx].next_hop_hash)); memcpy(_direct_retries[slot_idx].next_hop_hash, next_hop_hash, next_hop_hash_len); _direct_retries[slot_idx].next_hop_hash_len = next_hop_hash_len; + _direct_retries[slot_idx].payload_type = packet->getPayloadType(); _direct_retries[slot_idx].priority = priority; _direct_retries[slot_idx].progress_marker = progress_marker; _direct_retries[slot_idx].expect_path_growth = expect_path_growth; @@ -1496,7 +1557,7 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin packet->payload_len += path_len; packet->path_len = 0; - pri = 5; // maybe make this configurable + pri = getTraceDirectPriority(packet); } else { packet->path_len = Packet::copyPath(packet->path, path, path_len); if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { diff --git a/src/Mesh.h b/src/Mesh.h index 10beb4e6..c306a30f 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -48,6 +48,7 @@ class Mesh : public Dispatcher { uint8_t retry_key[MAX_HASH_SIZE]; uint8_t next_hop_hash[MAX_HASH_SIZE]; uint8_t next_hop_hash_len; + uint8_t payload_type; uint8_t priority; uint8_t progress_marker; bool expect_path_growth; @@ -201,7 +202,9 @@ protected: /** * \brief Optional hook for logging direct-retry lifecycle events. */ - virtual void onDirectRetryEvent(const char* event, const Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) { } + virtual void onDirectRetryEvent(const char* event, const Packet* packet, uint32_t delay_millis, uint8_t retry_attempt, + const uint8_t* target_hash = NULL, uint8_t target_hash_len = 0, + int16_t payload_type = -1) { } /** * \brief Optional hook for link-quality feedback when all direct-retry attempts fail. diff --git a/src/MeshCore.h b/src/MeshCore.h index 89e60b1f..cd0f72f1 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -64,12 +64,14 @@ public: virtual uint8_t getStartupReason() const = 0; virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; } virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported + virtual bool stopOTAUpdate(char reply[]) { return false; } // not supported virtual bool setLoRaFemLnaEnabled(bool enable) { return false; } virtual bool canControlLoRaFemLna() const { return false; } virtual bool isLoRaFemLnaEnabled() const { return false; } // Power management interface (boards with power management override these) virtual bool isExternalPowered() { return false; } + virtual bool isUsbDataConnected() { return false; } virtual uint16_t getBootVoltage() { return 0; } virtual uint32_t getResetReason() const { return 0; } virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 3937dd50..788e4b33 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -95,6 +95,150 @@ static bool looksUnsignedInteger(const char* s) { return saw_digit; } +static bool parseUint8Strict(const char* value, uint8_t min_value, uint8_t max_value, uint8_t& result) { + if (value == NULL || *value == 0) { + return false; + } + + uint16_t parsed = 0; + const char* sp = value; + while (*sp) { + if (*sp < '0' || *sp > '9') { + return false; + } + parsed = (uint16_t)((parsed * 10) + (*sp - '0')); + if (parsed > max_value) { + return false; + } + sp++; + } + if (parsed < min_value) { + return false; + } + result = (uint8_t)parsed; + return true; +} + +static bool bwMatches(float bw, float allowed) { + float diff = bw - allowed; + if (diff < 0.0f) diff = -diff; + return diff <= 0.001f; +} + +static bool isValidLoRaBandwidth(float bw) { +#if defined(USE_LR1110) + return bwMatches(bw, 62.5f) + || bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#elif defined(USE_LLCC68) || defined(USE_SX1272) + return bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#else + return bwMatches(bw, 7.8f) + || bwMatches(bw, 10.4f) + || bwMatches(bw, 15.6f) + || bwMatches(bw, 20.8f) + || bwMatches(bw, 31.25f) + || bwMatches(bw, 41.7f) + || bwMatches(bw, 62.5f) + || bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#endif +} + +static float defaultLoRaBandwidth() { +#ifdef LORA_BW + if (isValidLoRaBandwidth((float)LORA_BW)) { + return (float)LORA_BW; + } +#endif + return 125.0f; +} + +static const char* skipSpacesConst(const char* s) { + while (s != NULL && *s == ' ') s++; + return s; +} + +static bool parseUint32Strict(const char* s, uint32_t& out) { + if (!looksUnsignedInteger(s)) { + return false; + } + + uint64_t n = 0; + s = skipSpacesConst(s); + while (*s >= '0' && *s <= '9') { + n = (n * 10) + (uint32_t)(*s - '0'); + if (n > 0xFFFFFFFFULL) { + return false; + } + s++; + } + out = (uint32_t)n; + return true; +} + +static int countSeparatedParts(const char* s, char separator) { + if (s == NULL || *s == 0) { + return 0; + } + + int count = 1; + while (*s) { + if (*s++ == separator) { + count++; + } + } + return count; +} + +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) { + const int expected_parts = temporary ? 6 : 5; + args = skipSpacesConst(args); + if (countSeparatedParts(args, ',') != expected_parts) { + return false; + } + char local[96]; + if (strlen(args) >= sizeof(local)) { + return false; + } + StrHelper::strncpy(local, args, sizeof(local)); + const char* parts[6]; + int num = mesh::Utils::parseTextParts(local, parts, expected_parts, ','); + if (num != expected_parts) { + return false; + } + + uint32_t sf_u32 = 0; + uint32_t cr_u32 = 0; + if (!looksNumeric(parts[0]) || !looksNumeric(parts[1]) + || !parseUint32Strict(parts[2], sf_u32) + || !parseUint32Strict(parts[3], cr_u32) + || !parseUint32Strict(parts[4], start_time)) { + return false; + } + if (sf_u32 > 255 || cr_u32 > 255) { + return false; + } + + freq = atof(parts[0]); + bw = atof(parts[1]); + sf = (uint8_t)sf_u32; + cr = (uint8_t)cr_u32; + if (temporary && !parseUint32Strict(parts[5], end_time)) { + return false; + } + if (!temporary) { + end_time = 0; + } + return true; +} + static int16_t parseSnrDbX4(const char* s) { float db = atof(s); return (int16_t)(db * 4.0f + (db >= 0.0f ? 0.5f : -0.5f)); @@ -124,6 +268,93 @@ static void markDirectRetryPrefsValid(NodePrefs* prefs) { prefs->direct_retry_prefs_magic[1] = DIRECT_RETRY_PREFS_MAGIC_1; } +static void applyFloodRetryPreset(NodePrefs* prefs, uint8_t preset) { + if (preset == RETRY_PRESET_INFRA) { + prefs->flood_retry_attempts = FLOOD_RETRY_INFRA_COUNT; + prefs->flood_retry_max_path = FLOOD_RETRY_INFRA_MAX_PATH; + } else if (preset == RETRY_PRESET_MOBILE) { + prefs->flood_retry_attempts = FLOOD_RETRY_MOBILE_COUNT; + prefs->flood_retry_max_path = FLOOD_RETRY_MOBILE_MAX_PATH; + } else { + prefs->flood_retry_attempts = FLOOD_RETRY_ROOFTOP_COUNT; + prefs->flood_retry_max_path = FLOOD_RETRY_ROOFTOP_MAX_PATH; + } +} + +static bool parseFloodRetryPathGate(const char* value, uint8_t& path_gate) { + if (value == NULL) { + return false; + } + if (strcmp(value, "off") == 0 || strcmp(value, "disabled") == 0 || strcmp(value, "disable") == 0) { + path_gate = FLOOD_RETRY_PATH_GATE_DISABLED; + return true; + } + return parseUint8Strict(value, 0, 63, path_gate); +} + +static void formatFloodRetryPathGate(char* dest, uint8_t path_gate) { + if (path_gate == FLOOD_RETRY_PATH_GATE_DISABLED) { + strcpy(dest, "off"); + } else { + sprintf(dest, "%u", (unsigned int)path_gate); + } +} + +static void formatFloodRetryPrefixList(char* dest, const uint8_t prefixes[][FLOOD_RETRY_PREFIX_LEN], + uint8_t max_prefixes) { + char* out = dest; + bool first = true; + for (int i = 0; i < max_prefixes; i++) { + const uint8_t* prefix = prefixes[i]; + if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == 0) { + continue; + } + if (!first) { + *out++ = ','; + } + mesh::Utils::toHex(out, prefix, FLOOD_RETRY_PREFIX_LEN); + out += FLOOD_RETRY_PREFIX_LEN * 2; + first = false; + } + *out = 0; +} + +static bool parseFloodRetryPrefixList(uint8_t dest[][FLOOD_RETRY_PREFIX_LEN], uint8_t max_prefixes, const char* value) { + if (max_prefixes > FLOOD_RETRY_LIST_PREFIXES) { + return false; + } + uint8_t parsed[FLOOD_RETRY_LIST_PREFIXES][FLOOD_RETRY_PREFIX_LEN]; + memset(parsed, 0, sizeof(parsed)); + if (value == NULL || value[0] == 0 || strcmp(value, "none") == 0 || strcmp(value, "off") == 0) { + memcpy(dest, parsed, max_prefixes * FLOOD_RETRY_PREFIX_LEN); + return true; + } + + char local[FLOOD_RETRY_LIST_TEXT_MAX]; + StrHelper::strncpy(local, value, sizeof(local)); + const char* parts[FLOOD_RETRY_LIST_PREFIXES + 1]; + int num = mesh::Utils::parseTextParts(local, parts, FLOOD_RETRY_LIST_PREFIXES + 1); + if (num > max_prefixes) { + return false; + } + for (int i = 0; i < num; i++) { + if (strlen(parts[i]) != FLOOD_RETRY_PREFIX_LEN * 2) { + return false; + } + for (int j = 0; j < FLOOD_RETRY_PREFIX_LEN * 2; j++) { + if (!mesh::Utils::isHexChar(parts[i][j])) { + return false; + } + } + if (!mesh::Utils::fromHex(parsed[i], FLOOD_RETRY_PREFIX_LEN, parts[i]) + || (parsed[i][0] == 0 && parsed[i][1] == 0 && parsed[i][2] == 0)) { + return false; + } + } + memcpy(dest, parsed, max_prefixes * FLOOD_RETRY_PREFIX_LEN); + return true; +} + static void applyDirectRetryPreset(NodePrefs* prefs, uint8_t preset) { prefs->retry_preset = preset; if (preset == RETRY_PRESET_INFRA) { @@ -143,6 +374,7 @@ static void applyDirectRetryPreset(NodePrefs* prefs, uint8_t preset) { prefs->direct_retry_step_ms = DIRECT_RETRY_ROOFTOP_STEP_MS; prefs->direct_retry_snr_margin_x4 = DIRECT_RETRY_ROOFTOP_MARGIN_X4; } + applyFloodRetryPreset(prefs, prefs->retry_preset); markDirectRetryPrefsValid(prefs); } @@ -154,6 +386,7 @@ static void setDefaultDirectRetryPrefs(NodePrefs* prefs) { prefs->direct_retry_cr7_snr_x4 = DIRECT_RETRY_CR7_MIN_SNR_X4_DEFAULT; prefs->direct_retry_cr8_snr_x4 = DIRECT_RETRY_CR8_MAX_SNR_X4_DEFAULT; prefs->direct_retry_enabled = 1; + prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; markDirectRetryPrefsValid(prefs); } @@ -280,7 +513,48 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->direct_retry_enabled, sizeof(_prefs->direct_retry_enabled)); // 307 file.read((uint8_t *)&_prefs->direct_retry_cr_enabled, sizeof(_prefs->direct_retry_cr_enabled)); // 308 file.read((uint8_t *)&_prefs->direct_retry_prefs_magic, sizeof(_prefs->direct_retry_prefs_magic)); // 309 - // next: 311 + memset(_prefs->flood_retry_prefixes, 0, sizeof(_prefs->flood_retry_prefixes)); + _prefs->flood_retry_bridge_enabled = 0; + memset(_prefs->flood_retry_bridge_buckets, 0, sizeof(_prefs->flood_retry_bridge_buckets)); + memset(_prefs->flood_retry_ignore_prefixes, 0, sizeof(_prefs->flood_retry_ignore_prefixes)); + _prefs->flood_retry_advert_enabled = FLOOD_RETRY_ADVERT_DEFAULT; + _prefs->battery_alert_enabled = 0; + _prefs->battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT; + _prefs->battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT; + _prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; + bool has_flood_retry_prefs = file.available() >= 2; + if (has_flood_retry_prefs) { + file.read((uint8_t *)&_prefs->flood_retry_attempts, sizeof(_prefs->flood_retry_attempts)); // 311 + file.read((uint8_t *)&_prefs->flood_retry_max_path, sizeof(_prefs->flood_retry_max_path)); // 312 + if (file.available() >= (int)sizeof(_prefs->flood_retry_prefixes)) { + file.read((uint8_t *)&_prefs->flood_retry_prefixes[0][0], sizeof(_prefs->flood_retry_prefixes)); + } + if (file.available() >= (int)sizeof(_prefs->flood_retry_bridge_enabled)) { + file.read((uint8_t *)&_prefs->flood_retry_bridge_enabled, sizeof(_prefs->flood_retry_bridge_enabled)); + } + if (file.available() >= (int)sizeof(_prefs->flood_retry_bridge_buckets)) { + file.read((uint8_t *)&_prefs->flood_retry_bridge_buckets[0][0][0], sizeof(_prefs->flood_retry_bridge_buckets)); + } + if (file.available() >= (int)sizeof(_prefs->flood_retry_ignore_prefixes)) { + file.read((uint8_t *)&_prefs->flood_retry_ignore_prefixes[0][0], sizeof(_prefs->flood_retry_ignore_prefixes)); + } + if (file.available() >= (int)sizeof(_prefs->flood_retry_advert_enabled)) { + file.read((uint8_t *)&_prefs->flood_retry_advert_enabled, sizeof(_prefs->flood_retry_advert_enabled)); + } + if (file.available() >= (int)sizeof(_prefs->battery_alert_enabled)) { + file.read((uint8_t *)&_prefs->battery_alert_enabled, sizeof(_prefs->battery_alert_enabled)); + } + if (file.available() >= (int)sizeof(_prefs->battery_alert_low_percent)) { + file.read((uint8_t *)&_prefs->battery_alert_low_percent, sizeof(_prefs->battery_alert_low_percent)); + } + if (file.available() >= (int)sizeof(_prefs->battery_alert_critical_percent)) { + file.read((uint8_t *)&_prefs->battery_alert_critical_percent, sizeof(_prefs->battery_alert_critical_percent)); + } + if (file.available() >= (int)sizeof(_prefs->direct_retry_recent_enabled)) { + file.read((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled)); + } + } + // next: 672 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -288,7 +562,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0, 2.0f); _prefs->airtime_factor = constrain(_prefs->airtime_factor, 0, 9.0f); _prefs->freq = constrain(_prefs->freq, 150.0f, 2500.0f); - _prefs->bw = constrain(_prefs->bw, 7.8f, 500.0f); + _prefs->bw = isValidLoRaBandwidth(_prefs->bw) ? _prefs->bw : defaultLoRaBandwidth(); _prefs->sf = constrain(_prefs->sf, 5, 12); _prefs->cr = constrain(_prefs->cr, 5, 8); _prefs->tx_power_dbm = constrain(_prefs->tx_power_dbm, -9, 30); @@ -315,6 +589,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean if (!directRetryPrefsValid(_prefs)) { setDefaultDirectRetryPrefs(_prefs); + memset(_prefs->flood_retry_prefixes, 0, sizeof(_prefs->flood_retry_prefixes)); + _prefs->flood_retry_bridge_enabled = 0; + memset(_prefs->flood_retry_bridge_buckets, 0, sizeof(_prefs->flood_retry_bridge_buckets)); + memset(_prefs->flood_retry_ignore_prefixes, 0, sizeof(_prefs->flood_retry_ignore_prefixes)); + _prefs->flood_retry_advert_enabled = FLOOD_RETRY_ADVERT_DEFAULT; + } else if (!has_flood_retry_prefs) { + applyFloodRetryPreset(_prefs, _prefs->retry_preset); } if (_prefs->retry_preset > RETRY_PRESET_MOBILE && _prefs->retry_preset != RETRY_PRESET_CUSTOM) { _prefs->retry_preset = RETRY_PRESET_CUSTOM; @@ -325,6 +606,20 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->direct_retry_snr_margin_x4 = constrain(_prefs->direct_retry_snr_margin_x4, 0, 160); _prefs->direct_retry_enabled = constrain(_prefs->direct_retry_enabled, 0, 1); _prefs->direct_retry_cr_enabled = constrain(_prefs->direct_retry_cr_enabled, 0, 1); + _prefs->flood_retry_attempts = constrain(_prefs->flood_retry_attempts, 0, 15); + if (_prefs->flood_retry_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) { + _prefs->flood_retry_max_path = constrain(_prefs->flood_retry_max_path, 0, 63); + } + _prefs->flood_retry_bridge_enabled = constrain(_prefs->flood_retry_bridge_enabled, 0, 1); + _prefs->flood_retry_advert_enabled = constrain(_prefs->flood_retry_advert_enabled, 0, 1); + _prefs->battery_alert_enabled = constrain(_prefs->battery_alert_enabled, 0, 1); + _prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1); + if (_prefs->battery_alert_low_percent < 1 + || _prefs->battery_alert_low_percent > 100 + || _prefs->battery_alert_critical_percent >= _prefs->battery_alert_low_percent) { + _prefs->battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT; + _prefs->battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT; + } file.close(); } @@ -405,7 +700,18 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->direct_retry_enabled, sizeof(_prefs->direct_retry_enabled)); // 307 file.write((uint8_t *)&_prefs->direct_retry_cr_enabled, sizeof(_prefs->direct_retry_cr_enabled)); // 308 file.write((uint8_t *)&_prefs->direct_retry_prefs_magic, sizeof(_prefs->direct_retry_prefs_magic)); // 309 - // next: 311 + file.write((uint8_t *)&_prefs->flood_retry_attempts, sizeof(_prefs->flood_retry_attempts)); // 311 + file.write((uint8_t *)&_prefs->flood_retry_max_path, sizeof(_prefs->flood_retry_max_path)); // 312 + file.write((uint8_t *)&_prefs->flood_retry_prefixes[0][0], sizeof(_prefs->flood_retry_prefixes)); // 313 + file.write((uint8_t *)&_prefs->flood_retry_bridge_enabled, sizeof(_prefs->flood_retry_bridge_enabled)); + file.write((uint8_t *)&_prefs->flood_retry_bridge_buckets[0][0][0], sizeof(_prefs->flood_retry_bridge_buckets)); + file.write((uint8_t *)&_prefs->flood_retry_ignore_prefixes[0][0], sizeof(_prefs->flood_retry_ignore_prefixes)); + file.write((uint8_t *)&_prefs->flood_retry_advert_enabled, sizeof(_prefs->flood_retry_advert_enabled)); + file.write((uint8_t *)&_prefs->battery_alert_enabled, sizeof(_prefs->battery_alert_enabled)); + file.write((uint8_t *)&_prefs->battery_alert_low_percent, sizeof(_prefs->battery_alert_low_percent)); + file.write((uint8_t *)&_prefs->battery_alert_critical_percent, sizeof(_prefs->battery_alert_critical_percent)); + file.write((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled)); + // next: 672 file.close(); } @@ -466,10 +772,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "ERR: clock cannot go backwards"); } - } else if (memcmp(command, "start ota", 9) == 0) { + } else if (memcmp(command, "start ota", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { if (!_board->startOTAUpdate(_prefs->node_name, reply)) { strcpy(reply, "Error"); } + } else if (memcmp(command, "stop ota", 8) == 0 && (command[8] == 0 || command[8] == ' ')) { + if (!_board->stopOTAUpdate(reply)) { + strcpy(reply, "Error"); + } } else if (memcmp(command, "clock", 5) == 0) { uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); @@ -507,7 +817,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re uint8_t sf = num > 2 ? atoi(parts[2]) : 0; uint8_t cr = num > 3 ? atoi(parts[3]) : 0; int temp_timeout_mins = num > 4 ? atoi(parts[4]) : 0; - if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f && temp_timeout_mins > 0) { + if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && isValidLoRaBandwidth(bw) && temp_timeout_mins > 0) { _callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins); sprintf(reply, "OK - temp params for %d mins", temp_timeout_mins); } else { @@ -529,6 +839,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re handleGetCmd(sender_timestamp, command, reply); } else if (memcmp(command, "set ", 4) == 0) { handleSetCmd(sender_timestamp, command, reply); + } else if (memcmp(command, "del ", 4) == 0) { + handleDelCmd(command, reply); } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { bool s = _callbacks->formatFileSystem(); sprintf(reply, "File system erase: %s", s ? "OK" : "Err"); @@ -663,13 +975,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #endif } else if (memcmp(command, "powersaving on", 14) == 0) { #if defined(NRF52_PLATFORM) - _prefs->powersaving_enabled = 1; - savePrefs(); - strcpy(reply, "on - Immediate effect"); + if (sender_timestamp == 0 || _board->isUsbDataConnected()) { + strcpy(reply, "Error: USB serial connected"); + } else { + _prefs->powersaving_enabled = 1; + savePrefs(); + strcpy(reply, "on - Immediate effect"); + } #elif defined(ESP32) && !defined(WITH_BRIDGE) - _prefs->powersaving_enabled = 1; - savePrefs(); - strcpy(reply, "on - After 2 minutes"); + if (sender_timestamp == 0 || _board->isUsbDataConnected()) { + strcpy(reply, "Error: USB serial connected"); + } else { + _prefs->powersaving_enabled = 1; + savePrefs(); + strcpy(reply, "on - After 2 minutes"); + } #elif defined(WITH_BRIDGE) strcpy(reply, "Bridge not supported"); #else @@ -862,7 +1182,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep float bw = num > 1 ? strtof(parts[1], nullptr) : 0.0f; uint8_t sf = num > 2 ? atoi(parts[2]) : 0; uint8_t cr = num > 3 ? atoi(parts[3]) : 0; - if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f) { + if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && isValidLoRaBandwidth(bw)) { _prefs->sf = sf; _prefs->cr = cr; _prefs->freq = freq; @@ -872,6 +1192,28 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, invalid radio params"); } + } else if (memcmp(config, "radioat ", 8) == 0) { + float freq, bw; + uint8_t sf, cr; + uint32_t start_time, end_time; + if (!parseScheduledRadioArgs(&config[8], false, freq, bw, sf, cr, start_time, end_time)) { + strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start"); + } else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)) { + strcpy(reply, "Error, invalid radio params"); + } else { + _callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply); + } + } else if (memcmp(config, "tempradioat ", 12) == 0) { + float freq, bw; + uint8_t sf, cr; + uint32_t start_time, end_time; + if (!parseScheduledRadioArgs(&config[12], true, freq, bw, sf, cr, start_time, end_time)) { + strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end"); + } else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)) { + strcpy(reply, "Error, invalid radio params"); + } else { + _callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply); + } } else if (memcmp(config, "lat ", 4) == 0) { _prefs->node_lat = atof(&config[4]); savePrefs(); @@ -955,6 +1297,18 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, must be on or off"); } + } else if (memcmp(config, "direct.retry.heard ", 19) == 0) { + if (strcmp(&config[19], "on") == 0) { + _prefs->direct_retry_recent_enabled = 1; + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(&config[19], "off") == 0) { + _prefs->direct_retry_recent_enabled = 0; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be on or off"); + } } else if (memcmp(config, "direct.retry.margin ", 20) == 0) { if (!looksNumeric(&config[20])) { strcpy(reply, "Error, must be 0-40 dB"); @@ -999,6 +1353,81 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, must be 0-5000 ms"); } + } else if (memcmp(config, "flood.retry.count ", 18) == 0) { + int attempts = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1; + if (attempts >= 0 && attempts <= 15) { + _prefs->flood_retry_attempts = (uint8_t)attempts; + _prefs->retry_preset = RETRY_PRESET_CUSTOM; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-15"); + } + } else if (memcmp(config, "flood.retry.path ", 17) == 0) { + uint8_t path_gate; + if (parseFloodRetryPathGate(&config[17], path_gate)) { + _prefs->flood_retry_max_path = path_gate; + _prefs->retry_preset = RETRY_PRESET_CUSTOM; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-63 or off"); + } + } else if (memcmp(config, "flood.retry.prefixes ", 21) == 0) { + if (parseFloodRetryPrefixList(_prefs->flood_retry_prefixes, FLOOD_RETRY_PREFIX_SLOTS, &config[21])) { + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes", + (unsigned int)FLOOD_RETRY_PREFIX_SLOTS); + } + } else if (memcmp(config, "flood.retry.ignore ", 19) == 0) { + if (parseFloodRetryPrefixList(_prefs->flood_retry_ignore_prefixes, + FLOOD_RETRY_IGNORE_PREFIXES, &config[19])) { + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes", + (unsigned int)FLOOD_RETRY_IGNORE_PREFIXES); + } + } else if (memcmp(config, "flood.retry.advert ", 19) == 0) { + if (strcmp(&config[19], "on") == 0) { + _prefs->flood_retry_advert_enabled = 1; + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(&config[19], "off") == 0) { + _prefs->flood_retry_advert_enabled = 0; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be on or off"); + } + } else if (memcmp(config, "flood.retry.bridge ", 19) == 0) { + if (strcmp(&config[19], "on") == 0) { + _prefs->flood_retry_bridge_enabled = 1; + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(&config[19], "off") == 0) { + _prefs->flood_retry_bridge_enabled = 0; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be on or off"); + } + } else if (memcmp(config, "flood.retry.bucket ", 19) == 0) { + const char* params = &config[19]; + uint8_t bucket = atoi(params); + const char* list = strchr(params, ' '); + if (bucket < 1 || bucket > FLOOD_RETRY_BRIDGE_BUCKETS || list == NULL || *(list + 1) == 0) { + sprintf(reply, "Error, usage: set flood.retry.bucket <1-%d> ", FLOOD_RETRY_BRIDGE_BUCKETS); + } else if (parseFloodRetryPrefixList(_prefs->flood_retry_bridge_buckets[bucket - 1], + FLOOD_RETRY_BUCKET_PREFIXES, list + 1)) { + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes", + (unsigned int)FLOOD_RETRY_BUCKET_PREFIXES); + } } else if (memcmp(config, "direct.retry.cr ", 16) == 0) { if (strcmp(&config[16], "off") == 0) { _prefs->direct_retry_cr_enabled = 0; @@ -1234,6 +1663,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { sprintf(reply, "> %s", _board->isLoRaFemLnaEnabled() ? "on" : "off"); } + } else if (memcmp(config, "tempradioat", 11) == 0 && (config[11] == 0 || config[11] == ' ')) { + _callbacks->formatScheduledRadioParams(true, skipSpacesConst(&config[11]), reply); + } else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) { + _callbacks->formatScheduledRadioParams(false, skipSpacesConst(&config[7]), reply); } else if (memcmp(config, "radio", 5) == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(_prefs->freq)); @@ -1255,6 +1688,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", retryPresetName(_prefs->retry_preset)); } else if (memcmp(config, "direct.retry", 12) == 0 && (config[12] == 0 || config[12] == ' ')) { sprintf(reply, "> %s", _prefs->direct_retry_enabled ? "on" : "off"); + } else if (memcmp(config, "direct.retry.heard", 18) == 0) { + sprintf(reply, "> %s", _prefs->direct_retry_recent_enabled ? "on" : "off"); } else if (memcmp(config, "direct.retry.margin", 19) == 0) { char margin[12]; formatSnrDbX4(margin, sizeof(margin), _prefs->direct_retry_snr_margin_x4); @@ -1265,6 +1700,30 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_base_ms); } else if (memcmp(config, "direct.retry.step", 17) == 0) { sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_step_ms); + } else if (memcmp(config, "flood.retry.count", 17) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->flood_retry_attempts); + } else if (memcmp(config, "flood.retry.path", 16) == 0) { + char path_gate[8]; + formatFloodRetryPathGate(path_gate, _prefs->flood_retry_max_path); + sprintf(reply, "> %s", path_gate); + } else if (memcmp(config, "flood.retry.prefixes", 20) == 0) { + formatFloodRetryPrefixList(tmp, _prefs->flood_retry_prefixes, FLOOD_RETRY_PREFIX_SLOTS); + sprintf(reply, "> %s", tmp[0] ? tmp : "none"); + } else if (memcmp(config, "flood.retry.ignore", 18) == 0) { + formatFloodRetryPrefixList(tmp, _prefs->flood_retry_ignore_prefixes, FLOOD_RETRY_IGNORE_PREFIXES); + sprintf(reply, "> %s", tmp[0] ? tmp : "none"); + } else if (memcmp(config, "flood.retry.advert", 18) == 0) { + sprintf(reply, "> %s", _prefs->flood_retry_advert_enabled ? "on" : "off"); + } else if (memcmp(config, "flood.retry.bridge", 18) == 0) { + sprintf(reply, "> %s", _prefs->flood_retry_bridge_enabled ? "on" : "off"); + } else if (memcmp(config, "flood.retry.bucket.", 19) == 0) { + uint8_t bucket = atoi(&config[19]); + if (bucket >= 1 && bucket <= FLOOD_RETRY_BRIDGE_BUCKETS) { + formatFloodRetryPrefixList(tmp, _prefs->flood_retry_bridge_buckets[bucket - 1], FLOOD_RETRY_BUCKET_PREFIXES); + sprintf(reply, "> %s", tmp[0] ? tmp : "none"); + } else { + sprintf(reply, "Error, bucket 1-%d", FLOOD_RETRY_BRIDGE_BUCKETS); + } } else if (memcmp(config, "direct.retry.cr", 15) == 0) { if (!_prefs->direct_retry_cr_enabled) { strcpy(reply, "> off"); @@ -1398,6 +1857,18 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } } +void CommonCLI::handleDelCmd(char* command, char* reply) { + const char* config = &command[4]; + if (memcmp(config, "tempradioat", 11) == 0 && (config[11] == 0 || config[11] == ' ')) { + _callbacks->deleteScheduledRadioParams(true, skipSpacesConst(&config[11]), reply); + } else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) { + _callbacks->deleteScheduledRadioParams(false, skipSpacesConst(&config[7]), reply); + } else { + strcpy(reply, "unknown del: "); + StrHelper::strncpy(&reply[13], config, 160 - 14); + } +} + static char* skipSpaces(char* s) { while (*s == ' ') s++; return s; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 5a518ea9..00990703 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -38,6 +38,45 @@ #define DIRECT_RETRY_MOBILE_COUNT 15 #define DIRECT_RETRY_MOBILE_STEP_MS 50 #define DIRECT_RETRY_MOBILE_MARGIN_X4 0 +#define DIRECT_RETRY_RECENT_DEFAULT 1 + +#define FLOOD_RETRY_INFRA_COUNT 1 +#define FLOOD_RETRY_INFRA_MAX_PATH 1 + +#define FLOOD_RETRY_ROOFTOP_COUNT 3 +#define FLOOD_RETRY_ROOFTOP_MAX_PATH 2 + +#define FLOOD_RETRY_MOBILE_COUNT 15 +#define FLOOD_RETRY_MOBILE_MAX_PATH 1 +#define FLOOD_RETRY_ADVERT_DEFAULT 0 + +#define BATTERY_ALERT_LOW_PERCENT_DEFAULT 20 +#define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 10 + +#ifndef FLOOD_RETRY_PREFIX_SLOTS + #define FLOOD_RETRY_PREFIX_SLOTS 8 +#endif +#ifndef FLOOD_RETRY_PREFIX_LEN + #define FLOOD_RETRY_PREFIX_LEN 3 +#endif +#ifndef FLOOD_RETRY_BRIDGE_BUCKETS + #define FLOOD_RETRY_BRIDGE_BUCKETS 6 +#endif +#ifndef FLOOD_RETRY_BUCKET_PREFIXES + #define FLOOD_RETRY_BUCKET_PREFIXES 17 +#endif +#ifndef FLOOD_RETRY_IGNORE_PREFIXES + #define FLOOD_RETRY_IGNORE_PREFIXES 8 +#endif +#ifndef FLOOD_RETRY_LIST_PREFIXES + #define FLOOD_RETRY_LIST_PREFIXES ((FLOOD_RETRY_IGNORE_PREFIXES > FLOOD_RETRY_BUCKET_PREFIXES) ? FLOOD_RETRY_IGNORE_PREFIXES : FLOOD_RETRY_BUCKET_PREFIXES) +#endif +#ifndef FLOOD_RETRY_LIST_TEXT_MAX + #define FLOOD_RETRY_LIST_TEXT_MAX (FLOOD_RETRY_LIST_PREFIXES * FLOOD_RETRY_PREFIX_LEN * 2 + FLOOD_RETRY_LIST_PREFIXES) +#endif +#ifndef COMMON_CLI_TMP_LEN + #define COMMON_CLI_TMP_LEN ((FLOOD_RETRY_LIST_TEXT_MAX > (PRV_KEY_SIZE * 2 + 4)) ? FLOOD_RETRY_LIST_TEXT_MAX : (PRV_KEY_SIZE * 2 + 4)) +#endif #define DIRECT_RETRY_CR4_MIN_SNR_X4_DEFAULT 40 #define DIRECT_RETRY_CR5_MIN_SNR_X4_DEFAULT 30 @@ -106,6 +145,17 @@ struct NodePrefs { // persisted to file uint8_t direct_retry_enabled; uint8_t direct_retry_cr_enabled; uint8_t direct_retry_prefs_magic[2]; + uint8_t flood_retry_attempts; + uint8_t flood_retry_max_path; + uint8_t flood_retry_prefixes[FLOOD_RETRY_PREFIX_SLOTS][FLOOD_RETRY_PREFIX_LEN]; + uint8_t flood_retry_bridge_enabled; + uint8_t flood_retry_bridge_buckets[FLOOD_RETRY_BRIDGE_BUCKETS][FLOOD_RETRY_BUCKET_PREFIXES][FLOOD_RETRY_PREFIX_LEN]; + uint8_t flood_retry_ignore_prefixes[FLOOD_RETRY_IGNORE_PREFIXES][FLOOD_RETRY_PREFIX_LEN]; + uint8_t flood_retry_advert_enabled; + uint8_t battery_alert_enabled; + uint8_t battery_alert_low_percent; + uint8_t battery_alert_critical_percent; + uint8_t direct_retry_recent_enabled; }; class CommonCLICallbacks { @@ -145,6 +195,27 @@ public: virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; virtual void clearStats() = 0; virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 0; + virtual void addScheduledRadioParams(bool temporary, float freq, float bw, uint8_t sf, uint8_t cr, + uint32_t start_time, uint32_t end_time, char* reply) { + (void)temporary; + (void)freq; + (void)bw; + (void)sf; + (void)cr; + (void)start_time; + (void)end_time; + strcpy(reply, "Error: unsupported"); + } + virtual void formatScheduledRadioParams(bool temporary, const char* selector, char* reply) { + (void)temporary; + (void)selector; + strcpy(reply, "Error: unsupported"); + } + virtual void deleteScheduledRadioParams(bool temporary, const char* selector, char* reply) { + (void)temporary; + (void)selector; + strcpy(reply, "Error: unsupported"); + } virtual void startRegionsLoad() { // no op by default @@ -177,7 +248,7 @@ class CommonCLI { SensorManager* _sensors; RegionMap* _region_map; ClientACL* _acl; - char tmp[PRV_KEY_SIZE*2 + 4]; + char tmp[COMMON_CLI_TMP_LEN]; mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); @@ -186,6 +257,7 @@ class CommonCLI { void handleRegionCmd(char* command, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply); + void handleDelCmd(char* command, char* reply); public: CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, RegionMap& region_map, ClientACL& acl, NodePrefs* prefs, CommonCLICallbacks* callbacks) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 7da2c7ac..563acc9e 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -13,6 +13,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { inhibit_sleep = true; // prevent sleep during OTA + + if (ota_server != nullptr) { + sprintf(reply, "Started: http://%s/update", WiFi.softAPIP().toString().c_str()); + return true; + } + WiFi.softAP("MeshCore-OTA", NULL); sprintf(reply, "Started: http://%s/update", WiFi.softAPIP().toString().c_str()); @@ -23,18 +29,36 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { static char home_buf[90]; sprintf(home_buf, "

Hi! I am a MeshCore Repeater. ID: %s

", id); - AsyncWebServer* server = new AsyncWebServer(80); + ota_server = new AsyncWebServer(80); - server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) { + ota_server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(200, "text/html", home_buf); }); - server->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) { + ota_server->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(SPIFFS, "/packet_log", "text/plain"); }); AsyncElegantOTA.setID(id_buf); - AsyncElegantOTA.begin(server); // Start ElegantOTA - server->begin(); + AsyncElegantOTA.begin(ota_server); // Start ElegantOTA + ota_server->begin(); + + return true; +} + +bool ESP32Board::stopOTAUpdate(char reply[]) { + if (ota_server == nullptr) { + strcpy(reply, "OK - OTA not running"); + return true; + } + + ota_server->end(); + delete ota_server; + ota_server = nullptr; + WiFi.softAPdisconnect(true); + inhibit_sleep = false; + + strcpy(reply, "OK - OTA stopped"); + MESH_DEBUG_PRINTLN("stopOTAUpdate: %s", reply); return true; } @@ -43,6 +67,10 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { return false; // not supported } + +bool ESP32Board::stopOTAUpdate(char reply[]) { + return false; // not supported +} #endif void ESP32Board::powerOff() { diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 1f02b27a..1644fa6a 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -16,10 +16,13 @@ #include "esp_system.h" #include +class AsyncWebServer; + class ESP32Board : public mesh::MainBoard { protected: uint8_t startup_reason; bool inhibit_sleep = false; + AsyncWebServer* ota_server = nullptr; static inline portMUX_TYPE sleepMux = portMUX_INITIALIZER_UNLOCKED; public: @@ -155,6 +158,15 @@ public: } bool startOTAUpdate(const char* id, char reply[]) override; + bool stopOTAUpdate(char reply[]) override; + + bool isUsbDataConnected() override { +#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + return (bool)Serial; +#else + return false; +#endif + } void setInhibitSleep(bool inhibit) { inhibit_sleep = inhibit; diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index beee3212..da5eb0f9 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -3,18 +3,33 @@ #include #include +#include "ble_gap.h" +#include "ble_hci.h" #include static BLEDfu bledfu; +static uint16_t ota_conn_handle = BLE_CONN_HANDLE_INVALID; +static bool ota_active = false; +static bool ota_ble_started = false; + +static void format_ota_reply(char reply[]) { + uint8_t mac_addr[6]; + memset(mac_addr, 0, sizeof(mac_addr)); + Bluefruit.getAddr(mac_addr); + sprintf(reply, "OK - mac: %02X:%02X:%02X:%02X:%02X:%02X", mac_addr[5], mac_addr[4], mac_addr[3], + mac_addr[2], mac_addr[1], mac_addr[0]); +} static void connect_callback(uint16_t conn_handle) { - (void)conn_handle; + ota_conn_handle = conn_handle; MESH_DEBUG_PRINTLN("BLE client connected"); } static void disconnect_callback(uint16_t conn_handle, uint8_t reason) { - (void)conn_handle; (void)reason; + if (ota_conn_handle == conn_handle) { + ota_conn_handle = BLE_CONN_HANDLE_INVALID; + } MESH_DEBUG_PRINTLN("BLE client disconnected"); } @@ -252,6 +267,14 @@ bool NRF52Board::isExternalPowered() { } } +bool NRF52Board::isUsbDataConnected() { +#ifdef USE_TINYUSB + return Serial.dtr(); +#else + return false; +#endif +} + void NRF52Board::sleep(uint32_t secs) { // Clear FPU interrupt flags to avoid insomnia // see errata 87 for details https://docs.nordicsemi.com/bundle/errata_nRF52840_Rev3/page/ERR/nRF52840/Rev3/latest/anomaly_840_87.html @@ -349,13 +372,27 @@ bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { } bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { - // Config the peripheral connection with maximum bandwidth - // more SRAM required by SoftDevice - // Note: All config***() function must be called before begin() - Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); - Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); + (void)id; + + if (ota_active) { + format_ota_reply(reply); + return true; + } + + if (!ota_ble_started) { + // Config the peripheral connection with maximum bandwidth + // more SRAM required by SoftDevice + // Note: All config***() function must be called before begin() + Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); + Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); + + Bluefruit.begin(1, 0); + ota_ble_started = true; + + // To be consistent OTA DFU should be added first if it exists + bledfu.begin(); + } - Bluefruit.begin(1, 0); // Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4 Bluefruit.setTxPower(4); // Set the BLE device name @@ -364,8 +401,8 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { Bluefruit.Periph.setConnectCallback(connect_callback); Bluefruit.Periph.setDisconnectCallback(disconnect_callback); - // To be consistent OTA DFU should be added first if it exists - bledfu.begin(); + Bluefruit.Advertising.clearData(); + Bluefruit.ScanResponse.clearData(); // Set up and start advertising // Advertising packet @@ -387,12 +424,27 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { Bluefruit.Advertising.setFastTimeout(30); // number of seconds in fast mode Bluefruit.Advertising.start(0); // 0 = Don't stop advertising after n seconds - uint8_t mac_addr[6]; - memset(mac_addr, 0, sizeof(mac_addr)); - Bluefruit.getAddr(mac_addr); - sprintf(reply, "OK - mac: %02X:%02X:%02X:%02X:%02X:%02X", mac_addr[5], mac_addr[4], mac_addr[3], - mac_addr[2], mac_addr[1], mac_addr[0]); + ota_active = true; + format_ota_reply(reply); return true; } + +bool NRF52Board::stopOTAUpdate(char reply[]) { + if (!ota_active) { + strcpy(reply, "OK - OTA not running"); + return true; + } + + Bluefruit.Advertising.restartOnDisconnect(false); + Bluefruit.Advertising.stop(); + if (ota_conn_handle != BLE_CONN_HANDLE_INVALID) { + sd_ble_gap_disconnect(ota_conn_handle, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION); + ota_conn_handle = BLE_CONN_HANDLE_INVALID; + } + ota_active = false; + + strcpy(reply, "OK - OTA stopped"); + return true; +} #endif diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index cbf4cd49..f578c390 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -53,8 +53,10 @@ public: virtual void powerOff() override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[]) override; + virtual bool stopOTAUpdate(char reply[]) override; virtual void sleep(uint32_t secs) override; bool isExternalPowered() override; + bool isUsbDataConnected() override; #ifdef NRF52_POWER_MANAGEMENT uint16_t getBootVoltage() override { return boot_voltage_mv; } @@ -77,4 +79,4 @@ public: NRF52BoardDCDC() {} virtual void begin() override; }; -#endif \ No newline at end of file +#endif diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index fb717bfc..f2420473 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -1,6 +1,9 @@ #pragma once #include +#if ARDUINO + #include +#endif #ifdef ESP32 #include @@ -26,6 +29,7 @@ public: uint8_t prefix[MAX_ROUTE_HASH_BYTES]; uint8_t prefix_len; int8_t snr_x4; + uint32_t last_heard_millis; }; private: @@ -194,7 +198,10 @@ public: uint32_t getNumDirectDups() const { return _direct_dups; } uint32_t getNumFloodDups() const { return _flood_dups; } - bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4) { + bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4, + bool snr_locked = false, bool bypass_allow_filter = false) { + (void)snr_locked; + (void)bypass_allow_filter; if (prefix == NULL || prefix_len == 0) { return false; } @@ -211,6 +218,11 @@ public: continue; } existing.snr_x4 = weightedSnrX4RoundUp(existing.snr_x4, snr_x4); +#if ARDUINO + existing.last_heard_millis = millis(); +#else + existing.last_heard_millis = 0; +#endif return true; } @@ -222,15 +234,19 @@ public: } } if (slot_idx < 0) { - // Table is full: evict the weakest observed SNR entry. + // Table is full: evict the oldest heard entry. slot_idx = 0; - int8_t min_snr_x4 = _recent_repeaters[0].snr_x4; +#if ARDUINO + uint32_t now = millis(); + uint32_t oldest_age = (uint32_t)(now - _recent_repeaters[0].last_heard_millis); for (int i = 1; i < MAX_RECENT_REPEATERS; i++) { - if (_recent_repeaters[i].snr_x4 < min_snr_x4) { - min_snr_x4 = _recent_repeaters[i].snr_x4; + uint32_t age = (uint32_t)(now - _recent_repeaters[i].last_heard_millis); + if (age > oldest_age) { + oldest_age = age; slot_idx = i; } } +#endif } RecentRepeaterInfo& slot = _recent_repeaters[slot_idx]; @@ -238,6 +254,11 @@ public: memcpy(slot.prefix, prefix, prefix_len); slot.prefix_len = prefix_len; slot.snr_x4 = snr_x4; +#if ARDUINO + slot.last_heard_millis = millis(); +#else + slot.last_heard_millis = 0; +#endif return true; } bool decrementRecentRepeaterSnrX4(const uint8_t* prefix, uint8_t prefix_len, uint8_t amount_x4 = 1) { diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 4d30f515..85f6e2b4 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -4,6 +4,10 @@ #include "RadioLibWrappers.h" #include "LR11x0Reset.h" +#ifndef USE_LR1110 +#define USE_LR1110 +#endif + class CustomLR1110Wrapper : public RadioLibWrapper { public: CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } diff --git a/variants/minewsemi_me25ls01/platformio.ini b/variants/minewsemi_me25ls01/platformio.ini index 39d4252d..3468e072 100644 --- a/variants/minewsemi_me25ls01/platformio.ini +++ b/variants/minewsemi_me25ls01/platformio.ini @@ -12,6 +12,7 @@ build_flags = ${nrf52_base.build_flags} -D PIN_STATUS_LED=39 -D P_LORA_TX_LED=22 -D RADIO_CLASS=CustomLR1110 + -D USE_LR1110 -D WRAPPER_CLASS=CustomLR1110Wrapper -D LORA_TX_POWER=22 -D ENV_INCLUDE_GPS=0 diff --git a/variants/thinknode_m3/platformio.ini b/variants/thinknode_m3/platformio.ini index 0a3d4eda..f3047ea4 100644 --- a/variants/thinknode_m3/platformio.ini +++ b/variants/thinknode_m3/platformio.ini @@ -12,6 +12,7 @@ build_flags = ${nrf52_base.build_flags} -D PIN_USER_BTN=12 -D PIN_STATUS_LED=35 -D RADIO_CLASS=CustomLR1110 + -D USE_LR1110 -D WRAPPER_CLASS=CustomLR1110Wrapper -D LORA_TX_POWER=22 -D RF_SWITCH_TABLE diff --git a/variants/wio_wm1110/platformio.ini b/variants/wio_wm1110/platformio.ini index a7eac916..e1255df2 100644 --- a/variants/wio_wm1110/platformio.ini +++ b/variants/wio_wm1110/platformio.ini @@ -11,6 +11,7 @@ build_flags = ${nrf52_base.build_flags} -D WIO_WM1110 ; -D MESH_DEBUG=1 -D RADIO_CLASS=CustomLR1110 + -D USE_LR1110 -D WRAPPER_CLASS=CustomLR1110Wrapper -D LORA_TX_POWER=22 -D RX_BOOSTED_GAIN=true