From da00a004360749a9f9591601bce3133eaf426957 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 13 Jul 2026 01:03:27 -0700 Subject: [PATCH] Improve repeater reliability and build tooling --- .gitignore | 3 + build.sh | 334 +++++++++++++++++++--- docs/cli_commands.md | 26 +- docs/halo_keymind_settings.md | 37 ++- examples/companion_radio/MyMesh.cpp | 59 +++- examples/simple_repeater/MyMesh.cpp | 306 ++++++++++++++++---- examples/simple_repeater/MyMesh.h | 22 +- examples/simple_room_server/MyMesh.cpp | 13 +- examples/simple_room_server/MyMesh.h | 4 +- examples/simple_room_server/main.cpp | 3 + examples/simple_sensor/SensorMesh.cpp | 13 +- examples/simple_sensor/SensorMesh.h | 4 +- src/Dispatcher.cpp | 43 ++- src/Dispatcher.h | 1 + src/Identity.cpp | 4 +- src/Mesh.cpp | 79 +++-- src/Mesh.h | 11 +- src/Utils.cpp | 4 +- src/helpers/AlertReporter.cpp | 14 +- src/helpers/ClientACL.cpp | 8 +- src/helpers/CommonCLI.cpp | 34 ++- src/helpers/CommonCLI.h | 1 + src/helpers/RegionMap.cpp | 115 ++++++-- src/helpers/bridges/ESPNowBridge.cpp | 49 +++- src/helpers/bridges/ESPNowBridge.h | 2 +- src/helpers/esp32/ESPNOWRadio.h | 8 +- src/helpers/ota/OtaManager.cpp | 19 +- src/helpers/ota/OtaStoreFlashEsp32.cpp | 7 +- test/test_ota/test_ota_core.cpp | 59 ++++ test/test_utils/test_tohex.cpp | 22 ++ variants/lilygo_tlora_v2_1/platformio.ini | 4 +- 31 files changed, 1079 insertions(+), 229 deletions(-) diff --git a/.gitignore b/.gitignore index 4f540842..1c88d194 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .vscode/launch.json .vscode/ipch out/ +# Local firmware archives and build logs +out.*/ +build-logs/ .direnv/ .DS_Store .vscode/settings.json diff --git a/build.sh b/build.sh index 63627a2e..11d29f20 100755 --- a/build.sh +++ b/build.sh @@ -24,6 +24,12 @@ FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-}" BATCH_BUILD_MODE=0 RESOLVED_BUILD_TARGETS=() RESUME_BUILD_OUTPUT="${RESUME_BUILD_OUTPUT:-0}" +LOGGING_MATRIX_FAILURES=() +RADIO_PRESET_SELECTION="" +KISS_MODE_OVERRIDE="" +PARSED_COMMAND_ARGS=() +FIRMWARE_VERSION_EXPLICIT=0 +OUTPUT_POLICY_EXPLICIT=0 ENV_VARIANT_SUFFIX_PATTERN='companion_radio_serial|companion_radio_wifi|companion_radio_usb|comp_radio_usb|companion_usb|companion_radio_ble|companion_ble|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater' BOARD_MODIFIER_WITHOUT_DISPLAY="_without_display" @@ -52,20 +58,27 @@ FALLBACK_VERSION_DATE_FORMAT='+%Y-%m-%d-%H-%M' global_usage() { cat - < [target] +bash build.sh [target] [options] Commands: help|usage|-h|--help: Shows this message. list|-l: List firmwares available to build. build-firmware : Build the firmware for the given build target. build-firmwares: Build all firmwares for all targets. - build-firmwares-logging-matrix: Build all firmwares in three profiles: logging off, logging on for non-Bluetooth targets, and MQTT bridge on with logging off. + build-firmwares-logging-matrix: Build all firmwares in three profiles, logging each target under out/build-logs/ and continuing after failures. build-matching-firmwares : Build all firmwares for build targets containing the string given for . build-companion-firmwares: Build all companion firmwares for all build targets. build-repeater-firmwares: Build all repeater firmwares for all build targets. build-room-server-firmwares: Build all chat room server firmwares for all build targets. build-sensor-firmwares: Build all sensor firmwares for all build targets. +Options: + --firmware-version : Firmware version to embed. + --radio-preset : Use the numbered radio choice from the interactive menu (1 keeps target defaults). + --profile : Select the firmware settings profile. + --skip-kiss|--include-kiss: Exclude or include KISS modem targets in bulk builds. + --clean|--resume: Clean output or resume existing option 3 artifacts. + Examples: Build firmware for the "RAK_4631_repeater" device target $ bash build.sh build-firmware RAK_4631_repeater @@ -369,6 +382,103 @@ clear_firmware_profile_overrides() { FIRMWARE_PROFILE_OVERRIDE="" } +apply_cli_radio_preset() { + local selection=$1 + local preset_output row + local -a preset_rows=() + + if ! [[ "$selection" =~ ^[0-9]+$ ]] || [ "$selection" -lt 1 ]; then + echo "Invalid --radio-preset value: ${selection}" + return 1 + fi + clear_radio_overrides + if [ "$selection" -eq 1 ]; then + echo "Using target default radio settings." + return 0 + fi + + if ! preset_output=$(fetch_suggested_radio_settings) || [ -z "$preset_output" ]; then + echo "Could not fetch radio preset ${selection} from ${RADIO_SETTINGS_API_URL}." + return 1 + fi + mapfile -t preset_rows <<< "$preset_output" + if [ "$selection" -gt $((${#preset_rows[@]} + 1)) ]; then + echo "Radio preset ${selection} is out of range; available menu choices are 1-$((${#preset_rows[@]} + 1))." + return 1 + fi + + row=${preset_rows[$((selection - 2))]} + IFS=$'\t' read -r title description freq bw sf cr <<< "$row" + set_radio_overrides "$title" "$freq" "$bw" "$sf" "$cr" + echo "Using radio setting ${selection}: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})" +} + +parse_cli_options() { + local -a positional=() + + while [ $# -gt 0 ]; do + case "$1" in + --firmware-version|--version) + if [ $# -lt 2 ] || [ -z "$2" ]; then echo "$1 requires a value"; return 1; fi + FIRMWARE_VERSION=$2 + FIRMWARE_VERSION_EXPLICIT=1 + export FIRMWARE_VERSION + shift 2 + ;; + --radio-preset) + if [ $# -lt 2 ] || [ -z "$2" ]; then echo "$1 requires a value"; return 1; fi + RADIO_PRESET_SELECTION=$2 + shift 2 + ;; + --profile) + if [ $# -lt 2 ]; then echo "$1 requires a value"; return 1; fi + case "${2,,}" in + default) FIRMWARE_PROFILE_OVERRIDE="" ;; + cascade) FIRMWARE_PROFILE_OVERRIDE="cascade" ;; + *) echo "Invalid profile: $2 (use default or cascade)"; return 1 ;; + esac + shift 2 + ;; + --skip-kiss) + KISS_MODE_OVERRIDE="skip" + shift + ;; + --include-kiss) + KISS_MODE_OVERRIDE="build" + shift + ;; + --clean) + RESUME_BUILD_OUTPUT=0 + OUTPUT_POLICY_EXPLICIT=1 + shift + ;; + --resume) + RESUME_BUILD_OUTPUT=1 + OUTPUT_POLICY_EXPLICIT=1 + shift + ;; + --) + shift + positional+=("$@") + break + ;; + help|usage|-h|--help|list|-l) + positional+=("$1") + shift + ;; + -*) + echo "Unknown option: $1" + return 1 + ;; + *) + positional+=("$1") + shift + ;; + esac + done + PARSED_COMMAND_ARGS=("${positional[@]}") +} + set_radio_overrides() { RADIO_SETTING_TITLE=$1 RADIO_FREQ_OVERRIDE=$2 @@ -920,6 +1030,11 @@ prompt_for_resolved_firmware_version() { return 0 fi + if [ "$FIRMWARE_VERSION_EXPLICIT" -eq 1 ]; then + echo "Using firmware version from --firmware-version: ${FIRMWARE_VERSION}" + return 0 + fi + if ! [ -t 0 ]; then return 0 fi @@ -1036,6 +1151,27 @@ filter_out_bluetooth_targets() { done } +is_logging_size_constrained_target() { + case "$1" in + Tiny_Relay_repeater|RAK_3x72_repeater|wio-e5_repeater) + return 0 + ;; + *) + return 1 + ;; + esac +} + +filter_out_logging_size_constrained_targets() { + local target + + for target in "$@"; do + if ! is_logging_size_constrained_target "$target"; then + printf '%s\n' "$target" + fi + done +} + prompt_for_kiss_modem_build_policy() { local kiss_count=0 local target @@ -1051,6 +1187,18 @@ prompt_for_kiss_modem_build_policy() { return 0 fi + case "$KISS_MODE_OVERRIDE" in + skip) + filter_out_kiss_modem_targets + echo "Skipped ${kiss_count} KISS modem target(s)." + return 0 + ;; + build) + echo "Including ${kiss_count} KISS modem target(s)." + return 0 + ;; + esac + if ! [ -t 0 ]; then echo "Including ${kiss_count} KISS modem target(s)." return 0 @@ -1096,6 +1244,15 @@ prompt_for_logging_matrix_output_policy() { normalize_resume_build_output + if [ "$OUTPUT_POLICY_EXPLICIT" -eq 1 ]; then + if [ "$RESUME_BUILD_OUTPUT" == "1" ]; then + echo "Using --resume for existing option 3 output." + else + echo "Using --clean for option 3 output." + fi + return 0 + fi + if ! [ -d "$OUTPUT_DIR" ]; then RESUME_BUILD_OUTPUT=0 return 0 @@ -1802,11 +1959,70 @@ run_resolved_build_targets() { return "$build_status" } +run_logged_build_targets() { + local targets=("$@") + local env profile log_dir log_path log_tmp + local previous_batch_build_mode=$BATCH_BUILD_MODE + local build_status=0 + local overall_status=0 + local preserved_log=0 + + if [ ${#targets[@]} -eq 0 ]; then + echo "No build targets resolved." + return 1 + fi + + if [ -n "$FIRMWARE_FILENAME_INFIX" ]; then + profile=$FIRMWARE_FILENAME_INFIX + elif [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ]; then + profile="mqtt" + else + profile="standard" + fi + log_dir="${OUTPUT_DIR}/build-logs" + mkdir -p -- "$log_dir" || return 1 + + if [ ${#targets[@]} -gt 1 ]; then + BATCH_BUILD_MODE=1 + fi + for env in "${targets[@]}"; do + log_path="${log_dir}/${env}-${profile}.log" + log_tmp="${log_path}.tmp" + preserved_log=0 + echo "Building ${env} (${profile}); log: ${log_path}" + build_firmware "$env" > "$log_tmp" 2>&1 + build_status=$? + if [ "$build_status" -eq 0 ] \ + && grep -q "^Skipping ${env}; existing artifacts found" "$log_tmp" \ + && [ -s "$log_path" ]; then + rm -f -- "$log_tmp" + preserved_log=1 + else + mv -f -- "$log_tmp" "$log_path" + fi + if [ "$build_status" -ne 0 ]; then + overall_status=1 + LOGGING_MATRIX_FAILURES+=("${env} (${profile}) -> ${log_path}") + echo "FAILED: ${env} (${profile}), status ${build_status}" + echo "FAILED: ${env} (${profile}), status ${build_status}" >> "$log_path" + else + echo "SUCCEEDED: ${env} (${profile})" + if [ "$preserved_log" -eq 0 ]; then + echo "SUCCEEDED: ${env} (${profile})" >> "$log_path" + fi + fi + done + BATCH_BUILD_MODE=$previous_batch_build_mode + + return "$overall_status" +} + run_logging_matrix_build_targets() { local targets=("$@") local target local standard_targets=() local logging_targets=() + local constrained_logging_targets=() local mqtt_targets=() local original_meshdebug_override=$MESHDEBUG_OVERRIDE local original_packet_logging_override=$PACKET_LOGGING_OVERRIDE @@ -1814,12 +2030,15 @@ run_logging_matrix_build_targets() { local original_mqtt_debug_override=$MQTT_DEBUG_OVERRIDE local original_firmware_filename_infix=$FIRMWARE_FILENAME_INFIX local bluetooth_skip_count=0 + local logging_target_count=0 local build_status=0 + local pass_status=0 if [ ${#targets[@]} -eq 0 ]; then echo "No build targets resolved." return 1 fi + LOGGING_MATRIX_FAILURES=() for target in "${targets[@]}"; do if is_mqtt_bridge_target "$target"; then @@ -1835,45 +2054,66 @@ run_logging_matrix_build_targets() { MQTT_BRIDGE_OVERRIDE="off" FIRMWARE_FILENAME_INFIX="" if [ ${#standard_targets[@]} -gt 0 ]; then - run_resolved_build_targets "${standard_targets[@]}" - build_status=$? + run_logged_build_targets "${standard_targets[@]}" + pass_status=$? + if [ "$pass_status" -ne 0 ]; then build_status=1; fi fi - if [ "$build_status" -eq 0 ]; then - mapfile -t logging_targets < <(filter_out_bluetooth_targets "${standard_targets[@]}") - bluetooth_skip_count=$((${#standard_targets[@]} - ${#logging_targets[@]})) + mapfile -t logging_targets < <(filter_out_bluetooth_targets "${standard_targets[@]}") + bluetooth_skip_count=$((${#standard_targets[@]} - ${#logging_targets[@]})) - if [ "$bluetooth_skip_count" -gt 0 ]; then - echo "Skipping ${bluetooth_skip_count} Bluetooth target(s) for logging-on pass." - fi - - if [ ${#logging_targets[@]} -gt 0 ]; then - echo "Profile 2/3: building ${#logging_targets[@]} standard target(s) with logging on and MQTT bridge off." - echo "Logging-on artifacts use filename form: name-logging-version" - MESHDEBUG_OVERRIDE="on" - PACKET_LOGGING_OVERRIDE="on" - MQTT_BRIDGE_OVERRIDE="off" - FIRMWARE_FILENAME_INFIX="logging" - run_resolved_build_targets "${logging_targets[@]}" - build_status=$? - else - echo "No non-Bluetooth targets remain for logging-on pass." - fi + if [ "$bluetooth_skip_count" -gt 0 ]; then + echo "Skipping ${bluetooth_skip_count} Bluetooth target(s) for logging-on pass." fi - if [ "$build_status" -eq 0 ]; then - if [ ${#mqtt_targets[@]} -gt 0 ]; then - echo "Profile 3/3: building ${#mqtt_targets[@]} MQTT bridge target(s) for direct radio-to-MQTT forwarding over WiFi, with logging off." - MESHDEBUG_OVERRIDE="off" - PACKET_LOGGING_OVERRIDE="off" - MQTT_BRIDGE_OVERRIDE="on" - MQTT_DEBUG_OVERRIDE="off" - FIRMWARE_FILENAME_INFIX="" - run_resolved_build_targets "${mqtt_targets[@]}" - build_status=$? - else - echo "No MQTT bridge targets are configured; skipping profile 3/3." + for target in "${logging_targets[@]}"; do + if is_logging_size_constrained_target "$target"; then + constrained_logging_targets+=("$target") fi + done + mapfile -t logging_targets < <(filter_out_logging_size_constrained_targets "${logging_targets[@]}") + logging_target_count=$((${#logging_targets[@]} + ${#constrained_logging_targets[@]})) + + if [ "$logging_target_count" -gt 0 ]; then + echo "Profile 2/3: building ${logging_target_count} standard target(s) with logging on and MQTT bridge off." + echo "Logging-on artifacts use filename form: name-logging-version" + else + echo "No non-Bluetooth targets remain for logging-on pass." + fi + + if [ ${#logging_targets[@]} -gt 0 ]; then + MESHDEBUG_OVERRIDE="on" + PACKET_LOGGING_OVERRIDE="on" + MQTT_BRIDGE_OVERRIDE="off" + FIRMWARE_FILENAME_INFIX="logging" + run_logged_build_targets "${logging_targets[@]}" + pass_status=$? + if [ "$pass_status" -ne 0 ]; then build_status=1; fi + fi + + if [ ${#constrained_logging_targets[@]} -gt 0 ]; then + echo "Building ${#constrained_logging_targets[@]} 224KB STM32 repeater target(s) with packet logging on and MESH_DEBUG off to fit flash: Tiny_Relay_repeater, RAK_3x72_repeater, wio-e5_repeater." + MESHDEBUG_OVERRIDE="off" + PACKET_LOGGING_OVERRIDE="on" + MQTT_BRIDGE_OVERRIDE="off" + FIRMWARE_FILENAME_INFIX="logging" + run_logged_build_targets "${constrained_logging_targets[@]}" + pass_status=$? + if [ "$pass_status" -ne 0 ]; then build_status=1; fi + fi + + if [ ${#mqtt_targets[@]} -gt 0 ]; then + echo "Profile 3/3: building ${#mqtt_targets[@]} MQTT bridge target(s) for direct radio-to-MQTT forwarding over WiFi, with logging off." + MESHDEBUG_OVERRIDE="off" + PACKET_LOGGING_OVERRIDE="off" + MQTT_BRIDGE_OVERRIDE="on" + MQTT_DEBUG_OVERRIDE="off" + FIRMWARE_FILENAME_INFIX="" + run_logged_build_targets "${mqtt_targets[@]}" + pass_status=$? + if [ "$pass_status" -ne 0 ]; then build_status=1; fi + else + echo "No MQTT bridge targets are configured; skipping profile 3/3." fi MESHDEBUG_OVERRIDE=$original_meshdebug_override @@ -1882,6 +2122,15 @@ run_logging_matrix_build_targets() { MQTT_DEBUG_OVERRIDE=$original_mqtt_debug_override FIRMWARE_FILENAME_INFIX=$original_firmware_filename_infix + if [ ${#LOGGING_MATRIX_FAILURES[@]} -gt 0 ]; then + echo "Logging matrix completed with ${#LOGGING_MATRIX_FAILURES[@]} failed build(s):" + printf ' %s\n' "${LOGGING_MATRIX_FAILURES[@]}" + elif [ "$build_status" -ne 0 ]; then + echo "Logging matrix completed with an output/logging error; inspect ${OUTPUT_DIR}/build-logs/." + else + echo "Logging matrix completed successfully. Per-target logs are in ${OUTPUT_DIR}/build-logs/." + fi + return "$build_status" } @@ -1927,6 +2176,11 @@ run_command() { } main() { + if ! parse_cli_options "$@"; then + exit 1 + fi + set -- "${PARSED_COMMAND_ARGS[@]}" + case "${1:-}" in help|usage|-h|--help) global_usage @@ -1945,6 +2199,12 @@ main() { init_project_context + if [ -n "$RADIO_PRESET_SELECTION" ]; then + if ! apply_cli_radio_preset "$RADIO_PRESET_SELECTION"; then + exit 1 + fi + fi + if [ $# -eq 0 ]; then if ! [ -t 0 ]; then echo "No command provided and no interactive terminal is available." @@ -1985,4 +2245,6 @@ main() { run_command "$@" } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9cf5efe6..ef1e5dcd 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -465,11 +465,25 @@ send text.flood checking ridge link #### View or change battery alert state **Usage:** - `get battery.alert` -- `set battery.alert ` +- `get battery.alert.region` +- `set battery.alert on [region]` +- `set battery.alert off` -**Default:** `off` +**Parameters:** +- `region`: Optional named region scope. When omitted, the repeater selects the single deepest (most narrow) named region in the configured hierarchy. If multiple regions tie for deepest, specify one explicitly. -**Note:** When enabled, the repeater checks battery level once per minute and sends low-battery warnings to the `#repeaters` flood channel. +**Defaults:** +- `battery.alert`: `off` +- `battery.alert.region`: `` + +**Notes:** +- Enabling fails until at least one usable named region is defined. Alerts are never sent as unscoped floods. If the selected region is later removed, alerts stop until battery alerts are enabled again with a valid region. +- Region hierarchy edits are not persistent until `region save` is run. After `region def west pnw wa w-wa sea`, run `region save` before enabling the alert if the hierarchy must survive a reboot. +- A region must have a usable transport key. Public named regions derive one automatically; a private region without an available key is rejected. +- The first alert is suppressed until the repeater has been up for at least 30 minutes. After that, the repeater checks every 30 minutes and sends low-battery warnings to the `#repeaters` channel in the selected region. +- With `region def west pnw wa w-wa sea`, `set battery.alert on` selects `sea`; `set battery.alert on w-wa` overrides that default. +- `get battery.alert.region` returns the selected scope, for example `> sea`. +- The battery check never requests a wake earlier than its 30-minute deadline. If the normal loop is already awake when that deadline has elapsed, the check is effectively free of an additional wake. Time in light/event sleep counts toward the startup delay, and a queued alert keeps the repeater awake until the packet is handled. --- @@ -1378,6 +1392,10 @@ set flood.retry.ignore none Flood retry timing retains its fixed maximum-frame plus 20 packet-airtime wait, then adds a random `0-200%` of one additional packet airtime on every attempt. This de-synchronizes repeaters that may have missed the same echo while capping the added wait at two frames. +Only one active retry sequence is kept for a given logical flood packet. An identical flood can still transmit normally, but it does not create a second sequence of extra attempts. Retry state is released if a queued packet is evicted, and the final echo window retains metadata without reserving a packet-pool entry. + +Bridge reachability learned from earlier hops in a successful echo is cached separately from `recent.repeater`. Only the final RF hop updates `recent.repeater` and its SNR, so indirect path entries cannot affect direct-retry SNR gating or coding-rate selection. + **Examples:** ``` get flood.retry.bridge @@ -1833,7 +1851,7 @@ Requires WiFi connected and the MQTT bridge running. - `set bridge.secret ` **Parameters:** -- `secret`: ESP-NOW bridge secret, up to 15 characters +- `secret`: ESP-NOW bridge secret, 1-15 characters **Default:** Varies by board diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 07dea6fd..ec23b2c0 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -73,7 +73,7 @@ set flood.retry.ignore none | Setting | What it does | How to use | Example | | --- | --- | --- | --- | -| `battery.alert` | Sends opt-in low-battery warnings to `#repeaters`. | `get battery.alert`, `set battery.alert on/off` | `set battery.alert on` | +| `battery.alert` | Sends opt-in, region-scoped low-battery warnings to `#repeaters` after 30 minutes of uptime. | `get battery.alert`, `get battery.alert.region`, `set battery.alert on [region]`, `set battery.alert off` | `set battery.alert on sea` | | `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` | | `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` | @@ -92,9 +92,18 @@ set flood.retry.ignore none ## Battery Alerts -Battery alerts are off by default. When enabled, the repeater checks once per -minute and sends a flood text warning to `#repeaters` when voltage is above -`1 V` and the estimated battery percent is below `battery.alert.low`. +Battery alerts are off by default. Enabling requires a named region. With no +region argument, the repeater selects the single deepest (most narrow) region +in the hierarchy; if multiple regions tie, the command asks for an explicit +region. For example, after `region def west pnw wa w-wa sea`, `set +battery.alert on` selects `sea`, while `set battery.alert on w-wa` overrides +the default. Alerts are never sent as unscoped floods, and removing the selected +region stops alerts until a valid scope is selected again. + +The repeater suppresses alerts for its first 30 minutes of uptime. It then +checks every 30 minutes and sends a flood text warning to `#repeaters` when +voltage is above `1 V` and the estimated battery percent is below +`battery.alert.low`. Warnings repeat every `24` hours, or every `12` hours when the estimate is below `battery.alert.critical`. @@ -104,6 +113,7 @@ Defaults: | Setting | Default | | --- | ---: | | `battery.alert` | `off` | +| `battery.alert.region` | `` | | `battery.alert.low` | `20` | | `battery.alert.critical` | `10` | @@ -114,8 +124,17 @@ set battery.alert.low 20 set battery.alert.critical 10 set battery.alert on get battery.alert +get battery.alert.region ``` +CPU power saving remains compatible with the check. The battery timer never +requests a wake earlier than its 30-minute deadline; when the normal loop is +already awake after that deadline, no additional wake is needed. Sleeping time +counts toward the startup delay, and an outbound warning prevents another sleep +until the queued packet has been handled. This is separate from RX duty-cycle +power saving, which only cycles the LoRa receiver and does not stop the main +loop's battery timer. + ## Recent Repeater Table Direct retry uses the recent repeater table when `direct.retry.heard` is `on`. @@ -350,6 +369,16 @@ delay, then adds random jitter from zero to 200 percent of one additional packet airtime. This keeps nearby repeaters from repeating a collision on fixed timing while capping the added wait at two frames. +Only one enhanced retry sequence can be active for the same logical flood +packet. Identical floods still receive their normal transmission, but do not +multiply the extra attempts. Evicted queued retries release their bridge state, +and the final echo wait does not reserve a packet-pool entry. + +Earlier path hops from a successful bridge echo refresh a separate per-bucket +reachability cache without an SNR value. Only the final hop, which actually sent +the received RF frame, updates `recent.repeater` and its SNR. Indirect path hops +therefore cannot change direct-retry SNR gating or coding-rate selection. + ## Troubleshooting If advert packets are still retrying: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b302ca5b..84eca221 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1179,6 +1179,11 @@ void MyMesh::startInterface(BaseSerialInterface &serial) { } void MyMesh::handleCmdFrame(size_t len) { + if (len == 0) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } + if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection app_target_ver = cmd_frame[1]; // which version of protocol does app understand @@ -1288,6 +1293,10 @@ void MyMesh::handleCmdFrame(size_t len) { : ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_* } } else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel text msg + if (len < 7) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } int i = 1; uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN uint8_t channel_idx = cmd_frame[i++]; @@ -1308,7 +1317,7 @@ void MyMesh::handleCmdFrame(size_t len) { } } } else if (cmd_frame[0] == CMD_SEND_CHANNEL_DATA) { // send GroupChannel datagram - if (len < 4) { + if (len < 3) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); return; } @@ -1325,6 +1334,14 @@ void MyMesh::handleCmdFrame(size_t len) { // parse provided path if not flood uint8_t path[MAX_PATH_SIZE]; + size_t path_bytes = 0; + if (path_len != OUT_PATH_UNKNOWN) { + path_bytes = (size_t)(path_len & 63) * (size_t)((path_len >> 6) + 1); + } + if ((size_t)i + path_bytes + 2 > len) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } if (path_len != OUT_PATH_UNKNOWN) { i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); } @@ -1460,7 +1477,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } } - } else if (cmd_frame[0] == CMD_REMOVE_CONTACT) { + } else if (cmd_frame[0] == CMD_REMOVE_CONTACT && len >= 1 + PUB_KEY_SIZE) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient && removeContact(*recipient)) { @@ -1471,7 +1488,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove } - } else if (cmd_frame[0] == CMD_SHARE_CONTACT) { + } else if (cmd_frame[0] == CMD_SHARE_CONTACT && len >= 1 + PUB_KEY_SIZE) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { @@ -1483,7 +1500,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); } - } else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY) { + } else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY && len >= 1 + PUB_KEY_SIZE) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (contact) { @@ -1540,6 +1557,10 @@ void MyMesh::handleCmdFrame(size_t len) { _serial->writeFrame(out_frame, 1); } } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) { + if (len < 11) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } int i = 1; uint32_t freq; memcpy(&freq, &cmd_frame[i], 4); @@ -1579,6 +1600,10 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } } else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER) { + if (len < 2) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } int8_t power = (int8_t)cmd_frame[1]; if (power < -9 || power > MAX_LORA_TX_POWER) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -1589,6 +1614,10 @@ void MyMesh::handleCmdFrame(size_t len) { writeOKFrame(); } } else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) { + if (len < 9) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } int i = 1; uint32_t rx, af; memcpy(&rx, &cmd_frame[i], 4); @@ -1607,6 +1636,10 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], &af, 4); i += 4; _serial->writeFrame(out_frame, i); } else if (cmd_frame[0] == CMD_SET_OTHER_PARAMS) { + if (len < 2) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } _prefs.manual_add_contacts = cmd_frame[1]; if (len >= 3) { _prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+ @@ -1623,7 +1656,7 @@ void MyMesh::handleCmdFrame(size_t len) { updateGpsTelemetryPolicy(); savePrefs(); writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && cmd_frame[1] == 0 && len >= 3) { + } else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && len >= 3 && cmd_frame[1] == 0) { if (cmd_frame[2] >= 3) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { @@ -1631,7 +1664,7 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); writeOKFrame(); } - } else if (cmd_frame[0] == CMD_REBOOT && memcmp(&cmd_frame[1], "reboot", 6) == 0) { + } else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) { if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? saveContacts(); } @@ -1767,7 +1800,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found } - } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && cmd_frame[1] == 0 && len >= 2 + PUB_KEY_SIZE) { + } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) { uint8_t *pub_key = &cmd_frame[2]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { @@ -2100,7 +2133,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type } - } else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) { + } else if (cmd_frame[0] == CMD_FACTORY_RESET && len >= 6 && memcmp(&cmd_frame[1], "reset", 5) == 0) { if (_serial) { MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); _serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here @@ -2126,9 +2159,11 @@ void MyMesh::handleCmdFrame(size_t len) { writeOKFrame(); } else if (cmd_frame[0] == CMD_SET_DEFAULT_FLOOD_SCOPE && len >= 1) { if (len >= 1+31+16) { - int n = strlen((char *) &cmd_frame[1]); + const void* terminator = memchr(&cmd_frame[1], 0, 31); + size_t n = terminator == NULL ? 31 : (const uint8_t*)terminator - &cmd_frame[1]; if (n > 0 && n < 31) { - strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]); + memcpy(_prefs.default_scope_name, &cmd_frame[1], n); + _prefs.default_scope_name[n] = 0; memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); savePrefs(); writeOKFrame(); @@ -2159,6 +2194,10 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } } else if (cmd_frame[0] == CMD_SET_AUTOADD_CONFIG) { + if (len < 2) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } _prefs.autoadd_config = cmd_frame[1]; if (len >= 3) { _prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 09bbbf36..bcccbbe8 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -101,7 +101,8 @@ #endif #define LOW_BATTERY_MIN_VALID_MV 1000 -#define LOW_BATTERY_CHECK_INTERVAL (60UL * 1000UL) +#define LOW_BATTERY_STARTUP_DELAY (30ULL * 60ULL * 1000ULL) +#define LOW_BATTERY_CHECK_INTERVAL (30UL * 60UL * 1000UL) #define LOW_BATTERY_WARN_INTERVAL (24UL * 60UL * 60UL * 1000UL) #define LOW_BATTERY_CRITICAL_INTERVAL (12UL * 60UL * 60UL * 1000UL) @@ -315,6 +316,10 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr } client = acl.putClient(sender, 0); // add to contacts (if not already known) + if (client == NULL) { + MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts"); + return 0; + } if (sender_timestamp <= client->last_timestamp) { MESH_DEBUG_PRINTLN("Possible login replay attack!"); return 0; // FATAL: client table is full -OR- replay attack @@ -1391,6 +1396,61 @@ uint8_t MyMesh::floodRetrySourceMask(const mesh::Packet* packet) const { return floodRetryBucketMaskForPrefix(source_prefix, hash_size, true); } +bool MyMesh::floodRetryBridgeBucketFresh(uint8_t bucket) const { + if (bucket > FLOOD_RETRY_BRIDGE_OTHER_BUCKET) { + return false; + } + + const uint8_t (*prefixes)[FLOOD_RETRY_PREFIX_LEN]; + uint8_t prefix_count; + if (bucket == FLOOD_RETRY_BRIDGE_OTHER_BUCKET) { + prefixes = _prefs.flood_retry_prefixes; + prefix_count = FLOOD_RETRY_PREFIX_SLOTS; + } else { + prefixes = _prefs.flood_retry_bridge_buckets[bucket]; + prefix_count = FLOOD_RETRY_BUCKET_PREFIXES; + } + + for (uint8_t i = 0; i < prefix_count; i++) { + const uint8_t* configured = prefixes[i]; + if ((configured[0] != 0 || configured[1] != 0 || configured[2] != 0) + && !floodRetryPrefixIgnored(configured, FLOOD_RETRY_PREFIX_LEN) + && floodRetryPrefixFresh(configured, FLOOD_RETRY_PREFIX_LEN)) { + return true; + } + } + + const FloodRetryBridgeReachability& reachable = flood_retry_bridge_reachability[bucket]; + if (reachable.prefix_len == 0 || reachable.last_heard_millis == 0 + || (uint32_t)(millis() - reachable.last_heard_millis) > 3600000UL) { + return false; + } + return (floodRetryBucketMaskForPrefix(reachable.prefix, reachable.prefix_len, false) + & floodRetryBucketMask(bucket)) != 0; +} + +void MyMesh::recordFloodRetryBridgeReachability(const uint8_t* prefix, uint8_t prefix_len, + uint8_t bucket_mask) { + if (prefix == NULL || prefix_len == 0 || prefix_len > MAX_ROUTE_HASH_BYTES) { + return; + } + + uint32_t now = millis(); + if (now == 0) { + now = 1; // zero means unused + } + for (uint8_t bucket = 0; bucket <= FLOOD_RETRY_BRIDGE_OTHER_BUCKET; bucket++) { + if ((bucket_mask & floodRetryBucketMask(bucket)) == 0) { + continue; + } + FloodRetryBridgeReachability& reachable = flood_retry_bridge_reachability[bucket]; + memset(reachable.prefix, 0, sizeof(reachable.prefix)); + memcpy(reachable.prefix, prefix, prefix_len); + reachable.prefix_len = prefix_len; + reachable.last_heard_millis = now; + } +} + uint8_t MyMesh::floodRetryBridgeTargetMask(uint8_t source_mask) const { uint8_t mask = 0; for (int bucket = 0; bucket < FLOOD_RETRY_BRIDGE_BUCKETS; bucket++) { @@ -1398,27 +1458,14 @@ uint8_t MyMesh::floodRetryBridgeTargetMask(uint8_t source_mask) const { if ((source_mask & bucket_mask) != 0) { 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 |= bucket_mask; - break; - } + if (floodRetryBridgeBucketFresh((uint8_t)bucket)) { + mask |= bucket_mask; } } uint8_t other_mask = floodRetryBucketMask(FLOOD_RETRY_BRIDGE_OTHER_BUCKET); - if ((source_mask & other_mask) == 0) { - 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) - && !floodRetryPrefixIgnored(configured, FLOOD_RETRY_PREFIX_LEN) - && floodRetryPrefixFresh(configured, FLOOD_RETRY_PREFIX_LEN)) { - mask |= other_mask; - break; - } - } + if ((source_mask & other_mask) == 0 + && floodRetryBridgeBucketFresh(FLOOD_RETRY_BRIDGE_OTHER_BUCKET)) { + mask |= other_mask; } return mask; } @@ -1515,14 +1562,20 @@ bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { return true; } -void MyMesh::clearFloodRetryBridgeState(const mesh::Packet* packet) { - FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, false); - if (state != NULL) { - state->active = false; +void MyMesh::clearFloodRetryBridgeStateByKey(const uint8_t* retry_key) { + if (retry_key == NULL) { + return; + } + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (flood_retry_bridge_states[i].active + && memcmp(flood_retry_bridge_states[i].key, retry_key, MAX_HASH_SIZE) == 0) { + flood_retry_bridge_states[i].active = false; + return; + } } } -void MyMesh::refreshFloodRetryHeardRecent(const mesh::Packet* packet) { +void MyMesh::refreshFloodRetryReachability(const mesh::Packet* packet) { if (packet == NULL || !packet->isRouteFlood() || packet->getPathHashCount() == 0) { return; } @@ -1536,11 +1589,16 @@ void MyMesh::refreshFloodRetryHeardRecent(const mesh::Packet* packet) { if (tables == NULL) { return; } + + uint8_t path_count = packet->getPathHashCount(); + const uint8_t* last_hop = &packet->path[(path_count - 1) * hash_size]; + tables->setRecentRepeater(last_hop, hash_size, packet->_snr, false, true); + 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++) { + for (uint8_t hop = 0; hop < path_count; hop++) { if (state->progress_marker > 0 && hop == state->progress_marker - 1) { path += hash_size; continue; @@ -1548,17 +1606,13 @@ void MyMesh::refreshFloodRetryHeardRecent(const mesh::Packet* packet) { uint8_t bucket_mask = floodRetryBucketMaskForPathHop(path, hash_size, (uint8_t)hop, state->progress_marker); bucket_mask &= state->target_mask & (uint8_t)~state->source_mask; - if (bucket_mask != 0) { - tables->setRecentRepeater(path, hash_size, packet->_snr, false, true); + if (bucket_mask != 0 && hop != path_count - 1) { + recordFloodRetryBridgeReachability(path, hash_size, bucket_mask); } 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 { @@ -1676,19 +1730,26 @@ bool MyMesh::formatFloodRetryHeard(char* dest, size_t dest_len, const mesh::Pack } void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) { - if (event == NULL || packet == NULL) { + if (event == 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; } - if (strcmp(event, "failure") == 0) { + if (packet == NULL) { + MESH_DEBUG_PRINTLN("flood retry %s (retry=%u, elapsed_ms=%lu, packet=released)", + event, (unsigned int)retry_attempt, (unsigned long)delay_millis); + if (_logging) { + File f = openAppend(PACKET_LOG_FILE); + if (f) { + f.print(getLogDateTime()); + f.printf(": FLOOD RETRY %s (retry=%u, elapsed_ms=%lu, packet=released)\n", + event, (unsigned int)retry_attempt, (unsigned long)delay_millis); + f.close(); + } + } return; } @@ -1708,7 +1769,7 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui 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); + refreshFloodRetryReachability(packet); snprintf(heard_suffix, sizeof(heard_suffix), ", heard=%s", heard_log); } uint8_t log_cr = getRetryLogCodingRate(packet, getDefaultTxCodingRate()); @@ -1749,9 +1810,10 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui } } - if (clear_bridge_state) { - clearFloodRetryBridgeState(packet); - } +} + +void MyMesh::onFloodRetrySlotReleased(const uint8_t* retry_key) { + clearFloodRetryBridgeStateByKey(retry_key); } bool MyMesh::hasFloodRetryTargetPrefix(const mesh::Packet* packet) const { @@ -2247,6 +2309,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _logging = false; region_load_active = false; memset(flood_retry_bridge_states, 0, sizeof(flood_retry_bridge_states)); + memset(flood_retry_bridge_reachability, 0, sizeof(flood_retry_bridge_reachability)); recv_pkt_region = NULL; memset(flood_channel_blocks, 0, sizeof(flood_channel_blocks)); @@ -2445,14 +2508,14 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } -void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { +bool MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); + return sendFlood(pkt, delay_millis, path_hash_size); } else { uint16_t codes[2]; codes[0] = scope.calcTransportCode(pkt); codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); + return sendFlood(pkt, codes, delay_millis, path_hash_size); } } @@ -2477,7 +2540,66 @@ bool MyMesh::resolveAlertScope(TransportKey& dest) { return false; } -bool MyMesh::sendRepeatersFloodText(const char* text) { +const RegionEntry* MyMesh::findNarrowestBatteryAlertRegion(bool& ambiguous) { + ambiguous = false; + const RegionEntry* narrowest = NULL; + uint8_t narrowest_depth = 0; + const int region_count = region_map.getCount(); + + for (int i = 0; i < region_count; i++) { + const RegionEntry* candidate = region_map.getByIdx(i); + uint8_t depth = 1; + uint16_t parent_id = candidate->parent; + bool valid = true; + + // Region files are validated when loaded, but the live map can be edited + // before it is saved. Bound the walk so a temporary cycle cannot hang the + // repeater while selecting the default battery-alert scope. + for (int hops = 0; parent_id != 0; hops++) { + if (hops >= region_count) { + valid = false; + break; + } + const RegionEntry* parent = region_map.findById(parent_id); + if (parent == NULL || parent->isWildcard()) { + valid = false; + break; + } + depth++; + parent_id = parent->parent; + } + if (!valid) continue; + + // A named region is not necessarily a usable transport scope. In + // particular, private regions need a stored key; do not select one merely + // because it happens to be the deepest entry in the hierarchy. + TransportKey candidate_scope; + if (!getBatteryAlertScopeForRegion(*candidate, candidate_scope)) continue; + + if (depth > narrowest_depth) { + narrowest = candidate; + narrowest_depth = depth; + ambiguous = false; + } else if (depth == narrowest_depth) { + ambiguous = true; + } + } + return narrowest; +} + +bool MyMesh::getBatteryAlertScopeForRegion(const RegionEntry& region, TransportKey& scope) { + if (region.isWildcard()) return false; + return region_map.getTransportKeysFor(region, &scope, 1) > 0 && !scope.isNull(); +} + +bool MyMesh::resolveBatteryAlertScope(TransportKey& scope) { + if (_prefs.battery_alert_region[0] == 0) return false; + + const RegionEntry* region = region_map.findByName(_prefs.battery_alert_region); + return region != NULL && getBatteryAlertScopeForRegion(*region, scope); +} + +bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope) { if (text == NULL || *text == 0) return false; mesh::GroupChannel channel; @@ -2521,8 +2643,8 @@ bool MyMesh::sendRepeatersFloodText(const char* text) { return false; } - sendFloodScoped(default_scope, pkt, 0, _prefs.path_hash_mode + 1); - return true; + const TransportKey& send_scope = scope == NULL ? default_scope : *scope; + return sendFloodScoped(send_scope, pkt, 0, _prefs.path_hash_mode + 1); } void MyMesh::checkBatteryAlert() { @@ -2531,11 +2653,26 @@ void MyMesh::checkBatteryAlert() { return; } + // Ignore startup voltage sag and give solar/charger hardware time to settle. + // uptime_millis is 64-bit and includes time spent in the platform's light or + // event sleep, so this guard remains reliable across millis() wraparound. + if (uptime_millis < LOW_BATTERY_STARTUP_DELAY) { + return; + } + if (next_battery_alert_check && !millisHasNowPassed(next_battery_alert_check)) { return; } next_battery_alert_check = futureMillis(LOW_BATTERY_CHECK_INTERVAL); + // Check the cheap configuration path first. This avoids powering the ADC or + // battery-divider circuitry when the selected region has been removed or no + // longer has a usable transport key. + TransportKey alert_scope; + if (!resolveBatteryAlertScope(alert_scope)) { + return; // low-battery alerts are never sent as an unscoped flood + } + 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) { @@ -2552,7 +2689,7 @@ void MyMesh::checkBatteryAlert() { char text[96]; snprintf(text, sizeof(text), "LOW BATTERY %u%% (%u mV)", (uint32_t)batt_pct, (uint32_t)batt_mv); - if (sendRepeatersFloodText(text)) { + if (sendRepeatersFloodText(text, &alert_scope)) { battery_alert_sent = true; last_battery_alert_sent = millis(); } @@ -2579,6 +2716,19 @@ bool MyMesh::hasStartedScheduledTempRadio() const { return false; } +#if defined(ENABLE_OTA) +bool MyMesh::isTempRadioActive() const { + 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 && setting.temporary && setting.started && now < setting.end_time) { + return true; + } + } + return false; +} +#endif + int MyMesh::findFreeScheduledRadioSlot() const { for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { if (!scheduled_radio_settings[i].active) { @@ -2921,7 +3071,8 @@ void MyMesh::processScheduledRadioSettings() { 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) { + if (setting.active && setting.temporary && setting.started && now >= setting.end_time + && !hasOutbound()) { setting.active = false; setting.started = false; temp_ended = true; @@ -3180,6 +3331,7 @@ void MyMesh::formatNeighborsReply(char *reply) { void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { #if MAX_NEIGHBOURS + if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return; for (int i = 0; i < MAX_NEIGHBOURS; i++) { NeighbourInfo *neighbour = &neighbours[i]; if (memcmp(neighbour->id.pub_key, pubkey, key_len) == 0) { @@ -3768,13 +3920,14 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * if (sp == NULL) { strcpy(reply, "Err - bad params"); } else { + size_t hex_len = (size_t)(sp - hex); *sp++ = 0; // replace space with null terminator uint8_t pubkey[PUB_KEY_SIZE]; - int hex_len = min(sp - hex, PUB_KEY_SIZE*2); - if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { + if (hex_len > 0 && hex_len <= PUB_KEY_SIZE * 2 && (hex_len & 1) == 0 + && mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) { uint8_t perms = atoi(sp); - if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { + if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() strcpy(reply, "OK"); } else { @@ -3849,24 +4002,63 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } } else if (strcmp(command, "get battery.alert") == 0) { sprintf(reply, "> %s", _prefs.battery_alert_enabled ? "on" : "off"); + } else if (strcmp(command, "get battery.alert.region") == 0) { + sprintf(reply, "> %s", _prefs.battery_alert_region[0] + ? _prefs.battery_alert_region : ""); } 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) { + if (strncmp(value, "on", 2) == 0 && (value[2] == 0 || value[2] == ' ')) { + const char* region_name = skipLocalSpaces(value + 2); + const RegionEntry* region = NULL; + bool ambiguous = false; + + if (*region_name) { + if (strchr(region_name, ' ') != NULL) { + strcpy(reply, "Err - region names cannot contain spaces"); + return; + } + region = region_map.findByName(region_name); + if (region == NULL || region->isWildcard()) { + strcpy(reply, "Err - unknown or invalid alert region"); + return; + } + } else { + region = findNarrowestBatteryAlertRegion(ambiguous); + if (region == NULL) { + strcpy(reply, "Err - define a usable region before enabling battery alerts"); + return; + } + if (ambiguous) { + strcpy(reply, "Err - multiple narrowest regions; specify one"); + return; + } + } + + TransportKey region_scope; + if (!getBatteryAlertScopeForRegion(*region, region_scope)) { + strcpy(reply, "Err - alert region has no usable transport key"); + return; + } + + StrHelper::strncpy(_prefs.battery_alert_region, region->name, + sizeof(_prefs.battery_alert_region)); _prefs.battery_alert_enabled = 1; next_battery_alert_check = 0; + battery_alert_sent = false; savePrefs(); - strcpy(reply, "OK"); + sprintf(reply, "OK - battery alerts scoped to %s", _prefs.battery_alert_region); } else if (strcmp(value, "off") == 0) { _prefs.battery_alert_enabled = 0; + next_battery_alert_check = 0; battery_alert_sent = false; savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Err - usage: set battery.alert "); + strcpy(reply, "Err - usage: set battery.alert "); } } else if (strncmp(command, "set battery.alert.low ", 22) == 0) { uint8_t percent; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index e2d9bbdc..9173a480 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -143,6 +143,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t progress_marker; bool active; }; + struct FloodRetryBridgeReachability { + uint8_t prefix[MAX_ROUTE_HASH_BYTES]; + uint8_t prefix_len; + uint32_t last_heard_millis; + }; struct FloodChannelBlockEntry { bool active; uint8_t key_len; @@ -152,6 +157,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { char name[FLOOD_CHANNEL_BLOCK_NAME_LEN]; }; mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS]; + FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1]; FloodChannelBlockEntry flood_channel_blocks[FLOOD_CHANNEL_BLOCK_SLOTS]; uint32_t pending_discover_tag; unsigned long pending_discover_until; @@ -212,12 +218,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t floodRetryBucketMaskForPathHop(const uint8_t* prefix, uint8_t prefix_len, uint8_t hop, uint8_t progress_marker) const; uint8_t floodRetrySourceMask(const mesh::Packet* packet) const; + bool floodRetryBridgeBucketFresh(uint8_t bucket) const; + void recordFloodRetryBridgeReachability(const uint8_t* prefix, uint8_t prefix_len, uint8_t bucket_mask); uint8_t floodRetryBridgeTargetMask(uint8_t source_mask) const; uint8_t floodRetryBridgeHeardMask(const mesh::Packet* packet, uint8_t source_mask, 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 clearFloodRetryBridgeStateByKey(const uint8_t* retry_key); + void refreshFloodRetryReachability(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); @@ -227,7 +235,10 @@ 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); + bool sendRepeatersFloodText(const char* text, const TransportKey* scope = nullptr); + const RegionEntry* findNarrowestBatteryAlertRegion(bool& ambiguous); + bool getBatteryAlertScopeForRegion(const RegionEntry& region, TransportKey& scope); + bool resolveBatteryAlertScope(TransportKey& scope); void checkBatteryAlert(); void printRecentRepeatersSerial(); @@ -268,7 +279,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { protected: #if defined(ENABLE_OTA) - bool isTempRadioActive() const override { return hasStartedScheduledTempRadio(); } + bool isTempRadioActive() const override; #endif float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; @@ -299,6 +310,7 @@ protected: 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; + void onFloodRetrySlotReleased(const uint8_t* retry_key) 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; @@ -359,7 +371,7 @@ public: _cli.savePrefs(_fs); } - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); + bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 22c6f451..a4365730 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -360,6 +360,10 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m } client = acl.putClient(sender, 0); // add to known clients (if not already known) + if (client == NULL) { + MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts"); + return; + } if (sender_timestamp <= client->last_timestamp) { MESH_DEBUG_PRINTLN("possible replay attack!"); return; @@ -1013,13 +1017,14 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply if (sp == NULL) { strcpy(reply, "Err - bad params"); } else { + size_t hex_len = (size_t)(sp - hex); *sp++ = 0; // replace space with null terminator uint8_t pubkey[PUB_KEY_SIZE]; - int hex_len = min(sp - hex, PUB_KEY_SIZE*2); - if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { + if (hex_len > 0 && hex_len <= PUB_KEY_SIZE * 2 && (hex_len & 1) == 0 + && mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) { uint8_t perms = atoi(sp); - if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { + if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() strcpy(reply, "OK"); } else { @@ -1120,7 +1125,7 @@ void MyMesh::loop() { MESH_DEBUG_PRINTLN("Temp radio params"); } - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig + if (revert_radio_at && millisHasNowPassed(revert_radio_at) && !hasOutbound()) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); MESH_DEBUG_PRINTLN("Radio params restored"); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 790c41e5..9673e842 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -140,7 +140,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { protected: #if defined(ENABLE_OTA) - bool isTempRadioActive() const override { return set_radio_at == 0 && revert_radio_at != 0; } + bool isTempRadioActive() const override { + return set_radio_at == 0 && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); + } #endif float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 63951d2b..30abe6a6 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -127,6 +127,9 @@ void loop() { #endif if (the_mesh.getNodePrefs()->powersaving_enabled && !board.isUsbDataConnected()) { uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30); +#ifdef HAS_EXTERNAL_WATCHDOG + if (sleep_secs > 0) external_watchdog.feed(); +#endif #if defined(NRF52_PLATFORM) if (sleep_secs > 0) { board.sleep(0); // nrf ignores seconds param, sleeps whenever possible diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 24d81860..6cabc3b0 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -383,6 +383,10 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* } client = acl.putClient(sender, PERM_RECV_ALERTS_HI | PERM_RECV_ALERTS_LO); // add to contacts (if not already known) + if (client == NULL) { + MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts"); + return 0; + } if (sender_timestamp <= client->last_timestamp) { MESH_DEBUG_PRINTLN("Possible login replay attack!"); return 0; // FATAL: client table is full -OR- replay attack @@ -435,13 +439,14 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r if (sp == NULL) { strcpy(reply, "Err - bad params"); } else { + size_t hex_len = (size_t)(sp - hex); *sp++ = 0; // replace space with null terminator uint8_t pubkey[PUB_KEY_SIZE]; - int hex_len = min(sp - hex, PUB_KEY_SIZE*2); - if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { + if (hex_len > 0 && hex_len <= PUB_KEY_SIZE * 2 && (hex_len & 1) == 0 + && mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) { uint8_t perms = atoi(sp); - if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { + if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() updateGpsTelemetryPolicy(); strcpy(reply, "OK"); @@ -970,7 +975,7 @@ void SensorMesh::loop() { MESH_DEBUG_PRINTLN("Temp radio params"); } - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig + if (revert_radio_at && millisHasNowPassed(revert_radio_at) && !hasOutbound()) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); MESH_DEBUG_PRINTLN("Radio params restored"); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index d082d6b0..3d0f6415 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -85,7 +85,9 @@ public: protected: #if defined(ENABLE_OTA) - bool isTempRadioActive() const override { return set_radio_at == 0 && revert_radio_at != 0; } + bool isTempRadioActive() const override { + return set_radio_at == 0 && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); + } #endif // current telemetry data queries float getVoltage(uint8_t channel) { return getTelemValue(channel, LPP_VOLTAGE); } diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 76746899..71e75ed5 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -29,6 +29,12 @@ void Dispatcher::begin() { _radio->begin(); prev_isrecv_mode = _radio->isInRecvMode(); +#ifdef WITH_MQTT_BRIDGE + // Use begin() as the watchdog baseline even if the radio never reports an + // RX/IRQ timestamp. This lets a radio that is dead from startup recover. + last_radio_active_ms = _ms->getMillis(); + last_watchdog_recovery = last_radio_active_ms; +#endif } float Dispatcher::getAirtimeBudgetFactor() const { @@ -107,20 +113,20 @@ void Dispatcher::loop() { { const uint32_t watchdog_ms = getRadioWatchdogMillis(); if (watchdog_ms > 0) { - unsigned long last_recv = _radio->getLastRecvMillis(); - unsigned long last_irq = _radio->getLastRadioInterruptMillis(); - unsigned long last_active = (last_recv > last_irq ? last_recv : last_irq); - if (last_radio_active_ms > last_active) last_active = last_radio_active_ms; - if (is_recv && last_active > 0) { - unsigned long silent_ms = _ms->getMillis() - last_active; - unsigned long since_recovery = _ms->getMillis() - last_watchdog_recovery; - if (silent_ms > watchdog_ms && since_recovery > watchdog_ms) { - _err_flags |= ERR_EVENT_RADIO_WATCHDOG; - MESH_DEBUG_PRINTLN("Radio watchdog: silent %lu ms, state=%d, recovering", silent_ms, _radio->getRadioState()); - _radio->idle(); - _radio->startRecv(); - last_watchdog_recovery = _ms->getMillis(); - } + const unsigned long now = _ms->getMillis(); + unsigned long silent_ms = now - last_radio_active_ms; + const unsigned long last_recv = _radio->getLastRecvMillis(); + const unsigned long last_irq = _radio->getLastRadioInterruptMillis(); + if (last_recv != 0 && now - last_recv < silent_ms) silent_ms = now - last_recv; + if (last_irq != 0 && now - last_irq < silent_ms) silent_ms = now - last_irq; + const unsigned long since_recovery = now - last_watchdog_recovery; + if (is_recv && silent_ms >= watchdog_ms && since_recovery >= watchdog_ms) { + _err_flags |= ERR_EVENT_RADIO_WATCHDOG; + MESH_DEBUG_PRINTLN("Radio watchdog: silent %lu ms, state=%d, recovering", silent_ms, _radio->getRadioState()); + _radio->idle(); + _radio->startRecv(); + last_watchdog_recovery = now; + last_radio_active_ms = now; } } } @@ -358,6 +364,15 @@ void Dispatcher::checkSend() { outbound = _mgr->getNextOutbound(_ms->getMillis()); if (outbound) { + if (!allowPacketTransmit(outbound)) { + MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): packet no longer allowed, type=%u", getLogDateTime(), + (uint32_t)outbound->getPayloadType()); + onSendFail(outbound); + releasePacket(outbound); + outbound = NULL; + return; + } + int len = 0; uint8_t raw[MAX_TRANS_UNIT]; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 138d6891..9f44eea8 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -207,6 +207,7 @@ protected: virtual uint32_t getCADFailRetryDelay() const; virtual uint32_t getCADFailMaxDuration() const; virtual uint8_t getDefaultTxCodingRate() const { return 0; } + virtual bool allowPacketTransmit(const Packet* packet) const { return true; } virtual int getInterferenceThreshold() const { return 0; } // disabled by default virtual bool getCADEnabled() const { return false; } // hardware CAD disabled by default virtual int getAGCResetInterval() const { return 0; } // disabled by default diff --git a/src/Identity.cpp b/src/Identity.cpp index ea546274..e7539028 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -11,6 +11,7 @@ Identity::Identity() { } Identity::Identity(const char* pub_hex) { + memset(pub_key, 0, sizeof(pub_key)); Utils::fromHex(pub_key, PUB_KEY_SIZE, pub_hex); } @@ -39,6 +40,7 @@ LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } LocalIdentity::LocalIdentity(const char* prv_hex, const char* pub_hex) : Identity(pub_hex) { + memset(prv_key, 0, sizeof(prv_key)); Utils::fromHex(prv_key, PRV_KEY_SIZE, prv_hex); } @@ -140,4 +142,4 @@ void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_k ed25519_key_exchange(secret, other_pub_key, prv_key); } -} \ No newline at end of file +} diff --git a/src/Mesh.cpp b/src/Mesh.cpp index e312db68..b08f8b2c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -165,6 +165,7 @@ void Mesh::begin() { _flood_retries[i].retry_at = 0; _flood_retries[i].retry_delay = 0; _flood_retries[i].retry_attempts_sent = 0; + memset(_flood_retries[i].retry_key, 0, sizeof(_flood_retries[i].retry_key)); _flood_retries[i].priority = 0; _flood_retries[i].progress_marker = 0; _flood_retries[i].waiting_final_echo = false; @@ -221,6 +222,18 @@ void Mesh::loop() { if (_direct_retries[i].packet == getOutboundInFlight()) { continue; // currently transmitting; keep slot until onSendComplete/onSendFail emits event } + uint32_t elapsed_millis = _direct_retries[i].retry_started_at == 0 + ? 0 + : (uint32_t)(_ms->getMillis() - _direct_retries[i].retry_started_at); + onDirectRetryEvent("dropped_queue_removed", NULL, 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].payload_type); + onDirectRetryEvent("failure", NULL, 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].payload_type); + onDirectRetryFailed(_direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); } } @@ -252,6 +265,13 @@ void Mesh::loop() { if (_flood_retries[i].packet == getOutboundInFlight()) { continue; } + uint32_t elapsed_millis = _flood_retries[i].retry_started_at == 0 + ? 0 + : (uint32_t)(_ms->getMillis() - _flood_retries[i].retry_started_at); + onFloodRetryEvent("dropped_queue_removed", NULL, elapsed_millis, + _flood_retries[i].retry_attempts_sent + 1); + onFloodRetryEvent("failure", NULL, elapsed_millis, + _flood_retries[i].retry_attempts_sent + 1); clearFloodRetrySlot(i); } } @@ -333,6 +353,17 @@ void Mesh::loop() { #endif } +bool Mesh::allowPacketTransmit(const Packet* packet) const { +#if defined(ENABLE_OTA) + if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA) { + return isTempRadioActive(); + } +#else + (void)packet; +#endif + return true; +} + bool Mesh::allowPacketForward(const mesh::Packet* packet) { return false; // by default, Transport NOT enabled } @@ -1250,6 +1281,9 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { } void Mesh::clearFloodRetrySlot(int idx) { + if (_flood_retries[idx].active) { + onFloodRetrySlotReleased(_flood_retries[idx].retry_key); + } if (_flood_retries[idx].waiting_final_echo && _flood_retries[idx].packet != NULL) { releasePacket(_flood_retries[idx].packet); } @@ -1259,6 +1293,7 @@ void Mesh::clearFloodRetrySlot(int idx) { _flood_retries[idx].retry_at = 0; _flood_retries[idx].retry_delay = 0; _flood_retries[idx].retry_attempts_sent = 0; + memset(_flood_retries[idx].retry_key, 0, sizeof(_flood_retries[idx].retry_key)); _flood_retries[idx].priority = 0; _flood_retries[idx].progress_marker = 0; _flood_retries[idx].waiting_final_echo = false; @@ -1342,16 +1377,9 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) { max_attempts = FLOOD_RETRY_MAX_ATTEMPTS_HARD_MAX; } if (_flood_retries[i].retry_attempts_sent >= max_attempts) { - Packet* final_wait = obtainNewPacket(); - if (final_wait == NULL) { - onFloodRetryEvent("dropped_no_packet", packet, elapsed_millis, _flood_retries[i].retry_attempts_sent); - onFloodRetryEvent("failure", packet, elapsed_millis, _flood_retries[i].retry_attempts_sent); - clearFloodRetrySlot(i); - continue; - } - - *final_wait = *packet; - _flood_retries[i].packet = final_wait; + // Dispatcher releases the transmitted packet after this hook. Keep only + // retry metadata during the final echo window so RX retains the pool slot. + _flood_retries[i].packet = NULL; _flood_retries[i].retry_at = futureMillis(_flood_retries[i].retry_delay); _flood_retries[i].waiting_final_echo = true; _flood_retries[i].queued = false; @@ -1455,6 +1483,15 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) { return; } + uint8_t retry_key[MAX_HASH_SIZE]; + packet->calculatePacketHash(retry_key); + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (_flood_retries[i].active + && memcmp(retry_key, _flood_retries[i].retry_key, MAX_HASH_SIZE) == 0) { + return; // the normal flood still sends, but only one retry sequence owns this logical packet + } + } + int slot_idx = -1; for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { if (!_flood_retries[i].active) { @@ -1473,7 +1510,7 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) { } uint32_t retry_delay = getFloodRetryAttemptDelay(packet, 0); - packet->calculatePacketHash(_flood_retries[slot_idx].retry_key); + memcpy(_flood_retries[slot_idx].retry_key, retry_key, sizeof(retry_key)); _flood_retries[slot_idx].packet = NULL; _flood_retries[slot_idx].trigger_packet = const_cast(packet); _flood_retries[slot_idx].retry_started_at = 0; @@ -1755,14 +1792,16 @@ void Mesh::sendOtaFlood(Packet* packet, uint32_t delay_millis) { } #endif -void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) { +bool Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) { if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime()); - return; + releasePacket(packet); + return false; } if (path_hash_size == 0 || path_hash_size > 3) { MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): invalid path_hash_size", getLogDateTime()); - return; + releasePacket(packet); + return false; } packet->header &= ~PH_ROUTE_MASK; @@ -1780,17 +1819,19 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_si pri = 1; } maybeScheduleFloodRetry(packet, pri); - sendPacket(packet, pri, delay_millis); + return sendPacket(packet, pri, delay_millis); } -void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_hash_size) { +bool Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_hash_size) { if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime()); - return; + releasePacket(packet); + return false; } if (path_hash_size == 0 || path_hash_size > 3) { MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): invalid path_hash_size", getLogDateTime()); - return; + releasePacket(packet); + return false; } packet->header &= ~PH_ROUTE_MASK; @@ -1810,7 +1851,7 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m pri = 1; } maybeScheduleFloodRetry(packet, pri); - sendPacket(packet, pri, delay_millis); + return sendPacket(packet, pri, delay_millis); } void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uint32_t delay_millis) { diff --git a/src/Mesh.h b/src/Mesh.h index b21bd7cc..1abe5289 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -117,6 +117,7 @@ protected: DispatcherAction onRecvPacket(Packet* pkt) override; void onSendComplete(Packet* packet) override; void onSendFail(Packet* packet) override; + bool allowPacketTransmit(const Packet* packet) const override; virtual uint32_t getCADFailRetryDelay() const override; @@ -215,9 +216,15 @@ protected: /** * \brief Optional hook for logging flood-retry lifecycle events. + * packet is null when the retained packet has already been released. */ virtual void onFloodRetryEvent(const char* event, const Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) { } + /** + * \brief Called exactly once whenever an active flood-retry slot is released. + */ + virtual void onFloodRetrySlotReleased(const uint8_t* retry_key) { } + /** * \returns number of extra (Direct) ACK transmissions wanted. */ @@ -409,13 +416,13 @@ public: /** * \brief send a locally-generated Packet with flood routing */ - void sendFlood(Packet* packet, uint32_t delay_millis=0, uint8_t path_hash_size=1); + bool sendFlood(Packet* packet, uint32_t delay_millis=0, uint8_t path_hash_size=1); /** * \brief send a locally-generated Packet with flood routing * \param transport_codes array of 2 codes to attach to packet */ - void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0, uint8_t path_hash_size=1); + bool sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0, uint8_t path_hash_size=1); /** * \brief send a locally-generated Packet with Direct routing diff --git a/src/Utils.cpp b/src/Utils.cpp index 186c8720..5a829b23 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -120,6 +120,7 @@ bool Utils::isHexChar(char c) { } bool Utils::fromHex(uint8_t* dest, int dest_size, const char *src_hex) { + if (dest == NULL || src_hex == NULL || dest_size < 0) return false; int len = strlen(src_hex); if (len != dest_size*2) return false; // incorrect length @@ -127,6 +128,7 @@ bool Utils::fromHex(uint8_t* dest, int dest_size, const char *src_hex) { while (dp - dest < dest_size) { char ch = *src_hex++; char cl = *src_hex++; + if (!isHexChar(ch) || !isHexChar(cl)) return false; *dp++ = (hexVal(ch) << 4) | hexVal(cl); } return true; @@ -150,4 +152,4 @@ int Utils::parseTextParts(char* text, const char* parts[], int max_num, char sep return num; } -} \ No newline at end of file +} diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index a6841277..89542aad 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -178,9 +178,9 @@ bool AlertReporter::sendChannel(const char* text) { uint16_t codes[2]; codes[0] = scope.calcTransportCode(pkt); codes[1] = 0; - _mesh->sendFlood(pkt, codes, 0, path_hash_size); + if (!_mesh->sendFlood(pkt, codes, 0, path_hash_size)) return false; } else { - _mesh->sendFlood(pkt, 0, path_hash_size); + if (!_mesh->sendFlood(pkt, 0, path_hash_size)) return false; } ALERT_DEBUG_PRINTLN("sent: %s", text); return true; @@ -232,7 +232,7 @@ void AlertReporter::onLoop(unsigned long now_ms) { if (_wifi.state == OK) { if (wifi_down && down_ms >= thresh_ms && - (now_ms - _wifi.fired_at_ms) >= min_interval_ms) { + (_wifi.fired_at_ms == 0 || (now_ms - _wifi.fired_at_ms) >= min_interval_ms)) { char age[16]; formatAge(down_ms, age, sizeof(age)); uint8_t reason = MQTTBridge::getLastWifiDisconnectReason(); @@ -256,8 +256,7 @@ void AlertReporter::onLoop(unsigned long now_ms) { formatAge(total, age, sizeof(age)); char text[80]; snprintf(text, sizeof(text), "WiFi recovered after %s", age); - sendChannel(text); - _wifi.state = OK; + if (sendChannel(text)) _wifi.state = OK; } } } else if (_wifi.state == FIRING) { @@ -282,7 +281,7 @@ void AlertReporter::onLoop(unsigned long now_ms) { if (f.state == OK) { if (down && down_ms >= thresh_ms && - (now_ms - f.fired_at_ms) >= min_interval_ms) { + (f.fired_at_ms == 0 || (now_ms - f.fired_at_ms) >= min_interval_ms)) { char age[16]; formatAge(down_ms, age, sizeof(age)); char text[100]; @@ -303,8 +302,7 @@ void AlertReporter::onLoop(unsigned long now_ms) { char text[100]; snprintf(text, sizeof(text), "MQTT slot %d (%s) recovered after %s", i + 1, _bridge->getSlotPresetName(i), age); - sendChannel(text); - f.state = OK; + if (sendChannel(text)) f.state = OK; } } } diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 2ee73480..a1afe17e 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -98,6 +98,7 @@ bool ClientACL::clear() { } ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) { + if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return NULL; for (int i = 0; i < num_clients; i++) { if (memcmp(pubkey, clients[i].id.pub_key, key_len) == 0) return &clients[i]; // already known } @@ -106,7 +107,7 @@ ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) { ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { uint32_t min_time = 0xFFFFFFFF; - ClientInfo* oldest = &clients[MAX_CLIENTS - 1]; + ClientInfo* oldest = NULL; for (int i = 0; i < num_clients; i++) { if (id.matches(clients[i].id)) return &clients[i]; // already known if ( (!clients[i].isAdmin() && !clients[i].isRegionMgr()) @@ -120,6 +121,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { if (num_clients < MAX_CLIENTS) { c = &clients[num_clients++]; } else { + if (oldest == NULL) return NULL; // every entry is protected c = oldest; // evict least active contact } memset(c, 0, sizeof(*c)); @@ -131,6 +133,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { } bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) { + if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return false; ClientInfo* c; if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts c = getClient(pubkey, key_len); @@ -143,10 +146,11 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8 i++; } } else { - if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying + if (key_len != PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying mesh::Identity id(pubkey); c = putClient(id, 0); + if (c == NULL) return false; c->permissions = perms; // update their permissions self_id.calcSharedSecret(c->shared_secret, pubkey); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 5e10e254..a1a85e8f 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -5,6 +5,7 @@ #include "AlertReporter.h" // for alertReporterBannedChannelMatch() #include #include +#include #if defined(NRF52_PLATFORM) #include @@ -1048,6 +1049,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _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; + memset(_prefs->battery_alert_region, 0, sizeof(_prefs->battery_alert_region)); _prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; _prefs->flood_channel_data_enabled = 1; _prefs->flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; @@ -1151,6 +1153,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); file.read((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); } + if (file.available() >= (int)sizeof(_prefs->battery_alert_region)) { + file.read((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region)); + _prefs->battery_alert_region[sizeof(_prefs->battery_alert_region) - 1] = '\0'; + } } // sanitise bad pref values @@ -1369,7 +1375,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); // 816 file.write((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 820 file.write((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 821 - // next: 822 + file.write((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region)); // 822 + // next: 853 file.close(); } @@ -1422,12 +1429,15 @@ void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) { // hadn't appended a tail field) leaves the trailing fields at their // defaults, and a longer one (a future append) is truncated harmlessly. size_t payload_avail = file_size - sizeof(hdr); + if (hdr.payload_len < payload_avail) payload_avail = hdr.payload_len; size_t to_read = payload_avail < sizeof(_mqtt_prefs) ? payload_avail : sizeof(_mqtt_prefs); size_t got = file.read((uint8_t *)&_mqtt_prefs, to_read); if (got != to_read) { setMQTTPrefsDefaults(&_mqtt_prefs); } else { - has_observer_fields = true; // observer tail is part of the v1 payload + const size_t observer_tail_end = offsetof(MQTTPrefs, alert_region) + + sizeof(_mqtt_prefs.alert_region); + has_observer_fields = to_read >= observer_tail_end; } } else { // Unknown (newer) version: don't risk misreading a layout we don't know. @@ -1775,9 +1785,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(command, "neighbor.remove ", 16) == 0) { const char* hex = &command[16]; uint8_t pubkey[PUB_KEY_SIZE]; - int hex_len = min((int)strlen(hex), PUB_KEY_SIZE*2); - int pubkey_len = hex_len / 2; - if (mesh::Utils::fromHex(pubkey, pubkey_len, hex)) { + size_t hex_len = strlen(hex); + int pubkey_len = (int)(hex_len / 2); + if (hex_len > 0 && hex_len <= PUB_KEY_SIZE * 2 && (hex_len & 1) == 0 + && mesh::Utils::fromHex(pubkey, pubkey_len, hex)) { _callbacks->removeNeighbor(pubkey, pubkey_len); strcpy(reply, "OK"); } else { @@ -2781,10 +2792,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error: channel must be between 1-14"); } } else if (memcmp(config, "bridge.secret ", 14) == 0) { - StrHelper::strncpy(_prefs->bridge_secret, &config[14], sizeof(_prefs->bridge_secret)); - _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, "OK"); + const char* secret = &config[14]; + if (secret[0] == 0 || strlen(secret) >= sizeof(_prefs->bridge_secret)) { + sprintf(reply, "Error: secret must be 1-%u characters", (unsigned)(sizeof(_prefs->bridge_secret) - 1)); + } else { + StrHelper::strncpy(_prefs->bridge_secret, secret, sizeof(_prefs->bridge_secret)); + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); + } #endif } else if (memcmp(config, "adc.multiplier ", 15) == 0) { _prefs->adc_multiplier = atof(&config[15]); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c7b1c632..5c5bef40 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -214,6 +214,7 @@ struct NodePrefs { // persisted to file uint32_t rx_ps_sleep_us; uint8_t rx_ps_level; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change) uint8_t rx_ps_preamble; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc + char battery_alert_region[31]; // named scope for low-battery floods; empty = no alert scope }; #ifdef WITH_MQTT_BRIDGE diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 36c1d19d..25636c8f 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -79,36 +79,100 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { if (file) { uint8_t pad[128]; - - num_regions = 0; next_id = 1; - default_id = home_id = 0; + RegionEntry loaded[MAX_REGION_ENTRIES]; + uint16_t loaded_count = 0; + uint16_t loaded_default = 0; + uint16_t loaded_home = 0; + uint16_t loaded_next = 1; + uint8_t loaded_wildcard_flags = 0; bool success = file.read(pad, 3) == 3; // reserved header - success = success && file.read((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id); - success = success && file.read((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id); - success = success && file.read((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); - success = success && file.read((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id); + success = success && file.read((uint8_t *) &loaded_default, sizeof(loaded_default)) == sizeof(loaded_default); + success = success && file.read((uint8_t *) &loaded_home, sizeof(loaded_home)) == sizeof(loaded_home); + success = success && file.read((uint8_t *) &loaded_wildcard_flags, sizeof(loaded_wildcard_flags)) == sizeof(loaded_wildcard_flags); + success = success && file.read((uint8_t *) &loaded_next, sizeof(loaded_next)) == sizeof(loaded_next); - if (success) { - while (num_regions < MAX_REGION_ENTRIES) { - auto r = ®ions[num_regions]; - - success = file.read((uint8_t *) &r->id, sizeof(r->id)) == sizeof(r->id); - success = success && file.read((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent); - success = success && file.read((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name); - success = success && file.read((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags); - success = success && file.read(pad, sizeof(pad)) == sizeof(pad); - - if (!success) break; // EOF - - if (r->id >= next_id) { // make sure next_id is valid - next_id = r->id + 1; - } - num_regions++; + while (success && file.available() > 0) { + if (loaded_count >= MAX_REGION_ENTRIES) { + success = false; + break; } + RegionEntry& r = loaded[loaded_count]; + memset(&r, 0, sizeof(r)); + success = file.read((uint8_t *) &r.id, sizeof(r.id)) == sizeof(r.id); + success = success && file.read((uint8_t *) &r.parent, sizeof(r.parent)) == sizeof(r.parent); + success = success && file.read((uint8_t *) r.name, sizeof(r.name)) == sizeof(r.name); + success = success && file.read((uint8_t *) &r.flags, sizeof(r.flags)) == sizeof(r.flags); + success = success && file.read(pad, sizeof(pad)) == sizeof(pad); + if (!success) break; + + const char* terminator = (const char*)memchr(r.name, 0, sizeof(r.name)); + if (r.id == 0 || r.id == 0xFFFF || terminator == NULL || r.name[0] == 0) { + success = false; + break; + } + for (const char* p = r.name; *p; p++) { + if (!is_name_char((uint8_t)*p)) { + success = false; + break; + } + } + if (!success) break; + loaded_count++; } file.close(); - return true; + + // Validate IDs and parent links only after every record is present so forward + // parent references are allowed. A bounded walk also rejects cycles. + for (uint16_t i = 0; success && i < loaded_count; i++) { + for (uint16_t j = i + 1; j < loaded_count; j++) { + if (loaded[i].id == loaded[j].id) success = false; + } + uint16_t parent = loaded[i].parent; + for (uint16_t depth = 0; success && parent != 0; depth++) { + if (depth >= loaded_count || parent == loaded[i].id) { + success = false; + break; + } + const RegionEntry* found = NULL; + for (uint16_t j = 0; j < loaded_count; j++) { + if (loaded[j].id == parent) { + found = &loaded[j]; + break; + } + } + if (found == NULL) { + success = false; + break; + } + parent = found->parent; + } + } + + bool default_found = loaded_default == 0; + bool home_found = loaded_home == 0; + uint16_t minimum_next = 1; + for (uint16_t i = 0; success && i < loaded_count; i++) { + if (loaded[i].id == loaded_default) default_found = true; + if (loaded[i].id == loaded_home) home_found = true; + if (loaded[i].id >= minimum_next) minimum_next = loaded[i].id + 1; + } + if (!default_found || !home_found) success = false; + if (loaded_next == 0 || loaded_next == 0xFFFF || loaded_next < minimum_next) loaded_next = minimum_next; + if (loaded_next == 0xFFFF && loaded_count < MAX_REGION_ENTRIES) success = false; + + if (success) { + memcpy(regions, loaded, loaded_count * sizeof(RegionEntry)); + if (loaded_count < MAX_REGION_ENTRIES) { + memset(®ions[loaded_count], 0, (MAX_REGION_ENTRIES - loaded_count) * sizeof(RegionEntry)); + } + num_regions = loaded_count; + default_id = loaded_default; + home_id = loaded_home; + next_id = loaded_next; + wildcard.flags = loaded_wildcard_flags; + } + return success; } } return false; // failed @@ -157,7 +221,8 @@ RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t region->parent = parent_id; // re-parent / move this region in the hierarchy } else { - if (id == 0 && num_regions >= MAX_REGION_ENTRIES) return NULL; // full! + if (num_regions >= MAX_REGION_ENTRIES) return NULL; // full! + if (id == 0xFFFF || (id == 0 && (next_id == 0 || next_id == 0xFFFF))) return NULL; region = ®ions[num_regions++]; // alloc new RegionEntry region->flags = REGION_DENY_FLOOD; // DENY by default diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index ab205b03..47526a21 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -38,24 +38,34 @@ ESPNowBridge::ESPNowBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTC void ESPNowBridge::begin() { BRIDGE_DEBUG_PRINTLN("Initializing...\n"); + if (_initialized) return; + // Initialize WiFi in station mode WiFi.mode(WIFI_STA); // Set Wi-Fi channel if (esp_wifi_set_channel(_prefs->bridge_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error setting WIFI channel to %d\n", _prefs->bridge_channel); + WiFi.mode(WIFI_OFF); return; } // Initialize ESP-NOW if (esp_now_init() != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error initializing ESP-NOW\n"); + WiFi.mode(WIFI_OFF); return; } // Register callbacks - esp_now_register_recv_cb(recv_cb); - esp_now_register_send_cb(send_cb); + if (esp_now_register_recv_cb(recv_cb) != ESP_OK || esp_now_register_send_cb(send_cb) != ESP_OK) { + BRIDGE_DEBUG_PRINTLN("Error registering ESP-NOW callbacks\n"); + esp_now_register_recv_cb(nullptr); + esp_now_register_send_cb(nullptr); + esp_now_deinit(); + WiFi.mode(WIFI_OFF); + return; + } // Add broadcast peer esp_now_peer_info_t peerInfo = {}; @@ -66,6 +76,10 @@ void ESPNowBridge::begin() { if (esp_now_add_peer(&peerInfo) != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Failed to add broadcast peer\n"); + esp_now_register_recv_cb(nullptr); + esp_now_register_send_cb(nullptr); + esp_now_deinit(); + WiFi.mode(WIFI_OFF); return; } @@ -102,11 +116,17 @@ void ESPNowBridge::loop() { // Nothing to do here - ESP-NOW is callback based } -void ESPNowBridge::xorCrypt(uint8_t *data, size_t len) { - size_t keyLen = strlen(_prefs->bridge_secret); +bool ESPNowBridge::xorCrypt(uint8_t *data, size_t len) { + size_t keyLen = 0; + while (keyLen < sizeof(_prefs->bridge_secret) && _prefs->bridge_secret[keyLen] != 0) keyLen++; + if (keyLen == 0 || keyLen == sizeof(_prefs->bridge_secret)) { + BRIDGE_DEBUG_PRINTLN("Invalid empty or unterminated bridge secret\n"); + return false; + } for (size_t i = 0; i < len; i++) { data[i] ^= _prefs->bridge_secret[i % keyLen]; } + return true; } void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int len) { @@ -135,7 +155,7 @@ void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int len) memcpy(decrypted, data + BRIDGE_MAGIC_SIZE, encryptedDataLen); // Try to decrypt (checksum + payload) - xorCrypt(decrypted, encryptedDataLen); + if (!xorCrypt(decrypted, encryptedDataLen)) return; // Validate checksum uint16_t received_checksum = (decrypted[0] << 8) | decrypted[1]; @@ -178,14 +198,19 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) { if (!_seen_packets.wasSeen(packet)) { _seen_packets.markSeen(packet); - // Create a temporary buffer just for size calculation and reuse for actual writing + // Check the serialized size before writing into the ESP-NOW-sized buffer. + const int expectedMeshPacketLen = packet->getRawLength(); + if (expectedMeshPacketLen < 0 || (size_t)expectedMeshPacketLen > MAX_PAYLOAD_SIZE) { + BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", expectedMeshPacketLen, + MAX_PAYLOAD_SIZE); + return; + } + uint8_t sizingBuffer[MAX_PAYLOAD_SIZE]; uint16_t meshPacketLen = packet->writeTo(sizingBuffer); - - // Check if packet fits within our maximum payload size - if (meshPacketLen > MAX_PAYLOAD_SIZE) { - BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", meshPacketLen, - MAX_PAYLOAD_SIZE); + if (meshPacketLen != (uint16_t)expectedMeshPacketLen) { + BRIDGE_DEBUG_PRINTLN("TX packet length mismatch (actual=%d, expected=%d)\n", meshPacketLen, + expectedMeshPacketLen); return; } @@ -205,7 +230,7 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) { buffer[3] = checksum & 0xFF; // Low byte // Encrypt payload and checksum (not including magic header) - xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE); + if (!xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE)) return; // Total packet size: magic header + checksum + payload const size_t totalPacketSize = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + meshPacketLen; diff --git a/src/helpers/bridges/ESPNowBridge.h b/src/helpers/bridges/ESPNowBridge.h index 95658875..b229c0c0 100644 --- a/src/helpers/bridges/ESPNowBridge.h +++ b/src/helpers/bridges/ESPNowBridge.h @@ -85,7 +85,7 @@ private: * @param data Pointer to data to encrypt/decrypt * @param len Length of data in bytes */ - void xorCrypt(uint8_t *data, size_t len); + bool xorCrypt(uint8_t *data, size_t len); /** * ESP-NOW receive callback diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index f474215a..b6aed64a 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -38,9 +38,15 @@ public: * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual bool setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } + // ESP-NOW has no LoRa RX power-saving watchdog or noise-floor calibration. + uint32_t getRxPsWatchdogSoftCount() const { return 0; } + uint32_t getRxPsWatchdogHardCount() const { return 0; } + bool isWatchdogObserving() const { return false; } + bool isCalibratingNoiseFloor() const { return false; } + uint32_t intID(); void setTxPower(uint8_t dbm); }; diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index ea1de366..f47af79f 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -434,6 +434,7 @@ void OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) memcpy(_fid, mid, 4); _fstate = WANT_MANIFEST; _mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; _loop_last_mfmask = 0; // fresh manifest reassembly + memset(_mf_buf, 0, sizeof(_mf_buf)); GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); gm.want_mask = 0xFFFF; // first ask: send all fragments uint8_t b[16]; emit(b, encode_get_manifest(b, sizeof(b), gm), false); @@ -445,10 +446,21 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { if (_fstate != WANT_MANIFEST || memcmp(mm.manifest_id, _fid, 4) != 0) return; if (mm.frag_total == 0 || mm.frag_total > OTA_MF_MAXFRAG || mm.frag_idx >= mm.frag_total) return; + const uint8_t expected_total = (uint8_t)((MOTA_MFL + OTA_MF_FRAG - 1) / OTA_MF_FRAG); + const uint16_t expected_len = mm.frag_idx + 1 == expected_total + ? (uint16_t)(MOTA_MFL - (uint32_t)mm.frag_idx * OTA_MF_FRAG) + : (uint16_t)OTA_MF_FRAG; + if (mm.frag_total != expected_total || mm.len != expected_len) return; + // reassemble the (possibly multi-fragment) manifest into _mf_buf; place fragment frag_idx at its offset uint32_t foff = (uint32_t)mm.frag_idx * OTA_MF_FRAG; if (foff + mm.len > sizeof(_mf_buf)) return; - if (mm.frag_total != _mf_total) { _mf_total = mm.frag_total; _mf_mask = 0; _mf_len = 0; } // (re)start + if (mm.frag_total != _mf_total) { + _mf_total = mm.frag_total; + _mf_mask = 0; + _mf_len = 0; + memset(_mf_buf, 0, sizeof(_mf_buf)); + } memcpy(_mf_buf + foff, mm.bytes, mm.len); _mf_mask |= (uint16_t)(1u << mm.frag_idx); if (mm.frag_idx == mm.frag_total - 1) _mf_len = foff + mm.len; // last fragment fixes the length @@ -461,6 +473,7 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { if (!codecOk(mf[56])) { _fstate = IDLE; return; } // codec we can't apply (lying/stale ADV) — abort uint32_t payload_size = rd_u32le(mf + 15); uint8_t bsl = mf[19]; + if (bsl >= 32) { _fstate = FAILED; return; } uint32_t bs = 1u << bsl; // a block must fit our reassembly buffer (and be non-empty) — reject an oversized block_size up front if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { _fstate = FAILED; return; } @@ -534,7 +547,9 @@ void OtaManager::handleLeaves(const uint8_t* m, uint16_t n) { uint8_t ftotal = (uint8_t)((leaves_len + OTA_LEAVES_FRAG - 1) / OTA_LEAVES_FRAG); if (ftotal == 0) ftotal = 1; if (lv.frag_total != ftotal || lv.frag_idx >= ftotal) return; uint32_t foff = (uint32_t)lv.frag_idx * OTA_LEAVES_FRAG; - if (foff + lv.len > leaves_len) return; // slice out of range + uint32_t expected_len = leaves_len - foff; + if (expected_len > OTA_LEAVES_FRAG) expected_len = OTA_LEAVES_FRAG; + if (lv.len != expected_len) return; // reject short/oversized slices if (lv.frag_total != _lv_total) { _lv_total = lv.frag_total; _lv_mask = 0; } // (re)start memcpy(_leaves_buf + foff, lv.bytes, lv.len); _lv_mask |= (uint16_t)(1u << lv.frag_idx); diff --git a/src/helpers/ota/OtaStoreFlashEsp32.cpp b/src/helpers/ota/OtaStoreFlashEsp32.cpp index 9afcb803..adfda271 100644 --- a/src/helpers/ota/OtaStoreFlashEsp32.cpp +++ b/src/helpers/ota/OtaStoreFlashEsp32.cpp @@ -203,9 +203,8 @@ void OtaStoreFlashEsp32::checkpoint() { bool OtaStoreFlashEsp32::reopen() { if (!acquire() || _psize < SEC) return false; uint8_t hb[8]; - // the meta/container is staged at the BOTTOM of the slot, so scan upward from there; cap the scan (a - // miss just means "fetch fresh"). 128 sectors (512 KB) covers any full image's meta + typical deltas. - uint32_t scanned = 0; + // The meta/container is staged at the bottom of the slot. Large delta containers can begin well above + // the final 512 KB, so scan the complete partition rather than silently losing resumability after reboot. for (uint32_t o = align_down(_psize - SEC, SEC); ; o -= SEC) { if (esp_partition_read(_part, o, hb, 8) == ESP_OK && memcmp(hb, MOTA_MAGIC, 4) == 0) { uint32_t total = rd_u32le(hb + 4); @@ -232,7 +231,7 @@ bool OtaStoreFlashEsp32::reopen() { } } } - if (o == 0 || ++scanned >= 128) break; + if (o == 0) break; } return false; } diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 03510c9e..88bbe329 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -546,6 +546,65 @@ TEST(OtaTransfer, TwoManagersFullTransfer) { EXPECT_TRUE(mota_check_image_hash_full(m)); } +static void deliver_manifest_fragment(OtaManager& client, const uint8_t mid[4], uint8_t frag_idx, + const uint8_t* bytes, uint16_t len) { + uint8_t wire[MAX_PACKET_PAYLOAD]; + ManifestMsg msg; + memcpy(msg.manifest_id, mid, 4); + msg.frag_idx = frag_idx; + msg.frag_total = (uint8_t)((MOTA_MFL + OTA_MF_FRAG - 1) / OTA_MF_FRAG); + msg.bytes = bytes; + msg.len = len; + uint16_t wire_len = encode_manifest(wire, sizeof(wire), msg); + ASSERT_GT(wire_len, 0); + client.on_message(wire, wire_len); +} + +TEST(OtaTransfer, RejectsShortNonFinalManifestFragment) { + g_q.clear(); + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + + OtaManager client; + OtaStoreRam<4096> store; + SendTo to_none{&client}; + client.begin(SIM_TARGET_ID, sim_send, &to_none); + client.set_fetch_store(&store); + client.pull(manifest.merkle_root, manifest.target_id); + g_q.clear(); + + const uint8_t* bytes = manifest.manifest_start; + const uint16_t final_len = (uint16_t)(MOTA_MFL - OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes + OTA_MF_FRAG, final_len); + deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes, OTA_MF_FRAG - 1); + EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST); + + deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes, OTA_MF_FRAG); + EXPECT_EQ(client.fetchState(), OtaManager::FETCHING); +} + +TEST(OtaTransfer, RejectsManifestBlockExponentBeforeShift) { + g_q.clear(); + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest)); + std::array bytes; + memcpy(bytes.data(), manifest.manifest_start, bytes.size()); + bytes[19] = 32; + + OtaManager client; + OtaStoreRam<4096> store; + SendTo to_none{&client}; + client.begin(SIM_TARGET_ID, sim_send, &to_none); + client.set_fetch_store(&store); + client.pull(manifest.merkle_root, manifest.target_id); + g_q.clear(); + + deliver_manifest_fragment(client, manifest.merkle_root, 0, bytes.data(), OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, bytes.data() + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + EXPECT_EQ(client.fetchState(), OtaManager::FAILED); +} + // Same end-to-end transfer, but with 1 KB logical blocks: each block is delivered as several // self-describing DATA fragments (frag_off), reassembled by the client, then its merkle PROOF is // requested + verified separately before the block is committed. Exercises the multi-fragment path. diff --git a/test/test_utils/test_tohex.cpp b/test/test_utils/test_tohex.cpp index fec3ae48..51b2986e 100644 --- a/test/test_utils/test_tohex.cpp +++ b/test/test_utils/test_tohex.cpp @@ -51,6 +51,28 @@ TEST(UtilsToHex, NullTerminatesOnEmptyInput) { EXPECT_EQ('\0', output[0]); } +TEST(UtilsFromHex, ConvertsUpperAndLowerCase) { + uint8_t output[3] = {}; + + ASSERT_TRUE(Utils::fromHex(output, sizeof(output), "0aBCfF")); + EXPECT_EQ(0x0A, output[0]); + EXPECT_EQ(0xBC, output[1]); + EXPECT_EQ(0xFF, output[2]); +} + +TEST(UtilsFromHex, RejectsInvalidCharacters) { + uint8_t output[2] = {0xAA, 0xAA}; + + EXPECT_FALSE(Utils::fromHex(output, sizeof(output), "0G!1")); +} + +TEST(UtilsFromHex, RejectsWrongLength) { + uint8_t output[2] = {}; + + EXPECT_FALSE(Utils::fromHex(output, sizeof(output), "001")); + EXPECT_FALSE(Utils::fromHex(output, sizeof(output), "001122")); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index 231a130b..963ab0bf 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -150,7 +150,7 @@ extends = LilyGo_TLora_V2_1_1_6_core extra_scripts = ${esp32_base.extra_scripts} pre:scripts/generate_cert_bundle.py -board_ssl_cert_source = adafruit-full +board_ssl_cert_source = adafruit board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${LilyGo_TLora_V2_1_1_6_core.build_flags} @@ -196,7 +196,7 @@ extends = LilyGo_TLora_V2_1_1_6_core extra_scripts = ${esp32_base.extra_scripts} pre:scripts/generate_cert_bundle.py -board_ssl_cert_source = adafruit-full +board_ssl_cert_source = adafruit board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${LilyGo_TLora_V2_1_1_6_core.build_flags}