From 00c14cd88c30d34640b15c47a1a56f9fa1cc827b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 17 Apr 2026 16:40:00 -0700 Subject: [PATCH 01/11] Add local-only uf2reset CLI command --- docs/cli_commands.md | 10 ++++++++++ src/helpers/CommonCLI.cpp | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 0e785f4e83..57ab6c6459 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -30,6 +30,16 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +### Enter the UF2 bootloader (nRF52 only) +**Usage:** +- `uf2reset` + +**Serial Only:** Yes + +**Note:** Reboots directly into the UF2 bootloader on supported nRF52 boards. + +--- + ### Reset the clock and reboot **Usage:** - `clkreboot` diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 2f7a0fffcb..ab035f6623 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4,6 +4,29 @@ #include "AdvertDataHelpers.h" #include +#if defined(NRF52_PLATFORM) +#include +#include + +#ifndef DFU_MAGIC_UF2_RESET +#define DFU_MAGIC_UF2_RESET 0x57 +#endif + +static void resetToUf2Bootloader() { + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + + if (sd_enabled) { + sd_power_gpregret_clr(0, 0xFF); + sd_power_gpregret_set(0, DFU_MAGIC_UF2_RESET); + } else { + NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + } + + NVIC_SystemReset(); +} +#endif + #ifndef BRIDGE_MAX_BAUD #define BRIDGE_MAX_BAUD 115200 #endif @@ -210,6 +233,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _board->powerOff(); // doesn't return } else if (memcmp(command, "reboot", 6) == 0) { _board->reboot(); // doesn't return + } else if (sender_timestamp == 0 && memcmp(command, "uf2reset", 8) == 0 && (command[8] == 0 || command[8] == ' ')) { // from serial command line only +#if defined(NRF52_PLATFORM) + resetToUf2Bootloader(); // doesn't return +#else + strcpy(reply, "ERR: unsupported"); +#endif } else if (memcmp(command, "clkreboot", 9) == 0) { // Reset clock getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm From 0ce1a550178d82a569c0f4d998a3c576bf549f92 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 20 Apr 2026 13:40:07 -0700 Subject: [PATCH 02/11] Refactor build.sh menu and build flow --- build.sh | 764 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 615 insertions(+), 149 deletions(-) diff --git a/build.sh b/build.sh index 313c4c47a0..cd45e8d8d8 100755 --- a/build.sh +++ b/build.sh @@ -1,9 +1,41 @@ #!/usr/bin/env bash +ALL_PIO_ENVS=() +PIO_CONFIG_JSON="" +MENU_CHOICE="" +SELECTED_TARGET="" + +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" +BOARD_MODIFIER_LOGGING="_logging" +BOARD_MODIFIER_TFT="_tft" +BOARD_MODIFIER_EINK="_eink" +BOARD_MODIFIER_EINK_SUFFIX="Eink" +BOARD_LABEL_WITHOUT_DISPLAY="without_display" +BOARD_LABEL_LOGGING="logging" +BOARD_LABEL_TFT="tft" +BOARD_LABEL_EINK="eink" +DEFAULT_VARIANT_LABEL="default" +TAG_PREFIX_ROOM_SERVER="room-server" +TAG_PREFIX_COMPANION="companion" +TAG_PREFIX_REPEATER="repeater" +BULK_BUILD_SUFFIX_REPEATER="_repeater" +BULK_BUILD_SUFFIX_COMPANION_USB="_companion_radio_usb" +BULK_BUILD_SUFFIX_COMPANION_BLE="_companion_radio_ble" +BULK_BUILD_SUFFIX_ROOM_SERVER="_room_server" +SUPPORTED_PLATFORM_PATTERN='ESP32_PLATFORM|NRF52_PLATFORM|STM32_PLATFORM|RP2040_PLATFORM' +OUTPUT_DIR="out" +FALLBACK_VERSION_PREFIX="dev" +FALLBACK_VERSION_DATE_FORMAT='+%Y-%m-%d-%H-%M' + +# External programs invoked by this script: +# bash, cat, cp, date, git, grep, head, mkdir, pio, python3, rm, sed, sort +# Keep this list in sync when adding or removing non-builtin command usage. + global_usage() { cat - < [target] +bash build.sh [target] Commands: help|usage|-h|--help: Shows this message. @@ -17,21 +49,26 @@ Commands: Examples: Build firmware for the "RAK_4631_repeater" device target -$ sh build.sh build-firmware RAK_4631_repeater +$ bash build.sh build-firmware RAK_4631_repeater + +Run without arguments to choose a target from an interactive menu +$ bash build.sh Build all firmwares for device targets containing the string "RAK_4631" -$ sh build.sh build-matching-firmwares +$ bash build.sh build-matching-firmwares Build all companion firmwares -$ sh build.sh build-companion-firmwares +$ bash build.sh build-companion-firmwares Build all repeater firmwares -$ sh build.sh build-repeater-firmwares +$ bash build.sh build-repeater-firmwares Build all chat room server firmwares -$ sh build.sh build-room-server-firmwares +$ bash build.sh build-room-server-firmwares Environment Variables: + FIRMWARE_VERSION=vX.Y.Z: Firmware version to embed in the build output. + If not set, build.sh derives a default from the latest matching git tag and appends "-dev". DISABLE_DEBUG=1: Disables all debug logging flags (MESH_DEBUG, MESH_PACKET_LOGGING, etc.) If not set, debug flags from variant platformio.ini files are used. @@ -39,60 +76,425 @@ Examples: Build without debug logging: $ export FIRMWARE_VERSION=v1.0.0 $ export DISABLE_DEBUG=1 -$ sh build.sh build-firmware RAK_4631_repeater +$ bash build.sh build-firmware RAK_4631_repeater Build with debug logging (default, uses flags from variant files): $ export FIRMWARE_VERSION=v1.0.0 -$ sh build.sh build-firmware RAK_4631_repeater +$ bash build.sh build-firmware RAK_4631_repeater + +Build with the derived default version from git tags: +$ unset FIRMWARE_VERSION +$ bash build.sh EOF } -# get a list of pio env names that start with "env:" +init_project_context() { + if [ ${#ALL_PIO_ENVS[@]} -eq 0 ]; then + mapfile -t ALL_PIO_ENVS < <(pio project config | grep 'env:' | sed 's/env://') + fi + + if [ -z "$PIO_CONFIG_JSON" ]; then + PIO_CONFIG_JSON=$(pio project config --json-output) + fi +} + get_pio_envs() { - pio project config | grep 'env:' | sed 's/env://' + if [ ${#ALL_PIO_ENVS[@]} -gt 0 ]; then + printf '%s\n' "${ALL_PIO_ENVS[@]}" + else + pio project config | grep 'env:' | sed 's/env://' + fi +} + +canonicalize_variant_suffix() { + local variant_suffix=$1 + + case "${variant_suffix,,}" in + comp_radio_usb|companion_usb|companion_radio_usb) + echo "companion_radio_usb" + ;; + companion_ble|companion_radio_ble) + echo "companion_radio_ble" + ;; + room_svr|room_server) + echo "room_server" + ;; + repeatr|repeater) + echo "repeater" + ;; + *) + echo "${variant_suffix,,}" + ;; + esac +} + +trim_trailing_underscores() { + local value=$1 + + while [[ "$value" == *_ ]]; do + value=${value%_} + done + + echo "$value" +} + +sort_lines_case_insensitive() { + sort -f +} + +print_numbered_menu() { + local items=("$@") + local i + + for i in "${!items[@]}"; do + printf '%d) %s\n' "$((i + 1))" "${items[$i]}" + done +} + +prompt_menu_choice() { + local prompt_label=$1 + local max_choice=$2 + local allow_back=${3:-0} + local choice + + while true; do + if [ "$allow_back" -eq 1 ]; then + read -r -p "${prompt_label} [1-${max_choice}, B=Back, Q=Quit]: " choice + else + read -r -p "${prompt_label} [1-${max_choice}, Q=Quit]: " choice + fi + + case "${choice^^}" in + Q) + MENU_CHOICE="QUIT" + return 0 + ;; + B) + if [ "$allow_back" -eq 1 ]; then + MENU_CHOICE="BACK" + return 0 + fi + echo "Invalid selection." + ;; + *) + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "$max_choice" ]; then + MENU_CHOICE="$choice" + return 0 + fi + echo "Invalid selection." + ;; + esac + done +} + +get_env_metadata() { + local env_name=$1 + local trimmed_env_name + local board_part + local variant_part + local board_family + local board_modifier + local variant_label + local tag_prefix + + trimmed_env_name=$(trim_trailing_underscores "$env_name") + board_part=$trimmed_env_name + variant_part="" + + shopt -s nocasematch + # Split a raw env name into board and variant pieces using the normalized + # suffix vocabulary defined near the top of the file. + if [[ "$trimmed_env_name" =~ ^(.+)[_-](${ENV_VARIANT_SUFFIX_PATTERN})$ ]]; then + board_part=${BASH_REMATCH[1]} + variant_part=$(canonicalize_variant_suffix "${BASH_REMATCH[2]}") + fi + + # Fold display and form-factor suffixes into the variant label so related + # boards share one first-level menu entry. + case "$board_part" in + *"$BOARD_MODIFIER_WITHOUT_DISPLAY") + board_family=${board_part%"$BOARD_MODIFIER_WITHOUT_DISPLAY"} + board_modifier="$BOARD_LABEL_WITHOUT_DISPLAY" + ;; + *"$BOARD_MODIFIER_LOGGING") + board_family=${board_part%"$BOARD_MODIFIER_LOGGING"} + board_modifier="$BOARD_LABEL_LOGGING" + ;; + *"$BOARD_MODIFIER_TFT") + board_family=${board_part%"$BOARD_MODIFIER_TFT"} + board_modifier="$BOARD_LABEL_TFT" + ;; + *"$BOARD_MODIFIER_EINK") + board_family=${board_part%"$BOARD_MODIFIER_EINK"} + board_modifier="$BOARD_LABEL_EINK" + ;; + *"$BOARD_MODIFIER_EINK_SUFFIX") + board_family=${board_part%"$BOARD_MODIFIER_EINK_SUFFIX"} + board_modifier="$BOARD_LABEL_EINK" + ;; + *) + board_family=$board_part + board_modifier="" + ;; + esac + shopt -u nocasematch + + variant_label="$variant_part" + if [ -n "$board_modifier" ]; then + if [ -n "$variant_label" ]; then + variant_label="${board_modifier}_${variant_label}" + else + variant_label="$board_modifier" + fi + fi + + if [ -z "$variant_label" ]; then + variant_label="$DEFAULT_VARIANT_LABEL" + fi + + case "$variant_part" in + room_server) + tag_prefix="$TAG_PREFIX_ROOM_SERVER" + ;; + companion_radio_*) + tag_prefix="$TAG_PREFIX_COMPANION" + ;; + repeater*) + tag_prefix="$TAG_PREFIX_REPEATER" + ;; + *) + tag_prefix="" + ;; + esac + + printf '%s\t%s\t%s\n' "$board_family" "$variant_label" "$tag_prefix" +} + +get_metadata_field() { + local env_name=$1 + local field_index=$2 + local metadata + + metadata=$(get_env_metadata "$env_name") + case "$field_index" in + 1) + echo "${metadata%%$'\t'*}" + ;; + 2) + metadata=${metadata#*$'\t'} + echo "${metadata%%$'\t'*}" + ;; + 3) + echo "${metadata##*$'\t'}" + ;; + esac } -# Catch cries for help before doing anything else. -case $1 in - help|usage|-h|--help) +get_board_family_for_env() { + get_metadata_field "$1" 1 +} + +get_variant_name_for_env() { + get_metadata_field "$1" 2 +} + +get_release_tag_prefix_for_env() { + get_metadata_field "$1" 3 +} + +get_variants_for_board() { + local board_family=$1 + local env + + for env in "${ALL_PIO_ENVS[@]}"; do + if [ "$(get_board_family_for_env "$env")" == "$board_family" ]; then + echo "$env" + fi + done | sort_lines_case_insensitive +} + +prompt_for_variant_for_board() { + local board=$1 + local -A seen_variant_labels=() + local variants + local variant_labels + local i + local j + + mapfile -t variants < <(get_variants_for_board "$board") + if [ ${#variants[@]} -eq 0 ]; then + echo "No firmware variants were found for ${board}." + return 1 + fi + + if [ ${#variants[@]} -eq 1 ]; then + SELECTED_TARGET="${variants[0]}" + return 0 + fi + + variant_labels=() + for i in "${!variants[@]}"; do + variant_labels[i]=$(get_variant_name_for_env "${variants[$i]}") + seen_variant_labels["${variant_labels[$i]}"]=$(( ${seen_variant_labels["${variant_labels[$i]}"]:-0} + 1 )) + done + + # Stop early if normalization would present the user with ambiguous labels. + for i in "${!variant_labels[@]}"; do + if [ "${seen_variant_labels["${variant_labels[$i]}"]}" -gt 1 ]; then + echo "Ambiguous firmware variants detected for ${board}: ${variant_labels[$i]}" + echo "The normalized menu labels are not unique for this board family." + for j in "${!variants[@]}"; do + echo " ${variants[$j]}" + done + exit 1 + fi + done + + echo "Select a firmware variant for ${board}:" + while true; do + print_numbered_menu "${variant_labels[@]}" + prompt_menu_choice "Variant selection" "${#variant_labels[@]}" 1 + if [ "$MENU_CHOICE" == "BACK" ]; then + return 1 + fi + if [ "$MENU_CHOICE" == "QUIT" ]; then + echo "Cancelled." + exit 1 + fi + + SELECTED_TARGET="${variants[$((MENU_CHOICE - 1))]}" + return 0 + done +} + +prompt_for_board_target() { + local -A seen_boards=() + local boards=() + local board + local env + + if ! [ -t 0 ]; then + echo "No command provided and no interactive terminal is available." global_usage exit 1 - ;; - list|-l) - get_pio_envs - exit 0 - ;; -esac + fi -# cache project config json for use in get_platform_for_env() -PIO_CONFIG_JSON=$(pio project config --json-output) + if [ ${#ALL_PIO_ENVS[@]} -eq 0 ]; then + echo "No PlatformIO environments were found." + exit 1 + fi + + for env in "${ALL_PIO_ENVS[@]}"; do + board=$(get_board_family_for_env "$env") + if [ -z "${seen_boards[$board]}" ]; then + seen_boards["$board"]=1 + boards+=("$board") + fi + done + + mapfile -t boards < <(printf '%s\n' "${boards[@]}" | sort_lines_case_insensitive) + + echo "No command provided. Select a board family:" + while true; do + print_numbered_menu "${boards[@]}" + prompt_menu_choice "Board selection" "${#boards[@]}" + if [ "$MENU_CHOICE" == "QUIT" ]; then + echo "Cancelled." + exit 1 + fi + + board=${boards[$((MENU_CHOICE - 1))]} + if prompt_for_variant_for_board "$board"; then + echo "Building firmware for ${SELECTED_TARGET}" + return 0 + fi + done +} + +get_latest_version_from_tags() { + local env_name=$1 + local tag_prefix + local latest_tag + local fallback_version + + fallback_version="${FALLBACK_VERSION_PREFIX}-$(date "${FALLBACK_VERSION_DATE_FORMAT}")" + tag_prefix=$(get_release_tag_prefix_for_env "$env_name") + if [ -z "$tag_prefix" ]; then + echo "$fallback_version" + return 0 + fi + + latest_tag=$(git tag --list "${tag_prefix}-v*" --sort=-version:refname | head -n 1) + if [ -z "$latest_tag" ]; then + echo "$fallback_version" + return 0 + fi + + echo "${latest_tag#"${tag_prefix}"-}" +} + +derive_default_firmware_version() { + local env_name=$1 + local base_version + + base_version=$(get_latest_version_from_tags "$env_name") + case "$base_version" in + *-dev|dev-*) + echo "$base_version" + ;; + *) + echo "${base_version}-dev" + ;; + esac +} + +prompt_for_firmware_version() { + local env_name=$1 + local suggested_version + local entered_version + + suggested_version=$(derive_default_firmware_version "$env_name") + + if ! [ -t 0 ]; then + FIRMWARE_VERSION="$suggested_version" + echo "FIRMWARE_VERSION not set, using derived default: ${FIRMWARE_VERSION}" + return 0 + fi + + echo "Suggested firmware version for ${env_name}: ${suggested_version}" + read -r -e -i "${suggested_version}" -p "Firmware version: " entered_version + FIRMWARE_VERSION="${entered_version:-$suggested_version}" +} -# $1 should be the string to find (case insensitive) get_pio_envs_containing_string() { + local env + shopt -s nocasematch - envs=($(get_pio_envs)) - for env in "${envs[@]}"; do - if [[ "$env" == *${1}* ]]; then - echo $env - fi + for env in "${ALL_PIO_ENVS[@]}"; do + if [[ "$env" == *${1}* ]]; then + echo "$env" + fi done + shopt -u nocasematch } -# $1 should be the string to find (case insensitive) get_pio_envs_ending_with_string() { + local env + shopt -s nocasematch - envs=($(get_pio_envs)) - for env in "${envs[@]}"; do + for env in "${ALL_PIO_ENVS[@]}"; do if [[ "$env" == *${1} ]]; then - echo $env + echo "$env" fi done + shopt -u nocasematch } -# get platform flag for a given environment -# $1 should be the environment name get_platform_for_env() { local env_name=$1 + + # PlatformIO exposes project config as JSON; scan the selected env's + # build_flags to recover the platform token used for artifact collection. echo "$PIO_CONFIG_JSON" | python3 -c " import sys, json, re data = json.load(sys.stdin) @@ -101,142 +503,154 @@ for section, options in data: for key, value in options: if key == 'build_flags': for flag in value: - match = re.search(r'(ESP32_PLATFORM|NRF52_PLATFORM|STM32_PLATFORM|RP2040_PLATFORM)', flag) + match = re.search(r'($SUPPORTED_PLATFORM_PATTERN)', flag) if match: print(match.group(1)) sys.exit(0) " } -# disable all debug logging flags if DISABLE_DEBUG=1 is set +is_supported_platform() { + local env_platform=$1 + + [[ "$env_platform" =~ ^(${SUPPORTED_PLATFORM_PATTERN})$ ]] +} + disable_debug_flags() { if [ "$DISABLE_DEBUG" == "1" ]; then export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMESH_DEBUG -UBLE_DEBUG_LOGGING -UWIFI_DEBUG_LOGGING -UBRIDGE_DEBUG -UGPS_NMEA_DEBUG -UCORE_DEBUG_LEVEL -UESPNOW_DEBUG_LOGGING -UDEBUG_RP2040_WIRE -UDEBUG_RP2040_SPI -UDEBUG_RP2040_CORE -UDEBUG_RP2040_PORT -URADIOLIB_DEBUG_SPI -UCFG_DEBUG -URADIOLIB_DEBUG_BASIC -URADIOLIB_DEBUG_PROTOCOL" fi } -# build firmware for the provided pio env in $1 -build_firmware() { - # get env platform for post build actions - ENV_PLATFORM=($(get_platform_for_env $1)) +copy_build_output() { + local source_path=$1 + local output_path=$2 - # get git commit sha - COMMIT_HASH=$(git rev-parse --short HEAD) + if [ -f "$source_path" ]; then + cp -- "$source_path" "$output_path" + fi +} - # set firmware build date - FIRMWARE_BUILD_DATE=$(date '+%d-%b-%Y') +collect_esp32_artifacts() { + local env_name=$1 + local firmware_filename=$2 - # get FIRMWARE_VERSION, which should be provided by the environment - if [ -z "$FIRMWARE_VERSION" ]; then - echo "FIRMWARE_VERSION must be set in environment" - exit 1 - fi + pio run -t mergebin -e "$env_name" + copy_build_output ".pio/build/${env_name}/firmware.bin" "out/${firmware_filename}.bin" + copy_build_output ".pio/build/${env_name}/firmware-merged.bin" "out/${firmware_filename}-merged.bin" +} - # set firmware version string - # e.g: v1.0.0-abcdef - FIRMWARE_VERSION_STRING="${FIRMWARE_VERSION}-${COMMIT_HASH}" +collect_nrf52_artifacts() { + local env_name=$1 + local firmware_filename=$2 - # craft filename - # e.g: RAK_4631_Repeater-v1.0.0-SHA - FIRMWARE_FILENAME="$1-${FIRMWARE_VERSION_STRING}" + python3 bin/uf2conv/uf2conv.py ".pio/build/${env_name}/firmware.hex" -c -o ".pio/build/${env_name}/firmware.uf2" -f 0xADA52840 + copy_build_output ".pio/build/${env_name}/firmware.uf2" "out/${firmware_filename}.uf2" + copy_build_output ".pio/build/${env_name}/firmware.zip" "out/${firmware_filename}.zip" +} - # add firmware version info to end of existing platformio build flags in environment vars - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${FIRMWARE_VERSION_STRING}\"'" +collect_stm32_artifacts() { + local env_name=$1 + local firmware_filename=$2 - # disable debug flags if requested - disable_debug_flags + copy_build_output ".pio/build/${env_name}/firmware.bin" "out/${firmware_filename}.bin" + copy_build_output ".pio/build/${env_name}/firmware.hex" "out/${firmware_filename}.hex" +} - # build firmware target - pio run -e $1 +collect_rp2040_artifacts() { + local env_name=$1 + local firmware_filename=$2 - # build merge-bin for esp32 fresh install, copy .bins to out folder (e.g: Heltec_v3_room_server-v1.0.0-SHA.bin) - if [ "$ENV_PLATFORM" == "ESP32_PLATFORM" ]; then - pio run -t mergebin -e $1 - cp .pio/build/$1/firmware.bin out/${FIRMWARE_FILENAME}.bin 2>/dev/null || true - cp .pio/build/$1/firmware-merged.bin out/${FIRMWARE_FILENAME}-merged.bin 2>/dev/null || true - fi + copy_build_output ".pio/build/${env_name}/firmware.bin" "out/${firmware_filename}.bin" + copy_build_output ".pio/build/${env_name}/firmware.uf2" "out/${firmware_filename}.uf2" +} - # build .uf2 for nrf52 boards, copy .uf2 and .zip to out folder (e.g: RAK_4631_Repeater-v1.0.0-SHA.uf2) - if [ "$ENV_PLATFORM" == "NRF52_PLATFORM" ]; then - python3 bin/uf2conv/uf2conv.py .pio/build/$1/firmware.hex -c -o .pio/build/$1/firmware.uf2 -f 0xADA52840 - cp .pio/build/$1/firmware.uf2 out/${FIRMWARE_FILENAME}.uf2 2>/dev/null || true - cp .pio/build/$1/firmware.zip out/${FIRMWARE_FILENAME}.zip 2>/dev/null || true - fi +collect_build_artifacts() { + local env_name=$1 + local env_platform=$2 + local firmware_filename=$3 + + # Post-build outputs differ by platform, so dispatch to the matching + # collector after the main firmware build succeeds. + case "$env_platform" in + ESP32_PLATFORM) + collect_esp32_artifacts "$env_name" "$firmware_filename" + ;; + NRF52_PLATFORM) + collect_nrf52_artifacts "$env_name" "$firmware_filename" + ;; + STM32_PLATFORM) + collect_stm32_artifacts "$env_name" "$firmware_filename" + ;; + RP2040_PLATFORM) + collect_rp2040_artifacts "$env_name" "$firmware_filename" + ;; + esac +} - # for stm32, copy .bin and .hex to out folder - if [ "$ENV_PLATFORM" == "STM32_PLATFORM" ]; then - cp .pio/build/$1/firmware.bin out/${FIRMWARE_FILENAME}.bin 2>/dev/null || true - cp .pio/build/$1/firmware.hex out/${FIRMWARE_FILENAME}.hex 2>/dev/null || true +build_firmware() { + local env_name=$1 + local env_platform + local commit_hash + local firmware_build_date + local firmware_version_string + local firmware_filename + + env_platform=$(get_platform_for_env "$env_name") + if ! is_supported_platform "$env_platform"; then + echo "Unsupported or unknown platform for env: $env_name" + exit 1 fi - # for rp2040, copy .bin and .uf2 to out folder - if [ "$ENV_PLATFORM" == "RP2040_PLATFORM" ]; then - cp .pio/build/$1/firmware.bin out/${FIRMWARE_FILENAME}.bin 2>/dev/null || true - cp .pio/build/$1/firmware.uf2 out/${FIRMWARE_FILENAME}.uf2 2>/dev/null || true + commit_hash=$(git rev-parse --short HEAD) + firmware_build_date=$(date '+%d-%b-%Y') + + if [ -z "$FIRMWARE_VERSION" ]; then + prompt_for_firmware_version "$env_name" + echo "Using firmware version: ${FIRMWARE_VERSION}" fi + firmware_version_string="${FIRMWARE_VERSION}-${commit_hash}" + firmware_filename="${env_name}-${firmware_version_string}" + + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_VERSION='\"${firmware_version_string}\"'" + disable_debug_flags + + pio run -e "$env_name" + collect_build_artifacts "$env_name" "$env_platform" "$firmware_filename" } -# firmwares containing $1 will be built build_all_firmwares_matching() { - envs=($(get_pio_envs_containing_string "$1")) + local envs + local env + + mapfile -t envs < <(get_pio_envs_containing_string "$1") for env in "${envs[@]}"; do - build_firmware $env + build_firmware "$env" done } -# firmwares ending with $1 will be built build_all_firmwares_by_suffix() { - envs=($(get_pio_envs_ending_with_string "$1")) + local envs + local env + + mapfile -t envs < <(get_pio_envs_ending_with_string "$1") for env in "${envs[@]}"; do - build_firmware $env + build_firmware "$env" done } build_repeater_firmwares() { - -# # build specific repeater firmwares -# build_firmware "Heltec_v2_repeater" -# build_firmware "Heltec_v3_repeater" -# build_firmware "Xiao_C3_Repeater_sx1262" -# build_firmware "Xiao_S3_WIO_Repeater" -# build_firmware "LilyGo_T3S3_sx1262_Repeater" -# build_firmware "RAK_4631_Repeater" - - # build all repeater firmwares - build_all_firmwares_by_suffix "_repeater" - + build_all_firmwares_by_suffix "$BULK_BUILD_SUFFIX_REPEATER" } build_companion_firmwares() { - -# # build specific companion firmwares -# build_firmware "Heltec_v2_companion_radio_usb" -# build_firmware "Heltec_v2_companion_radio_ble" -# build_firmware "Heltec_v3_companion_radio_usb" -# build_firmware "Heltec_v3_companion_radio_ble" -# build_firmware "Xiao_S3_WIO_companion_radio_ble" -# build_firmware "LilyGo_T3S3_sx1262_companion_radio_usb" -# build_firmware "LilyGo_T3S3_sx1262_companion_radio_ble" -# build_firmware "RAK_4631_companion_radio_usb" -# build_firmware "RAK_4631_companion_radio_ble" -# build_firmware "t1000e_companion_radio_ble" - - # build all companion firmwares - build_all_firmwares_by_suffix "_companion_radio_usb" - build_all_firmwares_by_suffix "_companion_radio_ble" - + build_all_firmwares_by_suffix "$BULK_BUILD_SUFFIX_COMPANION_USB" + build_all_firmwares_by_suffix "$BULK_BUILD_SUFFIX_COMPANION_BLE" } build_room_server_firmwares() { - -# # build specific room server firmwares -# build_firmware "Heltec_v3_room_server" -# build_firmware "RAK_4631_room_server" - - # build all room server firmwares - build_all_firmwares_by_suffix "_room_server" - + build_all_firmwares_by_suffix "$BULK_BUILD_SUFFIX_ROOM_SERVER" } build_firmwares() { @@ -245,34 +659,86 @@ build_firmwares() { build_room_server_firmwares } -# clean build dir -rm -rf out -mkdir -p out +prepare_output_dir() { + local output_dir="$OUTPUT_DIR" -# handle script args -if [[ $1 == "build-firmware" ]]; then - TARGETS=${@:2} - if [ "$TARGETS" ]; then - for env in $TARGETS; do - build_firmware $env - done - else - echo "usage: $0 build-firmware " + if [ -z "$output_dir" ] || [ "$output_dir" == "/" ] || [ "$output_dir" == "." ]; then + echo "Refusing to clean unsafe output directory: $output_dir" exit 1 fi -elif [[ $1 == "build-matching-firmwares" ]]; then - if [ "$2" ]; then - build_all_firmwares_matching $2 - else - echo "usage: $0 build-matching-firmwares " + + rm -rf -- "$output_dir" + mkdir -p -- "$output_dir" +} + +run_build_firmware_command() { + local targets=("${@:2}") + local env + + if [ ${#targets[@]} -eq 0 ]; then + echo "usage: $0 build-firmware " exit 1 fi -elif [[ $1 == "build-firmwares" ]]; then - build_firmwares -elif [[ $1 == "build-companion-firmwares" ]]; then - build_companion_firmwares -elif [[ $1 == "build-repeater-firmwares" ]]; then - build_repeater_firmwares -elif [[ $1 == "build-room-server-firmwares" ]]; then - build_room_server_firmwares -fi + + for env in "${targets[@]}"; do + build_firmware "$env" + done +} + +run_command() { + case "$1" in + build-firmware) + run_build_firmware_command "$@" + ;; + build-matching-firmwares) + if [ -n "$2" ]; then + build_all_firmwares_matching "$2" + else + echo "usage: $0 build-matching-firmwares " + exit 1 + fi + ;; + build-firmwares) + build_firmwares + ;; + build-companion-firmwares) + build_companion_firmwares + ;; + build-repeater-firmwares) + build_repeater_firmwares + ;; + build-room-server-firmwares) + build_room_server_firmwares + ;; + *) + global_usage + exit 1 + ;; + esac +} + +main() { + case "${1:-}" in + help|usage|-h|--help) + global_usage + exit 0 + ;; + list|-l) + init_project_context + get_pio_envs + exit 0 + ;; + esac + + init_project_context + + if [ $# -eq 0 ]; then + prompt_for_board_target + set -- build-firmware "$SELECTED_TARGET" + fi + + prepare_output_dir + run_command "$@" +} + +main "$@" From 166f804da169bc3d0301fd3cb5ff5bca291c1ebe Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 20 Apr 2026 13:47:01 -0700 Subject: [PATCH 03/11] Remove unrelated CLI changes from PR branch --- docs/cli_commands.md | 10 ---------- src/helpers/CommonCLI.cpp | 29 ----------------------------- 2 files changed, 39 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 57ab6c6459..0e785f4e83 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -30,16 +30,6 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -### Enter the UF2 bootloader (nRF52 only) -**Usage:** -- `uf2reset` - -**Serial Only:** Yes - -**Note:** Reboots directly into the UF2 bootloader on supported nRF52 boards. - ---- - ### Reset the clock and reboot **Usage:** - `clkreboot` diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index ab035f6623..2f7a0fffcb 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4,29 +4,6 @@ #include "AdvertDataHelpers.h" #include -#if defined(NRF52_PLATFORM) -#include -#include - -#ifndef DFU_MAGIC_UF2_RESET -#define DFU_MAGIC_UF2_RESET 0x57 -#endif - -static void resetToUf2Bootloader() { - uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); - - if (sd_enabled) { - sd_power_gpregret_clr(0, 0xFF); - sd_power_gpregret_set(0, DFU_MAGIC_UF2_RESET); - } else { - NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; - } - - NVIC_SystemReset(); -} -#endif - #ifndef BRIDGE_MAX_BAUD #define BRIDGE_MAX_BAUD 115200 #endif @@ -233,12 +210,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _board->powerOff(); // doesn't return } else if (memcmp(command, "reboot", 6) == 0) { _board->reboot(); // doesn't return - } else if (sender_timestamp == 0 && memcmp(command, "uf2reset", 8) == 0 && (command[8] == 0 || command[8] == ' ')) { // from serial command line only -#if defined(NRF52_PLATFORM) - resetToUf2Bootloader(); // doesn't return -#else - strcpy(reply, "ERR: unsupported"); -#endif } else if (memcmp(command, "clkreboot", 9) == 0) { // Reset clock getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm From 4c393348dfd9a55e48e2f6bcd77ce70e4e6aa5e9 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 20 Apr 2026 13:53:38 -0700 Subject: [PATCH 04/11] Add new line at the end of the file. --- build.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/build.sh b/build.sh index cd45e8d8d8..4b47220d21 100755 --- a/build.sh +++ b/build.sh @@ -742,3 +742,4 @@ main() { } main "$@" + From d8552e33448391d90a0275a013db3b23d8da8b39 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 20 Apr 2026 14:06:39 -0700 Subject: [PATCH 05/11] Better json arg passing in build.sh --- build.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 4b47220d21..30b73b5ffa 100755 --- a/build.sh +++ b/build.sh @@ -495,7 +495,8 @@ get_platform_for_env() { # PlatformIO exposes project config as JSON; scan the selected env's # build_flags to recover the platform token used for artifact collection. - echo "$PIO_CONFIG_JSON" | python3 -c " + # Feed the cached JSON via stdin to avoid shell echo quirks and argv/env size limits. + python3 -c " import sys, json, re data = json.load(sys.stdin) for section, options in data: @@ -507,7 +508,7 @@ for section, options in data: if match: print(match.group(1)) sys.exit(0) -" +" <<<"$PIO_CONFIG_JSON" } is_supported_platform() { From 8525b4e980a9eef86cf4cf475f7936b3cd69a101 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 21 Apr 2026 16:24:00 -0700 Subject: [PATCH 06/11] For packets with a path set; auto try again if no echo was heard --- docs/cli_commands.md | 28 +++ examples/simple_repeater/MyMesh.cpp | 71 ++++++++ examples/simple_repeater/MyMesh.h | 7 + src/Dispatcher.cpp | 4 +- src/Dispatcher.h | 2 + src/Mesh.cpp | 271 +++++++++++++++++++++++++++- src/Mesh.h | 41 +++++ src/helpers/CommonCLI.cpp | 51 +++++- src/helpers/CommonCLI.h | 4 +- src/helpers/SimpleMeshTables.h | 172 +++++++++++++++--- 10 files changed, 619 insertions(+), 32 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 0e785f4e83..a5b17af96e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -467,6 +467,34 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change whether direct retries can fall back to the recently-heard repeater list +**Usage:** +- `get direct.retry.heard` +- `set direct.retry.heard ` + +**Parameters:** +- `state`: `on`|`off` + +**Default:** `off` + +**Note:** When enabled, a repeater can use recently-heard non-duplicate repeater prefixes as a fallback for direct retry eligibility when no suitable neighbor entry is available. + +--- + +#### View or change the SNR margin used for direct retry eligibility +**Usage:** +- `get direct.retry.margin` +- `set direct.retry.margin ` + +**Parameters:** +- `value`: Margin in dB above the SF-specific receive floor (minimum `0`, default `5`) + +**Default:** `5` + +**Note:** The retry gate uses the active SF floor of `SF5=-2.5`, `SF6=-5`, `SF7=-7.5`, `SF8=-10`, `SF9=-12.5`, `SF10=-15`, `SF11=-17.5`, `SF12=-20`, then adds this margin. + +--- + #### [Experimental] View or change the processing delay for received traffic **Usage:** - `get rxdelay` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 24e8894927..33c350e3e2 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -40,6 +40,9 @@ #ifndef TXT_ACK_DELAY #define TXT_ACK_DELAY 200 #endif +#ifndef HALO_DIRECT_RETRY_DELAY_MIN + #define HALO_DIRECT_RETRY_DELAY_MIN 200 +#endif #define FIRMWARE_VER_LEVEL 2 @@ -60,6 +63,20 @@ #define LAZY_CONTACTS_WRITE_DELAY 5000 +const NeighbourInfo* MyMesh::findNeighbourByHash(const uint8_t* hash, uint8_t hash_len) const { +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0 && neighbours[i].id.isHashMatch(hash, hash_len)) { + return &neighbours[i]; + } + } +#else + (void)hash; + (void)hash_len; +#endif + return NULL; +} + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -399,6 +416,8 @@ File MyMesh::openAppend(const char *fname) { static uint8_t max_loop_minimal[] = { 0, /* 1-byte */ 4, /* 2-byte */ 2, /* 3-byte */ 1 }; static uint8_t max_loop_moderate[] = { 0, /* 1-byte */ 2, /* 2-byte */ 1, /* 3-byte */ 1 }; static uint8_t max_loop_strict[] = { 0, /* 1-byte */ 1, /* 2-byte */ 1, /* 3-byte */ 1 }; +// SF5..SF12 receive floors, scaled by 4 so we can keep the retry gate in int8_t quarter-dB units. +static const int8_t direct_retry_floor_x4[] = { -10, -20, -30, -40, -50, -60, -70, -80 }; bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) { uint8_t hash_size = packet->getPathHashSize(); @@ -531,6 +550,44 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } +int8_t MyMesh::getDirectRetryMinSNRX4() const { + // Use the live SF so `tempradio` changes immediately affect the retry threshold. + uint8_t sf = constrain(active_sf, (uint8_t)5, (uint8_t)12); + int16_t threshold = direct_retry_floor_x4[sf - 5] + ((int16_t)_prefs.direct_retry_snr_margin_db * 4); + return (int8_t)constrain(threshold, -128, 127); +} +bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + if (_prefs.disable_fwd) { + return false; + } + + int8_t min_snr_x4 = getDirectRetryMinSNRX4(); + const NeighbourInfo* neighbour = findNeighbourByHash(next_hop_hash, next_hop_hash_len); + // Prefer the explicit neighbor table first; it is the strongest signal that this hop is still reachable. + if (neighbour != NULL && neighbour->snr >= min_snr_x4) { + return true; + } + + if (!_prefs.direct_retry_recent_enabled) { + return false; + } + + // If no neighbor entry exists, fall back to the recent-heard repeater cache keyed by the same path prefix. + const auto* recent = ((const SimpleMeshTables *)getTables())->findRecentRepeaterByHash(next_hop_hash, next_hop_hash_len); + return recent != NULL && recent->snr_x4 >= min_snr_x4; +} +uint32_t MyMesh::getDirectRetryEchoDelay(const mesh::Packet* packet) const { + // Approximate LoRa line rate in kilobits/sec from the live radio params the repeater is using now. + float kbps = (((float) active_sf) * active_bw * ((float) active_cr)) / ((float) (1UL << active_sf)); + if (kbps <= 0.0f) { + return HALO_DIRECT_RETRY_DELAY_MIN; + } + + // Wait roughly long enough for our transmission, the next hop's receive/forward window, and its echo back. + uint32_t bits = ((uint32_t) packet->getRawLength()) * 8; + uint32_t scaled_wait_millis = (uint32_t) ((((float) bits) * 4.0f) / kbps); + return max((uint32_t) HALO_DIRECT_RETRY_DELAY_MIN, scaled_wait_millis); +} bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { // just try to determine region for packet (apply later in allowPacketForward()) @@ -859,6 +916,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 + _prefs.direct_retry_recent_enabled = 0; + _prefs.direct_retry_snr_margin_db = 5; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; @@ -899,6 +958,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc pending_discover_tag = 0; pending_discover_until = 0; + active_bw = _prefs.bw; + active_sf = _prefs.sf; + active_cr = _prefs.cr; } void MyMesh::begin(FILESYSTEM *fs) { @@ -917,6 +979,9 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + active_bw = _prefs.bw; + active_sf = _prefs.sf; + active_cr = _prefs.cr; radio_set_tx_power(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1314,12 +1379,18 @@ void MyMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_set_params(pending_freq, pending_bw, pending_sf, pending_cr); + active_bw = pending_bw; + active_sf = pending_sf; + active_cr = pending_cr; MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + active_bw = _prefs.bw; + active_sf = _prefs.sf; + active_cr = _prefs.cr; MESH_DEBUG_PRINTLN("Radio params restored"); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 929584484f..2d7d8dc1fb 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -109,8 +109,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long set_radio_at, revert_radio_at; float pending_freq; float pending_bw; + float active_bw; // live BW, including temporary radio overrides uint8_t pending_sf; + uint8_t active_sf; // live SF, including temporary radio overrides uint8_t pending_cr; + uint8_t active_cr; // live CR, including temporary radio overrides int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_RS232_BRIDGE) RS232Bridge bridge; @@ -118,6 +121,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { ESPNowBridge bridge; #endif + const NeighbourInfo* findNeighbourByHash(const uint8_t* hash, uint8_t hash_len) const; + int8_t getDirectRetryMinSNRX4() const; void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); void sendNodeDiscoverReq(); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); @@ -146,6 +151,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; + bool allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const override; + uint32_t getDirectRetryEchoDelay(const mesh::Packet* packet) const override; int getInterferenceThreshold() const override { return _prefs.interference_threshold; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a11131d..cccbd36c79 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -106,6 +106,7 @@ void Dispatcher::loop() { _radio->onSendFinished(); logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); + onSendComplete(outbound); if (outbound->isRouteFlood()) { n_sent_flood++; } else { @@ -118,6 +119,7 @@ void Dispatcher::loop() { _radio->onSendFinished(); logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); + onSendFail(outbound); releasePacket(outbound); // return to pool outbound = NULL; @@ -386,4 +388,4 @@ unsigned long Dispatcher::futureMillis(int millis_from_now) const { return _ms->getMillis() + millis_from_now; } -} \ No newline at end of file +} diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 2a99d0682b..163c61963e 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -159,6 +159,8 @@ class Dispatcher { virtual void logRx(Packet* packet, int len, float score) { } // hooks for custom logging virtual void logTx(Packet* packet, int len) { } virtual void logTxFail(Packet* packet, int len) { } + virtual void onSendComplete(Packet* packet) { } + virtual void onSendFail(Packet* packet) { } virtual const char* getLogDateTime() { return ""; } virtual float getAirtimeBudgetFactor() const; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee14036..b9b39c952e 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -4,11 +4,32 @@ namespace mesh { void Mesh::begin() { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + _direct_retries[i].packet = NULL; + _direct_retries[i].trigger_packet = NULL; + _direct_retries[i].retry_at = 0; + _direct_retries[i].retry_delay = 0; + _direct_retries[i].priority = 0; + _direct_retries[i].progress_marker = 0; + _direct_retries[i].expect_path_growth = false; + _direct_retries[i].queued = false; + _direct_retries[i].active = false; + } Dispatcher::begin(); } void Mesh::loop() { Dispatcher::loop(); + + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active || !_direct_retries[i].queued || !millisHasNowPassed(_direct_retries[i].retry_at)) { + continue; + } + + if (!isDirectRetryQueued(_direct_retries[i].packet)) { + clearDirectRetrySlot(i); + } + } } bool Mesh::allowPacketForward(const mesh::Packet* packet) { @@ -22,10 +43,25 @@ uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) { uint32_t Mesh::getDirectRetransmitDelay(const Packet* packet) { return 0; // by default, no delay } +bool Mesh::allowDirectRetry(const Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + return false; +} +uint32_t Mesh::getDirectRetryEchoDelay(const Packet* packet) const { + // Keep the base fallback aligned with the repeater's minimum retry wait. + return 200; +} uint8_t Mesh::getExtraAckTransmitCount() const { return 0; } +void Mesh::onSendComplete(Packet* packet) { + armDirectRetryOnSendComplete(packet); +} + +void Mesh::onSendFail(Packet* packet) { + clearPendingDirectRetryOnSendFail(packet); +} + uint32_t Mesh::getCADFailRetryDelay() const { return _rng->nextInt(1, 4)*120; } @@ -39,6 +75,10 @@ int Mesh::searchChannelsByHash(const uint8_t* hash, GroupChannel channels[], int } DispatcherAction Mesh::onRecvPacket(Packet* pkt) { + if (pkt->isRouteDirect()) { + cancelDirectRetryOnEcho(pkt); + } + if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) { if (pkt->path_len < MAX_PATH_SIZE) { uint8_t i = 0; @@ -58,6 +98,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); uint32_t d = getDirectRetransmitDelay(pkt); + maybeScheduleDirectRetry(pkt, 5); return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable? } } @@ -98,6 +139,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { removeSelfFromPath(pkt); uint32_t d = getDirectRetransmitDelay(pkt); + maybeScheduleDirectRetry(pkt, 0); return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority } } @@ -372,6 +414,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len); a1->header &= ~PH_ROUTE_MASK; a1->header |= ROUTE_TYPE_DIRECT; + maybeScheduleDirectRetry(a1, 0); sendPacket(a1, 0, delay_millis); } extra--; @@ -382,11 +425,225 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len); a2->header &= ~PH_ROUTE_MASK; a2->header |= ROUTE_TYPE_DIRECT; + maybeScheduleDirectRetry(a2, 0); sendPacket(a2, 0, delay_millis); } } } +void Mesh::clearDirectRetrySlot(int idx) { + _direct_retries[idx].packet = NULL; + _direct_retries[idx].trigger_packet = NULL; + _direct_retries[idx].retry_at = 0; + _direct_retries[idx].retry_delay = 0; + _direct_retries[idx].priority = 0; + _direct_retries[idx].progress_marker = 0; + _direct_retries[idx].expect_path_growth = false; + _direct_retries[idx].queued = false; + _direct_retries[idx].active = false; +} + +bool Mesh::isDirectRetryQueued(const Packet* packet) const { + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + if (_mgr->getOutboundByIdx(i) == packet) { + return true; + } + } + return false; +} + +void Mesh::calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const { + uint8_t type = packet->getPayloadType(); + Utils::sha256(dest_key, MAX_HASH_SIZE, &type, 1, packet->payload, packet->payload_len); +} + +bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { + uint8_t recv_key[MAX_HASH_SIZE]; + calculateDirectRetryKey(packet, recv_key); + + bool cleared = false; + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active || memcmp(recv_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) != 0) { + continue; + } + + bool is_echo = _direct_retries[i].expect_path_growth + ? packet->path_len > _direct_retries[i].progress_marker + : packet->getPathHashCount() < _direct_retries[i].progress_marker; + if (!is_echo) { + continue; + } + + if (_direct_retries[i].queued) { + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + if (_mgr->getOutboundByIdx(j) == _direct_retries[i].packet) { + Packet* pending = _mgr->removeOutboundByIdx(j); + if (pending) { + releasePacket(pending); + } + break; + } + } + clearDirectRetrySlot(i); + } else { + clearDirectRetrySlot(i); + } + cleared = true; + } + + return cleared; +} + +void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active) { + continue; + } + + if (_direct_retries[i].queued) { + if (_direct_retries[i].packet == packet) { + // The retry packet itself just finished transmitting; Dispatcher will release it after this hook. + clearDirectRetrySlot(i); + } + continue; + } + + if (_direct_retries[i].trigger_packet != packet) { + continue; + } + + // Allocate the retry packet only after TX-complete so busy repeaters do not reserve pool slots early. + Packet* retry = obtainNewPacket(); + if (retry == NULL) { + clearDirectRetrySlot(i); + continue; + } + + *retry = *packet; + + // Start the echo wait only after the initial direct transmission actually completed. + sendPacket(retry, _direct_retries[i].priority, _direct_retries[i].retry_delay); + if (isDirectRetryQueued(retry)) { + _direct_retries[i].packet = retry; + _direct_retries[i].trigger_packet = NULL; + _direct_retries[i].queued = true; + _direct_retries[i].retry_at = futureMillis(_direct_retries[i].retry_delay); + } else { + clearDirectRetrySlot(i); + } + } +} + +void Mesh::clearPendingDirectRetryOnSendFail(const Packet* packet) { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active) { + continue; + } + + if (_direct_retries[i].queued) { + if (_direct_retries[i].packet == packet) { + // The queued retry itself failed; Dispatcher will release it after this hook. + clearDirectRetrySlot(i); + } + continue; + } + + if (_direct_retries[i].trigger_packet == packet) { + clearDirectRetrySlot(i); + } + } +} + +bool Mesh::getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_hash, uint8_t& next_hop_hash_len, + uint8_t& progress_marker, bool& expect_path_growth) const { + switch (packet->getPayloadType()) { + case PAYLOAD_TYPE_ACK: + case PAYLOAD_TYPE_PATH: + case PAYLOAD_TYPE_REQ: + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_TXT_MSG: + case PAYLOAD_TYPE_ANON_REQ: + if (packet->getPathHashCount() <= 1) { + return false; + } + next_hop_hash = packet->path; + next_hop_hash_len = packet->getPathHashSize(); + progress_marker = packet->getPathHashCount(); + expect_path_growth = false; + return true; + + case PAYLOAD_TYPE_MULTIPART: + if (packet->payload_len < 1 || (packet->payload[0] & 0x0F) != PAYLOAD_TYPE_ACK || packet->getPathHashCount() <= 1) { + return false; + } + next_hop_hash = packet->path; + next_hop_hash_len = packet->getPathHashSize(); + progress_marker = packet->getPathHashCount(); + expect_path_growth = false; + return true; + + case PAYLOAD_TYPE_TRACE: { + if (packet->payload_len < 9) { + return false; + } + + uint8_t hash_size = 1 << (packet->payload[8] & 0x03); + uint8_t route_bytes = packet->payload_len - 9; + uint8_t offset = packet->path_len * hash_size; + if (offset + hash_size > route_bytes) { + return false; + } + if (offset + (2 * hash_size) > route_bytes) { + return false; // no downstream repeater means there will be no forward echo to overhear. + } + + next_hop_hash = &packet->payload[9 + offset]; + next_hop_hash_len = hash_size; + progress_marker = packet->path_len; + expect_path_growth = true; + return true; + } + + default: + return false; + } +} + +void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { + const uint8_t* next_hop_hash; + uint8_t next_hop_hash_len; + uint8_t progress_marker; + bool expect_path_growth; + if (!getDirectRetryTarget(packet, next_hop_hash, next_hop_hash_len, progress_marker, expect_path_growth) + || !allowDirectRetry(packet, next_hop_hash, next_hop_hash_len)) { + return; + } + + int slot_idx = -1; + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active) { + slot_idx = i; + break; + } + } + if (slot_idx < 0) { + return; + } + + // Only store retry metadata here; allocate the retry packet after the initial TX really completes. + uint32_t retry_delay = getDirectRetryEchoDelay(packet); + calculateDirectRetryKey(packet, _direct_retries[slot_idx].retry_key); + _direct_retries[slot_idx].packet = NULL; + _direct_retries[slot_idx].trigger_packet = const_cast(packet); + _direct_retries[slot_idx].retry_at = 0; + _direct_retries[slot_idx].retry_delay = retry_delay; + _direct_retries[slot_idx].priority = priority; + _direct_retries[slot_idx].progress_marker = progress_marker; + _direct_retries[slot_idx].expect_path_growth = expect_path_growth; + _direct_retries[slot_idx].queued = false; + _direct_retries[slot_idx].active = true; +} + Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, size_t app_data_len) { if (app_data_len > MAX_ADVERT_DATA_SIZE) return NULL; @@ -634,7 +891,7 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_si packet->header |= ROUTE_TYPE_FLOOD; packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -663,7 +920,7 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m packet->transport_codes[1] = transport_codes[1]; packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -696,7 +953,8 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin pri = 0; } } - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us + maybeScheduleDirectRetry(packet, pri); sendPacket(packet, pri, delay_millis); } @@ -706,7 +964,7 @@ void Mesh::sendZeroHop(Packet* packet, uint32_t delay_millis) { packet->path_len = 0; // path_len of zero means Zero Hop - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us sendPacket(packet, 0, delay_millis); } @@ -719,9 +977,10 @@ void Mesh::sendZeroHop(Packet* packet, uint16_t* transport_codes, uint32_t delay packet->path_len = 0; // path_len of zero means Zero Hop - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us sendPacket(packet, 0, delay_millis); } -} \ No newline at end of file +} + diff --git a/src/Mesh.h b/src/Mesh.h index f9f8786320..94bfec9f1b 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -4,6 +4,10 @@ namespace mesh { +#ifndef MAX_DIRECT_RETRY_SLOTS + #define MAX_DIRECT_RETRY_SLOTS 6 +#endif + class GroupChannel { public: uint8_t hash[PATH_HASH_SIZE]; @@ -16,6 +20,7 @@ class GroupChannel { class MeshTables { public: virtual bool hasSeen(const Packet* packet) = 0; + virtual void markSent(const Packet* packet) = 0; virtual void clear(const Packet* packet) = 0; // remove this packet hash from table }; @@ -24,17 +29,42 @@ class MeshTables { * and provides virtual methods for sub-classes on handling incoming, and also preparing outbound Packets. */ class Mesh : public Dispatcher { + struct DirectRetryEntry { + Packet* packet; + Packet* trigger_packet; + unsigned long retry_at; + uint32_t retry_delay; + uint8_t retry_key[MAX_HASH_SIZE]; + uint8_t priority; + uint8_t progress_marker; + bool expect_path_growth; + bool queued; + bool active; + }; + RTCClock* _rtc; RNG* _rng; MeshTables* _tables; + DirectRetryEntry _direct_retries[MAX_DIRECT_RETRY_SLOTS]; void removeSelfFromPath(Packet* packet); void routeDirectRecvAcks(Packet* packet, uint32_t delay_millis); + void clearDirectRetrySlot(int idx); + bool isDirectRetryQueued(const Packet* packet) const; + void calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const; + bool cancelDirectRetryOnEcho(const Packet* packet); + void armDirectRetryOnSendComplete(const Packet* packet); + void clearPendingDirectRetryOnSendFail(const Packet* packet); + bool getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_hash, uint8_t& next_hop_hash_len, + uint8_t& progress_marker, bool& expect_path_growth) const; + void maybeScheduleDirectRetry(const Packet* packet, uint8_t priority); //void routeRecvAcks(Packet* packet, uint32_t delay_millis); DispatcherAction forwardMultipartDirect(Packet* pkt); protected: DispatcherAction onRecvPacket(Packet* pkt) override; + void onSendComplete(Packet* packet) override; + void onSendFail(Packet* packet) override; virtual uint32_t getCADFailRetryDelay() const override; @@ -65,6 +95,17 @@ class Mesh : public Dispatcher { */ virtual uint32_t getDirectRetransmitDelay(const Packet* packet); + /** + * \brief Decide whether a DIRECT packet should get one delayed retry if the next hop echo is not overheard. + * Sub-classes can use neighbour tables or other link-quality data to opt in selectively. + */ + virtual bool allowDirectRetry(const Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const; + + /** + * \returns milliseconds to wait for the next-hop echo before queueing one retry of the DIRECT packet. + */ + virtual uint32_t getDirectRetryEchoDelay(const Packet* packet) const; + /** * \returns number of extra (Direct) ACK transmissions wanted. */ diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 2f7a0fffcb..c7095b2640 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -8,6 +8,13 @@ #define BRIDGE_MAX_BAUD 115200 #endif +// These bytes used to be reserved/unused in persisted prefs, so keep a marker before trusting them. +#define DIRECT_RETRY_PREFS_MAGIC_0 0xD4 +#define DIRECT_RETRY_PREFS_MAGIC_1 0x52 +#define DIRECT_RETRY_RECENT_DEFAULT 0 +#define DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT 5 +#define DIRECT_RETRY_SNR_MARGIN_DB_MAX 40 + // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { uint32_t n = 0; @@ -60,7 +67,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 file.read((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 file.read((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.read(pad, 4); // 108 : 4 bytes unused + file.read((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled)); // 108 + file.read((uint8_t *)&_prefs->direct_retry_snr_margin_db, sizeof(_prefs->direct_retry_snr_margin_db)); // 109 + file.read((uint8_t *)&_prefs->direct_retry_prefs_magic[0], sizeof(_prefs->direct_retry_prefs_magic)); // 110 file.read((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 file.read((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 file.read((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 @@ -102,6 +111,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1); _prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f); _prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future + // Old firmware left offset 108..111 undefined, so require the marker before using the new retry prefs. + if (_prefs->direct_retry_prefs_magic[0] != DIRECT_RETRY_PREFS_MAGIC_0 + || _prefs->direct_retry_prefs_magic[1] != DIRECT_RETRY_PREFS_MAGIC_1) { + _prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; + _prefs->direct_retry_snr_margin_db = DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT; + } else { + _prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1); + _prefs->direct_retry_snr_margin_db = constrain(_prefs->direct_retry_snr_margin_db, 0, DIRECT_RETRY_SNR_MARGIN_DB_MAX); + } // sanitise bad bridge pref values _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); @@ -150,7 +168,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 file.write((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 file.write((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.write(pad, 4); // 108 : 4 byte unused + file.write((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled)); // 108 + file.write((uint8_t *)&_prefs->direct_retry_snr_margin_db, sizeof(_prefs->direct_retry_snr_margin_db)); // 109 + // Persist a marker so later loads can distinguish real values from legacy garbage in this reserved slot. + uint8_t retry_magic[2] = { DIRECT_RETRY_PREFS_MAGIC_0, DIRECT_RETRY_PREFS_MAGIC_1 }; + file.write(retry_magic, sizeof(retry_magic)); // 110 file.write((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 file.write((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 file.write((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 @@ -338,6 +360,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch sprintf(reply, "> %d", (uint32_t)_prefs->flood_max); } else if (memcmp(config, "direct.txdelay", 14) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor)); + } else if (memcmp(config, "direct.retry.heard", 18) == 0) { + sprintf(reply, "> %s", _prefs->direct_retry_recent_enabled ? "on" : "off"); + } else if (memcmp(config, "direct.retry.margin", 19) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_snr_margin_db); } else if (memcmp(config, "owner.info", 10) == 0) { *reply++ = '>'; *reply++ = ' '; @@ -587,6 +613,27 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "Error, cannot be negative"); } + } else if (memcmp(config, "direct.retry.heard ", 19) == 0) { + if (memcmp(&config[19], "on", 2) == 0) { + _prefs->direct_retry_recent_enabled = 1; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(&config[19], "off", 3) == 0) { + _prefs->direct_retry_recent_enabled = 0; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be on or off"); + } + } else if (memcmp(config, "direct.retry.margin ", 20) == 0) { + int db = atoi(&config[20]); + if (db >= 0 && db <= DIRECT_RETRY_SNR_MARGIN_DB_MAX) { + _prefs->direct_retry_snr_margin_db = (uint8_t)db; + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, min 0 and max %d", DIRECT_RETRY_SNR_MARGIN_DB_MAX); + } } else if (memcmp(config, "owner.info ", 11) == 0) { config += 11; char *dp = _prefs->owner_info; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3a4332d1f2..c1e0c5e9ed 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -32,7 +32,9 @@ struct NodePrefs { // persisted to file float tx_delay_factor; char guest_password[16]; float direct_tx_delay_factor; - uint32_t guard; + uint8_t direct_retry_recent_enabled; + uint8_t direct_retry_snr_margin_db; + uint8_t direct_retry_prefs_magic[2]; uint8_t sf; uint8_t cr; uint8_t allow_read_only; diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 2f8af52af1..217fd5a08c 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -8,13 +8,103 @@ #define MAX_PACKET_HASHES 128 #define MAX_PACKET_ACKS 64 +#define MAX_RECENT_REPEATERS 64 +#define MAX_ROUTE_HASH_BYTES 3 class SimpleMeshTables : public mesh::MeshTables { +public: + struct RecentRepeaterInfo { + // Just enough identity to match a next-hop path prefix plus the SNR that heard it. + uint8_t prefix[MAX_ROUTE_HASH_BYTES]; + uint8_t prefix_len; + int8_t snr_x4; + }; + +private: uint8_t _hashes[MAX_PACKET_HASHES*MAX_HASH_SIZE]; int _next_idx; uint32_t _acks[MAX_PACKET_ACKS]; int _next_ack_idx; uint32_t _direct_dups, _flood_dups; + RecentRepeaterInfo _recent_repeaters[MAX_RECENT_REPEATERS]; + int _next_recent_repeater_idx; + + bool hasSeenAck(uint32_t ack) const { + for (int i = 0; i < MAX_PACKET_ACKS; i++) { + if (ack == _acks[i]) { + return true; + } + } + return false; + } + + void storeAck(uint32_t ack) { + _acks[_next_ack_idx] = ack; + _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; + } + + bool hasSeenHash(const uint8_t* hash) const { + const uint8_t* sp = _hashes; + for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { + if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { + return true; + } + } + return false; + } + + void storeHash(const uint8_t* hash) { + memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); + _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; + } + + bool extractRecentRepeater(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { + // Learn repeater prefixes only from packet shapes that expose a trustworthy repeater ID. + if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->payload_len >= PUB_KEY_SIZE) { + memcpy(prefix, packet->payload, MAX_ROUTE_HASH_BYTES); + prefix_len = MAX_ROUTE_HASH_BYTES; + return true; + } + + if (packet->getPayloadType() == PAYLOAD_TYPE_CONTROL + && packet->isRouteDirect() + && packet->getPathHashCount() == 0 + && packet->payload_len >= 6 + MAX_ROUTE_HASH_BYTES + && (packet->payload[0] & 0xF0) == 0x90) { + memcpy(prefix, &packet->payload[6], MAX_ROUTE_HASH_BYTES); + prefix_len = MAX_ROUTE_HASH_BYTES; + return true; + } + + if (packet->isRouteFlood() && packet->getPathHashCount() > 0) { + prefix_len = packet->getPathHashSize(); + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + const uint8_t* last_hop = &packet->path[(packet->getPathHashCount() - 1) * packet->getPathHashSize()]; + memcpy(prefix, last_hop, prefix_len); + return true; + } + + return false; + } + + void recordRecentRepeater(const mesh::Packet* packet) { + uint8_t prefix[MAX_ROUTE_HASH_BYTES] = {0}; + uint8_t prefix_len = 0; + if (!extractRecentRepeater(packet, prefix, prefix_len) || prefix_len == 0) { + return; + } + + // Ring buffer is enough here; retry fallback only needs a recent prefix->SNR observation. + RecentRepeaterInfo& slot = _recent_repeaters[_next_recent_repeater_idx]; + memset(slot.prefix, 0, sizeof(slot.prefix)); + memcpy(slot.prefix, prefix, prefix_len); + slot.prefix_len = prefix_len; + slot.snr_x4 = packet->_snr; + _next_recent_repeater_idx = (_next_recent_repeater_idx + 1) % MAX_RECENT_REPEATERS; + } public: SimpleMeshTables() { @@ -23,6 +113,8 @@ class SimpleMeshTables : public mesh::MeshTables { memset(_acks, 0, sizeof(_acks)); _next_ack_idx = 0; _direct_dups = _flood_dups = 0; + memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); + _next_recent_repeater_idx = 0; } #ifdef ESP32 @@ -31,12 +123,16 @@ class SimpleMeshTables : public mesh::MeshTables { f.read((uint8_t *) &_next_idx, sizeof(_next_idx)); f.read((uint8_t *) &_acks[0], sizeof(_acks)); f.read((uint8_t *) &_next_ack_idx, sizeof(_next_ack_idx)); + f.read((uint8_t *) &_recent_repeaters[0], sizeof(_recent_repeaters)); + f.read((uint8_t *) &_next_recent_repeater_idx, sizeof(_next_recent_repeater_idx)); } void saveTo(File f) { f.write(_hashes, sizeof(_hashes)); f.write((const uint8_t *) &_next_idx, sizeof(_next_idx)); f.write((const uint8_t *) &_acks[0], sizeof(_acks)); f.write((const uint8_t *) &_next_ack_idx, sizeof(_next_ack_idx)); + f.write((const uint8_t *) &_recent_repeaters[0], sizeof(_recent_repeaters)); + f.write((const uint8_t *) &_next_recent_repeater_idx, sizeof(_next_recent_repeater_idx)); } #endif @@ -44,42 +140,55 @@ class SimpleMeshTables : public mesh::MeshTables { if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { uint32_t ack; memcpy(&ack, packet->payload, 4); - for (int i = 0; i < MAX_PACKET_ACKS; i++) { - if (ack == _acks[i]) { - if (packet->isRouteDirect()) { - _direct_dups++; // keep some stats - } else { - _flood_dups++; - } - return true; + + if (hasSeenAck(ack)) { + if (packet->isRouteDirect()) { + _direct_dups++; // keep some stats + } else { + _flood_dups++; } + return true; } - - _acks[_next_ack_idx] = ack; - _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; // cyclic table + + storeAck(ack); return false; } uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); - const uint8_t* sp = _hashes; - for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { - if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { - if (packet->isRouteDirect()) { - _direct_dups++; // keep some stats - } else { - _flood_dups++; - } - return true; + if (hasSeenHash(hash)) { + if (packet->isRouteDirect()) { + _direct_dups++; // keep some stats + } else { + _flood_dups++; } + return true; } - memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); - _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; // cyclic table + storeHash(hash); + recordRecentRepeater(packet); return false; } + void markSent(const mesh::Packet* packet) override { + // Outbound packets must be marked as already-sent without teaching the recent-heard cache about ourselves. + if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { + uint32_t ack; + memcpy(&ack, packet->payload, 4); + if (!hasSeenAck(ack)) { + storeAck(ack); + } + return; + } + + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + if (!hasSeenHash(hash)) { + storeHash(hash); + } + } + void clear(const mesh::Packet* packet) override { if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { uint32_t ack; @@ -107,5 +216,24 @@ class SimpleMeshTables : public mesh::MeshTables { uint32_t getNumDirectDups() const { return _direct_dups; } uint32_t getNumFloodDups() const { return _flood_dups; } + const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t* hash, uint8_t hash_len) const { + if (hash == NULL || hash_len == 0) { + return NULL; + } + + // Search newest-to-oldest so the retry gate prefers the freshest SNR sample for a prefix. + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; + const RecentRepeaterInfo* info = &_recent_repeaters[idx]; + if (info->prefix_len < hash_len || info->prefix_len == 0) { + continue; + } + if (memcmp(info->prefix, hash, hash_len) == 0) { + return info; + } + } + return NULL; + } + void resetStats() { _direct_dups = _flood_dups = 0; } }; From 577433ce476745945ae7568c6708716b338303da Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 23 Apr 2026 16:25:51 -0700 Subject: [PATCH 07/11] Retry 3 times with a 200ms,300ms,400ms backoff. --- docs/cli_commands.md | 13 +++++ examples/simple_repeater/MyMesh.cpp | 85 ++++++++++++++++++++++++++++ examples/simple_repeater/MyMesh.h | 2 + src/Dispatcher.h | 1 + src/Mesh.cpp | 56 ++++++++++++++++++- src/Mesh.h | 6 ++ src/helpers/SimpleMeshTables.h | 86 +++++++++++++++++++++++++---- 7 files changed, 235 insertions(+), 14 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 3e79019f94..774066b0a1 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -115,6 +115,19 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +### Get or set recent repeater fallback prefix/SNR +**Usage:** +- `recent.repeater` +- `recent.repeater ` + +**Parameters:** +- `prefix_hex`: 1-3 bytes of next-hop prefix (hex) +- `snr_db`: SNR in dB (supports decimals; stored at x4 precision) + +**Note:** `set` is rejected when the prefix already exists in neighbors. + +--- + ## Statistics ### Clear Stats diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7966404dea..71b532ed86 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -77,6 +77,15 @@ const NeighbourInfo* MyMesh::findNeighbourByHash(const uint8_t* hash, uint8_t ha return NULL; } +bool MyMesh::allowRecentRepeaterPrefixStore(const uint8_t* prefix, uint8_t prefix_len, void* ctx) { + if (ctx == NULL || prefix == NULL || prefix_len == 0) { + return true; + } + + const MyMesh* self = (const MyMesh*) ctx; + return self->findNeighbourByHash(prefix, prefix_len) == NULL; +} + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -550,6 +559,34 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } } +void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis) { + if (packet == NULL) { + return; + } + + MESH_DEBUG_PRINTLN("%s direct retry %s (type=%d, route=%s, payload_len=%d, delay=%lu)", + getLogDateTime(), + event, + (uint32_t)packet->getPayloadType(), + packet->isRouteDirect() ? "D" : "F", + (uint32_t)packet->payload_len, + (unsigned long)delay_millis); + + if (_logging) { + File f = openAppend(PACKET_LOG_FILE); + if (f) { + f.print(getLogDateTime()); + f.printf(": DIRECT RETRY %s (type=%d, route=%s, payload_len=%d, delay=%lu)\n", + event, + (uint32_t)packet->getPayloadType(), + packet->isRouteDirect() ? "D" : "F", + (uint32_t)packet->payload_len, + (unsigned long)delay_millis); + f.close(); + } + } +} + int MyMesh::calcRxDelay(float score, uint32_t air_time) const { if (_prefs.rx_delay_base <= 0.0f) return 0; return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); @@ -976,6 +1013,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc active_bw = _prefs.bw; active_sf = _prefs.sf; active_cr = _prefs.cr; + + ((SimpleMeshTables *)getTables())->setRecentRepeaterAllowFilter(&MyMesh::allowRecentRepeaterPrefixStore, this); } void MyMesh::begin(FILESYSTEM *fs) { @@ -1017,6 +1056,7 @@ void MyMesh::begin(FILESYSTEM *fs) { active_bw = _prefs.bw; active_sf = _prefs.sf; active_cr = _prefs.cr; + ((SimpleMeshTables *)getTables())->setRecentRepeaterMinSNRX4(getDirectRetryMinSNRX4()); radio_set_tx_power(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1305,6 +1345,48 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply Serial.printf("\n"); } reply[0] = 0; + } else if (memcmp(command, "recent.repeater", 15) == 0) { + const char* sub = command + 15; + while (*sub == ' ') sub++; + auto* tables = (SimpleMeshTables*)getTables(); + if (*sub == 0) { + const auto* info = tables->getLatestRecentRepeater(); + if (info == NULL) { + strcpy(reply, "> none"); + } else { + char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; + mesh::Utils::toHex(hex, info->prefix, info->prefix_len); + sprintf(reply, "> %s,%s", hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + } + } else { + char* params = (char*) sub; + char* arg_snr = strchr(params, ' '); + if (arg_snr == NULL) { + strcpy(reply, "Err - usage: recent.repeater "); + } else { + *arg_snr++ = 0; + while (*arg_snr == ' ') arg_snr++; + if (*arg_snr == 0) { + strcpy(reply, "Err - usage: recent.repeater "); + } else { + int hex_len = strlen(params); + int prefix_len = hex_len / 2; + uint8_t prefix[MAX_ROUTE_HASH_BYTES] = {0}; + if ((hex_len % 2) != 0 || prefix_len <= 0 || prefix_len > MAX_ROUTE_HASH_BYTES || !mesh::Utils::fromHex(prefix, prefix_len, params)) { + strcpy(reply, "Err - prefix must be 1-3 bytes hex"); + } else { + float snr_db = strtof(arg_snr, nullptr); + int snr_x4 = (int)(snr_db * 4.0f + (snr_db >= 0.0f ? 0.5f : -0.5f)); + snr_x4 = constrain(snr_x4, -128, 127); + if (tables->setRecentRepeater(prefix, (uint8_t)prefix_len, (int8_t)snr_x4)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - prefix is already in neighbors"); + } + } + } + } + } } else if (memcmp(command, "discover.neighbors", 18) == 0) { const char* sub = command + 18; while (*sub == ' ') sub++; @@ -1358,6 +1440,9 @@ void MyMesh::loop() { MESH_DEBUG_PRINTLN("Radio params restored"); } + // Keep recent-prefix learning aligned with the live retry SNR gate. + ((SimpleMeshTables *)getTables())->setRecentRepeaterMinSNRX4(getDirectRetryMinSNRX4()); + // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { acl.save(_fs); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d2c84b1a9f..16566dca25 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -123,6 +123,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #endif const NeighbourInfo* findNeighbourByHash(const uint8_t* hash, uint8_t hash_len) const; + static bool allowRecentRepeaterPrefixStore(const uint8_t* prefix, uint8_t prefix_len, void* ctx); int8_t getDirectRetryMinSNRX4() const; void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); @@ -153,6 +154,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; bool allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const override; uint32_t getDirectRetryEchoDelay(const mesh::Packet* packet) const override; + void onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis) override; int getInterferenceThreshold() const override { return _prefs.interference_threshold; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 163c61963e..90ee5cdbea 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -170,6 +170,7 @@ class Dispatcher { virtual int getInterferenceThreshold() const { return 0; } // disabled by default virtual int getAGCResetInterval() const { return 0; } // disabled by default virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } + const Packet* getOutboundInFlight() const { return outbound; } public: void begin(); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index b9b39c952e..b9892eedd3 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -3,12 +3,16 @@ namespace mesh { +static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS = 3; +static const uint32_t DIRECT_RETRY_BACKOFF_MS[DIRECT_RETRY_MAX_ATTEMPTS] = { 200, 300, 400 }; + void Mesh::begin() { for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { _direct_retries[i].packet = NULL; _direct_retries[i].trigger_packet = NULL; _direct_retries[i].retry_at = 0; _direct_retries[i].retry_delay = 0; + _direct_retries[i].retry_attempts_sent = 0; _direct_retries[i].priority = 0; _direct_retries[i].progress_marker = 0; _direct_retries[i].expect_path_growth = false; @@ -27,6 +31,9 @@ void Mesh::loop() { } if (!isDirectRetryQueued(_direct_retries[i].packet)) { + if (_direct_retries[i].packet == getOutboundInFlight()) { + continue; // currently transmitting; keep slot until onSendComplete/onSendFail emits event + } clearDirectRetrySlot(i); } } @@ -436,6 +443,7 @@ void Mesh::clearDirectRetrySlot(int idx) { _direct_retries[idx].trigger_packet = NULL; _direct_retries[idx].retry_at = 0; _direct_retries[idx].retry_delay = 0; + _direct_retries[idx].retry_attempts_sent = 0; _direct_retries[idx].priority = 0; _direct_retries[idx].progress_marker = 0; _direct_retries[idx].expect_path_growth = false; @@ -484,8 +492,12 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { break; } } + onDirectRetryEvent("canceled_echo", _direct_retries[i].packet, 0); + onDirectRetryEvent("good", _direct_retries[i].packet, 0); clearDirectRetrySlot(i); } else { + onDirectRetryEvent("canceled_echo", _direct_retries[i].trigger_packet, 0); + onDirectRetryEvent("good", _direct_retries[i].trigger_packet, 0); clearDirectRetrySlot(i); } cleared = true; @@ -503,7 +515,35 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { if (_direct_retries[i].queued) { if (_direct_retries[i].packet == packet) { // The retry packet itself just finished transmitting; Dispatcher will release it after this hook. - clearDirectRetrySlot(i); + onDirectRetryEvent("resent", packet, 0); + _direct_retries[i].retry_attempts_sent++; + if (_direct_retries[i].retry_attempts_sent >= DIRECT_RETRY_MAX_ATTEMPTS) { + onDirectRetryEvent("failure", packet, 0); + clearDirectRetrySlot(i); + continue; + } + + Packet* retry = obtainNewPacket(); + if (retry == NULL) { + onDirectRetryEvent("dropped_no_packet", packet, 0); + onDirectRetryEvent("failure", packet, 0); + clearDirectRetrySlot(i); + continue; + } + + *retry = *packet; + uint32_t retry_delay = DIRECT_RETRY_BACKOFF_MS[_direct_retries[i].retry_attempts_sent]; + sendPacket(retry, _direct_retries[i].priority, retry_delay); + if (isDirectRetryQueued(retry)) { + _direct_retries[i].packet = retry; + _direct_retries[i].retry_delay = retry_delay; + _direct_retries[i].retry_at = futureMillis(retry_delay); + onDirectRetryEvent("queued", retry, retry_delay); + } else { + onDirectRetryEvent("dropped_queue_full", retry, retry_delay); + onDirectRetryEvent("failure", retry, 0); + clearDirectRetrySlot(i); + } } continue; } @@ -515,6 +555,8 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { // Allocate the retry packet only after TX-complete so busy repeaters do not reserve pool slots early. Packet* retry = obtainNewPacket(); if (retry == NULL) { + onDirectRetryEvent("dropped_no_packet", packet, _direct_retries[i].retry_delay); + onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); continue; } @@ -528,7 +570,10 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { _direct_retries[i].trigger_packet = NULL; _direct_retries[i].queued = true; _direct_retries[i].retry_at = futureMillis(_direct_retries[i].retry_delay); + onDirectRetryEvent("queued", retry, _direct_retries[i].retry_delay); } else { + onDirectRetryEvent("dropped_queue_full", retry, _direct_retries[i].retry_delay); + onDirectRetryEvent("failure", retry, 0); clearDirectRetrySlot(i); } } @@ -543,12 +588,16 @@ void Mesh::clearPendingDirectRetryOnSendFail(const Packet* packet) { if (_direct_retries[i].queued) { if (_direct_retries[i].packet == packet) { // The queued retry itself failed; Dispatcher will release it after this hook. + onDirectRetryEvent("dropped_send_fail", packet, 0); + onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); } continue; } if (_direct_retries[i].trigger_packet == packet) { + onDirectRetryEvent("dropped_send_fail", packet, 0); + onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); } } @@ -631,17 +680,19 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { } // Only store retry metadata here; allocate the retry packet after the initial TX really completes. - uint32_t retry_delay = getDirectRetryEchoDelay(packet); + uint32_t retry_delay = DIRECT_RETRY_BACKOFF_MS[0]; calculateDirectRetryKey(packet, _direct_retries[slot_idx].retry_key); _direct_retries[slot_idx].packet = NULL; _direct_retries[slot_idx].trigger_packet = const_cast(packet); _direct_retries[slot_idx].retry_at = 0; _direct_retries[slot_idx].retry_delay = retry_delay; + _direct_retries[slot_idx].retry_attempts_sent = 0; _direct_retries[slot_idx].priority = priority; _direct_retries[slot_idx].progress_marker = progress_marker; _direct_retries[slot_idx].expect_path_growth = expect_path_growth; _direct_retries[slot_idx].queued = false; _direct_retries[slot_idx].active = true; + onDirectRetryEvent("armed", packet, retry_delay); } Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, size_t app_data_len) { @@ -983,4 +1034,3 @@ void Mesh::sendZeroHop(Packet* packet, uint16_t* transport_codes, uint32_t delay } } - diff --git a/src/Mesh.h b/src/Mesh.h index 94bfec9f1b..4441514b5e 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -34,6 +34,7 @@ class Mesh : public Dispatcher { Packet* trigger_packet; unsigned long retry_at; uint32_t retry_delay; + uint8_t retry_attempts_sent; uint8_t retry_key[MAX_HASH_SIZE]; uint8_t priority; uint8_t progress_marker; @@ -111,6 +112,11 @@ class Mesh : public Dispatcher { */ virtual uint8_t getExtraAckTransmitCount() const; + /** + * \brief Optional hook for logging direct-retry lifecycle events. + */ + virtual void onDirectRetryEvent(const char* event, const Packet* packet, uint32_t delay_millis) { } + /** * \brief Perform search of local DB of peers/contacts. * \returns Number of peers with matching hash diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 217fd5a08c..f5da272b1b 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -13,6 +13,8 @@ class SimpleMeshTables : public mesh::MeshTables { public: + typedef bool (*RecentRepeaterAllowFn)(const uint8_t* prefix, uint8_t prefix_len, void* ctx); + struct RecentRepeaterInfo { // Just enough identity to match a next-hop path prefix plus the SNR that heard it. uint8_t prefix[MAX_ROUTE_HASH_BYTES]; @@ -28,6 +30,9 @@ class SimpleMeshTables : public mesh::MeshTables { uint32_t _direct_dups, _flood_dups; RecentRepeaterInfo _recent_repeaters[MAX_RECENT_REPEATERS]; int _next_recent_repeater_idx; + int8_t _recent_repeater_min_snr_x4; + RecentRepeaterAllowFn _recent_repeater_allow_fn; + void* _recent_repeater_allow_ctx; bool hasSeenAck(uint32_t ack) const { for (int i = 0; i < MAX_PACKET_ACKS; i++) { @@ -58,6 +63,11 @@ class SimpleMeshTables : public mesh::MeshTables { _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; } + bool prefixesOverlap(const uint8_t* a, uint8_t a_len, const uint8_t* b, uint8_t b_len) const { + uint8_t n = a_len < b_len ? a_len : b_len; + return n > 0 && memcmp(a, b, n) == 0; + } + bool extractRecentRepeater(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { // Learn repeater prefixes only from packet shapes that expose a trustworthy repeater ID. if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->payload_len >= PUB_KEY_SIZE) { @@ -96,14 +106,10 @@ class SimpleMeshTables : public mesh::MeshTables { if (!extractRecentRepeater(packet, prefix, prefix_len) || prefix_len == 0) { return; } - - // Ring buffer is enough here; retry fallback only needs a recent prefix->SNR observation. - RecentRepeaterInfo& slot = _recent_repeaters[_next_recent_repeater_idx]; - memset(slot.prefix, 0, sizeof(slot.prefix)); - memcpy(slot.prefix, prefix, prefix_len); - slot.prefix_len = prefix_len; - slot.snr_x4 = packet->_snr; - _next_recent_repeater_idx = (_next_recent_repeater_idx + 1) % MAX_RECENT_REPEATERS; + if (packet->_snr < _recent_repeater_min_snr_x4) { + return; + } + setRecentRepeater(prefix, prefix_len, packet->_snr); } public: @@ -115,6 +121,9 @@ class SimpleMeshTables : public mesh::MeshTables { _direct_dups = _flood_dups = 0; memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); _next_recent_repeater_idx = 0; + _recent_repeater_min_snr_x4 = -128; + _recent_repeater_allow_fn = NULL; + _recent_repeater_allow_ctx = NULL; } #ifdef ESP32 @@ -216,19 +225,74 @@ class SimpleMeshTables : public mesh::MeshTables { uint32_t getNumDirectDups() const { return _direct_dups; } uint32_t getNumFloodDups() const { return _flood_dups; } + void setRecentRepeaterMinSNRX4(int8_t min_snr_x4) { + _recent_repeater_min_snr_x4 = min_snr_x4; + } + void setRecentRepeaterAllowFilter(RecentRepeaterAllowFn fn, void* ctx) { + _recent_repeater_allow_fn = fn; + _recent_repeater_allow_ctx = ctx; + } + bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4) { + if (prefix == NULL || prefix_len == 0) { + return false; + } + + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + if (_recent_repeater_allow_fn != NULL && !_recent_repeater_allow_fn(prefix, prefix_len, _recent_repeater_allow_ctx)) { + return false; + } + + // Keep one slot for overlapping prefixes so 1/2/3-byte paths share the same entry. + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0 || !prefixesOverlap(existing.prefix, existing.prefix_len, prefix, prefix_len)) { + continue; + } + if (prefix_len > existing.prefix_len) { + memset(existing.prefix, 0, sizeof(existing.prefix)); + memcpy(existing.prefix, prefix, prefix_len); + existing.prefix_len = prefix_len; + } + existing.snr_x4 = snr_x4; + return true; + } + + // Ring buffer is enough here; retry fallback only needs a recent prefix->SNR observation. + RecentRepeaterInfo& slot = _recent_repeaters[_next_recent_repeater_idx]; + memset(slot.prefix, 0, sizeof(slot.prefix)); + memcpy(slot.prefix, prefix, prefix_len); + slot.prefix_len = prefix_len; + slot.snr_x4 = snr_x4; + _next_recent_repeater_idx = (_next_recent_repeater_idx + 1) % MAX_RECENT_REPEATERS; + return true; + } + const RecentRepeaterInfo* getLatestRecentRepeater() const { + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; + const RecentRepeaterInfo* info = &_recent_repeaters[idx]; + if (info->prefix_len > 0) { + return info; + } + } + return NULL; + } + const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t* hash, uint8_t hash_len) const { if (hash == NULL || hash_len == 0) { return NULL; } - // Search newest-to-oldest so the retry gate prefers the freshest SNR sample for a prefix. + // Search newest-to-oldest and allow 1/2/3-byte prefixes to overlap-match. for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; const RecentRepeaterInfo* info = &_recent_repeaters[idx]; - if (info->prefix_len < hash_len || info->prefix_len == 0) { + if (info->prefix_len == 0) { continue; } - if (memcmp(info->prefix, hash, hash_len) == 0) { + if (prefixesOverlap(info->prefix, info->prefix_len, hash, hash_len)) { return info; } } From 9c2ac5aa1c7b8448f9189813120879d2b00a63fc Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 24 Apr 2026 00:08:10 -0700 Subject: [PATCH 08/11] Max retries is now a var that can be set between 1 to 15 --- docs/cli_commands.md | 39 +++++++- examples/simple_repeater/MyMesh.cpp | 134 ++++++++++++++++++++++++---- examples/simple_repeater/MyMesh.h | 1 + src/Mesh.cpp | 30 +++++-- src/Mesh.h | 10 +++ src/helpers/CommonCLI.cpp | 49 +++++++++- src/helpers/CommonCLI.h | 3 + src/helpers/SimpleMeshTables.h | 45 ++++++++++ 8 files changed, 283 insertions(+), 28 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 774066b0a1..e798cf0e86 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -117,14 +117,21 @@ This document provides an overview of CLI commands that can be sent to MeshCore ### Get or set recent repeater fallback prefix/SNR **Usage:** -- `recent.repeater` -- `recent.repeater ` +- `get recent.repeater` +- `get recent.repeater all` +- `get recent.repeater first ` +- `get recent.repeater last ` +- `set recent.repeater ` **Parameters:** - `prefix_hex`: 1-3 bytes of next-hop prefix (hex) - `snr_db`: SNR in dB (supports decimals; stored at x4 precision) +- `count`: number of entries to print -**Note:** `set` is rejected when the prefix already exists in neighbors. +**Notes:** +- `set` is rejected when the prefix already exists in neighbors. +- `all` prints oldest to newest; `first` prints the oldest N; `last` prints the newest N. +- Remote CLI replies include rows too, but may truncate when the packet payload limit is reached. --- @@ -545,6 +552,32 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change the number of direct retry attempts +**Usage:** +- `get direct.retry.count` +- `set direct.retry.count ` + +**Parameters:** +- `value`: Retry attempts after initial TX (`1`-`15`) + +**Default:** `3` + +--- + +#### View or change the base direct retry wait (milliseconds) +**Usage:** +- `get direct.retry.base` +- `set direct.retry.base ` + +**Parameters:** +- `value`: Base wait in milliseconds (`10`-`5000`) + +**Default:** `200` + +**Note:** The actual first retry wait is `base + computed_echo_wait_from_live_phy`. + +--- + #### [Experimental] View or change the processing delay for received traffic **Usage:** - `get rxdelay` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 71b532ed86..220f970421 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -627,16 +627,21 @@ bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_ho return recent != NULL && recent->snr_x4 >= min_snr_x4; } uint32_t MyMesh::getDirectRetryEchoDelay(const mesh::Packet* packet) const { + uint32_t base_wait_millis = constrain((uint32_t)_prefs.direct_retry_base_ms, (uint32_t)10, (uint32_t)5000); // Approximate LoRa line rate in kilobits/sec from the live radio params the repeater is using now. float kbps = (((float) active_sf) * active_bw * ((float) active_cr)) / ((float) (1UL << active_sf)); if (kbps <= 0.0f) { - return HALO_DIRECT_RETRY_DELAY_MIN; + return base_wait_millis; } // Wait roughly long enough for our transmission, the next hop's receive/forward window, and its echo back. uint32_t bits = ((uint32_t) packet->getRawLength()) * 8; uint32_t scaled_wait_millis = (uint32_t) ((((float) bits) * 4.0f) / kbps); - return max((uint32_t) HALO_DIRECT_RETRY_DELAY_MIN, scaled_wait_millis); + return base_wait_millis + scaled_wait_millis; +} +uint8_t MyMesh::getDirectRetryMaxAttempts(const mesh::Packet* packet) const { + (void)packet; + return constrain(_prefs.direct_retry_attempts, (uint8_t)1, (uint8_t)15); } bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { @@ -970,6 +975,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 _prefs.direct_retry_recent_enabled = 0; _prefs.direct_retry_snr_margin_db = 5; + _prefs.direct_retry_attempts = 3; + _prefs.direct_retry_base_ms = 200; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; @@ -1345,29 +1352,36 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply Serial.printf("\n"); } reply[0] = 0; - } else if (memcmp(command, "recent.repeater", 15) == 0) { - const char* sub = command + 15; + } else if (strncmp(command, "get recent.repeater", 19) == 0 + || strncmp(command, "set recent.repeater", 19) == 0 + || strncmp(command, "recent.repeater", 15) == 0) { + bool is_get = false; + bool is_set = false; + const char* sub = command; + + if (strncmp(command, "get recent.repeater", 19) == 0) { + is_get = true; + sub = command + 19; + } else if (strncmp(command, "set recent.repeater", 19) == 0) { + is_set = true; + sub = command + 19; + } else { + sub = command + 15; // legacy command format + } while (*sub == ' ') sub++; + auto* tables = (SimpleMeshTables*)getTables(); - if (*sub == 0) { - const auto* info = tables->getLatestRecentRepeater(); - if (info == NULL) { - strcpy(reply, "> none"); - } else { - char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; - mesh::Utils::toHex(hex, info->prefix, info->prefix_len); - sprintf(reply, "> %s,%s", hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); - } - } else { + + if (is_set || (!is_get && *sub != 0 && strcmp(sub, "all") != 0 && strncmp(sub, "first ", 6) != 0 && strncmp(sub, "last ", 5) != 0)) { char* params = (char*) sub; char* arg_snr = strchr(params, ' '); if (arg_snr == NULL) { - strcpy(reply, "Err - usage: recent.repeater "); + strcpy(reply, "Err - usage: set recent.repeater "); } else { *arg_snr++ = 0; while (*arg_snr == ' ') arg_snr++; if (*arg_snr == 0) { - strcpy(reply, "Err - usage: recent.repeater "); + strcpy(reply, "Err - usage: set recent.repeater "); } else { int hex_len = strlen(params); int prefix_len = hex_len / 2; @@ -1386,6 +1400,94 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } } } + } else if (*sub == 0) { + const auto* info = tables->getLatestRecentRepeater(); + if (info == NULL) { + strcpy(reply, "> none"); + } else { + char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; + mesh::Utils::toHex(hex, info->prefix, info->prefix_len); + sprintf(reply, "> %s,%s", hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + } + } else if (strcmp(sub, "all") == 0 || strncmp(sub, "first ", 6) == 0 || strncmp(sub, "last ", 5) == 0) { + int total = tables->getRecentRepeaterCount(); + if (total <= 0) { + strcpy(reply, "> none"); + } else { + bool newest_first = false; + int limit = total; + const char* mode = "all"; + if (strncmp(sub, "first ", 6) == 0 || strncmp(sub, "last ", 5) == 0) { + const char* nstr = sub + (sub[0] == 'f' ? 6 : 5); + while (*nstr == ' ') nstr++; + if (*nstr == 0) { + strcpy(reply, "Err - usage: get recent.repeater first|last "); + return; + } + char* end_ptr = NULL; + long parsed = strtol(nstr, &end_ptr, 10); + while (end_ptr != NULL && *end_ptr == ' ') end_ptr++; + if (end_ptr == NULL || *end_ptr != 0 || parsed <= 0) { + strcpy(reply, "Err - count must be > 0"); + return; + } + limit = (int)parsed; + if (sub[0] == 'l') { + newest_first = true; + mode = "last"; + } else { + mode = "first"; + } + } + if (limit > total) { + limit = total; + } + + if (sender_timestamp == 0) { + Serial.printf("Recent repeater table (%s %d/%d):\n", mode, limit, total); + for (int i = 0; i < limit; i++) { + const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(i) : tables->getRecentRepeaterOldestByIdx(i); + if (info == NULL) { + continue; + } + char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; + mesh::Utils::toHex(hex, info->prefix, info->prefix_len); + Serial.printf("%02d: %s,%s\n", i + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + } + sprintf(reply, "> showing %d/%d (%s)", limit, total, mode); + } else { + // Remote CLI replies are packet-bound, so include as many rows as fit. + int written = snprintf(reply, 160, "> showing %d/%d (%s)", limit, total, mode); + bool truncated = false; + if (written < 0) { + reply[0] = 0; + written = 0; + } + for (int i = 0; i < limit; i++) { + const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(i) : tables->getRecentRepeaterOldestByIdx(i); + if (info == NULL) { + continue; + } + if (written >= 154) { + truncated = true; + break; + } + char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; + mesh::Utils::toHex(hex, info->prefix, info->prefix_len); + int n = snprintf(reply + written, 160 - written, "\n%02d:%s,%s", i + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + if (n < 0 || n >= (160 - written)) { + truncated = true; + break; + } + written += n; + } + if (truncated && written < 156) { + snprintf(reply + written, 160 - written, "\n..."); + } + } + } + } else { + strcpy(reply, "Err - usage: get recent.repeater [all|first |last ]"); } } else if (memcmp(command, "discover.neighbors", 18) == 0) { const char* sub = command + 18; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 16566dca25..00a8a31b69 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -154,6 +154,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; bool allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const override; uint32_t getDirectRetryEchoDelay(const mesh::Packet* packet) const override; + uint8_t getDirectRetryMaxAttempts(const mesh::Packet* packet) const override; void onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis) override; int getInterferenceThreshold() const override { diff --git a/src/Mesh.cpp b/src/Mesh.cpp index b9892eedd3..47fc6e8dfe 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -3,8 +3,8 @@ namespace mesh { -static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS = 3; -static const uint32_t DIRECT_RETRY_BACKOFF_MS[DIRECT_RETRY_MAX_ATTEMPTS] = { 200, 300, 400 }; +static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS_DEFAULT = 3; +static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS_HARD_MAX = 15; void Mesh::begin() { for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { @@ -57,6 +57,14 @@ uint32_t Mesh::getDirectRetryEchoDelay(const Packet* packet) const { // Keep the base fallback aligned with the repeater's minimum retry wait. return 200; } +uint8_t Mesh::getDirectRetryMaxAttempts(const Packet* packet) const { + return DIRECT_RETRY_MAX_ATTEMPTS_DEFAULT; +} +uint32_t Mesh::getDirectRetryAttemptDelay(const Packet* packet, uint8_t attempt_idx) const { + uint32_t base = getDirectRetryEchoDelay(packet); + // Keep the historical linear spacing while allowing the base wait to vary by platform/profile. + return base + ((uint32_t)attempt_idx * 100UL); +} uint8_t Mesh::getExtraAckTransmitCount() const { return 0; } @@ -517,7 +525,13 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { // The retry packet itself just finished transmitting; Dispatcher will release it after this hook. onDirectRetryEvent("resent", packet, 0); _direct_retries[i].retry_attempts_sent++; - if (_direct_retries[i].retry_attempts_sent >= DIRECT_RETRY_MAX_ATTEMPTS) { + uint8_t max_attempts = getDirectRetryMaxAttempts(packet); + if (max_attempts < 1) { + max_attempts = 1; + } else if (max_attempts > DIRECT_RETRY_MAX_ATTEMPTS_HARD_MAX) { + max_attempts = DIRECT_RETRY_MAX_ATTEMPTS_HARD_MAX; + } + if (_direct_retries[i].retry_attempts_sent >= max_attempts) { onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); continue; @@ -532,7 +546,7 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { } *retry = *packet; - uint32_t retry_delay = DIRECT_RETRY_BACKOFF_MS[_direct_retries[i].retry_attempts_sent]; + uint32_t retry_delay = getDirectRetryAttemptDelay(packet, _direct_retries[i].retry_attempts_sent); sendPacket(retry, _direct_retries[i].priority, retry_delay); if (isDirectRetryQueued(retry)) { _direct_retries[i].packet = retry; @@ -612,7 +626,9 @@ bool Mesh::getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_h case PAYLOAD_TYPE_RESPONSE: case PAYLOAD_TYPE_TXT_MSG: case PAYLOAD_TYPE_ANON_REQ: - if (packet->getPathHashCount() <= 1) { + // Allow retries even when only one downstream hop remains so fixed direct paths + // (e.g. remote admin/login over 2-hop chains) use the same retry policy. + if (packet->getPathHashCount() == 0) { return false; } next_hop_hash = packet->path; @@ -622,7 +638,7 @@ bool Mesh::getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_h return true; case PAYLOAD_TYPE_MULTIPART: - if (packet->payload_len < 1 || (packet->payload[0] & 0x0F) != PAYLOAD_TYPE_ACK || packet->getPathHashCount() <= 1) { + if (packet->payload_len < 1 || (packet->payload[0] & 0x0F) != PAYLOAD_TYPE_ACK || packet->getPathHashCount() == 0) { return false; } next_hop_hash = packet->path; @@ -680,7 +696,7 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { } // Only store retry metadata here; allocate the retry packet after the initial TX really completes. - uint32_t retry_delay = DIRECT_RETRY_BACKOFF_MS[0]; + uint32_t retry_delay = getDirectRetryAttemptDelay(packet, 0); calculateDirectRetryKey(packet, _direct_retries[slot_idx].retry_key); _direct_retries[slot_idx].packet = NULL; _direct_retries[slot_idx].trigger_packet = const_cast(packet); diff --git a/src/Mesh.h b/src/Mesh.h index 4441514b5e..ad4d8a2f53 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -107,6 +107,16 @@ class Mesh : public Dispatcher { */ virtual uint32_t getDirectRetryEchoDelay(const Packet* packet) const; + /** + * \returns maximum number of retry transmissions after the initial direct TX. + */ + virtual uint8_t getDirectRetryMaxAttempts(const Packet* packet) const; + + /** + * \returns delay before a specific retry attempt, where attempt_idx=0 is the first retry. + */ + virtual uint32_t getDirectRetryAttemptDelay(const Packet* packet, uint8_t attempt_idx) const; + /** * \returns number of extra (Direct) ACK transmissions wanted. */ diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 38d4536a84..02d27830d8 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -14,6 +14,14 @@ #define DIRECT_RETRY_RECENT_DEFAULT 0 #define DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT 5 #define DIRECT_RETRY_SNR_MARGIN_DB_MAX 40 +#define DIRECT_RETRY_TIMING_MAGIC_0 0xD5 +#define DIRECT_RETRY_TIMING_MAGIC_1 0x54 +#define DIRECT_RETRY_COUNT_DEFAULT 3 +#define DIRECT_RETRY_COUNT_MIN 1 +#define DIRECT_RETRY_COUNT_MAX 15 +#define DIRECT_RETRY_BASE_MS_DEFAULT 200 +#define DIRECT_RETRY_BASE_MS_MIN 10 +#define DIRECT_RETRY_BASE_MS_MAX 5000 // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { @@ -97,7 +105,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.read((uint8_t *)&_prefs->direct_retry_attempts, sizeof(_prefs->direct_retry_attempts)); // 291 + file.read((uint8_t *)&_prefs->direct_retry_base_ms, sizeof(_prefs->direct_retry_base_ms)); // 292 + file.read((uint8_t *)&_prefs->direct_retry_timing_magic[0], sizeof(_prefs->direct_retry_timing_magic)); // 294 + // next: 296 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -121,6 +132,14 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1); _prefs->direct_retry_snr_margin_db = constrain(_prefs->direct_retry_snr_margin_db, 0, DIRECT_RETRY_SNR_MARGIN_DB_MAX); } + if (_prefs->direct_retry_timing_magic[0] != DIRECT_RETRY_TIMING_MAGIC_0 + || _prefs->direct_retry_timing_magic[1] != DIRECT_RETRY_TIMING_MAGIC_1) { + _prefs->direct_retry_attempts = DIRECT_RETRY_COUNT_DEFAULT; + _prefs->direct_retry_base_ms = DIRECT_RETRY_BASE_MS_DEFAULT; + } else { + _prefs->direct_retry_attempts = constrain(_prefs->direct_retry_attempts, DIRECT_RETRY_COUNT_MIN, DIRECT_RETRY_COUNT_MAX); + _prefs->direct_retry_base_ms = constrain(_prefs->direct_retry_base_ms, DIRECT_RETRY_BASE_MS_MIN, DIRECT_RETRY_BASE_MS_MAX); + } // sanitise bad bridge pref values _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); @@ -201,7 +220,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.write((uint8_t *)&_prefs->direct_retry_attempts, sizeof(_prefs->direct_retry_attempts)); // 291 + file.write((uint8_t *)&_prefs->direct_retry_base_ms, sizeof(_prefs->direct_retry_base_ms)); // 292 + uint8_t retry_timing_magic[2] = { DIRECT_RETRY_TIMING_MAGIC_0, DIRECT_RETRY_TIMING_MAGIC_1 }; + file.write(retry_timing_magic, sizeof(retry_timing_magic)); // 294 + // next: 296 file.close(); } @@ -363,6 +386,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re sprintf(reply, "> %s", _prefs->direct_retry_recent_enabled ? "on" : "off"); } else if (memcmp(config, "direct.retry.margin", 19) == 0) { sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_snr_margin_db); + } else if (memcmp(config, "direct.retry.count", 18) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_attempts); + } else if (memcmp(config, "direct.retry.base", 17) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_base_ms); } else if (memcmp(config, "owner.info", 10) == 0) { *reply++ = '>'; *reply++ = ' '; @@ -633,6 +660,24 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { sprintf(reply, "Error, min 0 and max %d", DIRECT_RETRY_SNR_MARGIN_DB_MAX); } + } else if (memcmp(config, "direct.retry.count ", 19) == 0) { + int count = atoi(&config[19]); + if (count >= DIRECT_RETRY_COUNT_MIN && count <= DIRECT_RETRY_COUNT_MAX) { + _prefs->direct_retry_attempts = (uint8_t)count; + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, min %d and max %d", DIRECT_RETRY_COUNT_MIN, DIRECT_RETRY_COUNT_MAX); + } + } else if (memcmp(config, "direct.retry.base ", 18) == 0) { + int delay_ms = atoi(&config[18]); + if (delay_ms >= DIRECT_RETRY_BASE_MS_MIN && delay_ms <= DIRECT_RETRY_BASE_MS_MAX) { + _prefs->direct_retry_base_ms = (uint16_t)delay_ms; + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, min %d and max %d", DIRECT_RETRY_BASE_MS_MIN, DIRECT_RETRY_BASE_MS_MAX); + } } else if (memcmp(config, "owner.info ", 11) == 0) { config += 11; char *dp = _prefs->owner_info; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 85962638fd..03b1fb649b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -63,6 +63,9 @@ struct NodePrefs { // persisted to file uint8_t rx_boosted_gain; // power settings uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; + uint8_t direct_retry_attempts; + uint16_t direct_retry_base_ms; + uint8_t direct_retry_timing_magic[2]; }; class CommonCLICallbacks { diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index f5da272b1b..705869ad35 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -279,6 +279,51 @@ class SimpleMeshTables : public mesh::MeshTables { } return NULL; } + int getRecentRepeaterCount() const { + int count = 0; + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + if (_recent_repeaters[i].prefix_len > 0) { + count++; + } + } + return count; + } + const RecentRepeaterInfo* getRecentRepeaterNewestByIdx(int idx_wanted) const { + if (idx_wanted < 0) { + return NULL; + } + int idx_seen = 0; + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; + const RecentRepeaterInfo* info = &_recent_repeaters[idx]; + if (info->prefix_len == 0) { + continue; + } + if (idx_seen == idx_wanted) { + return info; + } + idx_seen++; + } + return NULL; + } + const RecentRepeaterInfo* getRecentRepeaterOldestByIdx(int idx_wanted) const { + if (idx_wanted < 0) { + return NULL; + } + int idx_seen = 0; + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + int idx = (_next_recent_repeater_idx + i) % MAX_RECENT_REPEATERS; + const RecentRepeaterInfo* info = &_recent_repeaters[idx]; + if (info->prefix_len == 0) { + continue; + } + if (idx_seen == idx_wanted) { + return info; + } + idx_seen++; + } + return NULL; + } const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t* hash, uint8_t hash_len) const { if (hash == NULL || hash_len == 0) { From 756268e2ee46c992e662bcb0c0318de25d266783 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 24 Apr 2026 14:41:07 -0700 Subject: [PATCH 09/11] Fix issue with packet prefixes getting added to the table. --- docs/cli_commands.md | 6 +- examples/simple_repeater/MyMesh.cpp | 93 ++++++++++++++++++++++------- src/helpers/SimpleMeshTables.h | 24 ++++---- 3 files changed, 91 insertions(+), 32 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e798cf0e86..b6c87a7f02 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -119,19 +119,23 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Usage:** - `get recent.repeater` - `get recent.repeater all` +- `get recent.repeater all ` - `get recent.repeater first ` +- `get recent.repeater first ` - `get recent.repeater last ` +- `get recent.repeater last ` - `set recent.repeater ` **Parameters:** - `prefix_hex`: 1-3 bytes of next-hop prefix (hex) - `snr_db`: SNR in dB (supports decimals; stored at x4 precision) - `count`: number of entries to print +- `offset`: zero-based row offset into the selected order **Notes:** - `set` is rejected when the prefix already exists in neighbors. - `all` prints oldest to newest; `first` prints the oldest N; `last` prints the newest N. -- Remote CLI replies include rows too, but may truncate when the packet payload limit is reached. +- Over LoRa remote CLI, replies are packet-size limited; use `offset` to page through all rows. --- diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 220f970421..907ce2c8da 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1372,7 +1372,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply auto* tables = (SimpleMeshTables*)getTables(); - if (is_set || (!is_get && *sub != 0 && strcmp(sub, "all") != 0 && strncmp(sub, "first ", 6) != 0 && strncmp(sub, "last ", 5) != 0)) { + bool is_all = (strcmp(sub, "all") == 0 || strncmp(sub, "all ", 4) == 0); + bool is_first = (strncmp(sub, "first ", 6) == 0); + bool is_last = (strncmp(sub, "last ", 5) == 0); + + if (is_set || (!is_get && *sub != 0 && !is_all && !is_first && !is_last)) { char* params = (char*) sub; char* arg_snr = strchr(params, ' '); if (arg_snr == NULL) { @@ -1409,62 +1413,111 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply mesh::Utils::toHex(hex, info->prefix, info->prefix_len); sprintf(reply, "> %s,%s", hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); } - } else if (strcmp(sub, "all") == 0 || strncmp(sub, "first ", 6) == 0 || strncmp(sub, "last ", 5) == 0) { + } else if (is_all || is_first || is_last) { int total = tables->getRecentRepeaterCount(); if (total <= 0) { strcpy(reply, "> none"); } else { bool newest_first = false; int limit = total; + int offset = 0; const char* mode = "all"; - if (strncmp(sub, "first ", 6) == 0 || strncmp(sub, "last ", 5) == 0) { - const char* nstr = sub + (sub[0] == 'f' ? 6 : 5); + + if (is_first || is_last) { + const char* nstr = sub + (is_first ? 6 : 5); while (*nstr == ' ') nstr++; if (*nstr == 0) { - strcpy(reply, "Err - usage: get recent.repeater first|last "); + strcpy(reply, "Err - usage: get recent.repeater first|last [offset]"); return; } + char* end_ptr = NULL; - long parsed = strtol(nstr, &end_ptr, 10); + long parsed_count = strtol(nstr, &end_ptr, 10); while (end_ptr != NULL && *end_ptr == ' ') end_ptr++; - if (end_ptr == NULL || *end_ptr != 0 || parsed <= 0) { + if (end_ptr == NULL || parsed_count <= 0) { strcpy(reply, "Err - count must be > 0"); return; } - limit = (int)parsed; - if (sub[0] == 'l') { + + if (*end_ptr != 0) { + char* end_ptr2 = NULL; + long parsed_offset = strtol(end_ptr, &end_ptr2, 10); + while (end_ptr2 != NULL && *end_ptr2 == ' ') end_ptr2++; + if (end_ptr2 == NULL || *end_ptr2 != 0 || parsed_offset < 0) { + strcpy(reply, "Err - offset must be >= 0"); + return; + } + offset = (int)parsed_offset; + } + + limit = (int)parsed_count; + if (is_last) { newest_first = true; mode = "last"; } else { mode = "first"; } + } else if (strncmp(sub, "all ", 4) == 0) { + const char* arg = sub + 4; + while (*arg == ' ') arg++; + if (*arg == 0) { + strcpy(reply, "Err - usage: get recent.repeater all "); + return; + } + + char* end_ptr = NULL; + long parsed_a = strtol(arg, &end_ptr, 10); + while (end_ptr != NULL && *end_ptr == ' ') end_ptr++; + if (end_ptr == NULL || parsed_a <= 0) { + strcpy(reply, "Err - count must be > 0"); + return; + } + + char* end_ptr2 = NULL; + long parsed_b = strtol(end_ptr, &end_ptr2, 10); + while (end_ptr2 != NULL && *end_ptr2 == ' ') end_ptr2++; + if (end_ptr2 == NULL || *end_ptr2 != 0 || parsed_b < 0) { + strcpy(reply, "Err - usage: get recent.repeater all "); + return; + } + limit = (int)parsed_a; + offset = (int)parsed_b; } - if (limit > total) { - limit = total; + + if (offset >= total) { + sprintf(reply, "> none (%s off=%d/%d)", mode, offset, total); + return; + } + + int available = total - offset; + if (limit > available) { + limit = available; } if (sender_timestamp == 0) { - Serial.printf("Recent repeater table (%s %d/%d):\n", mode, limit, total); + Serial.printf("Recent repeater table (%s %d/%d, off=%d):\n", mode, limit, total, offset); for (int i = 0; i < limit; i++) { - const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(i) : tables->getRecentRepeaterOldestByIdx(i); + int idx = offset + i; + const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(idx) : tables->getRecentRepeaterOldestByIdx(idx); if (info == NULL) { continue; } char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; mesh::Utils::toHex(hex, info->prefix, info->prefix_len); - Serial.printf("%02d: %s,%s\n", i + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + Serial.printf("%02d: %s,%s\n", idx + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); } - sprintf(reply, "> showing %d/%d (%s)", limit, total, mode); + sprintf(reply, "> %s off=%d n=%d/%d", mode, offset, limit, total); } else { // Remote CLI replies are packet-bound, so include as many rows as fit. - int written = snprintf(reply, 160, "> showing %d/%d (%s)", limit, total, mode); + int written = snprintf(reply, 160, "> %s off=%d n=%d/%d", mode, offset, limit, total); bool truncated = false; if (written < 0) { reply[0] = 0; written = 0; } for (int i = 0; i < limit; i++) { - const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(i) : tables->getRecentRepeaterOldestByIdx(i); + int idx = offset + i; + const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(idx) : tables->getRecentRepeaterOldestByIdx(idx); if (info == NULL) { continue; } @@ -1474,7 +1527,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; mesh::Utils::toHex(hex, info->prefix, info->prefix_len); - int n = snprintf(reply + written, 160 - written, "\n%02d:%s,%s", i + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + int n = snprintf(reply + written, 160 - written, "\n%02d:%s,%s", idx + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); if (n < 0 || n >= (160 - written)) { truncated = true; break; @@ -1482,12 +1535,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply written += n; } if (truncated && written < 156) { - snprintf(reply + written, 160 - written, "\n..."); + snprintf(reply + written, 160 - written, "\n... use offset"); } } } } else { - strcpy(reply, "Err - usage: get recent.repeater [all|first |last ]"); + strcpy(reply, "Err - usage: get recent.repeater [all|all |first [offset]|last [offset]]"); } } else if (memcmp(command, "discover.neighbors", 18) == 0) { const char* sub = command + 18; diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 705869ad35..539bd5b641 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -70,6 +70,19 @@ class SimpleMeshTables : public mesh::MeshTables { bool extractRecentRepeater(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { // Learn repeater prefixes only from packet shapes that expose a trustworthy repeater ID. + // For flood traffic, the last path entry is the repeater we directly heard. + if (packet->isRouteFlood() && packet->getPathHashCount() > 0) { + prefix_len = packet->getPathHashSize(); + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + const uint8_t* last_hop = &packet->path[(packet->getPathHashCount() - 1) * packet->getPathHashSize()]; + memcpy(prefix, last_hop, prefix_len); + return true; + } + + // If there is no flood path to inspect, fall back to payload-derived identities. if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->payload_len >= PUB_KEY_SIZE) { memcpy(prefix, packet->payload, MAX_ROUTE_HASH_BYTES); prefix_len = MAX_ROUTE_HASH_BYTES; @@ -86,17 +99,6 @@ class SimpleMeshTables : public mesh::MeshTables { return true; } - if (packet->isRouteFlood() && packet->getPathHashCount() > 0) { - prefix_len = packet->getPathHashSize(); - if (prefix_len > MAX_ROUTE_HASH_BYTES) { - prefix_len = MAX_ROUTE_HASH_BYTES; - } - - const uint8_t* last_hop = &packet->path[(packet->getPathHashCount() - 1) * packet->getPathHashSize()]; - memcpy(prefix, last_hop, prefix_len); - return true; - } - return false; } From 0243ec41e8e71828afecb175346848c91071113d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 24 Apr 2026 15:19:48 -0700 Subject: [PATCH 10/11] Round up the SNR vs replacement to get a weighted average. --- src/helpers/SimpleMeshTables.h | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 539bd5b641..effea219b5 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -68,6 +68,21 @@ class SimpleMeshTables : public mesh::MeshTables { return n > 0 && memcmp(a, b, n) == 0; } + int8_t avgSnrX4RoundUp(int8_t curr_snr_x4, int8_t new_snr_x4) const { + int16_t sum = (int16_t)curr_snr_x4 + (int16_t)new_snr_x4; + int16_t avg = sum / 2; // truncates toward zero + // "Round up" means ceil(), which only differs from truncation for positive odd sums. + if (sum > 0 && (sum & 1)) { + avg++; + } + if (avg > 127) { + avg = 127; + } else if (avg < -128) { + avg = -128; + } + return (int8_t)avg; + } + bool extractRecentRepeater(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { // Learn repeater prefixes only from packet shapes that expose a trustworthy repeater ID. // For flood traffic, the last path entry is the repeater we directly heard. @@ -258,7 +273,7 @@ class SimpleMeshTables : public mesh::MeshTables { memcpy(existing.prefix, prefix, prefix_len); existing.prefix_len = prefix_len; } - existing.snr_x4 = snr_x4; + existing.snr_x4 = avgSnrX4RoundUp(existing.snr_x4, snr_x4); return true; } From 6c20a1062fd46d1cb18b7dc0d7c930227d96f04e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 27 Apr 2026 16:33:39 -0700 Subject: [PATCH 11/11] Refine direct retry SNR handling and recent repeater controls --- docs/cli_commands.md | 28 +- examples/simple_repeater/MyMesh.cpp | 432 ++++++++++++++++++++-------- examples/simple_repeater/MyMesh.h | 1 + src/Mesh.cpp | 15 +- src/helpers/CommonCLI.cpp | 75 ++++- src/helpers/CommonCLI.h | 2 +- src/helpers/SimpleMeshTables.h | 194 +++++++++++-- 7 files changed, 577 insertions(+), 170 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b6c87a7f02..9b4723e329 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -118,24 +118,20 @@ This document provides an overview of CLI commands that can be sent to MeshCore ### Get or set recent repeater fallback prefix/SNR **Usage:** - `get recent.repeater` -- `get recent.repeater all` -- `get recent.repeater all ` -- `get recent.repeater first ` -- `get recent.repeater first ` -- `get recent.repeater last ` -- `get recent.repeater last ` -- `set recent.repeater ` +- `get recent.repeater ` +- `get recent.repeater page ` +- `set recent.repeater ` **Parameters:** -- `prefix_hex`: 1-3 bytes of next-hop prefix (hex) +- `prefix_hex_6`: Exactly 3 bytes of next-hop prefix in hex (6 chars) - `snr_db`: SNR in dB (supports decimals; stored at x4 precision) -- `count`: number of entries to print -- `offset`: zero-based row offset into the selected order +- `page`: 1-based page number **Notes:** - `set` is rejected when the prefix already exists in neighbors. -- `all` prints oldest to newest; `first` prints the oldest N; `last` prints the newest N. -- Over LoRa remote CLI, replies are packet-size limited; use `offset` to page through all rows. +- Rows are shown newest-first. +- Serial CLI prints all rows (no paging). +- Over LoRa remote CLI, page size is fixed at `4` rows; choose page with `get recent.repeater `. --- @@ -536,7 +532,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `state`: `on`|`off` -**Default:** `off` +**Default:** `on` **Note:** When enabled, a repeater can use recently-heard non-duplicate repeater prefixes as a fallback for direct retry eligibility when no suitable neighbor entry is available. @@ -548,9 +544,9 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `set direct.retry.margin ` **Parameters:** -- `value`: Margin in dB above the SF-specific receive floor (minimum `0`, default `5`) +- `value`: Margin in dB above the SF-specific receive floor (minimum `0`, maximum `40`, quarter-dB precision, default `2.5`) -**Default:** `5` +**Default:** `2.5` **Note:** The retry gate uses the active SF floor of `SF5=-2.5`, `SF6=-5`, `SF7=-7.5`, `SF8=-10`, `SF9=-12.5`, `SF10=-15`, `SF11=-17.5`, `SF12=-20`, then adds this margin. @@ -564,7 +560,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `value`: Retry attempts after initial TX (`1`-`15`) -**Default:** `3` +**Default:** `15` --- diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 907ce2c8da..29b126fc37 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -86,6 +86,64 @@ bool MyMesh::allowRecentRepeaterPrefixStore(const uint8_t* prefix, uint8_t prefi return self->findNeighbourByHash(prefix, prefix_len) == NULL; } +static void formatRecentRepeaterPrefix(const SimpleMeshTables::RecentRepeaterInfo* info, char* out, size_t out_len) { + if (out == NULL || out_len == 0) { + return; + } + out[0] = 0; + if (info == NULL) { + return; + } + + uint8_t prefix_len = info->prefix_len; + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + if (prefix_len > 0) { + mesh::Utils::toHex(out, info->prefix, prefix_len); + } + + size_t used = strlen(out); + const size_t target_len = MAX_ROUTE_HASH_BYTES * 2; + while (used < target_len && used + 1 < out_len) { + out[used++] = ' '; + } + out[used] = 0; +} + +static void formatRecentRepeaterSnrX4(int8_t snr_x4, char* out, size_t out_len) { + if (out == NULL || out_len == 0) { + return; + } + + const char* snr_text = StrHelper::ftoa(((float)snr_x4) / 4.0f); + if (snr_text[0] == '-') { + snprintf(out, out_len, "%s", snr_text); + } else { + snprintf(out, out_len, " %s", snr_text); + } +} + +static uint8_t decodeTraceHashSize(uint8_t flags, uint8_t route_bytes) { + uint8_t code = flags & 0x03; + uint8_t size_pow2 = (uint8_t)(1U << code); // legacy TRACE interpretation + uint8_t size_linear = (uint8_t)(code + 1U); // packed-size interpretation (1..4) + + bool pow2_ok = size_pow2 > 0 && (route_bytes % size_pow2) == 0; + bool linear_ok = size_linear > 0 && (route_bytes % size_linear) == 0; + + if (pow2_ok && !linear_ok) { + return size_pow2; + } + if (linear_ok && !pow2_ok) { + return size_linear; + } + if (pow2_ok) { + return size_pow2; + } + return size_linear; +} + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -456,6 +514,30 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; + + if (packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE && packet->payload_len >= 9) { + auto* tables = (SimpleMeshTables *)getTables(); + uint8_t route_bytes = packet->payload_len - 9; + uint8_t hash_size = decodeTraceHashSize(packet->payload[8], route_bytes); + uint16_t offset = (uint16_t)packet->path_len * (uint16_t)hash_size; + uint8_t sf = constrain(active_sf, (uint8_t)5, (uint8_t)12); + int16_t fallback_snr_x4 = direct_retry_floor_x4[sf - 5] + 40; // fixed +10 dB above SF floor + + // A successful TRACE forward reveals the downstream next-hop hash. Seed/update the recent table immediately. + if (hash_size > 0 && offset + (2U * hash_size) <= route_bytes) { + uint8_t prefix_len = hash_size; + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + const uint8_t* next_hop_prefix = &packet->payload[9 + offset + hash_size]; + const auto* existing = tables->findRecentRepeaterByHash(next_hop_prefix, prefix_len); + // This point only proves we can forward TO next_hop; packet->_snr is upstream RX and not a + // trustworthy metric for next_hop. Seed with existing table value or fallback only. + int8_t trace_snr_x4 = (existing != NULL) ? existing->snr_x4 : (int8_t)constrain(fallback_snr_x4, -128, 127); + tables->setRecentRepeater(next_hop_prefix, prefix_len, trace_snr_x4, false, true); + } + } + if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false; if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); @@ -564,23 +646,102 @@ void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, u return; } - MESH_DEBUG_PRINTLN("%s direct retry %s (type=%d, route=%s, payload_len=%d, delay=%lu)", + uint8_t prefix[MAX_ROUTE_HASH_BYTES] = {0}; + uint8_t prefix_len = 0; + bool has_prefix = extractDirectRetryPrefix(packet, prefix, prefix_len); + auto* tables = (SimpleMeshTables *)getTables(); + const auto* existing = has_prefix ? tables->findRecentRepeaterByHash(prefix, prefix_len) : NULL; + char next_hop_hex[(MAX_ROUTE_HASH_BYTES * 2) + 1] = {0}; + if (has_prefix && prefix_len > 0) { + mesh::Utils::toHex(next_hop_hex, prefix, prefix_len); + } + const char* next_hop = (has_prefix && prefix_len > 0) ? next_hop_hex : "unknown"; + // Direct-retry events are TX-side and usually have no trustworthy RX SNR. + // Cap event SNR at fixed SF floor + 10 dB so trace-start retries can't inflate table SNR. + uint8_t sf = constrain(active_sf, (uint8_t)5, (uint8_t)12); + int16_t fallback_snr_x4_raw = direct_retry_floor_x4[sf - 5] + 40; + int8_t fallback_snr_x4 = (int8_t)constrain(fallback_snr_x4_raw, -128, 127); + bool is_success_event = (strcmp(event, "good") == 0 || strcmp(event, "canceled_echo") == 0); + int8_t retry_event_snr_x4; + const char* snr_src; + if (is_success_event && packet->_snr != 0) { + // On success, Mesh.cpp injects echo RX SNR for TRACE retries. + retry_event_snr_x4 = packet->_snr; + snr_src = "packet"; + } else if (existing != NULL) { + retry_event_snr_x4 = existing->snr_x4; + snr_src = "table"; + } else { + retry_event_snr_x4 = fallback_snr_x4; + snr_src = "fallback"; + } + char snr_used_text[12]; + char snr_pkt_text[12]; + char snr_table_text[12]; + snprintf(snr_used_text, sizeof(snr_used_text), "%s", StrHelper::ftoa(((float)retry_event_snr_x4) / 4.0f)); + snprintf(snr_pkt_text, sizeof(snr_pkt_text), "%s", StrHelper::ftoa(((float)packet->_snr) / 4.0f)); + if (existing != NULL) { + snprintf(snr_table_text, sizeof(snr_table_text), "%s", StrHelper::ftoa(((float)existing->snr_x4) / 4.0f)); + } else { + snprintf(snr_table_text, sizeof(snr_table_text), "na"); + } + + if (has_prefix && is_success_event) { + // Refresh SNR only on successful echo/progress events, not on queued/resent bookkeeping. + tables->setRecentRepeater(prefix, prefix_len, retry_event_snr_x4, false, true); + } + + if (strcmp(event, "resent") == 0) { + if (has_prefix) { + // Retry stats should be visible even when the prefix was never learned into recent.repeater. + tables->incrementRecentRepeaterRetryCount(prefix, prefix_len, true, retry_event_snr_x4, true); + } + } else if (strcmp(event, "failed_all_tries") == 0) { + if (has_prefix) { + // A failed_all_tries event means all retry attempts for this packet failed. + // Count failures by retry-attempts so fail% reflects failed retries, not just failed sessions. + uint8_t give_up_retries = getDirectRetryMaxAttempts(packet); + uint8_t failed_retries = give_up_retries; + if (failed_retries < 1) { + failed_retries = 1; + } + for (uint8_t i = 0; i < failed_retries; i++) { + tables->incrementRecentRepeaterFailCount(prefix, prefix_len, true, retry_event_snr_x4, true); + } + if (failed_retries >= give_up_retries && give_up_retries > 0) { + // If all configured retry attempts still fail, slightly degrade stored path quality. + tables->decrementRecentRepeaterSnrX4(prefix, prefix_len, 1); + } + } + } + + MESH_DEBUG_PRINTLN("%s direct retry %s (type=%d, route=%s, payload_len=%d, next_hop=%s, snr=%s, snr_src=%s, pkt_snr=%s, table_snr=%s, delay=%lu)", getLogDateTime(), event, (uint32_t)packet->getPayloadType(), packet->isRouteDirect() ? "D" : "F", (uint32_t)packet->payload_len, + next_hop, + snr_used_text, + snr_src, + snr_pkt_text, + snr_table_text, (unsigned long)delay_millis); if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { f.print(getLogDateTime()); - f.printf(": DIRECT RETRY %s (type=%d, route=%s, payload_len=%d, delay=%lu)\n", + f.printf(": DIRECT RETRY %s (type=%d, route=%s, payload_len=%d, next_hop=%s, snr=%s, snr_src=%s, pkt_snr=%s, table_snr=%s, delay=%lu)\n", event, (uint32_t)packet->getPayloadType(), packet->isRouteDirect() ? "D" : "F", (uint32_t)packet->payload_len, + next_hop, + snr_used_text, + snr_src, + snr_pkt_text, + snr_table_text, (unsigned long)delay_millis); f.close(); } @@ -603,28 +764,59 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { int8_t MyMesh::getDirectRetryMinSNRX4() const { // Use the live SF so `tempradio` changes immediately affect the retry threshold. uint8_t sf = constrain(active_sf, (uint8_t)5, (uint8_t)12); - int16_t threshold = direct_retry_floor_x4[sf - 5] + ((int16_t)_prefs.direct_retry_snr_margin_db * 4); + int16_t threshold = direct_retry_floor_x4[sf - 5] + (int16_t)_prefs.direct_retry_snr_margin_db; return (int8_t)constrain(threshold, -128, 127); } -bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { - if (_prefs.disable_fwd) { +bool MyMesh::extractDirectRetryPrefix(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { + if (packet == NULL || prefix == NULL) { return false; } - int8_t min_snr_x4 = getDirectRetryMinSNRX4(); - const NeighbourInfo* neighbour = findNeighbourByHash(next_hop_hash, next_hop_hash_len); - // Prefer the explicit neighbor table first; it is the strongest signal that this hop is still reachable. - if (neighbour != NULL && neighbour->snr >= min_snr_x4) { + // TRACE direct routes encode repeater hashes in payload; packet->path carries SNR trail bytes. + if (packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE && packet->payload_len >= 9) { + uint8_t route_bytes = packet->payload_len - 9; + uint8_t hash_size = decodeTraceHashSize(packet->payload[8], route_bytes); + uint8_t offset = packet->path_len * hash_size; + if (hash_size > 0 && offset + hash_size <= route_bytes) { + prefix_len = hash_size; + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + memcpy(prefix, &packet->payload[9 + offset], prefix_len); + return true; + } + } + + if (packet->isRouteDirect() && packet->getPathHashCount() > 0) { + prefix_len = packet->getPathHashSize(); + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + if (prefix_len == 0) { + return false; + } + memcpy(prefix, packet->path, prefix_len); return true; } - if (!_prefs.direct_retry_recent_enabled) { + return false; +} +bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + if (_prefs.disable_fwd) { return false; } - // If no neighbor entry exists, fall back to the recent-heard repeater cache keyed by the same path prefix. - const auto* recent = ((const SimpleMeshTables *)getTables())->findRecentRepeaterByHash(next_hop_hash, next_hop_hash_len); - return recent != NULL && recent->snr_x4 >= min_snr_x4; + int8_t min_snr_x4 = getDirectRetryMinSNRX4(); + if (_prefs.direct_retry_recent_enabled) { + // Prefer the 64-entry recent-prefix cache first, then fall back to neighbours. + const auto* recent = ((const SimpleMeshTables *)getTables())->findRecentRepeaterByHash(next_hop_hash, next_hop_hash_len); + if (recent != NULL && recent->snr_x4 >= min_snr_x4) { + return true; + } + } + + const NeighbourInfo* neighbour = findNeighbourByHash(next_hop_hash, next_hop_hash_len); + return neighbour != NULL && neighbour->snr >= min_snr_x4; } uint32_t MyMesh::getDirectRetryEchoDelay(const mesh::Packet* packet) const { uint32_t base_wait_millis = constrain((uint32_t)_prefs.direct_retry_base_ms, (uint32_t)10, (uint32_t)5000); @@ -973,9 +1165,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 - _prefs.direct_retry_recent_enabled = 0; - _prefs.direct_retry_snr_margin_db = 5; - _prefs.direct_retry_attempts = 3; + _prefs.direct_retry_recent_enabled = 1; + _prefs.direct_retry_snr_margin_db = 10; // 2.5 dB stored in x4 units + _prefs.direct_retry_attempts = 15; _prefs.direct_retry_base_ms = 200; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; @@ -1354,9 +1546,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply reply[0] = 0; } else if (strncmp(command, "get recent.repeater", 19) == 0 || strncmp(command, "set recent.repeater", 19) == 0 + || strncmp(command, "clear recent.repeater", 21) == 0 || strncmp(command, "recent.repeater", 15) == 0) { bool is_get = false; bool is_set = false; + bool is_clear = false; const char* sub = command; if (strncmp(command, "get recent.repeater", 19) == 0) { @@ -1365,38 +1559,55 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } else if (strncmp(command, "set recent.repeater", 19) == 0) { is_set = true; sub = command + 19; + } else if (strncmp(command, "clear recent.repeater", 21) == 0) { + is_clear = true; + sub = command + 21; } else { sub = command + 15; // legacy command format } while (*sub == ' ') sub++; auto* tables = (SimpleMeshTables*)getTables(); + if (!is_get && !is_set && !is_clear && strncmp(sub, "clear", 5) == 0 && (sub[5] == 0 || sub[5] == ' ')) { + is_clear = true; + sub += 5; + while (*sub == ' ') sub++; + } - bool is_all = (strcmp(sub, "all") == 0 || strncmp(sub, "all ", 4) == 0); - bool is_first = (strncmp(sub, "first ", 6) == 0); - bool is_last = (strncmp(sub, "last ", 5) == 0); - - if (is_set || (!is_get && *sub != 0 && !is_all && !is_first && !is_last)) { + if (is_clear) { + if (*sub != 0) { + strcpy(reply, "Err - usage: clear recent.repeater"); + } else { + tables->clearRecentRepeaters(); + strcpy(reply, "OK"); + } + } else if (is_set) { char* params = (char*) sub; char* arg_snr = strchr(params, ' '); if (arg_snr == NULL) { - strcpy(reply, "Err - usage: set recent.repeater "); + strcpy(reply, "Err - usage: set recent.repeater "); } else { *arg_snr++ = 0; while (*arg_snr == ' ') arg_snr++; if (*arg_snr == 0) { - strcpy(reply, "Err - usage: set recent.repeater "); + strcpy(reply, "Err - usage: set recent.repeater "); } else { - int hex_len = strlen(params); - int prefix_len = hex_len / 2; uint8_t prefix[MAX_ROUTE_HASH_BYTES] = {0}; - if ((hex_len % 2) != 0 || prefix_len <= 0 || prefix_len > MAX_ROUTE_HASH_BYTES || !mesh::Utils::fromHex(prefix, prefix_len, params)) { - strcpy(reply, "Err - prefix must be 1-3 bytes hex"); + int hex_len = strlen(params); + if (hex_len != (MAX_ROUTE_HASH_BYTES * 2) || !mesh::Utils::fromHex(prefix, MAX_ROUTE_HASH_BYTES, params)) { + strcpy(reply, "Err - prefix must be exactly 3 bytes hex (6 chars)"); } else { - float snr_db = strtof(arg_snr, nullptr); + char* end_snr = NULL; + float snr_db = strtof(arg_snr, &end_snr); + while (end_snr != NULL && *end_snr == ' ') end_snr++; + if (end_snr == arg_snr || (end_snr != NULL && *end_snr != 0)) { + strcpy(reply, "Err - snr must be numeric"); + return; + } + int snr_x4 = (int)(snr_db * 4.0f + (snr_db >= 0.0f ? 0.5f : -0.5f)); snr_x4 = constrain(snr_x4, -128, 127); - if (tables->setRecentRepeater(prefix, (uint8_t)prefix_len, (int8_t)snr_x4)) { + if (tables->setRecentRepeater(prefix, MAX_ROUTE_HASH_BYTES, (int8_t)snr_x4, true)) { strcpy(reply, "OK"); } else { strcpy(reply, "Err - prefix is already in neighbors"); @@ -1404,120 +1615,82 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } } } - } else if (*sub == 0) { - const auto* info = tables->getLatestRecentRepeater(); - if (info == NULL) { - strcpy(reply, "> none"); - } else { - char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; - mesh::Utils::toHex(hex, info->prefix, info->prefix_len); - sprintf(reply, "> %s,%s", hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); - } - } else if (is_all || is_first || is_last) { + } else { int total = tables->getRecentRepeaterCount(); if (total <= 0) { strcpy(reply, "> none"); } else { - bool newest_first = false; - int limit = total; - int offset = 0; - const char* mode = "all"; - - if (is_first || is_last) { - const char* nstr = sub + (is_first ? 6 : 5); - while (*nstr == ' ') nstr++; - if (*nstr == 0) { - strcpy(reply, "Err - usage: get recent.repeater first|last [offset]"); - return; - } + if (sender_timestamp == 0) { + // Serial CLI: print all entries (no paging). + Serial.printf("Recent repeater table (newest first, total=%d):\n", total); + for (int i = 0; i < total; i++) { + const auto* info = tables->getRecentRepeaterNewestByIdx(i); + if (info == NULL) { + continue; + } - char* end_ptr = NULL; - long parsed_count = strtol(nstr, &end_ptr, 10); - while (end_ptr != NULL && *end_ptr == ' ') end_ptr++; - if (end_ptr == NULL || parsed_count <= 0) { - strcpy(reply, "Err - count must be > 0"); - return; + char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; + formatRecentRepeaterPrefix(info, hex, sizeof(hex)); + char snr_text[12]; + formatRecentRepeaterSnrX4(info->snr_x4, snr_text, sizeof(snr_text)); + uint32_t fail_pct_x10 = 0; + if (info->retry_count > 0) { + fail_pct_x10 = (((uint32_t)info->fail_count * 1000UL) + (info->retry_count / 2U)) / (uint32_t)info->retry_count; + } + Serial.printf("%03d: %s,%s,fp=%lu.%01lu%%,r=%u,f=%u%s\n", + i + 1, + hex, + snr_text, + (unsigned long)(fail_pct_x10 / 10U), + (unsigned long)(fail_pct_x10 % 10U), + (uint32_t)info->retry_count, + (uint32_t)info->fail_count, + info->snr_locked ? ",l" : ""); + } + sprintf(reply, "> n=%d/%d", total, total); + } else { + // Remote CLI: page by fixed size to fit packet-limited reply payload. + long page_num = 1; + const long page_size = 4; + const char* arg = sub; + + if (strncmp(arg, "page ", 5) == 0) { + arg += 5; + while (*arg == ' ') arg++; } - if (*end_ptr != 0) { - char* end_ptr2 = NULL; - long parsed_offset = strtol(end_ptr, &end_ptr2, 10); - while (end_ptr2 != NULL && *end_ptr2 == ' ') end_ptr2++; - if (end_ptr2 == NULL || *end_ptr2 != 0 || parsed_offset < 0) { - strcpy(reply, "Err - offset must be >= 0"); + if (*arg != 0) { + char* end_ptr = NULL; + page_num = strtol(arg, &end_ptr, 10); + while (end_ptr != NULL && *end_ptr == ' ') end_ptr++; + if (end_ptr == NULL || page_num <= 0 || (end_ptr != NULL && *end_ptr != 0)) { + strcpy(reply, "Err - usage: get recent.repeater [page]"); return; } - offset = (int)parsed_offset; - } - - limit = (int)parsed_count; - if (is_last) { - newest_first = true; - mode = "last"; - } else { - mode = "first"; - } - } else if (strncmp(sub, "all ", 4) == 0) { - const char* arg = sub + 4; - while (*arg == ' ') arg++; - if (*arg == 0) { - strcpy(reply, "Err - usage: get recent.repeater all "); - return; } - char* end_ptr = NULL; - long parsed_a = strtol(arg, &end_ptr, 10); - while (end_ptr != NULL && *end_ptr == ' ') end_ptr++; - if (end_ptr == NULL || parsed_a <= 0) { - strcpy(reply, "Err - count must be > 0"); + int total_pages = (total + (int)page_size - 1) / (int)page_size; + if (page_num > total_pages) { + sprintf(reply, "> none (page=%ld/%d)", page_num, total_pages); return; } - char* end_ptr2 = NULL; - long parsed_b = strtol(end_ptr, &end_ptr2, 10); - while (end_ptr2 != NULL && *end_ptr2 == ' ') end_ptr2++; - if (end_ptr2 == NULL || *end_ptr2 != 0 || parsed_b < 0) { - strcpy(reply, "Err - usage: get recent.repeater all "); - return; + int offset = ((int)page_num - 1) * (int)page_size; + int limit = total - offset; + if (limit > (int)page_size) { + limit = (int)page_size; } - limit = (int)parsed_a; - offset = (int)parsed_b; - } - - if (offset >= total) { - sprintf(reply, "> none (%s off=%d/%d)", mode, offset, total); - return; - } - int available = total - offset; - if (limit > available) { - limit = available; - } - - if (sender_timestamp == 0) { - Serial.printf("Recent repeater table (%s %d/%d, off=%d):\n", mode, limit, total, offset); - for (int i = 0; i < limit; i++) { - int idx = offset + i; - const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(idx) : tables->getRecentRepeaterOldestByIdx(idx); - if (info == NULL) { - continue; - } - char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; - mesh::Utils::toHex(hex, info->prefix, info->prefix_len); - Serial.printf("%02d: %s,%s\n", idx + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); - } - sprintf(reply, "> %s off=%d n=%d/%d", mode, offset, limit, total); - } else { - // Remote CLI replies are packet-bound, so include as many rows as fit. - int written = snprintf(reply, 160, "> %s off=%d n=%d/%d", mode, offset, limit, total); + int written = snprintf(reply, 160, "> page=%ld/%d n=%d/%d", page_num, total_pages, limit, total); bool truncated = false; if (written < 0) { reply[0] = 0; written = 0; } + for (int i = 0; i < limit; i++) { int idx = offset + i; - const auto* info = newest_first ? tables->getRecentRepeaterNewestByIdx(idx) : tables->getRecentRepeaterOldestByIdx(idx); + const auto* info = tables->getRecentRepeaterNewestByIdx(idx); if (info == NULL) { continue; } @@ -1525,9 +1698,20 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply truncated = true; break; } + char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; - mesh::Utils::toHex(hex, info->prefix, info->prefix_len); - int n = snprintf(reply + written, 160 - written, "\n%02d:%s,%s", idx + 1, hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + formatRecentRepeaterPrefix(info, hex, sizeof(hex)); + char snr_text[12]; + formatRecentRepeaterSnrX4(info->snr_x4, snr_text, sizeof(snr_text)); + int n = snprintf(reply + written, + 160 - written, + "\n%03d:%s,%s,r=%u,f=%u%s", + idx + 1, + hex, + snr_text, + (uint32_t)info->retry_count, + (uint32_t)info->fail_count, + info->snr_locked ? ",l" : ""); if (n < 0 || n >= (160 - written)) { truncated = true; break; @@ -1535,12 +1719,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply written += n; } if (truncated && written < 156) { - snprintf(reply + written, 160 - written, "\n... use offset"); + snprintf(reply + written, 160 - written, "\n... next page"); } } } - } else { - strcpy(reply, "Err - usage: get recent.repeater [all|all |first [offset]|last [offset]]"); } } else if (memcmp(command, "discover.neighbors", 18) == 0) { const char* sub = command + 18; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 00a8a31b69..b2626e6021 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -123,6 +123,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #endif const NeighbourInfo* findNeighbourByHash(const uint8_t* hash, uint8_t hash_len) const; + bool extractDirectRetryPrefix(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const; static bool allowRecentRepeaterPrefixStore(const uint8_t* prefix, uint8_t prefix_len, void* ctx); int8_t getDirectRetryMinSNRX4() const; void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 47fc6e8dfe..f07484d69d 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -3,7 +3,7 @@ namespace mesh { -static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS_DEFAULT = 3; +static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS_DEFAULT = 15; static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS_HARD_MAX = 15; void Mesh::begin() { @@ -491,6 +491,12 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { } if (_direct_retries[i].queued) { + if (_direct_retries[i].expect_path_growth + && _direct_retries[i].packet != NULL + && _direct_retries[i].progress_marker < packet->path_len) { + // For retry-good quality, use the received echo packet SNR (return-link quality). + _direct_retries[i].packet->_snr = packet->_snr; + } for (int j = 0; j < _mgr->getOutboundTotal(); j++) { if (_mgr->getOutboundByIdx(j) == _direct_retries[i].packet) { Packet* pending = _mgr->removeOutboundByIdx(j); @@ -504,6 +510,12 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { onDirectRetryEvent("good", _direct_retries[i].packet, 0); clearDirectRetrySlot(i); } else { + if (_direct_retries[i].expect_path_growth + && _direct_retries[i].trigger_packet != NULL + && _direct_retries[i].progress_marker < packet->path_len) { + // For retry-good quality, use the received echo packet SNR (return-link quality). + _direct_retries[i].trigger_packet->_snr = packet->_snr; + } onDirectRetryEvent("canceled_echo", _direct_retries[i].trigger_packet, 0); onDirectRetryEvent("good", _direct_retries[i].trigger_packet, 0); clearDirectRetrySlot(i); @@ -532,6 +544,7 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { max_attempts = DIRECT_RETRY_MAX_ATTEMPTS_HARD_MAX; } if (_direct_retries[i].retry_attempts_sent >= max_attempts) { + onDirectRetryEvent("failed_all_tries", packet, 0); onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); continue; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 02d27830d8..9460a28fa3 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -11,12 +11,13 @@ // These bytes used to be reserved/unused in persisted prefs, so keep a marker before trusting them. #define DIRECT_RETRY_PREFS_MAGIC_0 0xD4 #define DIRECT_RETRY_PREFS_MAGIC_1 0x52 -#define DIRECT_RETRY_RECENT_DEFAULT 0 -#define DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT 5 -#define DIRECT_RETRY_SNR_MARGIN_DB_MAX 40 +#define DIRECT_RETRY_RECENT_DEFAULT 1 +#define DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT_X4 10 +#define DIRECT_RETRY_SNR_MARGIN_DB_MAX 40 +#define DIRECT_RETRY_SNR_MARGIN_X4_MAX (DIRECT_RETRY_SNR_MARGIN_DB_MAX * 4) #define DIRECT_RETRY_TIMING_MAGIC_0 0xD5 #define DIRECT_RETRY_TIMING_MAGIC_1 0x54 -#define DIRECT_RETRY_COUNT_DEFAULT 3 +#define DIRECT_RETRY_COUNT_DEFAULT 15 #define DIRECT_RETRY_COUNT_MIN 1 #define DIRECT_RETRY_COUNT_MAX 15 #define DIRECT_RETRY_BASE_MS_DEFAULT 200 @@ -33,6 +34,15 @@ static uint32_t _atoi(const char* sp) { return n; } +static uint8_t directRetryMarginDbToX4(float margin_db) { + int32_t scaled_x4 = (int32_t)((margin_db * 4.0f) + 0.5f); // nearest 0.25 dB + return (uint8_t)constrain(scaled_x4, 0, DIRECT_RETRY_SNR_MARGIN_X4_MAX); +} + +static float directRetryMarginX4ToDb(uint8_t margin_x4) { + return ((float)margin_x4) / 4.0f; +} + static bool isValidName(const char *n) { while (*n) { if (*n == '[' || *n == ']' || *n == '\\' || *n == ':' || *n == ',' || *n == '?' || *n == '*') return false; @@ -127,10 +137,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (_prefs->direct_retry_prefs_magic[0] != DIRECT_RETRY_PREFS_MAGIC_0 || _prefs->direct_retry_prefs_magic[1] != DIRECT_RETRY_PREFS_MAGIC_1) { _prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; - _prefs->direct_retry_snr_margin_db = DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT; + _prefs->direct_retry_snr_margin_db = DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT_X4; } else { _prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1); - _prefs->direct_retry_snr_margin_db = constrain(_prefs->direct_retry_snr_margin_db, 0, DIRECT_RETRY_SNR_MARGIN_DB_MAX); + _prefs->direct_retry_snr_margin_db = constrain(_prefs->direct_retry_snr_margin_db, 0, DIRECT_RETRY_SNR_MARGIN_X4_MAX); } if (_prefs->direct_retry_timing_magic[0] != DIRECT_RETRY_TIMING_MAGIC_0 || _prefs->direct_retry_timing_magic[1] != DIRECT_RETRY_TIMING_MAGIC_1) { @@ -385,7 +395,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(config, "direct.retry.heard", 18) == 0) { sprintf(reply, "> %s", _prefs->direct_retry_recent_enabled ? "on" : "off"); } else if (memcmp(config, "direct.retry.margin", 19) == 0) { - sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_snr_margin_db); + sprintf(reply, "> %s", StrHelper::ftoa(directRetryMarginX4ToDb(_prefs->direct_retry_snr_margin_db))); } else if (memcmp(config, "direct.retry.count", 18) == 0) { sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_attempts); } else if (memcmp(config, "direct.retry.base", 17) == 0) { @@ -652,9 +662,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, "Error, must be on or off"); } } else if (memcmp(config, "direct.retry.margin ", 20) == 0) { - int db = atoi(&config[20]); + float db = atof(&config[20]); if (db >= 0 && db <= DIRECT_RETRY_SNR_MARGIN_DB_MAX) { - _prefs->direct_retry_snr_margin_db = (uint8_t)db; + _prefs->direct_retry_snr_margin_db = directRetryMarginDbToX4(db); savePrefs(); strcpy(reply, "OK"); } else { @@ -1113,6 +1123,45 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, cannot be negative"); } + } else if (memcmp(config, "direct.retry.heard ", 19) == 0) { + if (memcmp(&config[19], "on", 2) == 0) { + _prefs->direct_retry_recent_enabled = 1; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(&config[19], "off", 3) == 0) { + _prefs->direct_retry_recent_enabled = 0; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be on or off"); + } + } else if (memcmp(config, "direct.retry.margin ", 20) == 0) { + float db = atof(&config[20]); + if (db >= 0 && db <= DIRECT_RETRY_SNR_MARGIN_DB_MAX) { + _prefs->direct_retry_snr_margin_db = directRetryMarginDbToX4(db); + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, min 0 and max %d", DIRECT_RETRY_SNR_MARGIN_DB_MAX); + } + } else if (memcmp(config, "direct.retry.count ", 19) == 0) { + int count = atoi(&config[19]); + if (count >= DIRECT_RETRY_COUNT_MIN && count <= DIRECT_RETRY_COUNT_MAX) { + _prefs->direct_retry_attempts = (uint8_t)count; + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, min %d and max %d", DIRECT_RETRY_COUNT_MIN, DIRECT_RETRY_COUNT_MAX); + } + } else if (memcmp(config, "direct.retry.base ", 18) == 0) { + int delay_ms = atoi(&config[18]); + if (delay_ms >= DIRECT_RETRY_BASE_MS_MIN && delay_ms <= DIRECT_RETRY_BASE_MS_MAX) { + _prefs->direct_retry_base_ms = (uint16_t)delay_ms; + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, min %d and max %d", DIRECT_RETRY_BASE_MS_MIN, DIRECT_RETRY_BASE_MS_MAX); + } } else if (memcmp(config, "owner.info ", 11) == 0) { config += 11; char *dp = _prefs->owner_info; @@ -1282,6 +1331,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t)_prefs->flood_max); } else if (memcmp(config, "direct.txdelay", 14) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor)); + } else if (memcmp(config, "direct.retry.heard", 18) == 0) { + sprintf(reply, "> %s", _prefs->direct_retry_recent_enabled ? "on" : "off"); + } else if (memcmp(config, "direct.retry.margin", 19) == 0) { + sprintf(reply, "> %s", StrHelper::ftoa(directRetryMarginX4ToDb(_prefs->direct_retry_snr_margin_db))); + } else if (memcmp(config, "direct.retry.count", 18) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_attempts); + } else if (memcmp(config, "direct.retry.base", 17) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_base_ms); } else if (memcmp(config, "owner.info", 10) == 0) { *reply++ = '>'; *reply++ = ' '; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 03b1fb649b..ddc92ff1ae 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -34,7 +34,7 @@ struct NodePrefs { // persisted to file char guest_password[16]; float direct_tx_delay_factor; uint8_t direct_retry_recent_enabled; - uint8_t direct_retry_snr_margin_db; + uint8_t direct_retry_snr_margin_db; // stored in quarter-dB units (x4) uint8_t direct_retry_prefs_magic[2]; uint8_t sf; uint8_t cr; diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index effea219b5..ac6d01b57b 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -8,7 +8,14 @@ #define MAX_PACKET_HASHES 128 #define MAX_PACKET_ACKS 64 -#define MAX_RECENT_REPEATERS 64 +#ifndef MAX_RECENT_REPEATERS + // Two defaults. Can be overridden with -D MAX_RECENT_REPEATERS=. + #if defined(ESP32) + #define MAX_RECENT_REPEATERS 512 + #else + #define MAX_RECENT_REPEATERS 64 + #endif +#endif #define MAX_ROUTE_HASH_BYTES 3 class SimpleMeshTables : public mesh::MeshTables { @@ -17,9 +24,12 @@ class SimpleMeshTables : public mesh::MeshTables { struct RecentRepeaterInfo { // Just enough identity to match a next-hop path prefix plus the SNR that heard it. + uint16_t retry_count; + uint16_t fail_count; uint8_t prefix[MAX_ROUTE_HASH_BYTES]; uint8_t prefix_len; int8_t snr_x4; + uint8_t snr_locked; }; private: @@ -68,19 +78,20 @@ class SimpleMeshTables : public mesh::MeshTables { return n > 0 && memcmp(a, b, n) == 0; } - int8_t avgSnrX4RoundUp(int8_t curr_snr_x4, int8_t new_snr_x4) const { - int16_t sum = (int16_t)curr_snr_x4 + (int16_t)new_snr_x4; - int16_t avg = sum / 2; // truncates toward zero - // "Round up" means ceil(), which only differs from truncation for positive odd sums. - if (sum > 0 && (sum & 1)) { - avg++; + int8_t weightedSnrX4RoundUp(int8_t curr_snr_x4, int8_t new_snr_x4) const { + // Keep existing SNR heavier than a single new sample: 75% existing + 25% new. + int16_t weighted_sum = ((int16_t)curr_snr_x4 * 3) + (int16_t)new_snr_x4; + int16_t blended = weighted_sum / 4; // truncates toward zero + // "Round up" means ceil(), which only differs from truncation for positive remainders. + if (weighted_sum > 0 && (weighted_sum % 4) != 0) { + blended++; } - if (avg > 127) { - avg = 127; - } else if (avg < -128) { - avg = -128; + if (blended > 127) { + blended = 127; + } else if (blended < -128) { + blended = -128; } - return (int8_t)avg; + return (int8_t)blended; } bool extractRecentRepeater(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { @@ -249,7 +260,8 @@ class SimpleMeshTables : public mesh::MeshTables { _recent_repeater_allow_fn = fn; _recent_repeater_allow_ctx = ctx; } - bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4) { + bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4, bool snr_locked = false, + bool bypass_allow_filter = false) { if (prefix == NULL || prefix_len == 0) { return false; } @@ -258,7 +270,8 @@ class SimpleMeshTables : public mesh::MeshTables { prefix_len = MAX_ROUTE_HASH_BYTES; } - if (_recent_repeater_allow_fn != NULL && !_recent_repeater_allow_fn(prefix, prefix_len, _recent_repeater_allow_ctx)) { + if (!bypass_allow_filter && _recent_repeater_allow_fn != NULL + && !_recent_repeater_allow_fn(prefix, prefix_len, _recent_repeater_allow_ctx)) { return false; } @@ -273,19 +286,160 @@ class SimpleMeshTables : public mesh::MeshTables { memcpy(existing.prefix, prefix, prefix_len); existing.prefix_len = prefix_len; } - existing.snr_x4 = avgSnrX4RoundUp(existing.snr_x4, snr_x4); + if (snr_locked) { + existing.snr_x4 = snr_x4; + existing.snr_locked = 1; + } else if (!existing.snr_locked) { + existing.snr_x4 = weightedSnrX4RoundUp(existing.snr_x4, snr_x4); + } return true; } - // Ring buffer is enough here; retry fallback only needs a recent prefix->SNR observation. - RecentRepeaterInfo& slot = _recent_repeaters[_next_recent_repeater_idx]; + int slot_idx = -1; + // Prefer empty slots first while preserving newest-order iteration. + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + int idx = (_next_recent_repeater_idx + i) % MAX_RECENT_REPEATERS; + if (_recent_repeaters[idx].prefix_len == 0) { + slot_idx = idx; + break; + } + } + if (slot_idx < 0) { + // Table is full: evict the weakest observed SNR entry. + slot_idx = 0; + int8_t min_snr_x4 = _recent_repeaters[0].snr_x4; + for (int i = 1; i < MAX_RECENT_REPEATERS; i++) { + if (_recent_repeaters[i].snr_x4 < min_snr_x4) { + min_snr_x4 = _recent_repeaters[i].snr_x4; + slot_idx = i; + } + } + } + + RecentRepeaterInfo& slot = _recent_repeaters[slot_idx]; memset(slot.prefix, 0, sizeof(slot.prefix)); memcpy(slot.prefix, prefix, prefix_len); slot.prefix_len = prefix_len; slot.snr_x4 = snr_x4; - _next_recent_repeater_idx = (_next_recent_repeater_idx + 1) % MAX_RECENT_REPEATERS; + slot.retry_count = 0; + slot.fail_count = 0; + slot.snr_locked = snr_locked ? 1 : 0; + _next_recent_repeater_idx = (slot_idx + 1) % MAX_RECENT_REPEATERS; return true; } + bool incrementRecentRepeaterRetryCount(const uint8_t* prefix, uint8_t prefix_len, + bool create_if_missing = false, int8_t seed_snr_x4 = 0, + bool bypass_allow_filter = false) { + if (prefix == NULL || prefix_len == 0) { + return false; + } + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0 || !prefixesOverlap(existing.prefix, existing.prefix_len, prefix, prefix_len)) { + continue; + } + if (prefix_len > existing.prefix_len) { + memset(existing.prefix, 0, sizeof(existing.prefix)); + memcpy(existing.prefix, prefix, prefix_len); + existing.prefix_len = prefix_len; + } + if (existing.retry_count < 0xFFFF) { + existing.retry_count++; + } + return true; + } + + if (!create_if_missing || !setRecentRepeater(prefix, prefix_len, seed_snr_x4, false, bypass_allow_filter)) { + return false; + } + + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0 || !prefixesOverlap(existing.prefix, existing.prefix_len, prefix, prefix_len)) { + continue; + } + if (existing.retry_count < 0xFFFF) { + existing.retry_count++; + } + return true; + } + return false; + } + bool incrementRecentRepeaterFailCount(const uint8_t* prefix, uint8_t prefix_len, + bool create_if_missing = false, int8_t seed_snr_x4 = 0, + bool bypass_allow_filter = false) { + if (prefix == NULL || prefix_len == 0) { + return false; + } + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0 || !prefixesOverlap(existing.prefix, existing.prefix_len, prefix, prefix_len)) { + continue; + } + if (prefix_len > existing.prefix_len) { + memset(existing.prefix, 0, sizeof(existing.prefix)); + memcpy(existing.prefix, prefix, prefix_len); + existing.prefix_len = prefix_len; + } + if (existing.fail_count < 0xFFFF) { + existing.fail_count++; + } + return true; + } + + if (!create_if_missing || !setRecentRepeater(prefix, prefix_len, seed_snr_x4, false, bypass_allow_filter)) { + return false; + } + + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0 || !prefixesOverlap(existing.prefix, existing.prefix_len, prefix, prefix_len)) { + continue; + } + if (existing.fail_count < 0xFFFF) { + existing.fail_count++; + } + return true; + } + return false; + } + bool decrementRecentRepeaterSnrX4(const uint8_t* prefix, uint8_t prefix_len, uint8_t amount_x4 = 1) { + if (prefix == NULL || prefix_len == 0 || amount_x4 == 0) { + return false; + } + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0 || !prefixesOverlap(existing.prefix, existing.prefix_len, prefix, prefix_len)) { + continue; + } + if (prefix_len > existing.prefix_len) { + memset(existing.prefix, 0, sizeof(existing.prefix)); + memcpy(existing.prefix, prefix, prefix_len); + existing.prefix_len = prefix_len; + } + if (!existing.snr_locked) { + int16_t lowered = (int16_t)existing.snr_x4 - (int16_t)amount_x4; + if (lowered < -128) { + lowered = -128; + } + existing.snr_x4 = (int8_t)lowered; + } + return true; + } + return false; + } const RecentRepeaterInfo* getLatestRecentRepeater() const { for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; @@ -360,6 +514,10 @@ class SimpleMeshTables : public mesh::MeshTables { } return NULL; } + void clearRecentRepeaters() { + memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); + _next_recent_repeater_idx = 0; + } void resetStats() { _direct_dups = _flood_dups = 0; } };