Harden marathon-tested OTA and hardware paths
Add durable TempRadio, ACL, clock, bridge, GPS/I2C, USB logging, and Indicator recovery safeguards together with expanded automation, build/profile contracts, release metadata, and the hardware validation ledger.
This commit is contained in:
1 parent
f873e51b43
commit
8920c35da5
109 files changed
+18697
-1094
No files matched your search
@@ -128,7 +128,7 @@ Commands:
|
||||
build-sensor-firmwares: Build all sensor firmwares for all build targets.
|
||||
build-kiss-radio-firmwares: Build all KISS radio firmwares for all build targets.
|
||||
get-companion-firmwares-to-build: List canonical attached companion targets for release automation; Full Companion replaces separate transport artifacts where qualified.
|
||||
get-repeater-firmwares-to-build: List standard and specialized external-storage repeater targets for release automation.
|
||||
get-repeater-firmwares-to-build: List canonical, specialized external-storage, and deployed-target OTA compatibility repeaters for release automation.
|
||||
get-room-server-firmwares-to-build: List standard room-server targets for release automation.
|
||||
|
||||
Options:
|
||||
@@ -1887,6 +1887,27 @@ get_pio_envs_ending_with_string() {
|
||||
shopt -u nocasematch
|
||||
}
|
||||
|
||||
get_deployed_lora_ota_compatibility_targets() {
|
||||
# These exact target names have distinct OTA IDs embedded in already
|
||||
# deployed RAK4631 Serial1/Serial2 bridge images. The merged repeater is the
|
||||
# recommended image for new installs, but it cannot address those nodes.
|
||||
# Continue publishing an image bearing each legacy ID unless a future,
|
||||
# explicitly compatible protocol migrates the installed identity.
|
||||
printf '%s\n' \
|
||||
RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors \
|
||||
RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors
|
||||
}
|
||||
|
||||
is_deployed_lora_ota_compatibility_target() {
|
||||
case "${1,,}" in
|
||||
rak_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|\
|
||||
rak_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
print_release_firmware_targets() {
|
||||
case "$1" in
|
||||
get-companion-firmwares-to-build)
|
||||
@@ -1912,6 +1933,16 @@ print_release_firmware_targets() {
|
||||
if is_supported_build_env "RAK_4631_repeater_rak15001_slot_c_lora_ota"; then
|
||||
printf '%s\n' "RAK_4631_repeater_rak15001_slot_c_lora_ota"
|
||||
fi
|
||||
# Functional consolidation does not change an installed image's mOTA
|
||||
# target ID. Keep exact Serial1/Serial2 bridge identities publishable as
|
||||
# compatibility assets while recommending the merged image for USB/new
|
||||
# installs and hiding these legacy profiles in the firmware picker.
|
||||
local compatibility_target
|
||||
while IFS= read -r compatibility_target; do
|
||||
if is_supported_build_env "$compatibility_target"; then
|
||||
printf '%s\n' "$compatibility_target"
|
||||
fi
|
||||
done < <(get_deployed_lora_ota_compatibility_targets)
|
||||
;;
|
||||
get-room-server-firmwares-to-build)
|
||||
get_pio_envs_ending_with_string "_room_server"
|
||||
@@ -2017,6 +2048,24 @@ is_rak_i2c_voltage_monitor_ota_target() {
|
||||
esac
|
||||
}
|
||||
|
||||
is_rak_gps_retaining_ota_target() {
|
||||
local target_lc=${1,,}
|
||||
|
||||
# Serial1 is the RAK12501 UART on RAK4631. The Serial1 RS-232 bridge owns
|
||||
# that port instead, so its reduced profile intentionally uses the INA-only
|
||||
# recipe and must not promise or require the WisBlock GPS provider.
|
||||
case "$target_lc" in
|
||||
rak_3401_repeater_lora_ota_no_external_sensors|\
|
||||
rak_4631_repeater_lora_ota_no_external_sensors|\
|
||||
rak_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_logging_size_constrained_target() {
|
||||
case "$1" in
|
||||
Tiny_Relay_companion_radio_usb|Tiny_Relay_repeater|RAK_3x72_companion_radio_usb|RAK_3x72_repeater|wio-e5_companion_radio_usb|wio-e5_repeater|wio-e5-repeater_bridge_rs232|wio-e5-mini_companion_radio_usb|wio-e5-mini_repeater|wio-e5-mini_sensor)
|
||||
@@ -2606,6 +2655,21 @@ is_room_server_role_target() {
|
||||
esac
|
||||
}
|
||||
|
||||
is_sensor_role_target() {
|
||||
case "${1,,}" in
|
||||
*_sensor|*_sensor_) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
requires_esp32_field_browser_ota() {
|
||||
[ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "ESP32_PLATFORM" ] \
|
||||
&& ! is_esp32_companion_build "$1" \
|
||||
&& { is_repeater_role_target "$1" \
|
||||
|| is_room_server_role_target "$1" \
|
||||
|| is_sensor_role_target "$1"; }
|
||||
}
|
||||
|
||||
get_reduced_lora_ota_target() {
|
||||
local target=$1
|
||||
local candidate
|
||||
@@ -2763,14 +2827,30 @@ declare_build_capability_contract() {
|
||||
record_build_expectation "ota.cli" "OTA: status"
|
||||
fi
|
||||
|
||||
if is_rak_i2c_voltage_monitor_ota_target "$env_name"; then
|
||||
# These two reduced RAK OTA profiles deliberately retain their board GPS
|
||||
# provider as well as the compact INA monitor set. Keep the release
|
||||
# manifest honest so a space-oriented target name is not mistaken for a
|
||||
# GPS-less image. This evidence is emitted only by the linked WisBlock GPS
|
||||
# provider; generic CLI command text is not sufficient proof.
|
||||
# A field-installed ESP32 must never leave the release pipeline with every
|
||||
# self-update path compiled out. Standard field/server profiles retain the
|
||||
# lightweight browser uploader below; prove that its linked HTML is really
|
||||
# present rather than trusting build flags or a generic `start ota` command
|
||||
# whose board implementation may still be the unsupported stub.
|
||||
if [ "$env_platform" = "ESP32_PLATFORM" ] \
|
||||
&& [ "$BUILD_PROFILE_FOR_TARGET" = "standard" ] \
|
||||
&& requires_esp32_field_browser_ota "$env_name"; then
|
||||
record_build_expectation \
|
||||
"web.lightweight_browser_ota" "MeshCore firmware update"
|
||||
fi
|
||||
|
||||
if is_rak_gps_retaining_ota_target "$env_name"; then
|
||||
# GPS-compatible reduced RAK OTA profiles retain the WisBlock provider.
|
||||
# The Serial1 RS-232 profile is deliberately excluded because that UART
|
||||
# cannot simultaneously carry the RAK12501 protocol. This evidence is
|
||||
# emitted only by the linked provider; generic CLI text is insufficient.
|
||||
record_build_expectation \
|
||||
"sensor.gps" "meshcore.capability.rak_wisblock_gps.v1"
|
||||
fi
|
||||
|
||||
if is_rak_i2c_voltage_monitor_ota_target "$env_name"; then
|
||||
# Every reduced RAK profile retains this compact monitor set, including
|
||||
# the Serial1 RS-232 profile that intentionally omits GPS.
|
||||
record_build_expectation "sensor.ina219" "INA219"
|
||||
record_build_expectation "sensor.ina226" "INA226"
|
||||
record_build_expectation "sensor.ina260" "INA260"
|
||||
@@ -2835,6 +2915,19 @@ declare_build_capability_contract() {
|
||||
record_build_expectation "web.webconfig" "start webconfig"
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$env_name_lc" in
|
||||
*sensecapindicator*companion_radio_full*)
|
||||
# A Full Indicator promises self-repair of the RP2040 font asset. Keep
|
||||
# the release manifest from treating an HTTPS downloader without the
|
||||
# fresh-time gate as equivalent: these stable messages are emitted on
|
||||
# the fail-closed NTP-before-TLS path and survive release builds.
|
||||
record_build_expectation "indicator.font_recovery_ntp_gate" \
|
||||
"requesting fresh NTP time before download"
|
||||
record_build_expectation "indicator.font_recovery_tls" \
|
||||
"opening TLS connection to"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
apply_esp32_lora_ota_size_profile() {
|
||||
@@ -2847,8 +2940,11 @@ apply_esp32_lora_ota_size_profile() {
|
||||
|
||||
# All non-companion ESP32 artifacts must remain installable into the legacy
|
||||
# 0x10000..0x150000 app slot. The WebConfig portal is deliberately omitted.
|
||||
# Lean LoRa-OTA repeaters retain the compact browser updater; other radio-only
|
||||
# roles avoid linking WiFi solely for that updater. MQTT observers and ESP-NOW
|
||||
# Every field/server role retains the compact browser updater. Publishing a
|
||||
# repeater, room server, or sensor with both LoRa OTA and browser OTA absent
|
||||
# strands it until someone reaches it with a cable. If the updater pushes a
|
||||
# constrained target over its slot ceiling, fail that target instead of
|
||||
# silently reducing away its last recovery path. MQTT observers and ESP-NOW
|
||||
# bridges are always promoted to the expanded FULL profile so they retain the
|
||||
# complete CLI and feature set.
|
||||
# Companions retain their target defaults because they are installed over USB.
|
||||
@@ -2857,7 +2953,8 @@ apply_esp32_lora_ota_size_profile() {
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWEBCONFIG_DISABLED=1"
|
||||
record_build_reduction \
|
||||
"web.webconfig omitted to preserve the legacy portable ESP32 app slot"
|
||||
if is_lora_ota_only_target "$env_name"; then
|
||||
if is_lora_ota_only_target "$env_name" \
|
||||
|| requires_esp32_field_browser_ota "$env_name"; then
|
||||
# The no-external-sensors image is also the self-updatable field image. Keep
|
||||
# manual browser OTA available on every ESP32 family through the compact
|
||||
# uploader, the complete role CLI, and the full one-byte neighbor-index
|
||||
@@ -3032,12 +3129,14 @@ apply_lora_ota_no_external_sensors_profile() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# The explicit LoRa-OTA sibling additionally drops optional external sensors.
|
||||
# The explicit LoRa-OTA sibling drops selected optional environmental and
|
||||
# ranging drivers; it does not globally disable I2C or board peripherals.
|
||||
# RAK3401/RAK4631 keep the compact INA voltage/current monitor set because it
|
||||
# costs less than 5 KiB and covers their most common external telemetry use.
|
||||
# The ordinary sibling remains fully sensor-enabled.
|
||||
# Keep board-integrated GPS support. Several target implementations require
|
||||
# their location provider even when optional external I2C sensors are absent.
|
||||
# GPS availability follows the exact target recipe. In particular, the
|
||||
# RAK4631 Serial1 RS-232 bridge intentionally omits GPS because both need the
|
||||
# same UART; other reduced RAK profiles below retain compatible GPS paths.
|
||||
if ! is_rak_i2c_voltage_monitor_ota_target "$env_name"; then
|
||||
omitted_sensor_flags+=" ${voltage_monitor_flags}"
|
||||
fi
|
||||
@@ -3052,12 +3151,17 @@ apply_lora_ota_no_external_sensors_profile() {
|
||||
retain_overrides+=" -D${flag}=1"
|
||||
done
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS}${omit_overrides}${retain_overrides}"
|
||||
record_build_reduction \
|
||||
"sensors.external omitted except INA219/INA226/INA260/INA3221 I2C voltage monitors"
|
||||
if is_rak_gps_retaining_ota_target "$env_name"; then
|
||||
record_build_reduction \
|
||||
"selected optional environmental/ranging drivers omitted; INA219/INA226/INA260/INA3221 and board display/RTC/GPS retained; RAK12500 and INA3221 require distinct configured I2C addresses"
|
||||
else
|
||||
record_build_reduction \
|
||||
"selected optional environmental/ranging drivers and GPS omitted; INA219/INA226/INA260/INA3221 and board display/RTC retained; GPS conflicts with RS-232 on Serial1"
|
||||
fi
|
||||
else
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS}${omit_overrides}"
|
||||
record_build_reduction \
|
||||
"sensors.external omitted by the explicitly named no_external_sensors target"
|
||||
"selected optional environmental/ranging drivers omitted; generic I2C and exact-target board peripherals remain unchanged"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -3311,6 +3415,10 @@ apply_companion_radio_full_profile() {
|
||||
sensecapindicator-espnow_companion_radio_full|\
|
||||
sensecapindicator-lora_companion_radio_full)
|
||||
export MESHCORE_ESP32_FULL_PARTITION_TABLE="variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv"
|
||||
# Both physical Indicator radio layouts gain ordinary WiFi in their Full
|
||||
# overlay. Keep font recovery tied to that effective capability rather
|
||||
# than to whichever historical USB/WiFi environment supplies the recipe.
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DINDICATOR_WIFI_FONT_RECOVERY=1"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -3585,8 +3693,15 @@ collect_build_artifacts() {
|
||||
local pio_env_name=$3
|
||||
local firmware_filename=$4
|
||||
|
||||
# Qualify the linked image before copying anything into out/. A failed
|
||||
# capability contract must not leave an apparently publishable firmware
|
||||
# binary behind (in particular, an ESP32 field image with no update path).
|
||||
write_build_capability_manifest \
|
||||
"$env_name" "$env_platform" "$pio_env_name" "$firmware_filename" \
|
||||
|| return $?
|
||||
|
||||
# Post-build outputs differ by platform, so dispatch to the matching
|
||||
# collector after the main firmware build succeeds.
|
||||
# collector after the main firmware build and capability checks succeed.
|
||||
case "$env_platform" in
|
||||
ESP32_PLATFORM)
|
||||
collect_esp32_artifacts "$env_name" "$pio_env_name" "$firmware_filename" || return $?
|
||||
@@ -3606,8 +3721,6 @@ collect_build_artifacts() {
|
||||
;;
|
||||
esac
|
||||
|
||||
write_build_capability_manifest \
|
||||
"$env_name" "$env_platform" "$pio_env_name" "$firmware_filename"
|
||||
}
|
||||
|
||||
get_firmware_filename() {
|
||||
@@ -4248,7 +4361,12 @@ is_redundant_bulk_build_target() {
|
||||
# `build-matching-firmwares`, but do not republish binaries that differ only
|
||||
# by a saved/default setting, or roles already supplied by Full Companion.
|
||||
# Its text terminal supersedes standalone Terminal Chat on the same exact
|
||||
# hardware, in addition to its combined attached transports.
|
||||
# hardware, in addition to its combined attached transports. Deployed mOTA
|
||||
# IDs are wire compatibility contracts, so their exact targets are the one
|
||||
# intentional exception to functional artifact consolidation.
|
||||
if is_deployed_lora_ota_compatibility_target "$1"; then
|
||||
return 1
|
||||
fi
|
||||
if is_runtime_setting_alias_target "$1" \
|
||||
|| is_firmware_role_replaced_by_canonical_artifact "$1"; then
|
||||
return 0
|
||||
@@ -4526,8 +4644,9 @@ configure_effective_build_profile() {
|
||||
AUTO_PREFER_FULL_BUILD=1
|
||||
AUTO_REDUCED_FALLBACK_TARGET=$(get_reduced_lora_ota_target "$target" || true)
|
||||
elif is_lora_ota_no_external_sensors_target "$target"; then
|
||||
# An explicitly selected reduced target means exactly what its
|
||||
# name says; do not silently put omitted sensors back into it.
|
||||
# An explicitly selected legacy-named reduced target keeps its
|
||||
# exact declared driver trim; do not silently put omitted optional
|
||||
# environmental/ranging drivers back into it.
|
||||
BUILD_PROFILE_EFFECTIVE="standard"
|
||||
elif reduced_target=$(get_reduced_lora_ota_target "$target"); then
|
||||
RESOLVED_BUILD_TARGETS[0]=$reduced_target
|
||||
|
||||
@@ -501,6 +501,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
function applyMergedRak4631RepeaterCapabilities(profiles) {
|
||||
return (profiles || []).map(function (profile) {
|
||||
const target = String(profile && profile.target || "").toLowerCase();
|
||||
if (
|
||||
target !== "rak_4631_repeater" &&
|
||||
target !== "rak_4631_repeater_lora_ota_no_external_sensors"
|
||||
) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
// The canonical RAK4631 repeater contains both the ordinary interface
|
||||
// and the runtime-selectable RS232 bridge. Legacy Serial1/Serial2 target
|
||||
// rows remain exact OTA compatibility assets; do not attach their files
|
||||
// to this profile merely to expose the merged image's second mode.
|
||||
profile.mode = "standard";
|
||||
profile.connectionModes = ["standard", "rs232"];
|
||||
return profile;
|
||||
});
|
||||
}
|
||||
|
||||
function applyMergedStandardUsbLoggingCapabilities(profiles) {
|
||||
return (profiles || []).map(function (profile) {
|
||||
const safeRole = [
|
||||
@@ -619,8 +639,10 @@
|
||||
return !isHiddenLegacyProfile(profile);
|
||||
});
|
||||
const profiles = applyMergedStandardUsbLoggingCapabilities(
|
||||
omitTransportsReplacedByFull(
|
||||
applyFullCompanionCapabilities(visibleProfiles)
|
||||
applyMergedRak4631RepeaterCapabilities(
|
||||
omitTransportsReplacedByFull(
|
||||
applyFullCompanionCapabilities(visibleProfiles)
|
||||
)
|
||||
)
|
||||
).sort(function (a, b) {
|
||||
return a.target.localeCompare(b.target, undefined, {
|
||||
@@ -655,6 +677,9 @@
|
||||
}
|
||||
|
||||
function profileFieldValues(profile, field) {
|
||||
if (field === "mode" && Array.isArray(profile.connectionModes)) {
|
||||
return profile.connectionModes;
|
||||
}
|
||||
if (field === "logging" && Array.isArray(profile.loggingModes)) {
|
||||
return profile.loggingModes;
|
||||
}
|
||||
@@ -714,10 +739,27 @@
|
||||
rak15001: "RAK15001",
|
||||
qspi: "QSPI",
|
||||
};
|
||||
return value.split("-").map(function (token) {
|
||||
return tokenLabels[token] ||
|
||||
token.charAt(0).toUpperCase() + token.slice(1);
|
||||
}).join(" ");
|
||||
const tokens = value.split("-");
|
||||
const labels = [];
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
if (
|
||||
tokens[index] === "no" &&
|
||||
tokens[index + 1] === "external" &&
|
||||
tokens[index + 2] === "sensors"
|
||||
) {
|
||||
// The legacy target token is an OTA identity, not a literal statement
|
||||
// that the I2C bus and every external peripheral have been removed.
|
||||
labels.push("Reduced optional environmental/ranging drivers");
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
const token = tokens[index];
|
||||
labels.push(
|
||||
tokenLabels[token] ||
|
||||
token.charAt(0).toUpperCase() + token.slice(1)
|
||||
);
|
||||
}
|
||||
return labels.join(" ");
|
||||
}
|
||||
|
||||
function labelFor(field, value) {
|
||||
@@ -860,7 +902,12 @@
|
||||
facts.className = "firmware-picker-facts";
|
||||
const factRows = [
|
||||
["Target", profile.target],
|
||||
["Connection / mode", labelFor("mode", profile.mode)],
|
||||
[
|
||||
"Connection / mode",
|
||||
profileFieldValues(profile, "mode").map(function (mode) {
|
||||
return labelFor("mode", mode);
|
||||
}).join(" / "),
|
||||
],
|
||||
["Logging", labelFor("logging", profile.logging)],
|
||||
["OTA", labelFor("ota", profile.ota)],
|
||||
["Feature profile", labelFor("feature", profile.feature)],
|
||||
@@ -1203,6 +1250,8 @@
|
||||
parseFirmwareAsset: parseFirmwareAsset,
|
||||
parseTargetProfile: parseTargetProfile,
|
||||
applyFullCompanionCapabilities: applyFullCompanionCapabilities,
|
||||
applyMergedRak4631RepeaterCapabilities:
|
||||
applyMergedRak4631RepeaterCapabilities,
|
||||
applyMergedStandardUsbLoggingCapabilities:
|
||||
applyMergedStandardUsbLoggingCapabilities,
|
||||
applyDualCdcFullCompanionCapabilities: applyFullCompanionCapabilities,
|
||||
|
||||
@@ -68,13 +68,24 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM.
|
||||
| Standard non-MQTT repeater or room server | Keeps the normal role CLI and, where USB is a safe plaintext console, embeds debug/packet logging behind persistent `get/set usb.logging`. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. |
|
||||
| Legacy standard logging | No longer emitted separately. Its behavior is compiled into the ordinary artifact. Size-constrained STM32 targets embed packet logging without verbose `MESH_DEBUG`. |
|
||||
| LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. ESP32 `no_external_sensors` artifacts retain the compact browser WiFi uploader, the complete CLI, and a 254-entry neighbor table. |
|
||||
| Internal-flash nRF52 repeater auto pair | `full-ota` retains the board's external-sensor drivers; `reduced-ota` omits the declared optional sensors to leave additional internal-flash staging room. RAK3401 and RAK4631 reduced builds retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors at a measured 4,808-byte flash cost. Both artifacts carry the same stable OTA target identity and are checked for `ota ...` and `retry.preset`; RAK artifacts also verify the retained monitor drivers. |
|
||||
| Internal-flash nRF52 repeater auto pair | `full-ota` retains the board's external-sensor drivers; `reduced-ota` omits the declared optional sensors to leave additional internal-flash staging room. RAK3401 and RAK4631 reduced builds retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors at a measured cost below 5 KiB. Both artifacts carry the same stable OTA target identity and are checked for `ota ...` and `retry.preset`; RAK artifacts also verify the retained monitor drivers. |
|
||||
| ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. |
|
||||
| FULL ESP32 USB + WiFi | Uses the matching MQTT target with packet logging on, verbose debug off, and the complete command surface supported by that role and hardware. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths. |
|
||||
| FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. |
|
||||
| nRF52 dual-CDC Full Companion | Fresh installs expose only interface `00`; it starts as an ASCII terminal and automatically hands a complete `<` frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. Enabling logging and rebooting adds interface `02` for plaintext logs. BLE and source-only LoRa OTA remain available. `get/set usb.logging` persistently controls whether the logging interface is present. |
|
||||
| ESP32 single-TTY Full Companion | Every ESP32 Full image starts with the ASCII terminal on its one USB TTY and automatically hands a complete `<` frame to framed Companion. `set usb.logging on` switches that TTY to an input-capable plaintext logging terminal and makes framed Companion unavailable on USB; `set usb.logging off` stops logging but leaves the TTY in normal ASCII mode. The terminal stop token or a valid incoming framed probe then performs the ordinary switch to Binary Companion. A saved logging-on setting starts directly in that logging terminal and disables automatic frame detection. BLE, WiFi, and source-only LoRa OTA remain available. ESP32 Full uses Arduino-ESP32 2.x where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but still expose only one TTY. |
|
||||
| `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. |
|
||||
| `no_external_sensors` | Trims selected optional environmental/ranging drivers and their settings; it does not remove generic I2C, core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because RAK12501/L76K GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. |
|
||||
|
||||
The four retained INA drivers are entries in the optional environmental-sensor
|
||||
table, not the complete set of RAK I2C consumers. Compatible reduced profiles
|
||||
also retain the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 I2C
|
||||
GPS as separate board peripherals. RAK12501/L76K GPS uses UART Serial1. The
|
||||
explicit RAK4631 Serial1 bridge omits the combined GPS provider, including the
|
||||
otherwise non-UART RAK12500 path.
|
||||
|
||||
The firmware-configured INA3221 and RAK12500 addresses are both `0x42`. To use
|
||||
both devices on one bus, leave RAK12500 at `0x42`, strap INA3221 A0 to SCL for
|
||||
`0x43`, and build with `-DTELEM_INA3221_ADDRESS=0x43`.
|
||||
|
||||
`logging`, `OTA`, and `FULL` describe independent build features in historical
|
||||
filenames. Current standard artifacts use no `-logging-` infix because their
|
||||
|
||||
@@ -15,8 +15,16 @@ Build columns mean:
|
||||
filename marker. Safe plaintext-USB roles embed runtime USB logging.
|
||||
- **Logging** - the legacy `-logging-` profile, now represented by the same
|
||||
ordinary artifact and retained as a comparison column only.
|
||||
- **LoRa OTA** - the explicit `-ota-` repeater or repeater-bridge artifact. Its
|
||||
optional external-sensor drivers are removed, but onboard GPS is retained.
|
||||
- **LoRa OTA** - the explicit `-ota-` repeater or repeater-bridge artifact. A
|
||||
legacy `no_external_sensors` profile trims selected optional environmental
|
||||
and ranging drivers; it does not disable the generic I2C bus or unrelated
|
||||
board-integrated peripherals. Reduced RAK3401 and RAK4631 profiles retain
|
||||
INA219/INA226/INA260/INA3221 as voltage/current entries in the optional sensor
|
||||
table. The SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS are
|
||||
separate I2C peripherals in compatible recipes. RAK12501/L76K GPS instead
|
||||
uses UART Serial1. The plain RAK profiles and RAK4631 Serial2 bridge retain
|
||||
the combined GPS provider; the explicit RAK4631 Serial1 bridge omits that
|
||||
provider, including RAK12500, because the bridge owns the RAK12501 UART.
|
||||
- **FULL unified** - the expanded-partition ESP32 artifact with LoRa OTA, the
|
||||
complete parser, USB packet logging, and direct WiFi MQTT. Its saved output
|
||||
mode can be off, USB, WiFi, or both. MQTT observers use this profile.
|
||||
@@ -24,6 +32,11 @@ Build columns mean:
|
||||
where there is no MQTT sibling. It has the complete parser and a saved
|
||||
USB-logging on/off gate. ESP-NOW bridges use this profile.
|
||||
|
||||
The firmware-configured INA3221 and RAK12500 addresses are both `0x42`; they
|
||||
cannot coexist on one bus at those addresses. Leave RAK12500 at `0x42`, strap
|
||||
INA3221 A0 to SCL for `0x43`, and use a build with
|
||||
`-DTELEM_INA3221_ADDRESS=0x43` when both are installed.
|
||||
|
||||
Cell values mean:
|
||||
|
||||
- **Yes** - the parser includes the command. The Scope column still applies.
|
||||
@@ -165,11 +178,11 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| GPS | [`gps setloc`](cli_commands.md#set-this-nodes-location-based-on-the-gps-coordinates) | Compiled GPS | Feature | Feature | Feature |
|
||||
| GPS | [`gps advert [none/share/prefs]`](cli_commands.md#view-or-change-the-gps-advert-policy) | Compiled GPS | Feature | Feature | Feature |
|
||||
| Sensors | [`get/set telemetry.access`](cli_commands.md#view-or-change-telemetry-access-mode) | Sensor-capable role | Feature | Feature | Limited |
|
||||
| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; OTA omits external sensors | Feature | Feature | Limited |
|
||||
| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; OTA omits external sensors | Feature | Feature | Limited |
|
||||
| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; reduced OTA trims target-selected optional environmental/ranging drivers | Feature | Feature | Limited |
|
||||
| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; reduced OTA trims target-selected optional environmental/ranging drivers | Feature | Feature | Limited |
|
||||
| Sensors | [`io [r/s/t]<hex>`](#sensor-io) | Simple sensor role with board GPIO support | Feature | Feature | No |
|
||||
| Bridge | [`get bridge.type`](cli_commands.md#view-the-compiled-bridge-type) | Compiled bridge | Feature | Feature | Feature |
|
||||
| Bridge | [`get/set bridge.enabled`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature |
|
||||
| Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature |
|
||||
| Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature |
|
||||
| Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature |
|
||||
| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature |
|
||||
@@ -332,11 +345,11 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|
||||
| GPS | [`gps setloc`](cli_commands.md#set-this-nodes-location-based-on-the-gps-coordinates) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature |
|
||||
| GPS | [`gps advert [none/share/prefs]`](cli_commands.md#view-or-change-the-gps-advert-policy) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature |
|
||||
| Sensors | [`get/set telemetry.access`](cli_commands.md#view-or-change-telemetry-access-mode) | Sensor-capable full parser | Feature | Feature | Limited | Feature | Feature |
|
||||
| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; `no_external_sensors` omits optional drivers | Feature | Feature | Limited | Feature | Feature |
|
||||
| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; `no_external_sensors` omits optional drivers | Feature | Feature | Limited | Feature | Feature |
|
||||
| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; `no_external_sensors` trims target-selected environmental/ranging drivers, not generic I2C | Feature | Feature | Limited | Feature | Feature |
|
||||
| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; `no_external_sensors` trims target-selected environmental/ranging drivers, not generic I2C | Feature | Feature | Limited | Feature | Feature |
|
||||
| Sensors | [`io [r/s/t]<hex>`](#sensor-io) | Simple sensor role with board GPIO support | Feature | Feature | No | No | Feature |
|
||||
| Bridge | [`get bridge.type`](cli_commands.md#view-the-compiled-bridge-type) | Compiled bridge | Feature | Feature | Feature | Yes | Feature |
|
||||
| Bridge | [`get/set bridge.enabled`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | Yes | Feature |
|
||||
| Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | Yes | Feature |
|
||||
| Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | Yes | Feature |
|
||||
| Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | Yes | Feature |
|
||||
| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature |
|
||||
|
||||
+19
-6
@@ -3427,12 +3427,16 @@ still overrides the first-boot default after an update.
|
||||
#### View or change the bridge enabled flag
|
||||
**Usage:**
|
||||
- `get bridge.enabled`
|
||||
- `get bridge.running`
|
||||
- `set bridge.enabled <state>`
|
||||
|
||||
**Parameters:**
|
||||
- `state`: `on`|`off`
|
||||
|
||||
**Default:** `off`
|
||||
`bridge.enabled` is the saved intent. `bridge.running` reports whether the
|
||||
bridge actually started in this boot; they can differ after a hardware conflict,
|
||||
missing credentials, or a transient initialization failure. Normal merged
|
||||
repeater images default to `off`; dedicated bridge images may default to `on`.
|
||||
|
||||
---
|
||||
|
||||
@@ -3455,10 +3459,10 @@ still overrides the first-boot default after an update.
|
||||
|
||||
**Parameters:**
|
||||
- `source`:
|
||||
- `logRx`: bridges received packets
|
||||
- `logTx`: bridges transmitted packets
|
||||
- `rx`: bridges received packets
|
||||
- `tx`: bridges transmitted packets
|
||||
|
||||
**Default:** `logTx`
|
||||
**Default:** `tx`
|
||||
|
||||
> **Note:** For MQTT bridges, use `mqtt.rx` and `mqtt.tx` instead of `bridge.source`. These provide independent per-direction control and support both RX and TX simultaneously. `bridge.source` still works as a convenience alias for MQTT (setting `bridge.source rx` sets `mqtt.rx on` + `mqtt.tx off`, and vice versa), but `mqtt.rx`/`mqtt.tx` are preferred.
|
||||
|
||||
@@ -3579,11 +3583,20 @@ Requires WiFi connected and the MQTT bridge running.
|
||||
**Parameters:**
|
||||
- `port`: Hardware UART number compiled for the board. Most boards expose one
|
||||
fixed UART. RAK4631 accepts `1` or `2`; UART 2 is the default so UART 1 can
|
||||
remain available to GPS.
|
||||
remain available to the RAK12501/L76K GPS. RAK12500 GPS uses I2C rather than
|
||||
this UART, but the explicit legacy Serial1 bridge omits the combined GPS
|
||||
provider and therefore does not expose either GPS path.
|
||||
|
||||
The setting is persistent and restarts an enabled bridge immediately. Normal
|
||||
repeater artifacts start with `bridge.enabled off`; configure the UART and baud
|
||||
rate before running `set bridge.enabled on`.
|
||||
rate before running `set bridge.enabled on`. On the canonical RAK4631 runtime
|
||||
image, UART 1 is reserved even if the bounded boot probe hears no RAK12501.
|
||||
Silence cannot prove that a cold L76K is physically absent, and that module
|
||||
remains powered by the shared WB_IO2/3V3_S rail. Use UART 2. UART 1 requires an
|
||||
explicit no-GPS/dedicated Serial1 bridge image. This fail-closed reservation
|
||||
also applies when the detected GPS is an I2C RAK12500; that receiver does not
|
||||
electrically use UART 1, but its presence cannot rule out another silent UART
|
||||
module.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+23
@@ -787,6 +787,29 @@ the PIN page until Bluetooth connects or the two-minute pairing window expires.
|
||||
8. The device is erased and newest firmware is installed
|
||||
9. You might need to remove the pairing in Bluetooth System Settings in order to re-pair the app again.
|
||||
|
||||
An erase application or ordinary application ZIP/UF2 does **not** repair a
|
||||
damaged or partially overwritten SoftDevice. This can happen after flashing an
|
||||
nRF52 image for a different board or SoftDevice layout. A characteristic
|
||||
failure is that the application can boot and read its filesystem, but reboots
|
||||
or hangs when it writes or formats internal flash. Repeating `Erase Flash` is
|
||||
not a recovery for that condition.
|
||||
|
||||
Recover it with a combined **bootloader + SoftDevice** package for the exact
|
||||
board (including XIAO Sense versus non-Sense), installed through serial/BLE DFU
|
||||
or SWD. With `adafruit-nrfutil`, the serial form is:
|
||||
|
||||
```bash
|
||||
adafruit-nrfutil dfu serial -p <port> \
|
||||
-pkg <exact-board-bootloader-with-softdevice>.zip -sb -t 1200
|
||||
```
|
||||
|
||||
Then install the exact-board MeshCore application. Do not use an application-
|
||||
only UF2, filesystem formatter, or a similarly named package from another
|
||||
nRF52 board as a substitute. See the confirmed XIAO recovery in
|
||||
[issue #3284](https://github.com/meshcore-dev/MeshCore/issues/3284) and obtain
|
||||
the matching package from the
|
||||
[OTAFIX releases](https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases).
|
||||
|
||||
Separately, starting in firmware version 1.7.0, there is a CLI Rescue mode. If your device has a user button (e.g. some RAK, T114), you can activate the rescue mode by holding down the user button of the device within 8 seconds of boot. Then you can use the 'Console' on <https://flasher.meshcore.io>
|
||||
|
||||
### 6.8. Q: WebFlasher fails on Linux with failed to open
|
||||
|
||||
+15
-4
@@ -257,10 +257,21 @@ Hardware families with multiple released targets get a second hardware-variant
|
||||
menu. It separates revisions, display type, expansion kit, radio/PA layout,
|
||||
pin map, and other physical differences without crowding the first menu. The
|
||||
firmware-variant menu separately exposes choices that still require different
|
||||
code or wiring, such as serial port or no-external-sensors. Companion power
|
||||
saving, controllable FEM receive gain, and radio-chip receive gain are saved
|
||||
settings rather than separate recommended firmware files. Do not substitute a
|
||||
similarly named physical target.
|
||||
code or wiring, such as serial port or the legacy `no_external_sensors` target
|
||||
suffix. The picker labels that suffix **Reduced optional environmental/ranging drivers**:
|
||||
it does not disable generic I2C or unrelated board-integrated peripherals.
|
||||
Reduced RAK3401 and RAK4631 targets retain INA219/INA226/INA260/INA3221 as
|
||||
voltage/current entries in the optional sensor table. They are not the only I2C
|
||||
users: the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS remain
|
||||
separate I2C peripherals in compatible recipes. RAK12501/L76K GPS uses Serial1
|
||||
instead. The explicit RAK4631 Serial1 bridge omits the combined GPS provider
|
||||
because its bridge owns the RAK12501 UART, so that legacy image does not expose
|
||||
RAK12500 either. The firmware-configured INA3221 and RAK12500 addresses are both
|
||||
`0x42`; to install both, keep RAK12500 at `0x42`, strap INA3221 A0 to SCL for
|
||||
`0x43`, and use a build with `-DTELEM_INA3221_ADDRESS=0x43`. Companion power saving,
|
||||
controllable FEM receive gain, and radio-chip receive gain are saved settings
|
||||
rather than separate recommended firmware files. Do not substitute a similarly
|
||||
named physical target.
|
||||
|
||||
The picker recommends one Full Companion image instead of separate USB, BLE,
|
||||
ordinary WiFi, and USB-logging images. On ESP32, logging is off by default so
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+79
-20
@@ -200,6 +200,9 @@ reported bootloader ABI and codec mask against the selected package. If the
|
||||
version command is unavailable on older firmware, the script warns and falls
|
||||
back to the legacy `ota self` platform marker. If an nRF52 bootloader lacks
|
||||
the required capabilities, install the exact-board OTAFIX bootloader first.
|
||||
`ota stats` is only an optional EndF version probe. It uses one bounded retry
|
||||
cycle; unsupported firmware or a lost reply falls back directly to the
|
||||
required `ver` command instead of entering an operator continuation loop.
|
||||
|
||||
The default TempRadio tuple is:
|
||||
|
||||
@@ -212,6 +215,30 @@ North American example: choose a legal frequency supported by every
|
||||
participating radio and appropriate to your location. Older radios that do not
|
||||
support SF5 require a complete replacement tuple passed with `--temp-radio`.
|
||||
|
||||
Before that long window is allowed, the live runner performs a mandatory
|
||||
independent three-minute rehearsal. Its exact normal-channel `ota status` and
|
||||
`ota self` identity proof gets one shared four-minute, read-only budget before
|
||||
any schedule, radio override, seeder, or transfer is armed; this tolerates a
|
||||
marginal link without consuming or extending a live lease. It advances a stale
|
||||
managed controller with exact readback and treats a source terminal's
|
||||
whole-minute display as an uncertainty window, never as an exact epoch. A
|
||||
source minute overlapping the host is preserved; a completely stale minute is
|
||||
pinned to a guarded future value and read back once. Neither clock is moved
|
||||
backward, and the runner stops if a managed clock cannot be proven within the
|
||||
ten-minute limit. Each remote participant must expose an empty `tempradioat`
|
||||
schedule. The runner projects one fixed start/end interval into each
|
||||
participant's independently sampled RTC, sends each mutation once, proves
|
||||
every exact identity on the temporary tuple, waits for natural expiry, and
|
||||
proves the complete normal path again. A lost schedule reply is resolved by
|
||||
those on-air proofs; it is never blindly replayed with a fresh duration.
|
||||
|
||||
The published RAK3401 `v1.16.7-c1caa5ad` LoRa-OTA image includes
|
||||
`get/set/del tempradioat` and can take this safe first step. A truly older or
|
||||
reduced build without fixed TempRadio scheduling is rejected before mutation;
|
||||
bootstrap it locally or with another explicitly controlled maintenance path.
|
||||
The automation does not substitute an immediate `tempradio` command whose
|
||||
first delivery could remain queued after cleanup.
|
||||
|
||||
### RXPS handling during TempRadio
|
||||
|
||||
RXPS improves receive performance per unit of radio-on time, so the runner
|
||||
@@ -265,8 +292,11 @@ moving back to a slower tuple returns to the operator's saved level.
|
||||
The OTA source has a stricter policy than the destination. For every source
|
||||
with a managed serial or TCP CLI, the runner reads and retains its exact RXPS
|
||||
preference, using the legacy fixed-period query only when the detailed query is
|
||||
unavailable. It then disables RXPS and verifies the readback before any target
|
||||
discovery or TempRadio change. Source RXPS stays off through catalog serving,
|
||||
unavailable. It builds and verifies the package, completes the read-only target
|
||||
checks, and obtains confirmation while that preference remains unchanged.
|
||||
Immediately before the first radio mutation, it reads the source RXPS state
|
||||
again, disables RXPS, and verifies the readback. Source RXPS stays off through
|
||||
catalog serving,
|
||||
download, installation, and post-install identity verification. Cleanup first
|
||||
proves that the source has returned to its normal radio, then restores and
|
||||
verifies the exact saved level/preamble or fixed-period state once. A source
|
||||
@@ -487,11 +517,15 @@ Useful controls:
|
||||
downloading or staged on the target. Without it, that update is preserved.
|
||||
- `--source-shares-controller` is for a Full Companion whose USB Binary API is
|
||||
the controller while its TCP port `5001` is the source. It verifies that the
|
||||
source's port-`5000` public key equals the controller key. Port `5002` uses a
|
||||
bounded local `tempradio` override to move the shared physical radio without
|
||||
overwriting its saved normal tuple; the Binary API remains the authenticated
|
||||
transport. Cleanup sends local `normalradio`, proves that override inactive,
|
||||
and then reasserts the saved Binary tuple. It cannot be combined with
|
||||
source's port-`5000` public key equals the controller key, then binds the
|
||||
fresh port-`5002` Full Terminal banner's complete public key to that Binary
|
||||
identity and challenges it with the supported `ver` command. It does not
|
||||
assume the Full Terminal implements repeater-only `get public.key`. Port
|
||||
`5002` uses a bounded local `tempradio` override to move the shared physical
|
||||
radio without overwriting its saved normal tuple; the Binary API remains the
|
||||
authenticated transport. Cleanup sends local `normalradio`, proves that
|
||||
override inactive, and then reasserts the saved Binary tuple. It cannot be
|
||||
combined with
|
||||
`--leave-controller-radio`,
|
||||
because exact source RXPS restoration requires that shared physical radio to
|
||||
be back on its verified normal tuple.
|
||||
@@ -505,6 +539,12 @@ Useful controls:
|
||||
restores both values before the relay leaves TempRadio.
|
||||
- `--work-dir PATH` chooses a new, non-existent work directory.
|
||||
- `--meshcli PATH` and `--motatool PATH` select binaries not on `PATH`.
|
||||
- `--package-build-timeout SECONDS` bounds local mOTA generation. It defaults
|
||||
to 3600 seconds. Run detools, delta/compression, and raw-firmware package
|
||||
generation on a workstation or build VM, then transfer the completed,
|
||||
hash-verified `.mota` to a Pi-class radio host. The Pi should perform only
|
||||
lightweight identity-gated hardware I/O and serve that finished artifact;
|
||||
preparation still occurs before any radio state changes.
|
||||
- `--debug` prints redacted child commands, timeouts, process status, stdout,
|
||||
and stderr. Admin passwords are never printed, but node names, addresses,
|
||||
paths, and command replies can still be sensitive; share the log carefully.
|
||||
@@ -527,30 +567,41 @@ the destination.
|
||||
## What happens during a run
|
||||
|
||||
1. Validate the input paths and host tools, then prove the source is either an
|
||||
OTA-enabled raw CLI or a source-only full Companion control interface.
|
||||
2. Authenticate to the target and query its target ID, hardware, running body
|
||||
OTA-enabled raw CLI or a source-only full Companion control interface. Read
|
||||
and durably save a managed source's exact RXPS preference without changing
|
||||
it.
|
||||
2. Before sending any remote packet, prove the managed source has no active,
|
||||
pending, or fixed TempRadio work, then gate the source and controller
|
||||
clocks. Advance a stale exact clock to host time; for a minute-resolution
|
||||
source, preserve an overlapping minute or use one guarded forward value
|
||||
with readback. Preserve a small future lead and fail closed above the
|
||||
ten-minute drift limit. Then
|
||||
authenticate to the target, query its target ID, hardware, running body
|
||||
hash, firmware version, bootloader version, and nRF52 bootloader
|
||||
capabilities.
|
||||
capabilities, and save the controller's normal radio tuple.
|
||||
3. Select or build one compatible **v2 application** mOTA and verify all block
|
||||
hashes, Merkle root, full-image hash where applicable, identity fields,
|
||||
signature, codec, base, and the firmware's 1024-byte maximum block size.
|
||||
Version-3 bootloader packages are refused before any target state changes.
|
||||
4. Read and save a managed source's exact RXPS preference, disable and verify
|
||||
source RXPS, save the controller's normal radio tuple, read every
|
||||
participant's version, save the destination's RXPS state, select the
|
||||
qualified destination policy, and show the confirmation prompt.
|
||||
5. Apply and verify the destination RXPS policy, then start TempRadio on the
|
||||
4. Read every participant's version, save the destination's RXPS state,
|
||||
select the qualified destination policy, and show the confirmation prompt.
|
||||
5. Run the fixed-window three-minute rehearsal described above. This phase
|
||||
does not disable RXPS, start a seeder, request a pull, or install anything;
|
||||
the long transfer remains completely unarmed until temporary and normal
|
||||
reachability both pass.
|
||||
6. Fresh-read, disable, and verify source RXPS; apply and verify the
|
||||
destination RXPS policy; then start TempRadio on the
|
||||
target, far-to-near relays, and source. A separate controller is moved and
|
||||
read back through Binary; a shared Full Companion instead schedules its
|
||||
bounded local override while Binary remains the transport. The runner
|
||||
rejects a TempRadio window that cannot cover setup, seeder startup,
|
||||
discovery, the transfer timeout, final polling, and install checks.
|
||||
6. Start `motatool serve`, discover the exact eight-hex manifest ID, request
|
||||
7. Start `motatool serve`, discover the exact eight-hex manifest ID, request
|
||||
`ota pull <id> flash`, and poll until that same ID reports ready. A seeder
|
||||
process exit stops the run immediately. For `--no-install`, schedule all
|
||||
script-controlled nodes back to their normal radios before restoring the
|
||||
controller, unless `--leave-controller-radio` was requested.
|
||||
7. Recheck that exact ID, give the target a short final TempRadio safety window,
|
||||
8. Recheck that exact ID, give the target a short final TempRadio safety window,
|
||||
and request `ota install`. Then shorten each relay's TempRadio window so the
|
||||
normal multi-hop route returns, stop the seeder, shorten the source window,
|
||||
restore the controller, and probe `ota self` every 10 seconds through the
|
||||
@@ -573,9 +624,17 @@ status for another manifest ID is an error, never permission to install it.
|
||||
|
||||
Read-only and replay-safe transmissions retry up to three times. Three retries
|
||||
or 90 seconds, whichever comes first, opens a 10-second stop-or-continue
|
||||
prompt. Continue is the default on timeout, Enter, and unattended input, so a
|
||||
temporary outage does not silently abandon a resumable transfer. Enter `s` or
|
||||
`stop` to end the run; Ctrl-C also remains immediate.
|
||||
prompt when stdin is an interactive terminal. Continue remains the default on
|
||||
timeout or Enter, so an operator can persist through a temporary outage; enter
|
||||
`s` or `stop` to end the run, and Ctrl-C remains immediate. With non-interactive
|
||||
stdin, the runner stops after that finite automatic retry cycle instead of
|
||||
silently starting another cycle forever. Optional participant-version probes
|
||||
always use a bounded cycle and degrade to `unknown`; optional destination
|
||||
`ota stats` falls back directly to `ver`. The mandatory normal-channel
|
||||
destination baseline is the narrow exception: it may make nine total read-only
|
||||
attempts but is stopped by one hard four-minute deadline before any TempRadio
|
||||
lease or OTA mutation exists. Proofs inside the live rehearsal retain the
|
||||
smaller four-attempt limit.
|
||||
|
||||
Commands that change OTA state are reconciled before replay:
|
||||
|
||||
|
||||
+23
-8
@@ -19,7 +19,8 @@ complete container stays off-chip. See [nRF52 repeater OTA with external QSPI](o
|
||||
[MeshTower V2 microSD LoRa OTA](ota_meshtower_v2_sdcard.md).
|
||||
|
||||
MeshTower V2 microSD application and bootloader containers must be Ed25519-signed by a key in the device
|
||||
allowlist; pass `--sign-key` when building one. Its BLM2-capable SD-aware bootloader is also required before
|
||||
allowlist; pass `--sign` to `motatool build` (or `--sign-key` to the end-to-end
|
||||
runner) when building one. Its BLM2-capable SD-aware bootloader is also required before
|
||||
ordinary application or bootloader OTA can use the reset-retained authorization record. Upgrade preview.12
|
||||
locally over USB/BLE DFU or SWD first; there is no raw-card compatibility handoff.
|
||||
|
||||
@@ -85,13 +86,25 @@ payload. If any required window closes, the transfer stops making progress and c
|
||||
overlapping window.
|
||||
|
||||
`build.sh` provides a `*_repeater_lora_ota_no_external_sensors` build for standalone ESP32 and nRF52 repeater
|
||||
targets that need a smaller internal update workspace. Those siblings omit optional external I2C
|
||||
environmental sensors while retaining board-native features such as displays, buttons, battery monitoring,
|
||||
targets that need a smaller internal update workspace. Those siblings omit selected optional environmental
|
||||
and ranging drivers while retaining board-native features such as displays, buttons, battery monitoring,
|
||||
and GPS where the target uses the GPS-preserving lean profile. RAK3401 and RAK4631 reduced builds also
|
||||
retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors; together these drivers add 4,808
|
||||
bytes over the otherwise reduced image. The RAK3401 OTA repeater retains RAK12501 support in sensor slot A;
|
||||
slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631 OTA repeater retains GPS and defaults
|
||||
its runtime RS-232 bridge to Serial2; Serial1 and GPS use the same UART and must not be enabled together.
|
||||
retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors; together these drivers cost less
|
||||
than 5 KiB in the measured profile. Those four monitor drivers are not the only retained I2C support: the
|
||||
board's SSD1306 OLED, supported autodiscovered RTCs, and the RAK12500 GPS remain separate I2C peripherals in
|
||||
GPS-compatible RAK recipes. RAK12501/L76K GPS instead uses UART Serial1. The RAK3401 OTA repeater supports
|
||||
either GPS module in sensor slot A; slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631
|
||||
OTA repeater defaults its runtime RS-232 bridge to Serial2 so the UART RAK12501 can retain Serial1. The
|
||||
runtime guard always reserves Serial1 because a bounded silent probe cannot prove a cold RAK12501 is absent;
|
||||
the shared WB_IO2/3V3_S rail keeps a fitted module powered and driving the UART. Use Serial2. Serial1 requires
|
||||
an explicit no-GPS/dedicated image. The merged image remains fail-closed even when it detects an I2C RAK12500.
|
||||
The explicit legacy Serial1 bridge omits the combined GPS provider, including the otherwise non-UART
|
||||
RAK12500 path.
|
||||
|
||||
The firmware-configured INA3221 address and the RAK12500 address are both `0x42`; they cannot coexist on one
|
||||
bus at those addresses. Keep RAK12500 at `0x42`, strap INA3221 A0 to SCL for `0x43`, and use a build with
|
||||
`-DTELEM_INA3221_ADDRESS=0x43` when both are installed.
|
||||
|
||||
Selected nRF52 boards with matched external
|
||||
QSPI application and bootloader support can instead make the normal full-sensor
|
||||
repeater install-capable; those targets do not need to reserve internal flash
|
||||
@@ -283,8 +296,10 @@ running application. The hash check in the next step proves that it is the right
|
||||
|
||||
On RAK4631 repeaters, use the
|
||||
`RAK_4631_repeater_lora_ota_no_external_sensors` environment. It retains built-in battery monitoring but
|
||||
omits optional external environmental sensor packages so the delta fits the safe in-place workspace. INA219,
|
||||
omits selected optional environmental/ranging drivers so the delta fits the safe in-place workspace. INA219,
|
||||
INA226, INA260, and INA3221 I2C voltage/current monitors remain supported despite the legacy target name.
|
||||
That trim does not remove the SSD1306 OLED, autodiscovered I2C RTCs, or GPS-compatible RAK12500 I2C path.
|
||||
The `0x42` RAK12500/INA3221 addressing rule above still applies.
|
||||
If the device has a RAK15001 installed in sensor slot C and the matching
|
||||
RAK15001 OTAFIX bootloader, use
|
||||
`RAK_4631_repeater_rak15001_slot_c_lora_ota` instead. That target retains the
|
||||
|
||||
@@ -46,7 +46,7 @@ trailer is `0xC7000` bytes (815,104 bytes). To package a full self-update:
|
||||
|
||||
```bash
|
||||
motatool build --fw ./Heltec_tower_v2_sdcard-new.hex \
|
||||
--sign-key ./trusted-signer.key --out-dir ./motas
|
||||
--sign ./trusted-signer.key --out-dir ./motas
|
||||
motatool verify ./motas/*.mota
|
||||
```
|
||||
|
||||
@@ -60,7 +60,7 @@ motatool build \
|
||||
--fw ./Heltec_tower_v2_sdcard-new.hex \
|
||||
--patch-type in-place \
|
||||
--inplace-memory 0xC7000 \
|
||||
--sign-key ./trusted-signer.key \
|
||||
--sign ./trusted-signer.key \
|
||||
--out-dir ./motas
|
||||
motatool verify ./motas/*.mota
|
||||
```
|
||||
|
||||
@@ -142,10 +142,22 @@ stable release uses `0xFF`, and low-byte zero or all-ones values are rejected.
|
||||
There is no remote rollback/migration override; intentional rollback uses
|
||||
USB/BLE DFU or SWD.
|
||||
|
||||
The `no_external_sensors` profiles omit optional/add-on sensor packages to
|
||||
preserve flash headroom. A board recipe can retain GPS. The RAK3401 OTA
|
||||
repeater retains RAK12501 GPS in sensor slot A, and the RAK4631 OTA repeater
|
||||
retains GPS except where a Serial1 RS232 bridge owns the same UART.
|
||||
The legacy `no_external_sensors` profiles trim selected optional environmental
|
||||
and ranging drivers to preserve flash headroom; they do not generally disable
|
||||
the I2C bus. Reduced RAK3401 and RAK4631 recipes retain INA219, INA226, INA260,
|
||||
and INA3221 voltage/current monitors. Those are entries in the optional sensor
|
||||
table, not the complete set of I2C consumers: the SSD1306 OLED, supported
|
||||
autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals where the
|
||||
exact board recipe enables them. RAK3401 retains its compatible RAK12500 I2C
|
||||
and RAK12501/L76K UART GPS paths in sensor slot A. The plain RAK4631 repeater
|
||||
and Serial2 bridge retain GPS. Its RAK12501 path uses Serial1; the explicitly
|
||||
compiled Serial1 RS232 bridge therefore omits the combined GPS provider,
|
||||
including RAK12500, even though RAK12500 itself is I2C rather than UART.
|
||||
|
||||
The firmware-configured INA3221 and RAK12500 addresses are both `0x42`. They
|
||||
cannot coexist on the same bus at those addresses. Leave RAK12500 at `0x42`,
|
||||
strap INA3221 A0 to SCL for `0x43`, and use firmware built with
|
||||
`-DTELEM_INA3221_ADDRESS=0x43` when both modules are installed.
|
||||
|
||||
Boards with onboard external flash are not silently redirected to internal
|
||||
staging. Mesh Solar, Nano G2 Ultra, T-Impulse Plus, ThinkNode M8, T-Echo
|
||||
|
||||
+17
-7
@@ -128,13 +128,23 @@ established target-specific 1920 KiB or larger A/B app layout and are checked ag
|
||||
partition. For standalone ESP32 and nRF52 repeaters that need a lean staging
|
||||
profile, `build.sh` also exposes an explicit `*_lora_ota_no_external_sensors`
|
||||
artifact: the ordinary repeater remains sensor-enabled, while that sibling
|
||||
disables optional external environmental-sensor drivers for LoRa distribution. SolarXiao 30S and 33S use
|
||||
trims selected optional environmental/ranging drivers for LoRa distribution. SolarXiao 30S and 33S use
|
||||
matched external QSPI staging, so their ordinary full-sensor repeater is already install-capable and no
|
||||
redundant lean sibling is generated. Integrated GPS and other
|
||||
board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The RAK3401
|
||||
OTA repeater also retains RAK12501 GPS support; install that module in sensor slot A because slot D conflicts
|
||||
with the RAK13302 radio's BUSY/DIO1 lines. RAK4631 OTA repeaters retain GPS and default the runtime RS-232
|
||||
bridge to UART 2; do not enable GPS and an explicitly selected UART 1 bridge at the same time.
|
||||
board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The legacy
|
||||
suffix describes a driver trim, not removal of the generic I2C bus. Reduced RAK3401 and RAK4631 profiles
|
||||
retain INA219, INA226, INA260, and INA3221 voltage/current monitors. These are retained optional-sensor-table
|
||||
drivers, not the only I2C consumers: SSD1306 display, supported autodiscovered RTCs, and RAK12500 GPS remain
|
||||
separate I2C peripherals where selected by the board recipe. The RAK3401 OTA repeater also retains RAK12500
|
||||
I2C and RAK12501/L76K UART GPS support; install either GPS module in sensor slot A because slot D conflicts
|
||||
with the RAK13302 radio's BUSY/DIO1 lines. The plain RAK4631 OTA repeater and its Serial2 bridge retain GPS.
|
||||
RAK12501 uses Serial1. Its explicitly compiled Serial1 bridge therefore omits the combined GPS provider,
|
||||
including RAK12500, even though RAK12500 itself does not use the UART.
|
||||
|
||||
The firmware-configured INA3221 address and RAK12500 address are both `0x42`, so those devices cannot share
|
||||
one bus at those addresses. Keep RAK12500 at `0x42`, strap INA3221 A0 to SCL for `0x43`, and use firmware
|
||||
built with `-DTELEM_INA3221_ADDRESS=0x43` when both are installed.
|
||||
|
||||
ESP32 siblings retain the compact browser WiFi updater and use the full
|
||||
254-entry neighbor table. RP2040 and STM32 targets are not offered because
|
||||
those platforms do not yet have a safe bootloader/apply path.
|
||||
@@ -149,8 +159,8 @@ promotes every ESP32 MQTT observer and ESP-NOW bridge to the expanded FULL parti
|
||||
retain the complete role CLI, WebConfig where supported, display and optional sensor support, full timezone
|
||||
and TLS behavior, and the board's normal power-management implementation. The compact CLI is not compiled
|
||||
into any build. Ordinary repeater builds remain sensor-enabled; only explicitly named
|
||||
`*_lora_ota_no_external_sensors` siblings omit sensors for LoRa distribution, and those siblings retain the
|
||||
complete CLI.
|
||||
`*_lora_ota_no_external_sensors` siblings trim selected optional environmental/ranging drivers for LoRa
|
||||
distribution, and those siblings retain the complete CLI and target-declared I2C peripherals.
|
||||
|
||||
MQTT observer radio and bridge preferences use verified temporary files plus a recoverable backup. A reset
|
||||
during a settings save restores the last committed common preference image or publishes the completed new
|
||||
|
||||
+33
-13
@@ -42,22 +42,41 @@ preserves the RX benefit without risking missed packets from a legacy
|
||||
> install-capable image nor OTAFIX. Check the bootloader release for an exact board match before attempting an update.
|
||||
|
||||
The following nRF52 repeater families gained firmware-side LoRa OTA targets in
|
||||
this release. Their ordinary repeater remains the full-sensor build; the
|
||||
install-capable `lora_ota_no_external_sensors` sibling is smaller:
|
||||
this release. Their ordinary repeater keeps its complete declared sensor recipe;
|
||||
the install-capable `lora_ota_no_external_sensors` sibling is smaller:
|
||||
|
||||
- Heltec Mesh Solar, T1, and Tower V2
|
||||
- Keepteen LT1, LilyGo T-Impulse Plus, Mesh Pocket, and Nano G2 Ultra
|
||||
- Minewsemi ME25LS01, RAK3401, SenseCAP Solar, and Wio WM1110
|
||||
|
||||
The RAK3401 `RAK_3401_repeater_lora_ota_no_external_sensors` image retains
|
||||
RAK12501 GPS while omitting the other optional environmental sensors. Install
|
||||
the GPS in sensor slot A. Slot D's reset/PPS lines conflict with the RAK13302
|
||||
radio's BUSY/DIO1 lines.
|
||||
The RAK3401 `RAK_3401_repeater_lora_ota_no_external_sensors` image trims selected
|
||||
optional environmental/ranging drivers but does not disable I2C. It retains the
|
||||
INA219, INA226, INA260, and INA3221 voltage/current monitors, board display and
|
||||
RTC support, and both RAK12500 I2C and RAK12501/L76K UART GPS paths. The four
|
||||
INA drivers are only the retained voltage/current entries in the optional
|
||||
environmental-sensor table; they are not the only I2C users. The SSD1306 OLED,
|
||||
autodiscovered DS3231/RV3028/PCF8563/RX8130CE RTCs, and RAK12500 GPS use I2C as
|
||||
separate board peripherals. Install one GPS module in sensor slot A. Slot D's
|
||||
reset/PPS lines conflict with the RAK13302 radio's BUSY/DIO1 lines.
|
||||
|
||||
The RAK4631 internal-flash OTA repeater likewise retains GPS. A RAK12501 can
|
||||
use sensor slot A or D; a RAK12500 can use slot A or C. Its runtime RS-232
|
||||
bridge defaults to Serial2 so GPS can retain Serial1. If you explicitly select
|
||||
Serial1 with `set bridge.uart 1`, turn GPS off before enabling the bridge.
|
||||
The firmware-configured INA3221 address and the RAK12500 address are both
|
||||
`0x42`, so those devices cannot share one bus at those addresses. To install
|
||||
both, leave the RAK12500 at `0x42`, strap INA3221 A0 to SCL for `0x43`, and use
|
||||
a firmware build with `-DTELEM_INA3221_ADDRESS=0x43`.
|
||||
|
||||
The reduced RAK4631 profiles likewise retain the four INA monitors. The plain
|
||||
repeater and Serial2 bridge retain GPS: a RAK12501 can use sensor slot A or D,
|
||||
and a RAK12500 can use slot A or C. The runtime RS-232 bridge defaults to
|
||||
Serial2 so the UART RAK12501 can retain Serial1. The merged runtime image always
|
||||
reserves Serial1: a bounded silent probe cannot prove that a cold RAK12501 is
|
||||
absent, and turning the GPS setting off cannot stop a fitted module from driving
|
||||
the shared UART while WB_IO2/3V3_S remains powered. Use Serial2. Serial1 requires
|
||||
an explicit no-GPS/dedicated image. The reservation remains fail-closed even
|
||||
when an I2C RAK12500 is detected. The explicitly compiled
|
||||
`RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors` target
|
||||
omits the combined GPS provider because its bridge owns the UART used by
|
||||
RAK12501; consequently that legacy image does not expose the I2C RAK12500 path
|
||||
either, even though RAK12500 itself does not use the UART.
|
||||
|
||||
Selected nRF52 repeaters with dedicated external QSPI can now stage the
|
||||
complete package off-chip, so their normal full-sensor repeater build can
|
||||
@@ -69,9 +88,10 @@ RAK15001 slot-C target. These require the corresponding QSPI-aware OTAFIX bootlo
|
||||
|
||||
The ordinary full-sensor `RAK_4631_repeater` image remains too large for the
|
||||
safe internal in-place update limit. Without external flash, use
|
||||
`RAK_4631_repeater_lora_ota_no_external_sensors`; it removes optional external
|
||||
environmental/GPS packages but retains battery monitoring. A RAK4631 fitted
|
||||
with RAK15001 in sensor slot C can instead use
|
||||
`RAK_4631_repeater_lora_ota_no_external_sensors`; it trims selected optional
|
||||
environmental/ranging drivers while retaining generic I2C, battery monitoring,
|
||||
the four INA monitors, and target-compatible GPS. A RAK4631 fitted with
|
||||
RAK15001 in sensor slot C can instead use
|
||||
`RAK_4631_repeater_rak15001_slot_c_lora_ota` to retain the full sensor/GPS set
|
||||
and stage full images or deltas off-chip.
|
||||
|
||||
|
||||
@@ -2233,6 +2233,29 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strncmp(command, "set pin", 7) == 0
|
||||
&& (command[7] == 0 || command[7] == ' '
|
||||
|| command[7] == '\t')) {
|
||||
const char* value = command + 7;
|
||||
while (*value == ' ' || *value == '\t') value++;
|
||||
int32_t parsed = 0;
|
||||
if (!mesh::cli::parseIntegerStrict(value, parsed)
|
||||
|| parsed < 0 || parsed > 999999) {
|
||||
snprintf(reply, reply_size, "Error: pin must be 0-999999");
|
||||
} else {
|
||||
const uint32_t previous = _prefs.ble_pin;
|
||||
_prefs.ble_pin = static_cast<uint32_t>(parsed);
|
||||
if (!savePrefs()) {
|
||||
_prefs.ble_pin = previous;
|
||||
snprintf(reply, reply_size, "Error: pin changed but save failed");
|
||||
} else {
|
||||
snprintf(reply, reply_size, "> pin is now %06lu",
|
||||
(unsigned long)_prefs.ble_pin);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
||||
if (strcmp(command, "get espnow.channel") == 0) {
|
||||
formatEspNowChannel(reply, reply_size);
|
||||
@@ -5999,7 +6022,17 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
terminalOutput().printf(" OK - FEM TX gain %s\r\n", value);
|
||||
}
|
||||
} else {
|
||||
terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config);
|
||||
// The terminal has a few presentation-specific setters above, while
|
||||
// handleCommand() owns the shared radio-pref and board-specific command
|
||||
// surface used by framed/rescue clients. Use it only as the fallback so
|
||||
// Full Companion does not develop holes such as `set radio ...` without
|
||||
// changing the established terminal behavior of `set tx`, `set name`,
|
||||
// or the WiFi commands.
|
||||
if (handleCommand(command, 0, local_reply)) {
|
||||
terminalOutput().printf(" %s\r\n", local_reply);
|
||||
} else {
|
||||
terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config);
|
||||
}
|
||||
}
|
||||
} else if (strcmp(command, "reboot") == 0) {
|
||||
terminalOutput().print(" OK - rebooting in 1 second\r\n");
|
||||
@@ -6018,6 +6051,7 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n");
|
||||
terminalOutput().print(" get bluetooth.name\r\n");
|
||||
terminalOutput().print(" set bluetooth.name <name|default>\r\n");
|
||||
terminalOutput().print(" set pin <0-999999>\r\n");
|
||||
terminalOutput().print(" powersaving [on|off]\r\n");
|
||||
#if MESH_USB_LOGGING_AVAILABLE
|
||||
terminalOutput().print(" get usb.logging\r\n");
|
||||
@@ -6086,6 +6120,11 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
} else {
|
||||
terminalOutput().print(" disconnect (closes the TCP terminal)\r\n");
|
||||
}
|
||||
} else if (handleCommand(command, 0, local_reply)) {
|
||||
// Fill the same safe shared-command surface for getters (`get name`,
|
||||
// `get radio`, `get tx`, and variant commands). Terminal-only commands
|
||||
// above still win, including the richer `ver` response and reboot flow.
|
||||
terminalOutput().printf(" %s\r\n", local_reply);
|
||||
} else {
|
||||
terminalOutput().printf(" ERROR: unknown command: %s\r\n", command);
|
||||
}
|
||||
@@ -6235,25 +6274,6 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strncmp(command, "set pin ", 8) == 0) {
|
||||
int32_t parsed = 0;
|
||||
if (!mesh::cli::parseIntegerStrict(command + 8, parsed)
|
||||
|| parsed < 0 || parsed > 999999) {
|
||||
strcpy(reply, "Error: pin must be 0-999999");
|
||||
} else {
|
||||
const uint32_t previous = _prefs.ble_pin;
|
||||
_prefs.ble_pin = static_cast<uint32_t>(parsed);
|
||||
if (!savePrefs()) {
|
||||
_prefs.ble_pin = previous;
|
||||
strcpy(reply, "Error: pin changed but save failed");
|
||||
} else {
|
||||
snprintf(reply, reply_capacity, "> pin is now %06lu",
|
||||
(unsigned long)_prefs.ble_pin);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strcmp(command, "ver") == 0) {
|
||||
snprintf(reply, reply_capacity, "%s (Build: %s)", FIRMWARE_VERSION,
|
||||
FIRMWARE_BUILD_DATE);
|
||||
|
||||
@@ -67,6 +67,7 @@ MultiSerialInterface interface_manager;
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/WiFiReconnectPolicy.h>
|
||||
#include <helpers/WiFiPowerSave.h>
|
||||
#include <helpers/ui/DisplayTextLayout.h>
|
||||
#include <helpers/esp32/WiFiRadioPolicy.h>
|
||||
#include <helpers/esp32/WiFiStationPolicy.h>
|
||||
#include <Preferences.h>
|
||||
@@ -946,6 +947,47 @@ void halt() {
|
||||
static DisplayDriver* companion_setup_display = nullptr;
|
||||
static unsigned long companion_setup_display_refresh = 0;
|
||||
|
||||
static bool hasLargeCompanionSetupDisplay() {
|
||||
return companion_setup_display->width() >= 128
|
||||
&& companion_setup_display->height() >= 128;
|
||||
}
|
||||
|
||||
static int drawCompanionSetupValue(int y, int max_lines,
|
||||
const char* value) {
|
||||
static constexpr int margin = 6;
|
||||
static constexpr int line_height = 13;
|
||||
return mesh::ui::drawTextWrapped(
|
||||
*companion_setup_display, margin, y,
|
||||
companion_setup_display->width() - margin * 2, line_height,
|
||||
max_lines, value != nullptr && value[0] != 0 ? value : "(not set)");
|
||||
}
|
||||
|
||||
static void renderLargeCompanionSetupDisplay(const char* title,
|
||||
const char* wifi_label,
|
||||
const char* wifi_name,
|
||||
const char* address,
|
||||
bool connecting) {
|
||||
static constexpr int line_height = 13;
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 4, title);
|
||||
companion_setup_display->setCursor(6, 24);
|
||||
companion_setup_display->print(wifi_label);
|
||||
const int wifi_lines = drawCompanionSetupValue(38, 3, wifi_name);
|
||||
|
||||
int next_y = 38 + (wifi_lines > 0 ? wifi_lines : 1) * line_height + 8;
|
||||
if (connecting) {
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, next_y, "Please wait...");
|
||||
return;
|
||||
}
|
||||
|
||||
companion_setup_display->setCursor(6, next_y);
|
||||
companion_setup_display->print("Open in browser:");
|
||||
char url[32];
|
||||
snprintf(url, sizeof(url), "http://%s/", address ? address : "");
|
||||
drawCompanionSetupValue(next_y + 14, 2, url);
|
||||
}
|
||||
|
||||
static void renderCompanionSetupDisplay() {
|
||||
if (!companion_setup_display
|
||||
|| static_cast<int32_t>(millis() - companion_setup_display_refresh) < 0) return;
|
||||
@@ -960,37 +1002,54 @@ void halt() {
|
||||
char setup_ip[16] = {0};
|
||||
if (WebConfigServer::getSetupInfo(setup_ssid, sizeof(setup_ssid),
|
||||
setup_ip, sizeof(setup_ip))) {
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 0, "WebUI setup");
|
||||
companion_setup_display->setCursor(0, 14);
|
||||
companion_setup_display->print("Join open WiFi:");
|
||||
companion_setup_display->drawTextEllipsized(
|
||||
0, 25, companion_setup_display->width(), setup_ssid);
|
||||
companion_setup_display->setCursor(0, 39);
|
||||
companion_setup_display->print("Open in browser:");
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 51, setup_ip);
|
||||
if (hasLargeCompanionSetupDisplay()) {
|
||||
renderLargeCompanionSetupDisplay(
|
||||
"WebUI setup", "Join open WiFi:", setup_ssid, setup_ip, false);
|
||||
} else {
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 0, "WebUI setup");
|
||||
companion_setup_display->setCursor(0, 14);
|
||||
companion_setup_display->print("Join open WiFi:");
|
||||
companion_setup_display->drawTextEllipsized(
|
||||
0, 25, companion_setup_display->width(), setup_ssid);
|
||||
companion_setup_display->setCursor(0, 39);
|
||||
companion_setup_display->print("Open in browser:");
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 51, setup_ip);
|
||||
}
|
||||
} else if (WiFi.status() == WL_CONNECTED) {
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 0, "WebUI");
|
||||
companion_setup_display->setCursor(0, 14);
|
||||
companion_setup_display->print("Join WiFi:");
|
||||
companion_setup_display->drawTextEllipsized(
|
||||
0, 25, companion_setup_display->width(), configured_wifi_ssid);
|
||||
companion_setup_display->setCursor(0, 39);
|
||||
companion_setup_display->print("Open in browser:");
|
||||
const String ip = WiFi.localIP().toString();
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 51, ip.c_str());
|
||||
if (hasLargeCompanionSetupDisplay()) {
|
||||
renderLargeCompanionSetupDisplay(
|
||||
"WebUI ready", "Join WiFi:", configured_wifi_ssid, ip.c_str(),
|
||||
false);
|
||||
} else {
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 0, "WebUI");
|
||||
companion_setup_display->setCursor(0, 14);
|
||||
companion_setup_display->print("Join WiFi:");
|
||||
companion_setup_display->drawTextEllipsized(
|
||||
0, 25, companion_setup_display->width(), configured_wifi_ssid);
|
||||
companion_setup_display->setCursor(0, 39);
|
||||
companion_setup_display->print("Open in browser:");
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 51, ip.c_str());
|
||||
}
|
||||
} else {
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 8, "WiFi connecting");
|
||||
companion_setup_display->setCursor(0, 25);
|
||||
companion_setup_display->print("SSID:");
|
||||
companion_setup_display->drawTextEllipsized(
|
||||
0, 38, companion_setup_display->width(), configured_wifi_ssid);
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 52, "Please wait...");
|
||||
if (hasLargeCompanionSetupDisplay()) {
|
||||
renderLargeCompanionSetupDisplay(
|
||||
"WiFi connecting", "WiFi name:", configured_wifi_ssid, nullptr,
|
||||
true);
|
||||
} else {
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 8, "WiFi connecting");
|
||||
companion_setup_display->setCursor(0, 25);
|
||||
companion_setup_display->print("SSID:");
|
||||
companion_setup_display->drawTextEllipsized(
|
||||
0, 38, companion_setup_display->width(), configured_wifi_ssid);
|
||||
companion_setup_display->drawTextCentered(
|
||||
companion_setup_display->width() / 2, 52, "Please wait...");
|
||||
}
|
||||
}
|
||||
companion_setup_display->endFrame();
|
||||
}
|
||||
@@ -1698,6 +1757,13 @@ void loop() {
|
||||
#endif
|
||||
sensors.loop();
|
||||
#ifdef DISPLAY_CLASS
|
||||
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
||||
// The Indicator keeps rendering with its built-in fallback while a missing
|
||||
// SD font is recovered by a background TLS task. This poll only launches
|
||||
// work after station Wi-Fi connects and installs a completed font on the
|
||||
// main/UI task.
|
||||
display.serviceFontRecovery();
|
||||
#endif
|
||||
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG)
|
||||
if (isCompanionWiFiEnabled() && (the_mesh.isWebConfigSetupActive()
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
#include <stdlib.h> // for qsort()
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/ClockSyncUtils.h>
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/DatagramPayloadLimits.h>
|
||||
#include <helpers/FloodFilterPolicy.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/RegionNameUtils.h>
|
||||
#if MESH_PACKET_LOGGING
|
||||
#include <helpers/SerialPacketLog.h>
|
||||
@@ -525,18 +528,23 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn
|
||||
}
|
||||
|
||||
uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) {
|
||||
ClientInfo* client = NULL;
|
||||
if (strcmp((char *)data, _prefs.password) != 0) { // admin pw bypasses ACL (allows upgrade)
|
||||
client = acl.getClient(sender.pub_key, PUB_KEY_SIZE);
|
||||
if (client == NULL) {
|
||||
ClientInfo* existing_client =
|
||||
acl.getClient(sender.pub_key, PUB_KEY_SIZE);
|
||||
const bool admin_password =
|
||||
strcmp((char *)data, _prefs.password) == 0;
|
||||
ClientInfo* client = admin_password ? NULL : existing_client;
|
||||
uint8_t perms;
|
||||
if (client != NULL) {
|
||||
// A preauthorized ACL identity is authenticated by the anonymous packet's
|
||||
// ECDH MAC and does not need to repeat a password. Preserve its role.
|
||||
perms = client->permissions & PERM_ACL_ROLE_MASK;
|
||||
} else {
|
||||
if (!admin_password && existing_client == NULL) {
|
||||
#if MESH_DEBUG
|
||||
MESH_DEBUG_PRINTLN("Login, sender not in ACL");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
if (client == NULL) {
|
||||
uint8_t perms;
|
||||
if (strcmp((char *)data, _prefs.password) == 0) { // check for valid admin password
|
||||
if (admin_password) { // valid admin password bypasses ACL (allows upgrade)
|
||||
perms = PERM_ACL_ADMIN;
|
||||
} else if (strcmp((char *)data, _prefs.guest_password) == 0) { // check guest password
|
||||
perms = PERM_ACL_GUEST;
|
||||
@@ -546,31 +554,46 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const bool client_existed = existing_client != NULL;
|
||||
const uint32_t previous_timestamp =
|
||||
client_existed ? existing_client->last_timestamp : 0;
|
||||
if (!acl.authorizeLoginTimestamp(
|
||||
sender.pub_key, sender_timestamp, previous_timestamp, perms)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Login rejected: replayed timestamp or replay state unavailable");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (client == NULL) {
|
||||
client = acl.putClient(sender, 0); // add to contacts (if not already known)
|
||||
if (client == NULL) {
|
||||
MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts");
|
||||
return 0;
|
||||
}
|
||||
if (sender_timestamp <= client->last_timestamp) {
|
||||
MESH_DEBUG_PRINTLN("Possible login replay attack!");
|
||||
return 0; // FATAL: client table is full -OR- replay attack
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("Login success!");
|
||||
client->last_timestamp = sender_timestamp;
|
||||
client->last_activity = getRTCClock()->getCurrentTime();
|
||||
client->permissions &= ~PERM_ACL_ROLE_MASK;
|
||||
client->permissions |= perms;
|
||||
memcpy(client->shared_secret, secret, PUB_KEY_SIZE);
|
||||
|
||||
if (perms != PERM_ACL_GUEST) { // keep number of FS writes to a minimum
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_flood) {
|
||||
client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path
|
||||
MESH_DEBUG_PRINTLN("Login success!");
|
||||
const uint8_t previous_permissions = client->permissions;
|
||||
const bool reset_out_path = is_flood
|
||||
&& client->out_path_len != OUT_PATH_FORCE_FLOOD;
|
||||
const bool persisted_changed = mesh::applySuccessfulClientLogin(
|
||||
*client, client_existed, perms, PERM_ACL_ROLE_MASK,
|
||||
secret, sender_timestamp,
|
||||
getRTCClock()->getCurrentTime(), reset_out_path, OUT_PATH_UNKNOWN);
|
||||
|
||||
const bool persistence_needed =
|
||||
mesh::successfulClientLoginNeedsPersistence(
|
||||
client_existed, previous_permissions, client->permissions,
|
||||
PERM_ACL_ROLE_MASK, PERM_ACL_GUEST,
|
||||
persisted_changed);
|
||||
if (persistence_needed) {
|
||||
// Keep writes bounded: replay/activity timestamps are transient, so an
|
||||
// unchanged preauthorized/admin login does not rewrite the complete ACL.
|
||||
mesh::scheduleLazyPersistenceMutation(
|
||||
dirty_contacts_expiry, contacts_save_failures,
|
||||
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
||||
}
|
||||
|
||||
uint32_t now = getRTCClock()->getCurrentTimeUnique();
|
||||
@@ -671,9 +694,9 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender
|
||||
memcpy(&reply_data[4], &now, 4); // include our clock (for easy clock sync, and packet hash uniqueness)
|
||||
reply_data[8] = 0; // features
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
if (_prefs.bridge_enabled) reply_data[8] |= 0x01; // is bridge, type UART
|
||||
if (isBridgeRunning()) reply_data[8] |= 0x01; // is bridge, type UART
|
||||
#elif WITH_ESPNOW_BRIDGE
|
||||
reply_data[8] |= 0x03; // is bridge, type ESP-NOW
|
||||
if (isBridgeRunning()) reply_data[8] |= 0x03; // is bridge, type ESP-NOW
|
||||
#endif
|
||||
if (_prefs.disable_fwd) { // is this repeater currently disabled
|
||||
reply_data[8] |= 0x80; // is disabled
|
||||
@@ -933,23 +956,30 @@ void MyMesh::sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned
|
||||
fallback_scope_ptr);
|
||||
}
|
||||
|
||||
void MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet,
|
||||
bool MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet,
|
||||
unsigned long delay_millis, uint8_t path_hash_size,
|
||||
const TransportKey* fallback_scope) {
|
||||
const TransportKey* fallback_scope,
|
||||
bool allow_redundant_copies) {
|
||||
if (packet == NULL) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (client == NULL || !mesh::Packet::isValidPathLen(client->out_path_len)) {
|
||||
const uint8_t flood_retry_attempts = _prefs.flood_retry_attempts;
|
||||
if (!allow_redundant_copies) _prefs.flood_retry_attempts = 0;
|
||||
bool queued;
|
||||
if (fallback_scope != NULL) {
|
||||
sendFloodScoped(*fallback_scope, packet, delay_millis, path_hash_size);
|
||||
queued = sendFloodScoped(*fallback_scope, packet, delay_millis,
|
||||
path_hash_size);
|
||||
} else {
|
||||
sendFlood(packet, delay_millis, path_hash_size);
|
||||
queued = sendFlood(packet, delay_millis, path_hash_size);
|
||||
}
|
||||
return;
|
||||
_prefs.flood_retry_attempts = flood_retry_attempts;
|
||||
return queued;
|
||||
}
|
||||
|
||||
mesh::Packet* alt = NULL;
|
||||
if (mesh::Packet::isValidPathLen(client->alt_path_len)
|
||||
if (allow_redundant_copies
|
||||
&& mesh::Packet::isValidPathLen(client->alt_path_len)
|
||||
&& !directPathsEqual(client->out_path, client->out_path_len, client->alt_path, client->alt_path_len)) {
|
||||
alt = obtainNewPacket();
|
||||
if (alt != NULL) {
|
||||
@@ -959,13 +989,17 @@ void MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet*
|
||||
}
|
||||
}
|
||||
|
||||
sendDirect(packet, client->out_path, client->out_path_len, delay_millis);
|
||||
const uint8_t direct_retry_enabled = _prefs.direct_retry_enabled;
|
||||
if (!allow_redundant_copies) _prefs.direct_retry_enabled = 0;
|
||||
const bool primary_queued =
|
||||
sendDirect(packet, client->out_path, client->out_path_len, delay_millis);
|
||||
_prefs.direct_retry_enabled = direct_retry_enabled;
|
||||
if (alt != NULL) {
|
||||
uint8_t direct_retry_enabled = _prefs.direct_retry_enabled;
|
||||
_prefs.direct_retry_enabled = 0;
|
||||
sendDirect(alt, client->alt_path, client->alt_path_len, delay_millis);
|
||||
_prefs.direct_retry_enabled = direct_retry_enabled;
|
||||
}
|
||||
return primary_queued;
|
||||
}
|
||||
|
||||
bool MyMesh::floodChannelDataHopApplies(const mesh::Packet* packet) const {
|
||||
@@ -1088,8 +1122,10 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
|
||||
if (mqtt_bridge) mqtt_bridge->onPacketReceived(pkt);
|
||||
#elif defined(WITH_BRIDGE)
|
||||
// Non-MQTT bridge: use bridge.source setting
|
||||
if (_prefs.bridge_pkt_src == 1) {
|
||||
activeBridge()->sendPacket(pkt);
|
||||
AbstractBridge* active_bridge = activeBridge();
|
||||
if (_prefs.bridge_pkt_src == 1 && active_bridge
|
||||
&& active_bridge->isRunning()) {
|
||||
active_bridge->sendPacket(pkt);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1131,8 +1167,10 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) {
|
||||
if (mqtt_bridge) mqtt_bridge->sendPacket(pkt);
|
||||
#elif defined(WITH_BRIDGE)
|
||||
// Non-MQTT bridge: use bridge.source setting
|
||||
if (_prefs.bridge_pkt_src == 0) {
|
||||
activeBridge()->sendPacket(pkt);
|
||||
AbstractBridge* active_bridge = activeBridge();
|
||||
if (_prefs.bridge_pkt_src == 0 && active_bridge
|
||||
&& active_bridge->isRunning()) {
|
||||
active_bridge->sendPacket(pkt);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2503,6 +2541,10 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
|
||||
uint8_t *data, size_t len) {
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin
|
||||
// client (unknown at this stage)
|
||||
if (len < 4) {
|
||||
MESH_DEBUG_PRINTLN("Rejected incomplete anonymous request");
|
||||
return;
|
||||
}
|
||||
uint32_t timestamp;
|
||||
memcpy(×tamp, data, 4);
|
||||
|
||||
@@ -2750,10 +2792,22 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
&& region_map.getTransportKeysFor(*recv_pkt_region, &reply_scope, 1) > 0;
|
||||
|
||||
if (cached_retry) {
|
||||
MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply");
|
||||
sendRemoteCliReply(client, secret, packet->getPathHashSize(),
|
||||
sender_timestamp, cached_response,
|
||||
reply_scoped ? &reply_scope : NULL);
|
||||
const bool cached_temp_radio_success =
|
||||
strncmp(command, "tempradio ", 10) == 0
|
||||
&& strncmp(cached_response, "OK - temp params for ", 21) == 0;
|
||||
if (cached_temp_radio_success) {
|
||||
// The original acknowledgement is the only packet allowed to
|
||||
// release its radio-mutation barrier. Replaying this success would
|
||||
// create another untracked copy, including after a TX failure
|
||||
// cancelled the handoff.
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"onPeerDataRecv: TempRadio acknowledgement is already authoritative");
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply");
|
||||
sendRemoteCliReply(client, secret, packet->getPathHashSize(),
|
||||
sender_timestamp, cached_response,
|
||||
reply_scoped ? &reply_scope : NULL);
|
||||
}
|
||||
} else if (deferred_cli_command.matches(i, request_id, command,
|
||||
command_len)) {
|
||||
// The original request is already queued. Let it produce the one
|
||||
@@ -2787,10 +2841,12 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
|
||||
bool MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
|
||||
uint8_t path_hash_size, uint32_t sender_timestamp,
|
||||
const char* reply, const TransportKey* fallback_scope) {
|
||||
if (client == NULL || secret == NULL || reply == NULL) return;
|
||||
const char* reply, const TransportKey* fallback_scope,
|
||||
mesh::Packet** queued_packet) {
|
||||
if (queued_packet != NULL) *queued_packet = NULL;
|
||||
if (client == NULL || secret == NULL || reply == NULL) return false;
|
||||
if (reply[0] == 0) reply = "OK";
|
||||
|
||||
size_t text_len = strlen(reply);
|
||||
@@ -2811,8 +2867,17 @@ void MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
|
||||
|
||||
mesh::Packet* packet = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret,
|
||||
reply_data, 5 + text_len);
|
||||
sendClientReplyWithFallbackScope(client, packet, CLI_REPLY_DELAY_MILLIS,
|
||||
path_hash_size, fallback_scope);
|
||||
// A caller which requests the exact queued packet is going to make a state
|
||||
// transition depend on that packet's TX result. Keep one authoritative
|
||||
// copy: an untracked alternate or retry could report success after primary
|
||||
// failure cancelled the transition, or still be queued when primary success
|
||||
// lets the radio tuple change. Ordinary replies retain redundant delivery.
|
||||
const bool allow_redundant_copies = queued_packet == NULL;
|
||||
const bool queued = sendClientReplyWithFallbackScope(
|
||||
client, packet, CLI_REPLY_DELAY_MILLIS, path_hash_size, fallback_scope,
|
||||
allow_redundant_copies);
|
||||
if (queued && queued_packet != NULL) *queued_packet = packet;
|
||||
return queued;
|
||||
}
|
||||
|
||||
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
|
||||
@@ -3059,13 +3124,31 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() {
|
||||
mesh::RemoteCliReplyCache::fingerprint(
|
||||
deferred_cli_command.command,
|
||||
strlen(deferred_cli_command.command));
|
||||
const bool arms_temp_radio =
|
||||
strncmp(deferred_cli_command.command, "tempradio ", 10) == 0
|
||||
&& strncmp(reply, "OK - temp params for ", 21) == 0;
|
||||
remote_cli_reply_cache.remember(client->id.pub_key,
|
||||
deferred_cli_command.request_id,
|
||||
command_fingerprint, reply);
|
||||
sendRemoteCliReply(client, deferred_cli_command.secret,
|
||||
deferred_cli_command.path_hash_size,
|
||||
deferred_cli_command.sender_timestamp, reply,
|
||||
deferred_cli_reply_scoped ? &deferred_cli_reply_scope : NULL);
|
||||
mesh::Packet* queued_reply = NULL;
|
||||
const bool reply_queued = sendRemoteCliReply(
|
||||
client, deferred_cli_command.secret,
|
||||
deferred_cli_command.path_hash_size,
|
||||
deferred_cli_command.sender_timestamp, reply,
|
||||
deferred_cli_reply_scoped ? &deferred_cli_reply_scope : NULL,
|
||||
arms_temp_radio ? &queued_reply : NULL);
|
||||
if (arms_temp_radio) {
|
||||
if (reply_queued && queued_reply != NULL) {
|
||||
// The old two-RTC-second delay could expire while this exact packet was
|
||||
// still parked behind CAD/duty/queue work. Do not change modulation
|
||||
// until Dispatcher confirms that the reply physically transmitted.
|
||||
temp_radio_reply_barrier.arm(queued_reply);
|
||||
} else {
|
||||
// A command which cannot queue its acknowledgement must never strand
|
||||
// the administrator on an unconfirmed tuple.
|
||||
scheduleNormalRadio();
|
||||
}
|
||||
}
|
||||
clearDeferredCliCommand();
|
||||
}
|
||||
|
||||
@@ -3078,10 +3161,16 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t
|
||||
MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len);
|
||||
auto client = acl.getClientByIdx(i);
|
||||
|
||||
// store a copy of path, for sendDirect()
|
||||
if (client->out_path_len != OUT_PATH_FORCE_FLOOD) {
|
||||
client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len);
|
||||
}
|
||||
// PATH packets authenticate the sender and are de-duplicated in RAM, but
|
||||
// the protocol carries no timestamp or request nonce. Keep a learned route
|
||||
// useful for this boot without making an arbitrarily old replay durable.
|
||||
const bool persistence_allowed = mesh::clientPathPersistenceAllowed(
|
||||
client->permissions != 0, false /* no durable replay proof */);
|
||||
const mesh::ClientPathUpdateResult path_update =
|
||||
mesh::applyReceivedClientPath(
|
||||
*client, path, path_len, persistence_allowed,
|
||||
OUT_PATH_FORCE_FLOOD);
|
||||
(void)path_update;
|
||||
client->last_activity = getRTCClock()->getCurrentTime();
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i);
|
||||
@@ -3207,6 +3296,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
pending_battery_alert_packet = NULL;
|
||||
battery_alert_sent = false;
|
||||
dirty_contacts_expiry = 0;
|
||||
contacts_save_failures = 0;
|
||||
active_bw = 0.0f;
|
||||
active_sf = 0;
|
||||
active_cr = 0;
|
||||
@@ -3501,7 +3591,17 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
if (!bridge) {
|
||||
bridge = createRS232Bridge();
|
||||
}
|
||||
#endif
|
||||
if (!bridge || !beginRS232Bridge()) {
|
||||
// Keep configured intent for a later explicit retry, but clean any
|
||||
// partial object/GPS ownership. Advertisements and bridge.running report
|
||||
// the actual stopped state rather than the saved preference.
|
||||
MESH_DEBUG_PRINTLN("RS232 bridge configured on but failed to start");
|
||||
if (!setBridgeState(false)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"RS232 bridge cleanup failed; UART/GPS ownership remains tracked");
|
||||
}
|
||||
}
|
||||
#else
|
||||
AbstractBridge* active_bridge = activeBridge();
|
||||
if (active_bridge) {
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
@@ -3534,6 +3634,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
|
||||
active_bridge->begin();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3754,6 +3855,7 @@ bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope,
|
||||
|
||||
void MyMesh::onSendComplete(mesh::Packet* packet) {
|
||||
mesh::Mesh::onSendComplete(packet);
|
||||
temp_radio_reply_barrier.complete(packet);
|
||||
if (packet == pending_battery_alert_packet) {
|
||||
pending_battery_alert_packet = NULL;
|
||||
battery_alert_sent = true;
|
||||
@@ -3763,6 +3865,12 @@ void MyMesh::onSendComplete(mesh::Packet* packet) {
|
||||
|
||||
void MyMesh::onSendFail(mesh::Packet* packet) {
|
||||
mesh::Mesh::onSendFail(packet);
|
||||
if (temp_radio_reply_barrier.fail(packet)) {
|
||||
// Failure of the exact acknowledgement packet cancels the unconfirmed
|
||||
// handoff. A later cached-command retry may replay the reply, but it does
|
||||
// not re-run the mutation.
|
||||
scheduleNormalRadio();
|
||||
}
|
||||
if (packet == pending_battery_alert_packet) {
|
||||
pending_battery_alert_packet = NULL;
|
||||
}
|
||||
@@ -4075,6 +4183,7 @@ void MyMesh::clearScheduledRadioSetting(int idx, bool restore_if_started) {
|
||||
&& scheduled_radio_settings[idx].started;
|
||||
scheduled_radio_settings[idx].active = false;
|
||||
scheduled_radio_settings[idx].started = false;
|
||||
scheduled_radio_settings[idx].hard_end_uptime_millis = 0;
|
||||
refreshScheduledRadioState();
|
||||
if (scheduled_radio_settings[idx].temporary && temp_radio_handoff_pending
|
||||
&& countScheduledRadioSettings(true) == 0) {
|
||||
@@ -4197,6 +4306,12 @@ void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8
|
||||
scheduled_radio_settings[slot].cr = cr;
|
||||
scheduled_radio_settings[slot].start_time = start_time;
|
||||
scheduled_radio_settings[slot].end_time = temporary ? end_time : 0;
|
||||
const uint64_t current_uptime_millis =
|
||||
uptime_millis + (uint32_t)(millis() - last_millis);
|
||||
scheduled_radio_settings[slot].hard_end_uptime_millis = temporary
|
||||
? mesh::TempRadioLeaseDeadline::fromEpochEnd(
|
||||
current_uptime_millis, now, end_time)
|
||||
: 0;
|
||||
// A newly requested schedule must not inherit the backoff of an older radio
|
||||
// apply failure, especially when its deadline is sooner than that retry.
|
||||
scheduled_radio_retry_at = 0;
|
||||
@@ -4275,6 +4390,7 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch
|
||||
restore_radio = restore_radio || (setting.temporary && setting.started);
|
||||
setting.active = false;
|
||||
setting.started = false;
|
||||
setting.hard_end_uptime_millis = 0;
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
@@ -4306,7 +4422,40 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch
|
||||
}
|
||||
|
||||
void MyMesh::processScheduledRadioSettings() {
|
||||
if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) return;
|
||||
const uint64_t current_uptime_millis =
|
||||
uptime_millis + (uint32_t)(millis() - last_millis);
|
||||
bool hard_temp_end_due = false;
|
||||
for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) {
|
||||
const ScheduledRadioSetting& setting = scheduled_radio_settings[i];
|
||||
if (setting.active && setting.temporary
|
||||
&& mesh::TempRadioLeaseDeadline::expired(
|
||||
current_uptime_millis, setting.hard_end_uptime_millis)) {
|
||||
hard_temp_end_due = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (temp_radio_reply_barrier.waiting() && !hard_temp_end_due) {
|
||||
// The exact parameterized-TempRadio reply is queued or on air. Unlike a
|
||||
// fixed RTC delay, this remains correct under CAD, duty throttling, and
|
||||
// unrelated queue pressure. TX completion/failure releases the barrier.
|
||||
scheduled_radio_retry_at = futureMillis(RADIO_APPLY_RETRY_INTERVAL_MILLIS);
|
||||
return;
|
||||
}
|
||||
if (hard_temp_end_due) {
|
||||
// Expiry is stronger than reply delivery. This matters when a new
|
||||
// immediate TempRadio command arrives while an older temporary tuple is
|
||||
// already active: a permanently queued acknowledgement must not preserve
|
||||
// that old tuple beyond the newly accepted hard lease. The late reply can
|
||||
// still leave on the restored channel, but it can no longer cause a switch.
|
||||
temp_radio_reply_barrier.clear();
|
||||
}
|
||||
// A radio-apply backoff must not turn into a lease extension. Bypass it
|
||||
// only for an expired temporary window; ordinary starts and permanent
|
||||
// changes retain their existing bounded retry behavior.
|
||||
if (scheduled_radio_retry_at
|
||||
&& !millisHasNowPassed(scheduled_radio_retry_at)
|
||||
&& !hard_temp_end_due) return;
|
||||
|
||||
const bool schedule_check_due = next_scheduled_radio_time != 0
|
||||
&& (next_scheduled_radio_check_at == 0
|
||||
@@ -4320,7 +4469,7 @@ void MyMesh::processScheduledRadioSettings() {
|
||||
}
|
||||
bool saved_apply_due = saved_radio_apply_pending && !temp_radio_handoff_pending
|
||||
&& !scheduled_temp_radio_started;
|
||||
if (!schedule_due && !saved_apply_due) return;
|
||||
if (!schedule_due && !saved_apply_due && !hard_temp_end_due) return;
|
||||
|
||||
// Never touch modulation registers while a packet is still on air. Back off
|
||||
// this check too; a long packet should not make the scheduler poll every loop.
|
||||
@@ -4332,6 +4481,28 @@ void MyMesh::processScheduledRadioSettings() {
|
||||
bool apply_failed = false;
|
||||
bool saved_params_changed = false;
|
||||
|
||||
if (hard_temp_end_due) {
|
||||
// This applies to pending as well as active windows. A backward wall-clock
|
||||
// correction can therefore neither postpone activation into the future nor
|
||||
// keep an already-active node on the temporary tuple past the accepted
|
||||
// monotonic lease bound.
|
||||
for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) {
|
||||
ScheduledRadioSetting& setting = scheduled_radio_settings[i];
|
||||
if (setting.active && setting.temporary
|
||||
&& mesh::TempRadioLeaseDeadline::expired(
|
||||
current_uptime_millis, setting.hard_end_uptime_millis)) {
|
||||
const bool was_started = setting.started;
|
||||
setting.active = false;
|
||||
setting.started = false;
|
||||
setting.hard_end_uptime_millis = 0;
|
||||
if (was_started || temp_radio_handoff_pending) {
|
||||
temp_radio_handoff_pending = false;
|
||||
queueSavedRadioApply();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (schedule_due) {
|
||||
int due_idx = -1;
|
||||
for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) {
|
||||
@@ -4374,6 +4545,7 @@ void MyMesh::processScheduledRadioSettings() {
|
||||
if (setting.active && setting.temporary && setting.started && now >= setting.end_time) {
|
||||
setting.active = false;
|
||||
setting.started = false;
|
||||
setting.hard_end_uptime_millis = 0;
|
||||
queueSavedRadioApply();
|
||||
}
|
||||
}
|
||||
@@ -4383,6 +4555,7 @@ void MyMesh::processScheduledRadioSettings() {
|
||||
if (setting.active && setting.temporary && !setting.started && now >= setting.start_time) {
|
||||
if (now >= setting.end_time) {
|
||||
setting.active = false;
|
||||
setting.hard_end_uptime_millis = 0;
|
||||
if (temp_radio_handoff_pending) {
|
||||
temp_radio_handoff_pending = false;
|
||||
queueSavedRadioApply();
|
||||
@@ -4431,6 +4604,18 @@ bool MyMesh::isMillisTimerDue(unsigned long timestamp) const {
|
||||
}
|
||||
|
||||
bool MyMesh::hasScheduledRadioWorkDue() const {
|
||||
const uint64_t current_uptime_millis =
|
||||
uptime_millis + (uint32_t)(millis() - last_millis);
|
||||
for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) {
|
||||
const ScheduledRadioSetting& setting = scheduled_radio_settings[i];
|
||||
if (setting.active && setting.temporary
|
||||
&& mesh::TempRadioLeaseDeadline::expired(
|
||||
current_uptime_millis, setting.hard_end_uptime_millis)) return true;
|
||||
}
|
||||
// The monotonic lease end above is authoritative even while an ordinary
|
||||
// radio-apply retry is backed off. Checking the retry first could let the
|
||||
// power manager sleep through the hard end and extend TempRadio by up to a
|
||||
// complete retry interval.
|
||||
if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) return false;
|
||||
if (saved_radio_apply_pending && !temp_radio_handoff_pending
|
||||
&& !scheduled_temp_radio_started) return true;
|
||||
@@ -4453,10 +4638,21 @@ uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep
|
||||
}
|
||||
|
||||
uint32_t MyMesh::limitSleepToScheduledRadioWork(uint32_t sleep_secs) const {
|
||||
if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) {
|
||||
return limitSleepToMillisTimer(scheduled_radio_retry_at, sleep_secs);
|
||||
sleep_secs = limitSleepToMillisTimer(next_scheduled_radio_check_at, sleep_secs);
|
||||
const uint64_t current_uptime_millis =
|
||||
uptime_millis + (uint32_t)(millis() - last_millis);
|
||||
for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) {
|
||||
const ScheduledRadioSetting& setting = scheduled_radio_settings[i];
|
||||
if (!setting.active || !setting.temporary) continue;
|
||||
const uint32_t hard_end_seconds =
|
||||
mesh::TempRadioLeaseDeadline::secondsUntil(
|
||||
current_uptime_millis, setting.hard_end_uptime_millis);
|
||||
if (hard_end_seconds < sleep_secs) sleep_secs = hard_end_seconds;
|
||||
}
|
||||
return limitSleepToMillisTimer(next_scheduled_radio_check_at, sleep_secs);
|
||||
if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) {
|
||||
sleep_secs = limitSleepToMillisTimer(scheduled_radio_retry_at, sleep_secs);
|
||||
}
|
||||
return sleep_secs;
|
||||
}
|
||||
|
||||
uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
|
||||
@@ -4487,6 +4683,10 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
|
||||
}
|
||||
|
||||
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
|
||||
// A newer TempRadio command supersedes the reply barrier belonging to the
|
||||
// old schedule. processDeferredCliCommand() arms the new exact reply after
|
||||
// it has been composed and successfully queued.
|
||||
temp_radio_reply_barrier.clear();
|
||||
scheduled_radio_retry_at = 0;
|
||||
scheduled_radio_retry_failures = 0;
|
||||
bool cancelled_started_temp = false;
|
||||
@@ -4495,6 +4695,7 @@ void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr,
|
||||
cancelled_started_temp = cancelled_started_temp || scheduled_radio_settings[i].started;
|
||||
scheduled_radio_settings[i].active = false;
|
||||
scheduled_radio_settings[i].started = false;
|
||||
scheduled_radio_settings[i].hard_end_uptime_millis = 0;
|
||||
}
|
||||
}
|
||||
if (cancelled_started_temp) {
|
||||
@@ -4524,6 +4725,12 @@ void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr,
|
||||
scheduled_radio_settings[slot].cr = cr;
|
||||
scheduled_radio_settings[slot].start_time = start_time;
|
||||
scheduled_radio_settings[slot].end_time = start_time + ((uint32_t)timeout_mins * 60);
|
||||
const uint64_t current_uptime_millis =
|
||||
uptime_millis + (uint32_t)(millis() - last_millis);
|
||||
scheduled_radio_settings[slot].hard_end_uptime_millis =
|
||||
mesh::TempRadioLeaseDeadline::fromEpochEnd(
|
||||
current_uptime_millis, getRTCClock()->getCurrentTime(),
|
||||
scheduled_radio_settings[slot].end_time);
|
||||
refreshScheduledRadioState();
|
||||
}
|
||||
|
||||
@@ -4531,10 +4738,12 @@ bool MyMesh::scheduleNormalRadio() {
|
||||
// Cancel every pending/active temporary entry, but leave permanent radioat
|
||||
// changes intact. The saved apply waits for the CLI reply to leave the
|
||||
// outbound queue before changing modulation parameters.
|
||||
temp_radio_reply_barrier.clear();
|
||||
for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) {
|
||||
if (!scheduled_radio_settings[i].temporary) continue;
|
||||
scheduled_radio_settings[i].active = false;
|
||||
scheduled_radio_settings[i].started = false;
|
||||
scheduled_radio_settings[i].hard_end_uptime_millis = 0;
|
||||
}
|
||||
temp_radio_handoff_pending = false;
|
||||
refreshScheduledRadioState();
|
||||
@@ -10435,13 +10644,33 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint8_t previous_path_len = *stored_path_len;
|
||||
uint8_t previous_path[MAX_PATH_SIZE];
|
||||
memcpy(previous_path, stored_path, sizeof(previous_path));
|
||||
const bool previous_out_path_persistable =
|
||||
sender->out_path_is_persistable;
|
||||
if (path_len == OUT_PATH_UNKNOWN || path_len == OUT_PATH_FORCE_FLOOD) {
|
||||
memset(stored_path, 0, MAX_PATH_SIZE);
|
||||
*stored_path_len = path_len;
|
||||
} else {
|
||||
*stored_path_len = mesh::Packet::copyPath(stored_path, path, path_len);
|
||||
}
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
if (!is_alt) sender->out_path_is_persistable = true;
|
||||
|
||||
// Explicit operator routes are rare and must become durable before the CLI
|
||||
// acknowledges them. Otherwise a replay-unproven PATH arriving during the
|
||||
// lazy-write window could replace the only RAM copy. ClientACL::save also
|
||||
// preserves durable routes for any *other* client currently using a newer
|
||||
// transient route.
|
||||
if (!acl.save(_fs)) {
|
||||
*stored_path_len = previous_path_len;
|
||||
memcpy(stored_path, previous_path, sizeof(previous_path));
|
||||
sender->out_path_is_persistable = previous_out_path_persistable;
|
||||
strcpy(reply, "Err - path save failed");
|
||||
return true;
|
||||
}
|
||||
mesh::resetLazyPersistenceAfterSuccess(
|
||||
dirty_contacts_expiry, contacts_save_failures);
|
||||
formatPathReply(stored_path, *stored_path_len, reply, 160);
|
||||
return true;
|
||||
}
|
||||
@@ -11173,7 +11402,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
&& mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) {
|
||||
uint8_t perms = atoi(sp);
|
||||
if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) {
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save()
|
||||
mesh::scheduleLazyPersistenceMutation(
|
||||
dirty_contacts_expiry, contacts_save_failures,
|
||||
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
strcpy(reply, "Err - invalid params");
|
||||
@@ -11751,8 +11982,21 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
|
||||
|
||||
// is pending dirty contacts write needed?
|
||||
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
|
||||
acl.save(_fs);
|
||||
dirty_contacts_expiry = 0;
|
||||
const bool saved = acl.save(_fs);
|
||||
if (saved) {
|
||||
mesh::resetLazyPersistenceAfterSuccess(
|
||||
dirty_contacts_expiry, contacts_save_failures);
|
||||
} else {
|
||||
const uint32_t retry_delay =
|
||||
mesh::recordLazyPersistenceSaveFailure(
|
||||
contacts_save_failures, LAZY_CONTACTS_WRITE_DELAY,
|
||||
mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS);
|
||||
mesh::completeLazyPersistenceSave(
|
||||
dirty_contacts_expiry, false, futureMillis(retry_delay));
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"ERROR: contacts save failed; retry in %lu ms",
|
||||
(unsigned long)retry_delay);
|
||||
}
|
||||
}
|
||||
|
||||
// update uptime
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
#endif
|
||||
#include <helpers/RemoteCliReplyCache.h>
|
||||
#include <helpers/RemoteCliRequest.h>
|
||||
#include <helpers/TempRadioReplyBarrier.h>
|
||||
#include <helpers/TempRadioLeaseDeadline.h>
|
||||
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
|
||||
#include <helpers/UserGpioReplyTracker.h>
|
||||
#endif
|
||||
@@ -281,6 +283,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
uint8_t cr;
|
||||
uint32_t start_time;
|
||||
uint32_t end_time;
|
||||
uint64_t hard_end_uptime_millis;
|
||||
};
|
||||
|
||||
FILESYSTEM* _fs;
|
||||
@@ -289,6 +292,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
unsigned long next_local_advert, next_flood_advert;
|
||||
mesh::DeferredCliCommand deferred_cli_command;
|
||||
mesh::RemoteCliReplyCache remote_cli_reply_cache;
|
||||
mesh::TempRadioReplyBarrier temp_radio_reply_barrier;
|
||||
TransportKey deferred_cli_reply_scope;
|
||||
bool deferred_cli_reply_scoped;
|
||||
#if MESH_ENABLE_HOST_CLI
|
||||
@@ -462,6 +466,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
unsigned long pending_discover_until;
|
||||
bool region_load_active;
|
||||
unsigned long dirty_contacts_expiry;
|
||||
uint8_t contacts_save_failures;
|
||||
#if MAX_NEIGHBOURS
|
||||
NeighbourInfo neighbours[MAX_NEIGHBOURS];
|
||||
#endif
|
||||
@@ -501,6 +506,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
MQTTBridge* mqtt_bridge;
|
||||
#elif defined(WITH_RS232_BRIDGE)
|
||||
RS232Bridge* bridge;
|
||||
uint8_t active_rs232_bridge_uart = 0;
|
||||
#elif defined(WITH_ESPNOW_BRIDGE)
|
||||
ESPNowBridge bridge;
|
||||
#endif
|
||||
@@ -636,12 +642,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
#if MESH_ENABLE_HOST_CLI
|
||||
bool completeHostCliRequest(const char* service_reply);
|
||||
#endif
|
||||
void sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
|
||||
bool sendRemoteCliReply(ClientInfo* client, const uint8_t* secret,
|
||||
uint8_t path_hash_size, uint32_t sender_timestamp,
|
||||
const char* reply, const TransportKey* fallback_scope);
|
||||
void sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet,
|
||||
const char* reply, const TransportKey* fallback_scope,
|
||||
mesh::Packet** queued_packet = NULL);
|
||||
bool sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet,
|
||||
unsigned long delay_millis, uint8_t path_hash_size,
|
||||
const TransportKey* fallback_scope);
|
||||
const TransportKey* fallback_scope,
|
||||
bool allow_redundant_copies = true);
|
||||
void servicePostMeshLoop();
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
void sampleTelemetryHistory();
|
||||
@@ -1007,7 +1015,73 @@ public:
|
||||
WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX,
|
||||
_mgr, getRTCClock());
|
||||
}
|
||||
|
||||
bool beginRS232Bridge() {
|
||||
if (!bridge) return false;
|
||||
const uint8_t selected_uart = _prefs.bridge_uart;
|
||||
bool gps_uart_blocked = false;
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (sensors.gpsSerialTransportMayConflict(selected_uart)) {
|
||||
if (!sensors.gpsUsesSerialUart(selected_uart)
|
||||
|| !sensors.gpsSerialTransportCanYield(selected_uart)) {
|
||||
return false;
|
||||
}
|
||||
if (!sensors.setGpsSerialTransportBlocked(selected_uart, true)) {
|
||||
return false;
|
||||
}
|
||||
gps_uart_blocked = true;
|
||||
// Record ownership as soon as the GPS yields. If bridge startup then
|
||||
// fails and the first release attempt also fails, normal bridge cleanup
|
||||
// still knows which UART must be released and can retry safely.
|
||||
active_rs232_bridge_uart = selected_uart;
|
||||
}
|
||||
#endif
|
||||
bridge->begin();
|
||||
if (!bridge->isRunning()) {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (gps_uart_blocked) {
|
||||
if (sensors.setGpsSerialTransportBlocked(selected_uart, false)) {
|
||||
active_rs232_bridge_uart = 0;
|
||||
}
|
||||
}
|
||||
#else
|
||||
(void)gps_uart_blocked;
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
active_rs232_bridge_uart = selected_uart;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool endRS232Bridge() {
|
||||
const uint8_t released_uart = active_rs232_bridge_uart;
|
||||
bool stopped = true;
|
||||
// A fail-closed preflight can reject this object before begin() touches
|
||||
// its UART. Adafruit nRF Uart::end() is not safe as a generic cleanup for
|
||||
// an unstarted peripheral: it can stop a GPS-owned Serial1 and wait on
|
||||
// hardware events which will never arrive. End only a bridge which proved
|
||||
// that it reached running state.
|
||||
if (bridge && bridge->isRunning()) {
|
||||
bridge->end();
|
||||
stopped = !bridge->isRunning();
|
||||
}
|
||||
bool gps_released = true;
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
// Release the bridge first, then restore a UART GPS. I2C GPS providers do
|
||||
// not claim any UART and therefore never enter this path.
|
||||
if (released_uart != 0 && sensors.gpsUsesSerialUart(released_uart)) {
|
||||
gps_released = sensors.setGpsSerialTransportBlocked(released_uart, false);
|
||||
}
|
||||
#endif
|
||||
if (stopped && gps_released) active_rs232_bridge_uart = 0;
|
||||
return stopped && gps_released;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool isBridgeRunning() const override {
|
||||
const AbstractBridge* active_bridge = activeBridge();
|
||||
return active_bridge != nullptr && active_bridge->isRunning();
|
||||
}
|
||||
|
||||
bool setBridgeState(bool enable) override {
|
||||
// Disabling an already-absent heap-backed bridge is successful and must
|
||||
@@ -1034,16 +1108,18 @@ public:
|
||||
bridge = createRS232Bridge();
|
||||
if (!bridge) return false;
|
||||
}
|
||||
if (!enable) {
|
||||
const bool stopped = endRS232Bridge();
|
||||
if (stopped) {
|
||||
delete bridge;
|
||||
bridge = nullptr;
|
||||
}
|
||||
return stopped;
|
||||
}
|
||||
#endif
|
||||
AbstractBridge* active_bridge = activeBridge();
|
||||
if (!active_bridge) return false;
|
||||
if (enable == active_bridge->isRunning()) {
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
if (!enable) {
|
||||
delete bridge;
|
||||
bridge = nullptr;
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
if (enable)
|
||||
@@ -1059,28 +1135,47 @@ public:
|
||||
mqtt_bridge->setBuildDate(getBuildDate());
|
||||
mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
|
||||
#endif
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
const bool started = beginRS232Bridge();
|
||||
#else
|
||||
active_bridge->begin();
|
||||
const bool started = active_bridge->isRunning();
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
_alerter.setBridge(mqtt_bridge);
|
||||
#endif
|
||||
return started;
|
||||
}
|
||||
else
|
||||
{
|
||||
active_bridge->end();
|
||||
const bool stopped = !active_bridge->isRunning();
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
const bool stopped = endRS232Bridge();
|
||||
delete bridge;
|
||||
bridge = nullptr;
|
||||
#else
|
||||
active_bridge->end();
|
||||
const bool stopped = !active_bridge->isRunning();
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
_alerter.setBridge(nullptr);
|
||||
#endif
|
||||
return stopped;
|
||||
}
|
||||
return active_bridge->isRunning();
|
||||
}
|
||||
|
||||
bool restartBridge() override {
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
// RS-232 changes must be applied synchronously so the CLI can commit or
|
||||
// roll back the selected pins/baud. This branch also reconstructs a bridge
|
||||
// after a prior allocation/start failure and is not WebConfig-coalesced.
|
||||
if (bridge && (bridge->isRunning() || active_rs232_bridge_uart != 0)
|
||||
&& !endRS232Bridge()) {
|
||||
return false;
|
||||
}
|
||||
delete bridge;
|
||||
bridge = createRS232Bridge();
|
||||
return bridge && beginRS232Bridge();
|
||||
#else
|
||||
AbstractBridge* active_bridge = activeBridge();
|
||||
if (!active_bridge) return false;
|
||||
#ifdef WITH_WEBCONFIG
|
||||
@@ -1090,12 +1185,6 @@ public:
|
||||
}
|
||||
#endif
|
||||
if (active_bridge->isRunning()) active_bridge->end();
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
delete bridge;
|
||||
bridge = createRS232Bridge();
|
||||
if (bridge) bridge->begin();
|
||||
return bridge && bridge->isRunning();
|
||||
#endif
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
// Set device metadata before restarting bridge (same as in begin())
|
||||
char device_id[65];
|
||||
@@ -1109,6 +1198,7 @@ public:
|
||||
#endif
|
||||
active_bridge->begin();
|
||||
return active_bridge->isRunning();
|
||||
#endif
|
||||
}
|
||||
|
||||
void restartBridgeSlot(int slot) override {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "MyMesh.h"
|
||||
#include <helpers/radiolib/RxBoostedGainDefaults.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#if MESH_PACKET_LOGGING
|
||||
#include <helpers/SerialPacketLog.h>
|
||||
#endif
|
||||
@@ -642,23 +645,29 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
|
||||
uint8_t *data, size_t len) {
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin
|
||||
// client (unknown at this stage)
|
||||
if (len < 8) {
|
||||
MESH_DEBUG_PRINTLN("Rejected incomplete anonymous request");
|
||||
return;
|
||||
}
|
||||
uint32_t sender_timestamp, sender_sync_since;
|
||||
memcpy(&sender_timestamp, data, 4);
|
||||
memcpy(&sender_sync_since, &data[4], 4); // sender's "sync messags SINCE x" timestamp
|
||||
|
||||
data[len] = 0; // ensure null terminator
|
||||
|
||||
ClientInfo* client = NULL;
|
||||
ClientInfo* existing_client =
|
||||
acl.getClient(sender.pub_key, PUB_KEY_SIZE);
|
||||
ClientInfo* client = existing_client;
|
||||
uint8_t perm;
|
||||
if (data[8] == 0) { // blank password, just check if sender is in ACL
|
||||
client = acl.getClient(sender.pub_key, PUB_KEY_SIZE);
|
||||
if (client == NULL) {
|
||||
#if MESH_DEBUG
|
||||
MESH_DEBUG_PRINTLN("Login, sender not in ACL");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (client == NULL) {
|
||||
uint8_t perm;
|
||||
perm = client->permissions & PERM_ACL_ROLE_MASK;
|
||||
} else {
|
||||
if (strcmp((char *)&data[8], _prefs.password) == 0) { // check for valid admin password
|
||||
perm = PERM_ACL_ADMIN;
|
||||
} else {
|
||||
@@ -671,33 +680,46 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
|
||||
return; // no response. Client will timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bool client_existed = existing_client != NULL;
|
||||
const uint32_t previous_timestamp =
|
||||
client_existed ? existing_client->last_timestamp : 0;
|
||||
if (!acl.authorizeLoginTimestamp(
|
||||
sender.pub_key, sender_timestamp, previous_timestamp, perm)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Login rejected: replayed timestamp or replay state unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
if (client == NULL) {
|
||||
client = acl.putClient(sender, 0); // add to known clients (if not already known)
|
||||
if (client == NULL) {
|
||||
MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts");
|
||||
return;
|
||||
}
|
||||
if (sender_timestamp <= client->last_timestamp) {
|
||||
MESH_DEBUG_PRINTLN("possible replay attack!");
|
||||
return;
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("Login success!");
|
||||
client->last_timestamp = sender_timestamp;
|
||||
client->extra.room.sync_since = sender_sync_since;
|
||||
client->extra.room.pending_ack = 0;
|
||||
client->extra.room.push_failures = 0;
|
||||
|
||||
client->last_activity = getRTCClock()->getCurrentTime();
|
||||
client->permissions &= ~0x03;
|
||||
client->permissions |= perm;
|
||||
memcpy(client->shared_secret, secret, PUB_KEY_SIZE);
|
||||
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
}
|
||||
|
||||
if (packet->isRouteFlood()) {
|
||||
client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path
|
||||
MESH_DEBUG_PRINTLN("Login success!");
|
||||
const uint8_t previous_permissions = client->permissions;
|
||||
const uint32_t previous_sync_since = client->extra.room.sync_since;
|
||||
const bool reset_out_path = packet->isRouteFlood()
|
||||
&& client->out_path_len != OUT_PATH_FORCE_FLOOD;
|
||||
bool persisted_changed = mesh::applySuccessfulClientLogin(
|
||||
*client, client_existed, perm, PERM_ACL_ROLE_MASK,
|
||||
secret, sender_timestamp, getRTCClock()->getCurrentTime(),
|
||||
reset_out_path, OUT_PATH_UNKNOWN);
|
||||
client->extra.room.sync_since = sender_sync_since;
|
||||
client->extra.room.pending_ack = 0;
|
||||
client->extra.room.push_failures = 0;
|
||||
persisted_changed = persisted_changed
|
||||
|| previous_sync_since != sender_sync_since;
|
||||
if (mesh::successfulClientLoginNeedsPersistence(
|
||||
client_existed, previous_permissions, client->permissions,
|
||||
PERM_ACL_ROLE_MASK, PERM_ACL_GUEST, persisted_changed)) {
|
||||
mesh::scheduleLazyPersistenceMutation(
|
||||
dirty_contacts_expiry, contacts_save_failures,
|
||||
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
||||
}
|
||||
|
||||
uint32_t now = getRTCClock()->getCurrentTimeUnique();
|
||||
@@ -705,7 +727,9 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
|
||||
// TODO: maybe reply with count of messages waiting to be synced for THIS client?
|
||||
reply_data[4] = RESP_SERVER_LOGIN_OK;
|
||||
reply_data[5] = 0; // Legacy: was recommended keep-alive interval (secs / 16)
|
||||
reply_data[6] = (client->isAdmin() ? 1 : (client->permissions == 0 ? 2 : 0));
|
||||
reply_data[6] = (client->isAdmin() ? 1
|
||||
: ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST
|
||||
? 2 : 0));
|
||||
// LEGACY: reply_data[7] = getUnsyncedCount(client);
|
||||
reply_data[7] = client->permissions; // NEW
|
||||
getRNG()->random(&reply_data[8], 4); // random blob to help packet-hash uniqueness
|
||||
@@ -1033,7 +1057,15 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t
|
||||
if (i >= 0 && i < acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context)
|
||||
MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len);
|
||||
auto client = acl.getClientByIdx(i);
|
||||
client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len); // store a copy of path, for sendDirect()
|
||||
// PATH has no durable replay freshness signal. Keep it RAM-only and do not
|
||||
// let a replay replace an operator-selected force-flood route.
|
||||
const bool persistence_allowed = mesh::clientPathPersistenceAllowed(
|
||||
MyMesh::saveFilter(client), false /* no durable replay proof */);
|
||||
const mesh::ClientPathUpdateResult path_update =
|
||||
mesh::applyReceivedClientPath(
|
||||
*client, path, path_len, persistence_allowed,
|
||||
OUT_PATH_FORCE_FLOOD);
|
||||
(void)path_update;
|
||||
client->last_activity = getRTCClock()->getCurrentTime();
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i);
|
||||
@@ -1175,6 +1207,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
uptime_millis = 0;
|
||||
next_local_advert = next_flood_advert = 0;
|
||||
dirty_contacts_expiry = 0;
|
||||
contacts_save_failures = 0;
|
||||
_logging = false;
|
||||
region_load_active = false;
|
||||
set_radio_at = revert_radio_at = 0;
|
||||
@@ -2196,7 +2229,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
|
||||
&& mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) {
|
||||
uint8_t perms = atoi(sp);
|
||||
if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) {
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save()
|
||||
mesh::scheduleLazyPersistenceMutation(
|
||||
dirty_contacts_expiry, contacts_save_failures,
|
||||
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
strcpy(reply, "Err - invalid params");
|
||||
@@ -2481,8 +2516,21 @@ void MyMesh::loop() {
|
||||
|
||||
// is pending dirty contacts write needed?
|
||||
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
|
||||
acl.save(_fs, MyMesh::saveFilter);
|
||||
dirty_contacts_expiry = 0;
|
||||
const bool saved = acl.save(_fs, MyMesh::saveFilter);
|
||||
if (saved) {
|
||||
mesh::resetLazyPersistenceAfterSuccess(
|
||||
dirty_contacts_expiry, contacts_save_failures);
|
||||
} else {
|
||||
const uint32_t retry_delay =
|
||||
mesh::recordLazyPersistenceSaveFailure(
|
||||
contacts_save_failures, LAZY_CONTACTS_WRITE_DELAY,
|
||||
mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS);
|
||||
mesh::completeLazyPersistenceSave(
|
||||
dirty_contacts_expiry, false, futureMillis(retry_delay));
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"ERROR: contacts save failed; retry in %lu ms",
|
||||
(unsigned long)retry_delay);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: periodically check for OLD/inactive entries in known_clients[], and evict
|
||||
|
||||
@@ -183,6 +183,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
|
||||
UserGpioReplyTracker _gpio_reply_tracker;
|
||||
#endif
|
||||
unsigned long dirty_contacts_expiry;
|
||||
uint8_t contacts_save_failures;
|
||||
uint8_t reply_data[MAX_PACKET_PAYLOAD];
|
||||
unsigned long next_push;
|
||||
uint16_t _num_posted, _num_post_pushes;
|
||||
@@ -481,6 +482,10 @@ public:
|
||||
void loop();
|
||||
|
||||
#if defined(WITH_BRIDGE)
|
||||
bool isBridgeRunning() const override {
|
||||
return bridge != nullptr && bridge->isRunning();
|
||||
}
|
||||
|
||||
bool setBridgeState(bool enable) override {
|
||||
// An absent MQTT bridge is already stopped. Do not allocate one solely to
|
||||
// satisfy an idempotent disable request.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "SensorMesh.h"
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/radiolib/RXPowerSaving.h>
|
||||
|
||||
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
|
||||
@@ -444,15 +447,17 @@ int SensorMesh::getAGCResetInterval() const {
|
||||
}
|
||||
|
||||
uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) {
|
||||
ClientInfo* client;
|
||||
ClientInfo* existing_client = acl.getClient(sender.pub_key, PUB_KEY_SIZE);
|
||||
ClientInfo* client = existing_client;
|
||||
uint8_t role_permissions;
|
||||
if (data[0] == 0) { // blank password, just check if sender is in ACL
|
||||
client = acl.getClient(sender.pub_key, PUB_KEY_SIZE);
|
||||
if (client == NULL) {
|
||||
#if MESH_DEBUG
|
||||
MESH_DEBUG_PRINTLN("Login, sender not in ACL");
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
role_permissions = client->permissions & PERM_ACL_ROLE_MASK;
|
||||
} else {
|
||||
if (strcmp((char *) data, _prefs.password) != 0) { // check for valid admin password
|
||||
#if MESH_DEBUG
|
||||
@@ -460,30 +465,45 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t*
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
role_permissions = PERM_ACL_ADMIN;
|
||||
}
|
||||
|
||||
client = acl.putClient(sender, PERM_RECV_ALERTS_HI | PERM_RECV_ALERTS_LO); // add to contacts (if not already known)
|
||||
const bool client_existed = existing_client != NULL;
|
||||
const uint32_t previous_timestamp =
|
||||
client_existed ? existing_client->last_timestamp : 0;
|
||||
if (!acl.authorizeLoginTimestamp(
|
||||
sender.pub_key, sender_timestamp, previous_timestamp,
|
||||
role_permissions)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Login rejected: replayed timestamp or replay state unavailable");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (client == NULL) {
|
||||
client = acl.putClient(
|
||||
sender, PERM_RECV_ALERTS_HI | PERM_RECV_ALERTS_LO);
|
||||
if (client == NULL) {
|
||||
MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts");
|
||||
return 0;
|
||||
}
|
||||
if (sender_timestamp <= client->last_timestamp) {
|
||||
MESH_DEBUG_PRINTLN("Possible login replay attack!");
|
||||
return 0; // FATAL: client table is full -OR- replay attack
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("Login success!");
|
||||
client->last_timestamp = sender_timestamp;
|
||||
client->last_activity = getRTCClock()->getCurrentTime();
|
||||
client->permissions |= PERM_ACL_ADMIN;
|
||||
memcpy(client->shared_secret, secret, PUB_KEY_SIZE);
|
||||
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
updateGpsTelemetryPolicy();
|
||||
}
|
||||
|
||||
if (is_flood) {
|
||||
client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path
|
||||
MESH_DEBUG_PRINTLN("Login success!");
|
||||
const uint8_t previous_permissions = client->permissions;
|
||||
const bool reset_out_path = is_flood
|
||||
&& client->out_path_len != OUT_PATH_FORCE_FLOOD;
|
||||
const bool persisted_changed = mesh::applySuccessfulClientLogin(
|
||||
*client, client_existed, role_permissions, PERM_ACL_ROLE_MASK,
|
||||
secret, sender_timestamp, getRTCClock()->getCurrentTime(),
|
||||
reset_out_path, OUT_PATH_UNKNOWN);
|
||||
if (mesh::successfulClientLoginNeedsPersistence(
|
||||
client_existed, previous_permissions, client->permissions,
|
||||
PERM_ACL_ROLE_MASK, PERM_ACL_GUEST, persisted_changed)) {
|
||||
mesh::scheduleLazyPersistenceMutation(
|
||||
dirty_contacts_expiry, contacts_save_failures,
|
||||
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
||||
}
|
||||
updateGpsTelemetryPolicy();
|
||||
|
||||
uint32_t now = getRTCClock()->getCurrentTimeUnique();
|
||||
memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp
|
||||
@@ -590,7 +610,9 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r
|
||||
&& mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) {
|
||||
uint8_t perms = atoi(sp);
|
||||
if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) {
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save()
|
||||
mesh::scheduleLazyPersistenceMutation(
|
||||
dirty_contacts_expiry, contacts_save_failures,
|
||||
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
||||
updateGpsTelemetryPolicy();
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
@@ -651,6 +673,10 @@ void SensorMesh::onGroupPacketRecv(mesh::Packet* packet) {
|
||||
|
||||
void SensorMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) {
|
||||
if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin client (unknown at this stage)
|
||||
if (len < 4) {
|
||||
MESH_DEBUG_PRINTLN("Rejected incomplete anonymous request");
|
||||
return;
|
||||
}
|
||||
uint32_t timestamp;
|
||||
memcpy(×tamp, data, 4);
|
||||
|
||||
@@ -871,17 +897,16 @@ bool SensorMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint
|
||||
ClientInfo* from = acl.getClientByIdx(i);
|
||||
|
||||
MESH_DEBUG_PRINTLN("PATH to contact, path_len=%d", (uint32_t) path_len);
|
||||
// NOTE: for this impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path.
|
||||
// FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?)
|
||||
from->out_path_len = mesh::Packet::copyPath(from->out_path, path, path_len); // store a copy of path, for sendDirect()
|
||||
// PATH has an authenticated sender but no timestamp/request nonce. Keep the
|
||||
// learned route in RAM only so an old replay cannot become durable.
|
||||
const bool persistence_allowed = mesh::clientPathPersistenceAllowed(
|
||||
from->permissions != 0, false /* no durable replay proof */);
|
||||
const mesh::ClientPathUpdateResult path_update =
|
||||
mesh::applyReceivedClientPath(
|
||||
*from, path, path_len, persistence_allowed, OUT_PATH_FORCE_FLOOD);
|
||||
(void)path_update;
|
||||
from->last_activity = getRTCClock()->getCurrentTime();
|
||||
|
||||
// REVISIT: maybe make ALL out_paths non-persisted to minimise flash writes??
|
||||
if (from->isAdmin()) {
|
||||
// only do saveContacts() (of this out_path change) if this is an admin
|
||||
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
|
||||
}
|
||||
|
||||
// NOTE: no reciprocal path send!!
|
||||
return false;
|
||||
}
|
||||
@@ -909,6 +934,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
|
||||
{
|
||||
next_local_advert = next_flood_advert = 0;
|
||||
dirty_contacts_expiry = 0;
|
||||
contacts_save_failures = 0;
|
||||
last_read_time = 0;
|
||||
num_alert_tasks = 0;
|
||||
set_radio_at = revert_radio_at = 0;
|
||||
@@ -1298,8 +1324,21 @@ void SensorMesh::loop() {
|
||||
|
||||
// is there are pending dirty contacts write needed?
|
||||
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
|
||||
acl.save(_fs);
|
||||
dirty_contacts_expiry = 0;
|
||||
const bool saved = acl.save(_fs);
|
||||
if (saved) {
|
||||
mesh::resetLazyPersistenceAfterSuccess(
|
||||
dirty_contacts_expiry, contacts_save_failures);
|
||||
} else {
|
||||
const uint32_t retry_delay =
|
||||
mesh::recordLazyPersistenceSaveFailure(
|
||||
contacts_save_failures, LAZY_CONTACTS_WRITE_DELAY,
|
||||
mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS);
|
||||
mesh::completeLazyPersistenceSave(
|
||||
dirty_contacts_expiry, false, futureMillis(retry_delay));
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"ERROR: contacts save failed; retry in %lu ms",
|
||||
(unsigned long)retry_delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ private:
|
||||
#endif
|
||||
uint8_t reply_data[MAX_PACKET_PAYLOAD];
|
||||
unsigned long dirty_contacts_expiry;
|
||||
uint8_t contacts_save_failures;
|
||||
CayenneLPP telemetry;
|
||||
TransportKeyStore key_store;
|
||||
RegionMap region_map;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -79,6 +79,36 @@ LEGACY_COMPANION_ARTIFACT_SUFFIXES = (
|
||||
"-ota",
|
||||
)
|
||||
|
||||
REDUCED_PROFILE_MARKER = "_lora_ota_no_external_sensors"
|
||||
REDUCED_PROFILE_DESCRIPTION = (
|
||||
"PROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this "
|
||||
"exact target's board functions. To fit update staging, it omits selected "
|
||||
"optional environmental/ranging drivers; I2C remains available and is not "
|
||||
"globally disabled. USB debug/packet logging is OFF."
|
||||
)
|
||||
REDUCED_PROFILE_SELECTION = "selected optional sensor drivers omitted"
|
||||
RAK_INA_RETENTION_NOTE = (
|
||||
"RAK SENSOR RETENTION - This reduced RAK build retains INA219, INA226, "
|
||||
"INA260, and INA3221 I2C current/voltage monitor drivers."
|
||||
)
|
||||
RAK3401_GPS_RETENTION_NOTE = (
|
||||
"GPS RETENTION - Compatible RAK3401 GPS options remain available: "
|
||||
"RAK12500 over I2C and RAK12501 over UART."
|
||||
)
|
||||
RAK4631_GPS_RETENTION_NOTE = (
|
||||
"GPS RETENTION - Compatible RAK4631 GPS options remain available: "
|
||||
"RAK12500 over I2C and RAK12501 over Serial1."
|
||||
)
|
||||
RAK4631_SERIAL1_GPS_OMISSION_NOTE = (
|
||||
"GPS / RS-232 - GPS is omitted from this RAK4631 build because the "
|
||||
"RS-232 bridge owns Serial1."
|
||||
)
|
||||
REDUCED_PROFILE_SPECIAL_NOTE_PREFIXES = (
|
||||
"RAK SENSOR RETENTION ",
|
||||
"GPS RETENTION ",
|
||||
"GPS / RS-232 ",
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
@@ -89,19 +119,18 @@ def parse_args() -> argparse.Namespace:
|
||||
default=script_dir / "keymind-cascade-v1.16.0-provider.json",
|
||||
help="existing curated provider catalog",
|
||||
)
|
||||
parser.add_argument("--release-dir", type=Path, required=True)
|
||||
parser.add_argument("--release-dir", type=Path)
|
||||
parser.add_argument(
|
||||
"--artifact-version",
|
||||
required=True,
|
||||
help="filename version token, for example v1.17.1.1-759a35fc",
|
||||
)
|
||||
parser.add_argument("--main-tag", required=True)
|
||||
parser.add_argument("--advanced-tag", required=True)
|
||||
parser.add_argument("--main-tag")
|
||||
parser.add_argument("--advanced-tag")
|
||||
parser.add_argument(
|
||||
"--full-tag",
|
||||
help="FULL-profile release tag (defaults to --advanced-tag for compatibility)",
|
||||
)
|
||||
parser.add_argument("--utility-tag", required=True)
|
||||
parser.add_argument("--utility-tag")
|
||||
parser.add_argument("--repo", default="mikecarper/MeshCore")
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument(
|
||||
@@ -117,8 +146,41 @@ def parse_args() -> argparse.Namespace:
|
||||
"all unaffected roles"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--normalize-reduced-metadata-only",
|
||||
action="store_true",
|
||||
help=(
|
||||
"normalize Compact LoRa OTA descriptions in an existing catalog "
|
||||
"without changing versions, files, or release URLs"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.full_tag:
|
||||
if args.normalize_reduced_metadata_only:
|
||||
if args.companion_only:
|
||||
parser.error(
|
||||
"--companion-only cannot be combined with "
|
||||
"--normalize-reduced-metadata-only"
|
||||
)
|
||||
else:
|
||||
required = (
|
||||
"release_dir",
|
||||
"artifact_version",
|
||||
"main_tag",
|
||||
"advanced_tag",
|
||||
"utility_tag",
|
||||
)
|
||||
missing = [
|
||||
f"--{name.replace('_', '-')}"
|
||||
for name in required
|
||||
if not getattr(args, name)
|
||||
]
|
||||
if missing:
|
||||
parser.error(
|
||||
"the following arguments are required unless "
|
||||
"--normalize-reduced-metadata-only is used: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
if not args.full_tag and args.advanced_tag:
|
||||
args.full_tag = args.advanced_tag
|
||||
return args
|
||||
|
||||
@@ -168,6 +230,99 @@ def ordered_catalog_identities(firmware: dict) -> list[str]:
|
||||
return identities
|
||||
|
||||
|
||||
def is_reduced_lora_ota_profile(identities: list[str]) -> bool:
|
||||
return any(
|
||||
REDUCED_PROFILE_MARKER in identity.lower()
|
||||
for identity in identities
|
||||
)
|
||||
|
||||
|
||||
def reduced_profile_special_notes(identities: list[str]) -> list[str]:
|
||||
lowered = [identity.lower() for identity in identities]
|
||||
is_rak3401 = any(identity.startswith("rak_3401_") for identity in lowered)
|
||||
is_rak4631 = any(identity.startswith("rak_4631_") for identity in lowered)
|
||||
if not (is_rak3401 or is_rak4631):
|
||||
return []
|
||||
|
||||
notes = [RAK_INA_RETENTION_NOTE]
|
||||
if is_rak3401:
|
||||
notes.append(RAK3401_GPS_RETENTION_NOTE)
|
||||
elif any(
|
||||
"_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors"
|
||||
in identity
|
||||
for identity in lowered
|
||||
):
|
||||
notes.append(RAK4631_SERIAL1_GPS_OMISSION_NOTE)
|
||||
else:
|
||||
notes.append(RAK4631_GPS_RETENTION_NOTE)
|
||||
return notes
|
||||
|
||||
|
||||
def normalize_reduced_profile_metadata(
|
||||
firmware: dict,
|
||||
notes: str,
|
||||
identities: list[str],
|
||||
) -> str:
|
||||
"""Describe reduced LoRa OTA profiles without claiming I2C is absent."""
|
||||
if not is_reduced_lora_ota_profile(identities):
|
||||
return notes
|
||||
|
||||
subtitle = firmware.get("subTitle")
|
||||
if isinstance(subtitle, str):
|
||||
firmware["subTitle"] = re.sub(
|
||||
r"no external sensors",
|
||||
REDUCED_PROFILE_SELECTION,
|
||||
subtitle,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
paragraphs: list[str] = []
|
||||
profile_found = False
|
||||
for paragraph in notes.split("\n\n"):
|
||||
if paragraph.startswith(REDUCED_PROFILE_SPECIAL_NOTE_PREFIXES):
|
||||
continue
|
||||
if paragraph.startswith("PROFILE ") and "Compact LoRa OTA" in paragraph:
|
||||
paragraphs.append(REDUCED_PROFILE_DESCRIPTION)
|
||||
paragraphs.extend(reduced_profile_special_notes(identities))
|
||||
profile_found = True
|
||||
continue
|
||||
if paragraph.startswith("SELECTION "):
|
||||
paragraph = re.sub(
|
||||
r"no external sensors",
|
||||
REDUCED_PROFILE_SELECTION,
|
||||
paragraph,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
paragraphs.append(paragraph)
|
||||
|
||||
if not profile_found:
|
||||
raise ValueError(
|
||||
"reduced LoRa OTA catalog entry has no Compact LoRa OTA profile: "
|
||||
+ ", ".join(identities)
|
||||
)
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
|
||||
def normalize_reduced_catalog_metadata(catalog: dict) -> int:
|
||||
"""Normalize every reduced entry in place and return its entry count."""
|
||||
normalized = 0
|
||||
for device in catalog["device"]:
|
||||
for firmware in device["firmware"]:
|
||||
identities = ordered_catalog_identities(firmware)
|
||||
if not is_reduced_lora_ota_profile(identities):
|
||||
continue
|
||||
for version in firmware["version"].values():
|
||||
version["notes"] = normalize_reduced_profile_metadata(
|
||||
firmware,
|
||||
version["notes"],
|
||||
identities,
|
||||
)
|
||||
normalized += 1
|
||||
if normalized == 0:
|
||||
raise ValueError("catalog has no reduced LoRa OTA entries to normalize")
|
||||
return normalized
|
||||
|
||||
|
||||
def canonical_runtime_identity(identity: str) -> str:
|
||||
"""Map legacy setting/profile names to the canonical release identity."""
|
||||
result = re.sub(
|
||||
@@ -868,6 +1023,12 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar
|
||||
notes = replace_legacy_companion_ota_notes(notes)
|
||||
notes = append_partition_warning(notes, resolved_identities)
|
||||
|
||||
notes = normalize_reduced_profile_metadata(
|
||||
firmware,
|
||||
notes,
|
||||
resolved_identities,
|
||||
)
|
||||
|
||||
if firmware["role"].startswith("companion"):
|
||||
notes = append_companion_power_saving_note(notes, display_version)
|
||||
notes = normalize_runtime_companion_metadata(firmware, notes)
|
||||
@@ -936,12 +1097,27 @@ def main() -> int:
|
||||
args = parse_args()
|
||||
output = args.output or args.catalog
|
||||
try:
|
||||
if not args.artifact_version.startswith("v"):
|
||||
raise ValueError("--artifact-version must start with 'v'")
|
||||
with args.catalog.open(encoding="utf-8") as handle:
|
||||
catalog = json.load(handle)
|
||||
release_files = index_release_files(args.release_dir, args.artifact_version)
|
||||
catalog = update_catalog(catalog, release_files, args)
|
||||
if args.normalize_reduced_metadata_only:
|
||||
normalized = normalize_reduced_catalog_metadata(catalog)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"normalized_reduced_entries": normalized,
|
||||
"update_mode": "reduced-metadata-only",
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if not args.artifact_version.startswith("v"):
|
||||
raise ValueError("--artifact-version must start with 'v'")
|
||||
release_files = index_release_files(
|
||||
args.release_dir,
|
||||
args.artifact_version,
|
||||
)
|
||||
catalog = update_catalog(catalog, release_files, args)
|
||||
if not args.check_only:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
|
||||
@@ -15,22 +15,40 @@ from __future__ import with_statement
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import requests
|
||||
from io import open
|
||||
|
||||
Import("env")
|
||||
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
except ImportError:
|
||||
env.Execute("$PYTHONEXE -m pip install cryptography")
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
import struct
|
||||
import sys
|
||||
import warnings
|
||||
import requests
|
||||
from io import open
|
||||
|
||||
Import("env")
|
||||
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.utils import CryptographyDeprecationWarning
|
||||
except ImportError:
|
||||
env.Execute("$PYTHONEXE -m pip install cryptography")
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.utils import CryptographyDeprecationWarning
|
||||
|
||||
|
||||
# Common platform trust stores still contain serial-zero roots. ESP-IDF's
|
||||
# bundle generator suppresses this narrowly while pyca retains parsing support:
|
||||
# https://github.com/espressif/esp-idf/blob/master/components/mbedtls/esp_crt_bundle/gen_crt_bundle.py
|
||||
# https://github.com/pyca/cryptography/issues/12948
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=(
|
||||
r"^Parsed a serial number which wasn't positive \(i.e., it was negative or zero\), "
|
||||
r"which is disallowed by RFC 5280\. "
|
||||
r"Loading this certificate will cause an exception in a future release of cryptography\.$"
|
||||
),
|
||||
category=CryptographyDeprecationWarning,
|
||||
)
|
||||
|
||||
|
||||
ca_bundle_bin_file = 'x509_crt_bundle.bin'
|
||||
|
||||
+79
-6
@@ -23,25 +23,98 @@
|
||||
#define MAX_PATH_SIZE 64
|
||||
#define MAX_TRANS_UNIT 255
|
||||
|
||||
#if defined(ARDUINO) && defined(NRF52_PLATFORM) && \
|
||||
((defined(MESH_DEBUG) && MESH_DEBUG) || \
|
||||
(defined(BRIDGE_DEBUG) && BRIDGE_DEBUG) || \
|
||||
(defined(POWERSAVING_DEBUG) && POWERSAVING_DEBUG))
|
||||
#include <Arduino.h>
|
||||
#include <atomic>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "helpers/NonBlockingWriteStream.h"
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Adafruit_USBD_CDC::write() waits until the complete buffer has entered the
|
||||
// TinyUSB FIFO. That is normally convenient, but it can wait forever when a
|
||||
// host has opened the dedicated logging CDC without draining it. Keep debug
|
||||
// output best-effort on nRF52: format into one bounded record, preserve a
|
||||
// visible truncation marker, and submit it only when the whole record fits in
|
||||
// the FIFO snapshot. The atomic flag also prevents two debug callers from both
|
||||
// relying on the same availableForWrite() result.
|
||||
inline size_t nrf52DebugPrintf(const char* format, ...) {
|
||||
if (format == nullptr || !isUsbLoggingEnabled()) return 0;
|
||||
|
||||
static std::atomic_flag writer_busy = ATOMIC_FLAG_INIT;
|
||||
if (writer_busy.test_and_set(std::memory_order_acquire)) return 0;
|
||||
|
||||
char output[256];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
const int required = vsnprintf(output, sizeof(output), format, args);
|
||||
va_end(args);
|
||||
|
||||
size_t length = 0;
|
||||
if (required > 0) {
|
||||
length = static_cast<size_t>(required);
|
||||
if (length >= sizeof(output)) {
|
||||
length = sizeof(output) - 1;
|
||||
const size_t format_length = strlen(format);
|
||||
const bool preserve_newline = format_length > 0
|
||||
&& format[format_length - 1] == '\n';
|
||||
const size_t marker_length = preserve_newline ? 4 : 3;
|
||||
memcpy(output + length - marker_length, "...", 3);
|
||||
if (preserve_newline) output[length - 1] = '\n';
|
||||
}
|
||||
}
|
||||
|
||||
size_t written = 0;
|
||||
if (length > 0) {
|
||||
Stream& port = usbLoggingPort();
|
||||
written = port.write(reinterpret_cast<const uint8_t*>(output), length);
|
||||
}
|
||||
|
||||
writer_busy.clear(std::memory_order_release);
|
||||
return written;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
#endif
|
||||
|
||||
#if MESH_DEBUG && ARDUINO
|
||||
#include <Arduino.h>
|
||||
#define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F, ##__VA_ARGS__); } } while(0)
|
||||
#define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0)
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#define MESH_DEBUG_PRINT(F, ...) do { mesh::nrf52DebugPrintf("DEBUG: " F, ##__VA_ARGS__); } while(0)
|
||||
#define MESH_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("DEBUG: " F "\n", ##__VA_ARGS__); } while(0)
|
||||
#else
|
||||
#define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F, ##__VA_ARGS__); } } while(0)
|
||||
#define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0)
|
||||
#endif
|
||||
#else
|
||||
#define MESH_DEBUG_PRINT(...) {}
|
||||
#define MESH_DEBUG_PRINTLN(...) {}
|
||||
#endif
|
||||
|
||||
#if BRIDGE_DEBUG && ARDUINO
|
||||
#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0)
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#define BRIDGE_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } while(0)
|
||||
#else
|
||||
#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0)
|
||||
#endif
|
||||
#else
|
||||
#define BRIDGE_DEBUG_PRINTLN(...) {}
|
||||
#define BRIDGE_DEBUG_PRINTLN(...) {}
|
||||
#endif
|
||||
|
||||
#if POWERSAVING_DEBUG && ARDUINO
|
||||
#include <Arduino.h>
|
||||
#define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0)
|
||||
#define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0)
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#define POWERSAVING_DEBUG_PRINT(F, ...) do { mesh::nrf52DebugPrintf("POWERSAVING: " F, ##__VA_ARGS__); } while(0)
|
||||
#define POWERSAVING_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("POWERSAVING: " F "\n", ##__VA_ARGS__); } while(0)
|
||||
#else
|
||||
#define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0)
|
||||
#define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0)
|
||||
#endif
|
||||
#else
|
||||
#define POWERSAVING_DEBUG_PRINT(...) {}
|
||||
#define POWERSAVING_DEBUG_PRINTLN(...) {}
|
||||
|
||||
+583
-38
@@ -1,11 +1,110 @@
|
||||
#include "ClientACL.h"
|
||||
#include "ClientACLFileTransaction.h"
|
||||
#include "ClientACLFileIntegrity.h"
|
||||
#include "ClientLoginPersistence.h"
|
||||
#include "ClientPathPersistence.h"
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#include "AtomicFileWriter.h"
|
||||
#endif
|
||||
|
||||
static const uint8_t CONTACT_RECORD_VERSION_ALT_PATH = 1;
|
||||
static const uint8_t EMPTY_OUT_PATH[MAX_PATH_SIZE] = {};
|
||||
|
||||
static File openRead(FILESYSTEM* fs, const char* filename) {
|
||||
#if defined(RP2040_PLATFORM)
|
||||
return fs->open(filename, "r");
|
||||
#else
|
||||
return fs->open(filename);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
static const size_t CONTACT_RECORD_SIZE =
|
||||
32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE + 1 + 64;
|
||||
static const size_t LEGACY_CONTACT_RECORD_SIZE =
|
||||
32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE;
|
||||
#endif
|
||||
|
||||
static bool readPersistedClientPath(FILESYSTEM* fs,
|
||||
const uint8_t pubkey[PUB_KEY_SIZE],
|
||||
uint8_t* path_len,
|
||||
uint8_t path[MAX_PATH_SIZE]) {
|
||||
if (!fs->exists(mesh::CLIENT_ACL_PRIMARY_PATH)) return false;
|
||||
File file = openRead(fs, mesh::CLIENT_ACL_PRIMARY_PATH);
|
||||
if (!file) return false;
|
||||
|
||||
bool found = false;
|
||||
while (!found) {
|
||||
uint8_t record_pubkey[PUB_KEY_SIZE];
|
||||
uint8_t permissions;
|
||||
uint32_t sync_since;
|
||||
uint8_t version[2];
|
||||
uint8_t record_path_len;
|
||||
uint8_t record_path[MAX_PATH_SIZE];
|
||||
uint8_t shared_secret[PUB_KEY_SIZE];
|
||||
|
||||
bool success = file.read(record_pubkey, sizeof(record_pubkey))
|
||||
== (int)sizeof(record_pubkey);
|
||||
success = success && file.read(&permissions, sizeof(permissions))
|
||||
== (int)sizeof(permissions);
|
||||
success = success
|
||||
&& file.read((uint8_t*)&sync_since, sizeof(sync_since))
|
||||
== (int)sizeof(sync_since);
|
||||
success = success && file.read(version, sizeof(version))
|
||||
== (int)sizeof(version);
|
||||
success = success && file.read(&record_path_len, sizeof(record_path_len))
|
||||
== (int)sizeof(record_path_len);
|
||||
success = success && file.read(record_path, sizeof(record_path))
|
||||
== (int)sizeof(record_path);
|
||||
success = success && file.read(shared_secret, sizeof(shared_secret))
|
||||
== (int)sizeof(shared_secret);
|
||||
if (!success) break;
|
||||
|
||||
if (version[0] >= CONTACT_RECORD_VERSION_ALT_PATH) {
|
||||
uint8_t alt_path_len;
|
||||
uint8_t alt_path[MAX_PATH_SIZE];
|
||||
success = file.read(&alt_path_len, sizeof(alt_path_len))
|
||||
== (int)sizeof(alt_path_len);
|
||||
success = success && file.read(alt_path, sizeof(alt_path))
|
||||
== (int)sizeof(alt_path);
|
||||
if (!success) break;
|
||||
}
|
||||
|
||||
if (memcmp(record_pubkey, pubkey, PUB_KEY_SIZE) == 0
|
||||
&& (record_path_len == OUT_PATH_UNKNOWN
|
||||
|| record_path_len == OUT_PATH_FORCE_FLOOD
|
||||
|| mesh::isValidEncodedClientPathLength(
|
||||
record_path_len, MAX_PATH_SIZE))) {
|
||||
*path_len = record_path_len;
|
||||
memcpy(path, record_path, MAX_PATH_SIZE);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
return found;
|
||||
}
|
||||
|
||||
static mesh::StoredClientPathView storedClientPathForSave(
|
||||
FILESYSTEM* fs,
|
||||
const ClientInfo* client,
|
||||
uint8_t prior_path[MAX_PATH_SIZE],
|
||||
uint8_t* prior_path_len) {
|
||||
const bool prior_exists = !client->out_path_is_persistable
|
||||
&& readPersistedClientPath(
|
||||
fs, client->id.pub_key, prior_path_len, prior_path);
|
||||
return mesh::selectStoredClientPath(
|
||||
client->out_path_is_persistable,
|
||||
client->out_path_len,
|
||||
client->out_path,
|
||||
prior_exists,
|
||||
*prior_path_len,
|
||||
prior_path,
|
||||
OUT_PATH_UNKNOWN,
|
||||
EMPTY_OUT_PATH);
|
||||
}
|
||||
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
|
||||
static File openWrite(FILESYSTEM* _fs, const char* filename) {
|
||||
#if defined(STM32_PLATFORM)
|
||||
_fs->remove(filename);
|
||||
@@ -16,11 +115,337 @@ static File openWrite(FILESYSTEM* _fs, const char* filename) {
|
||||
return _fs->open(filename, "w", true);
|
||||
#endif
|
||||
}
|
||||
|
||||
static bool readMatches(File& file, const uint8_t* expected, size_t length) {
|
||||
uint8_t buffer[32];
|
||||
while (length > 0) {
|
||||
const size_t chunk = length < sizeof(buffer) ? length : sizeof(buffer);
|
||||
if (file.read(buffer, chunk) != (int)chunk
|
||||
|| memcmp(buffer, expected, chunk) != 0) {
|
||||
return false;
|
||||
}
|
||||
expected += chunk;
|
||||
length -= chunk;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool validateContactsFileIntegrity(FILESYSTEM* fs,
|
||||
const char* filename) {
|
||||
File file = openRead(fs, filename);
|
||||
if (!file) return false;
|
||||
const size_t size = file.size();
|
||||
const bool has_crc = size >= 8 && (size - 8) % CONTACT_RECORD_SIZE == 0;
|
||||
// A backup means publication was interrupted or is being finalized. In that
|
||||
// state a primary without its CRC trailer is a torn new image, not a legacy
|
||||
// image, even when truncation lands exactly on a record-size multiple.
|
||||
const bool crc_required = strcmp(filename, mesh::CLIENT_ACL_PRIMARY_PATH) == 0
|
||||
&& fs->exists(mesh::CLIENT_ACL_BACKUP_PATH);
|
||||
if (!has_crc && size % CONTACT_RECORD_SIZE != 0
|
||||
&& size % LEGACY_CONTACT_RECORD_SIZE != 0) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
if (!has_crc) {
|
||||
file.close();
|
||||
return !crc_required; // standalone legacy fixed-record image
|
||||
}
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
size_t remaining = size - 8;
|
||||
uint8_t buffer[32];
|
||||
while (remaining != 0) {
|
||||
const size_t chunk = remaining < sizeof(buffer) ? remaining : sizeof(buffer);
|
||||
if (file.read(buffer, chunk) != (int)chunk) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
crc = mesh::updateClientACLCRC(crc, buffer, chunk);
|
||||
remaining -= chunk;
|
||||
}
|
||||
uint8_t magic[4];
|
||||
uint32_t stored_crc;
|
||||
const bool valid = file.read(magic, sizeof(magic)) == (int)sizeof(magic)
|
||||
&& file.read((uint8_t*)&stored_crc, sizeof(stored_crc))
|
||||
== (int)sizeof(stored_crc)
|
||||
&& memcmp(magic, mesh::CLIENT_ACL_CRC_MAGIC, sizeof(magic)) == 0
|
||||
&& stored_crc == (crc ^ 0xFFFFFFFFUL);
|
||||
file.close();
|
||||
return valid;
|
||||
}
|
||||
|
||||
static bool verifyContactsFile(
|
||||
FILESYSTEM* fs,
|
||||
const char* filename,
|
||||
ClientInfo* clients,
|
||||
int num_clients,
|
||||
bool (*filter)(ClientInfo*),
|
||||
size_t expected_size) {
|
||||
File file = openRead(fs, filename);
|
||||
if (!file || file.size() != expected_size + 8) {
|
||||
if (file) file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t unused[2] = {CONTACT_RECORD_VERSION_ALT_PATH, 0};
|
||||
bool matches = true;
|
||||
for (int i = 0; matches && i < num_clients; i++) {
|
||||
ClientInfo* c = &clients[i];
|
||||
if (!mesh::clientRoleIsPersisted(
|
||||
c->permissions, PERM_ACL_ROLE_MASK, PERM_ACL_GUEST)
|
||||
|| (filter && !filter(c))) continue;
|
||||
uint8_t prior_path_len = OUT_PATH_UNKNOWN;
|
||||
uint8_t prior_path[MAX_PATH_SIZE] = {};
|
||||
const mesh::StoredClientPathView persisted_path =
|
||||
storedClientPathForSave(
|
||||
fs, c, prior_path, &prior_path_len);
|
||||
matches = readMatches(file, c->id.pub_key, 32)
|
||||
&& readMatches(file, &c->permissions, 1)
|
||||
&& readMatches(file, (uint8_t*)&c->extra.room.sync_since, 4)
|
||||
&& readMatches(file, unused, sizeof(unused))
|
||||
&& readMatches(file, &persisted_path.encoded_path_len, 1)
|
||||
&& readMatches(file, persisted_path.path, 64)
|
||||
&& readMatches(file, c->shared_secret, PUB_KEY_SIZE)
|
||||
&& readMatches(file, &c->alt_path_len, 1)
|
||||
&& readMatches(file, c->alt_path, 64);
|
||||
}
|
||||
uint8_t magic[4];
|
||||
uint32_t stored_crc;
|
||||
matches = matches
|
||||
&& file.read(magic, sizeof(magic)) == (int)sizeof(magic)
|
||||
&& file.read((uint8_t*)&stored_crc, sizeof(stored_crc))
|
||||
== (int)sizeof(stored_crc)
|
||||
&& memcmp(magic, mesh::CLIENT_ACL_CRC_MAGIC, sizeof(magic)) == 0;
|
||||
file.close();
|
||||
return matches && validateContactsFileIntegrity(fs, filename);
|
||||
}
|
||||
#endif
|
||||
|
||||
static bool validateLoginReplayFileIntegrity(FILESYSTEM* fs,
|
||||
const char* filename) {
|
||||
File file = openRead(fs, filename);
|
||||
if (!file) return false;
|
||||
const size_t size = file.size();
|
||||
if (size < mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
const size_t payload_size =
|
||||
size - mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE;
|
||||
const size_t record_count =
|
||||
payload_size / mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE;
|
||||
if (payload_size % mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE != 0
|
||||
|| record_count > mesh::MAX_CLIENT_LOGIN_REPLAY_IDENTITIES) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
bool valid = true;
|
||||
for (size_t i = 0; valid && i < record_count; i++) {
|
||||
uint8_t pubkey[PUB_KEY_SIZE];
|
||||
uint32_t ceiling;
|
||||
valid = file.read(pubkey, sizeof(pubkey)) == (int)sizeof(pubkey)
|
||||
&& file.read((uint8_t*)&ceiling, sizeof(ceiling))
|
||||
== (int)sizeof(ceiling)
|
||||
&& ceiling != 0;
|
||||
if (valid) {
|
||||
crc = mesh::updateClientLoginReplayCRC(
|
||||
crc, pubkey, sizeof(pubkey));
|
||||
crc = mesh::updateClientLoginReplayCRC(
|
||||
crc, (const uint8_t*)&ceiling, sizeof(ceiling));
|
||||
}
|
||||
}
|
||||
uint8_t magic[sizeof(mesh::CLIENT_LOGIN_REPLAY_MAGIC)];
|
||||
uint32_t stored_crc;
|
||||
valid = valid
|
||||
&& file.read(magic, sizeof(magic)) == (int)sizeof(magic)
|
||||
&& file.read((uint8_t*)&stored_crc, sizeof(stored_crc))
|
||||
== (int)sizeof(stored_crc)
|
||||
&& memcmp(magic, mesh::CLIENT_LOGIN_REPLAY_MAGIC,
|
||||
sizeof(magic)) == 0
|
||||
&& stored_crc == (crc ^ 0xFFFFFFFFUL);
|
||||
file.close();
|
||||
return valid;
|
||||
}
|
||||
|
||||
// The image is validated before this scan. Choose the maximum if a legacy or
|
||||
// manually recovered image somehow contains a duplicate identity; taking the
|
||||
// strongest boundary remains fail-safe.
|
||||
static bool readClientLoginReplayCeiling(
|
||||
FILESYSTEM* fs,
|
||||
const uint8_t pubkey[PUB_KEY_SIZE],
|
||||
uint32_t* ceiling,
|
||||
bool* found) {
|
||||
*ceiling = 0;
|
||||
*found = false;
|
||||
if (!fs->exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) return true;
|
||||
if (!validateLoginReplayFileIntegrity(
|
||||
fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) {
|
||||
return false;
|
||||
}
|
||||
File file = openRead(fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH);
|
||||
if (!file) return false;
|
||||
const size_t record_count =
|
||||
(file.size() - mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE)
|
||||
/ mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE;
|
||||
bool success = true;
|
||||
for (size_t i = 0; success && i < record_count; i++) {
|
||||
uint8_t record_pubkey[PUB_KEY_SIZE];
|
||||
uint32_t record_ceiling;
|
||||
success = file.read(record_pubkey, sizeof(record_pubkey))
|
||||
== (int)sizeof(record_pubkey);
|
||||
success = success
|
||||
&& file.read((uint8_t*)&record_ceiling, sizeof(record_ceiling))
|
||||
== (int)sizeof(record_ceiling);
|
||||
if (success && memcmp(record_pubkey, pubkey, PUB_KEY_SIZE) == 0
|
||||
&& (!*found || record_ceiling > *ceiling)) {
|
||||
*ceiling = record_ceiling;
|
||||
*found = true;
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
return success;
|
||||
}
|
||||
|
||||
template <typename Writer>
|
||||
static bool writeClientLoginReplayRecord(
|
||||
Writer& writer,
|
||||
const uint8_t pubkey[PUB_KEY_SIZE],
|
||||
uint32_t ceiling,
|
||||
uint32_t* crc) {
|
||||
const bool success = writer.write(pubkey, PUB_KEY_SIZE) == PUB_KEY_SIZE
|
||||
&& writer.write((const uint8_t*)&ceiling, sizeof(ceiling))
|
||||
== sizeof(ceiling);
|
||||
if (success) {
|
||||
*crc = mesh::updateClientLoginReplayCRC(
|
||||
*crc, pubkey, PUB_KEY_SIZE);
|
||||
*crc = mesh::updateClientLoginReplayCRC(
|
||||
*crc, (const uint8_t*)&ceiling, sizeof(ceiling));
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
static bool writeClientLoginReplayCeiling(
|
||||
FILESYSTEM* fs,
|
||||
const uint8_t pubkey[PUB_KEY_SIZE],
|
||||
uint32_t new_ceiling,
|
||||
mesh::ClientLoginReplayReservationAction action) {
|
||||
if (action == mesh::ClientLoginReplayReservationAction::None) return true;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
if (fs->exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)
|
||||
&& !validateLoginReplayFileIntegrity(
|
||||
fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) {
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
if (!mesh::recoverClientLoginReplayFiles(
|
||||
fs, validateLoginReplayFileIntegrity)) {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
File source = openRead(fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH);
|
||||
const size_t record_count = source
|
||||
? (source.size() - mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE)
|
||||
/ mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE
|
||||
: 0;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
mesh::AtomicFileWriter destination(
|
||||
fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH);
|
||||
#else
|
||||
File destination = openWrite(
|
||||
fs, mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH);
|
||||
#endif
|
||||
if (!destination) {
|
||||
if (source) source.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
bool found = false;
|
||||
bool success = true;
|
||||
for (size_t i = 0; success && i < record_count; i++) {
|
||||
uint8_t record_pubkey[PUB_KEY_SIZE];
|
||||
uint32_t record_ceiling;
|
||||
success = source.read(record_pubkey, sizeof(record_pubkey))
|
||||
== (int)sizeof(record_pubkey);
|
||||
success = success
|
||||
&& source.read((uint8_t*)&record_ceiling, sizeof(record_ceiling))
|
||||
== (int)sizeof(record_ceiling);
|
||||
if (!success) break;
|
||||
if (memcmp(record_pubkey, pubkey, PUB_KEY_SIZE) == 0) {
|
||||
found = true;
|
||||
if (record_ceiling < new_ceiling) record_ceiling = new_ceiling;
|
||||
}
|
||||
success = writeClientLoginReplayRecord(
|
||||
destination, record_pubkey, record_ceiling, &crc);
|
||||
}
|
||||
if (source) source.close();
|
||||
|
||||
if (success
|
||||
&& action == mesh::ClientLoginReplayReservationAction::UpdateExisting
|
||||
&& !found) {
|
||||
// Do not turn a low-trust update into insertion if the image changed
|
||||
// between its admission read and this transaction.
|
||||
success = false;
|
||||
}
|
||||
if (success && !found
|
||||
&& action == mesh::ClientLoginReplayReservationAction::CreateNew) {
|
||||
success = mesh::clientLoginReplayCanInsert(record_count);
|
||||
}
|
||||
if (success && !found) {
|
||||
success = writeClientLoginReplayRecord(
|
||||
destination, pubkey, new_ceiling, &crc);
|
||||
}
|
||||
if (success) {
|
||||
const uint32_t final_crc = crc ^ 0xFFFFFFFFUL;
|
||||
success = destination.write(
|
||||
mesh::CLIENT_LOGIN_REPLAY_MAGIC,
|
||||
sizeof(mesh::CLIENT_LOGIN_REPLAY_MAGIC))
|
||||
== sizeof(mesh::CLIENT_LOGIN_REPLAY_MAGIC)
|
||||
&& destination.write(
|
||||
(const uint8_t*)&final_crc, sizeof(final_crc))
|
||||
== sizeof(final_crc);
|
||||
}
|
||||
|
||||
#if defined(NRF52_PLATFORM)
|
||||
return destination.commit(success);
|
||||
#else
|
||||
destination.close();
|
||||
const bool verified = success && validateLoginReplayFileIntegrity(
|
||||
fs, mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH);
|
||||
return mesh::publishClientLoginReplayTemp(
|
||||
fs, verified, validateLoginReplayFileIntegrity);
|
||||
#endif
|
||||
}
|
||||
|
||||
void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
_fs = fs;
|
||||
num_clients = 0;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
// AtomicFileWriter may leave only a harmless temp image when reset before
|
||||
// rename. The live image remains authoritative.
|
||||
mesh::removeClientLoginReplayArtifact(
|
||||
_fs, mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH);
|
||||
login_replay_store_available =
|
||||
!_fs->exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)
|
||||
|| validateLoginReplayFileIntegrity(
|
||||
_fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH);
|
||||
#else
|
||||
login_replay_store_available = mesh::recoverClientLoginReplayFiles(
|
||||
_fs, validateLoginReplayFileIntegrity);
|
||||
#endif
|
||||
if (!login_replay_store_available) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"ERROR: ClientACL::load could not recover login replay state");
|
||||
}
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
if (!mesh::recoverClientACLFilesVerified(
|
||||
_fs, validateContactsFileIntegrity)) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: ClientACL::load could not recover contacts files");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (_fs->exists("/s_contacts")) {
|
||||
#if defined(RP2040_PLATFORM)
|
||||
File file = _fs->open("/s_contacts", "r");
|
||||
@@ -52,6 +477,22 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
if (!success) break; // EOF
|
||||
|
||||
c.id = mesh::Identity(pub_key);
|
||||
c.out_path_is_persistable = true;
|
||||
if (login_replay_store_available) {
|
||||
uint32_t replay_ceiling;
|
||||
bool replay_entry_found;
|
||||
if (!readClientLoginReplayCeiling(
|
||||
_fs, pub_key, &replay_ceiling, &replay_entry_found)) {
|
||||
login_replay_store_available = false;
|
||||
c.last_timestamp = UINT32_MAX;
|
||||
} else if (replay_entry_found) {
|
||||
// On boot, skip the unused reservation tail. During this boot a
|
||||
// successful login replaces this with its exact accepted value.
|
||||
c.last_timestamp = replay_ceiling;
|
||||
}
|
||||
} else {
|
||||
c.last_timestamp = UINT32_MAX;
|
||||
}
|
||||
self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed
|
||||
if (num_clients < MAX_CLIENTS) {
|
||||
clients[num_clients++] = c;
|
||||
@@ -64,43 +505,146 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
|
||||
}
|
||||
}
|
||||
|
||||
void ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) {
|
||||
bool ClientACL::authorizeLoginTimestamp(
|
||||
const uint8_t* pubkey,
|
||||
uint32_t sender_timestamp,
|
||||
uint32_t runtime_last_timestamp,
|
||||
uint8_t login_permissions) {
|
||||
if (_fs == NULL || pubkey == NULL || !login_replay_store_available) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t persisted_ceiling;
|
||||
bool replay_entry_found;
|
||||
if (!readClientLoginReplayCeiling(
|
||||
_fs, pubkey, &persisted_ceiling, &replay_entry_found)) {
|
||||
login_replay_store_available = false;
|
||||
return false;
|
||||
}
|
||||
if (!replay_entry_found) persisted_ceiling = 0;
|
||||
|
||||
const bool role_needs_durable_replay =
|
||||
mesh::clientLoginRoleNeedsDurableReplay(
|
||||
login_permissions, PERM_ACL_ROLE_MASK,
|
||||
PERM_ACL_GUEST, PERM_ACL_READ_ONLY);
|
||||
const mesh::ClientLoginReplayPlan plan = mesh::planClientLoginReplay(
|
||||
runtime_last_timestamp, persisted_ceiling, sender_timestamp);
|
||||
if (!plan.fresh) return false;
|
||||
const mesh::ClientLoginReplayReservationAction action =
|
||||
mesh::selectClientLoginReplayReservation(
|
||||
plan.reservation_needed, replay_entry_found,
|
||||
role_needs_durable_replay);
|
||||
if (action != mesh::ClientLoginReplayReservationAction::None
|
||||
&& !writeClientLoginReplayCeiling(
|
||||
_fs, pubkey, plan.reservation_ceiling, action)) {
|
||||
// The old valid image remains usable after an ordinary write failure, but
|
||||
// this login is not safe to accept until its new boundary is durable.
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) {
|
||||
_fs = fs;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
mesh::AtomicFileWriter file(_fs, "/s_contacts");
|
||||
#else
|
||||
File file = openWrite(_fs, "/s_contacts");
|
||||
#endif
|
||||
if (file) {
|
||||
uint8_t unused[2];
|
||||
unused[0] = CONTACT_RECORD_VERSION_ALT_PATH;
|
||||
unused[1] = 0;
|
||||
|
||||
bool success = true;
|
||||
for (int i = 0; success && i < num_clients; i++) {
|
||||
auto c = &clients[i];
|
||||
if (c->permissions == 0 || (filter && !filter(c))) continue; // skip deleted entries, or by filter function
|
||||
|
||||
success = (file.write(c->id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *) &c->permissions, 1) == 1);
|
||||
success = success && (file.write((uint8_t *) &c->extra.room.sync_since, 4) == 4);
|
||||
success = success && (file.write(unused, 2) == 2);
|
||||
success = success && (file.write((uint8_t *)&c->out_path_len, 1) == 1);
|
||||
success = success && (file.write(c->out_path, 64) == 64);
|
||||
success = success && (file.write(c->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE);
|
||||
success = success && (file.write((uint8_t *)&c->alt_path_len, 1) == 1);
|
||||
success = success && (file.write(c->alt_path, 64) == 64);
|
||||
|
||||
if (!success) break; // write failed
|
||||
}
|
||||
#if defined(NRF52_PLATFORM)
|
||||
if (!file.commit(success)) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: ClientACL::save atomic commit failed");
|
||||
}
|
||||
#else
|
||||
file.close();
|
||||
#endif
|
||||
if (!mesh::recoverClientACLFilesVerified(
|
||||
_fs, validateContactsFileIntegrity)) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: ClientACL::save recovery is incomplete");
|
||||
return false;
|
||||
}
|
||||
File file = openWrite(_fs, mesh::CLIENT_ACL_TEMP_PATH);
|
||||
#endif
|
||||
if (!file) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: ClientACL::save could not open contacts file");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t unused[2];
|
||||
unused[0] = CONTACT_RECORD_VERSION_ALT_PATH;
|
||||
unused[1] = 0;
|
||||
|
||||
bool success = true;
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
size_t expected_size = 0;
|
||||
uint32_t contacts_crc = 0xFFFFFFFFUL;
|
||||
#endif
|
||||
for (int i = 0; success && i < num_clients; i++) {
|
||||
auto c = &clients[i];
|
||||
if (!mesh::clientRoleIsPersisted(
|
||||
c->permissions, PERM_ACL_ROLE_MASK, PERM_ACL_GUEST)
|
||||
|| (filter && !filter(c))) continue; // skip guests/deleted entries or filtered clients
|
||||
|
||||
success = (file.write(c->id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *) &c->permissions, 1) == 1);
|
||||
success = success && (file.write((uint8_t *) &c->extra.room.sync_since, 4) == 4);
|
||||
success = success && (file.write(unused, 2) == 2);
|
||||
uint8_t prior_path_len = OUT_PATH_UNKNOWN;
|
||||
uint8_t prior_path[MAX_PATH_SIZE] = {};
|
||||
const mesh::StoredClientPathView persisted_path =
|
||||
storedClientPathForSave(
|
||||
_fs, c, prior_path, &prior_path_len);
|
||||
success = success
|
||||
&& (file.write(&persisted_path.encoded_path_len, 1) == 1);
|
||||
success = success
|
||||
&& (file.write(persisted_path.path, MAX_PATH_SIZE) == MAX_PATH_SIZE);
|
||||
success = success && (file.write(c->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE);
|
||||
success = success && (file.write((uint8_t *)&c->alt_path_len, 1) == 1);
|
||||
success = success && (file.write(c->alt_path, 64) == 64);
|
||||
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
if (success) {
|
||||
expected_size += 32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE + 1 + 64;
|
||||
contacts_crc = mesh::updateClientACLCRC(contacts_crc, c->id.pub_key, 32);
|
||||
contacts_crc = mesh::updateClientACLCRC(contacts_crc, &c->permissions, 1);
|
||||
contacts_crc = mesh::updateClientACLCRC(
|
||||
contacts_crc, (uint8_t*)&c->extra.room.sync_since, 4);
|
||||
contacts_crc = mesh::updateClientACLCRC(contacts_crc, unused, sizeof(unused));
|
||||
contacts_crc = mesh::updateClientACLCRC(
|
||||
contacts_crc, &persisted_path.encoded_path_len, 1);
|
||||
contacts_crc = mesh::updateClientACLCRC(
|
||||
contacts_crc, persisted_path.path, MAX_PATH_SIZE);
|
||||
contacts_crc = mesh::updateClientACLCRC(contacts_crc, c->shared_secret, PUB_KEY_SIZE);
|
||||
contacts_crc = mesh::updateClientACLCRC(contacts_crc, &c->alt_path_len, 1);
|
||||
contacts_crc = mesh::updateClientACLCRC(contacts_crc, c->alt_path, 64);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if !defined(NRF52_PLATFORM)
|
||||
if (success) {
|
||||
const uint32_t final_crc = contacts_crc ^ 0xFFFFFFFFUL;
|
||||
success = file.write(mesh::CLIENT_ACL_CRC_MAGIC,
|
||||
sizeof(mesh::CLIENT_ACL_CRC_MAGIC))
|
||||
== sizeof(mesh::CLIENT_ACL_CRC_MAGIC)
|
||||
&& file.write((const uint8_t*)&final_crc, sizeof(final_crc))
|
||||
== sizeof(final_crc);
|
||||
}
|
||||
#endif
|
||||
#if defined(NRF52_PLATFORM)
|
||||
success = file.commit(success);
|
||||
if (!success) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: ClientACL::save atomic commit failed");
|
||||
}
|
||||
#else
|
||||
file.close();
|
||||
if (success) {
|
||||
success = verifyContactsFile(
|
||||
_fs, mesh::CLIENT_ACL_TEMP_PATH, clients, num_clients,
|
||||
filter, expected_size);
|
||||
}
|
||||
if (!success) {
|
||||
mesh::publishVerifiedClientACLTemp(_fs, false);
|
||||
MESH_DEBUG_PRINTLN("ERROR: ClientACL::save close/readback failed");
|
||||
return false;
|
||||
}
|
||||
success = mesh::publishVerifiedClientACLTemp(
|
||||
_fs, true, validateContactsFileIntegrity);
|
||||
if (!success) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: ClientACL::save atomic publish failed");
|
||||
}
|
||||
#endif
|
||||
return success;
|
||||
}
|
||||
|
||||
bool ClientACL::clear() {
|
||||
@@ -108,14 +652,14 @@ bool ClientACL::clear() {
|
||||
if (_fs->exists("/s_contacts")) {
|
||||
_fs->remove("/s_contacts");
|
||||
}
|
||||
#if defined(NRF52_PLATFORM)
|
||||
if (_fs->exists("/s_contacts.tmp")) {
|
||||
_fs->remove("/s_contacts.tmp");
|
||||
}
|
||||
#endif
|
||||
if (_fs->exists("/s_contacts.tmp")) _fs->remove("/s_contacts.tmp");
|
||||
if (_fs->exists("/s_contacts.bak")) _fs->remove("/s_contacts.bak");
|
||||
const bool files_cleared = !_fs->exists("/s_contacts")
|
||||
&& !_fs->exists("/s_contacts.tmp")
|
||||
&& !_fs->exists("/s_contacts.bak");
|
||||
memset(clients, 0, sizeof(clients));
|
||||
num_clients = 0;
|
||||
return true;
|
||||
return files_cleared;
|
||||
}
|
||||
|
||||
ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) {
|
||||
@@ -148,6 +692,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
|
||||
c->permissions = init_perms;
|
||||
c->id = id;
|
||||
c->out_path_len = OUT_PATH_UNKNOWN;
|
||||
c->out_path_is_persistable = true;
|
||||
c->alt_path_len = OUT_PATH_UNKNOWN;
|
||||
return c;
|
||||
}
|
||||
|
||||
+20
-3
@@ -20,10 +20,11 @@ struct ClientInfo {
|
||||
uint8_t permissions;
|
||||
uint8_t out_path_len;
|
||||
uint8_t out_path[MAX_PATH_SIZE];
|
||||
bool out_path_is_persistable; // live route may replace, but not erase, durable route
|
||||
uint8_t alt_path_len;
|
||||
uint8_t alt_path[MAX_PATH_SIZE];
|
||||
uint8_t shared_secret[PUB_KEY_SIZE];
|
||||
uint32_t last_timestamp; // by THEIR clock (transient)
|
||||
uint32_t last_timestamp; // by THEIR clock (exact live floor; reserved ceiling after load)
|
||||
uint32_t last_activity; // by OUR clock (transient)
|
||||
union {
|
||||
struct {
|
||||
@@ -50,16 +51,32 @@ class ClientACL {
|
||||
FILESYSTEM* _fs;
|
||||
ClientInfo clients[MAX_CLIENTS];
|
||||
int num_clients;
|
||||
bool login_replay_store_available;
|
||||
|
||||
public:
|
||||
ClientACL() {
|
||||
ClientACL() {
|
||||
_fs = NULL;
|
||||
memset(clients, 0, sizeof(clients));
|
||||
num_clients = 0;
|
||||
login_replay_store_available = false;
|
||||
}
|
||||
void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id);
|
||||
void save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL);
|
||||
bool save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL);
|
||||
bool clear();
|
||||
|
||||
// Authenticate the sender/password first, then call this before allocating
|
||||
// or mutating a client. A true result means any required replay high-water
|
||||
// reservation was durably published. runtime_last_timestamp is zero when
|
||||
// this boot has no live state for the identity (including after eviction or
|
||||
// revocation), in which case the durable tombstone is authoritative.
|
||||
// login_permissions selects whether a missing identity may allocate durable
|
||||
// state. Guest/read-only sessions still enforce and advance an existing
|
||||
// tombstone, but otherwise use only their exact in-boot floor.
|
||||
bool authorizeLoginTimestamp(const uint8_t* pubkey,
|
||||
uint32_t sender_timestamp,
|
||||
uint32_t runtime_last_timestamp,
|
||||
uint8_t login_permissions);
|
||||
|
||||
ClientInfo* getClient(const uint8_t* pubkey, int key_len);
|
||||
ClientInfo* putClient(const mesh::Identity& id, uint8_t init_perms);
|
||||
bool applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
static const uint8_t CLIENT_ACL_CRC_MAGIC[4] = {'M', 'C', 'A', '1'};
|
||||
|
||||
inline uint32_t updateClientACLCRC(uint32_t crc, const uint8_t* data,
|
||||
size_t length) {
|
||||
while (length-- != 0) {
|
||||
crc ^= *data++;
|
||||
for (uint8_t bit = 0; bit < 8; bit++) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (uint32_t)-(int32_t)(crc & 1U));
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
enum ClientACLImageKind {
|
||||
CLIENT_ACL_IMAGE_INVALID,
|
||||
CLIENT_ACL_IMAGE_LEGACY,
|
||||
CLIENT_ACL_IMAGE_CRC,
|
||||
};
|
||||
|
||||
inline ClientACLImageKind classifyClientACLImage(
|
||||
const uint8_t* image, size_t size,
|
||||
size_t current_record_size, size_t legacy_record_size) {
|
||||
if (size >= 8 && (size - 8) % current_record_size == 0) {
|
||||
const size_t payload_size = size - 8;
|
||||
uint32_t stored_crc;
|
||||
memcpy(&stored_crc, image + payload_size + 4, sizeof(stored_crc));
|
||||
uint32_t crc = updateClientACLCRC(0xFFFFFFFFUL, image, payload_size)
|
||||
^ 0xFFFFFFFFUL;
|
||||
if (memcmp(image + payload_size, CLIENT_ACL_CRC_MAGIC, 4) == 0
|
||||
&& stored_crc == crc) {
|
||||
return CLIENT_ACL_IMAGE_CRC;
|
||||
}
|
||||
}
|
||||
if (size % current_record_size == 0
|
||||
|| size % legacy_record_size == 0) {
|
||||
return CLIENT_ACL_IMAGE_LEGACY;
|
||||
}
|
||||
return CLIENT_ACL_IMAGE_INVALID;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,119 @@
|
||||
#pragma once
|
||||
|
||||
namespace mesh {
|
||||
|
||||
static const char CLIENT_ACL_PRIMARY_PATH[] = "/s_contacts";
|
||||
static const char CLIENT_ACL_TEMP_PATH[] = "/s_contacts.tmp";
|
||||
static const char CLIENT_ACL_BACKUP_PATH[] = "/s_contacts.bak";
|
||||
|
||||
template <typename Filesystem>
|
||||
bool removeClientACLArtifact(Filesystem* fs, const char* path) {
|
||||
if (!fs->exists(path)) return true;
|
||||
fs->remove(path);
|
||||
return !fs->exists(path);
|
||||
}
|
||||
|
||||
// Recover the last published image after any power-loss boundary in the
|
||||
// temp -> backup -> primary transaction. A present primary is authoritative;
|
||||
// if publication had not completed, the backup is restored instead.
|
||||
template <typename Filesystem>
|
||||
bool recoverClientACLFiles(Filesystem* fs) {
|
||||
if (fs->exists(CLIENT_ACL_PRIMARY_PATH)) {
|
||||
// The primary is already authoritative. Cleanup is best-effort: a
|
||||
// filesystem which cannot remove a stale artifact must not make the
|
||||
// committed image appear unavailable.
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH);
|
||||
return true;
|
||||
}
|
||||
if (fs->exists(CLIENT_ACL_BACKUP_PATH)) {
|
||||
if (!fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH)) {
|
||||
return false;
|
||||
}
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
return true;
|
||||
}
|
||||
return removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
}
|
||||
|
||||
template <typename Filesystem, typename Validator>
|
||||
bool recoverClientACLFilesVerified(Filesystem* fs, Validator is_valid) {
|
||||
if (fs->exists(CLIENT_ACL_PRIMARY_PATH)
|
||||
&& is_valid(fs, CLIENT_ACL_PRIMARY_PATH)) {
|
||||
// A validated primary remains authoritative even when stale-artifact
|
||||
// cleanup is temporarily unavailable.
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH);
|
||||
return true;
|
||||
}
|
||||
if (fs->exists(CLIENT_ACL_BACKUP_PATH)
|
||||
&& is_valid(fs, CLIENT_ACL_BACKUP_PATH)) {
|
||||
if (!removeClientACLArtifact(fs, CLIENT_ACL_PRIMARY_PATH)
|
||||
|| !fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH)) {
|
||||
return false;
|
||||
}
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
return true;
|
||||
}
|
||||
return !fs->exists(CLIENT_ACL_PRIMARY_PATH)
|
||||
&& !fs->exists(CLIENT_ACL_BACKUP_PATH)
|
||||
&& removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
}
|
||||
|
||||
template <typename Filesystem>
|
||||
bool publishVerifiedClientACLTemp(Filesystem* fs, bool temp_verified) {
|
||||
if (!temp_verified) {
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
return false;
|
||||
}
|
||||
if (fs->exists(CLIENT_ACL_BACKUP_PATH)) return false;
|
||||
|
||||
const bool had_primary = fs->exists(CLIENT_ACL_PRIMARY_PATH);
|
||||
if (had_primary
|
||||
&& !fs->rename(CLIENT_ACL_PRIMARY_PATH, CLIENT_ACL_BACKUP_PATH)) {
|
||||
return false;
|
||||
}
|
||||
if (!fs->rename(CLIENT_ACL_TEMP_PATH, CLIENT_ACL_PRIMARY_PATH)) {
|
||||
// Restore the last committed image immediately when possible. If this
|
||||
// rename also fails, leave both artifacts for recoverClientACLFiles().
|
||||
if (had_primary && !fs->exists(CLIENT_ACL_PRIMARY_PATH)) {
|
||||
fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A stale backup does not make the newly published primary unsuccessful;
|
||||
// recovery removes it before the next load/save.
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH);
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename Filesystem, typename Validator>
|
||||
bool publishVerifiedClientACLTemp(Filesystem* fs, bool temp_verified,
|
||||
Validator is_valid) {
|
||||
if (!temp_verified) {
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH);
|
||||
return false;
|
||||
}
|
||||
if (fs->exists(CLIENT_ACL_BACKUP_PATH)) return false;
|
||||
const bool had_primary = fs->exists(CLIENT_ACL_PRIMARY_PATH);
|
||||
if (had_primary
|
||||
&& !fs->rename(CLIENT_ACL_PRIMARY_PATH, CLIENT_ACL_BACKUP_PATH)) {
|
||||
return false;
|
||||
}
|
||||
if (!fs->rename(CLIENT_ACL_TEMP_PATH, CLIENT_ACL_PRIMARY_PATH)
|
||||
|| !is_valid(fs, CLIENT_ACL_PRIMARY_PATH)) {
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_PRIMARY_PATH);
|
||||
if (had_primary) {
|
||||
fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Publication committed at the successful temp -> primary rename and
|
||||
// validation. A stale backup is recoverable housekeeping, not a failed
|
||||
// save (which could otherwise make RAM roll back while disk holds new data).
|
||||
removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,298 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Login timestamps are security state, not ordinary contact activity. Keep
|
||||
// their durable high-water marks outside /s_contacts so an ACL eviction or an
|
||||
// explicit role revocation cannot erase the replay boundary with the contact.
|
||||
static const char CLIENT_LOGIN_REPLAY_PRIMARY_PATH[] = "/s_login_replay";
|
||||
static const char CLIENT_LOGIN_REPLAY_TEMP_PATH[] = "/s_login_replay.tmp";
|
||||
static const char CLIENT_LOGIN_REPLAY_BACKUP_PATH[] = "/s_login_replay.bak";
|
||||
static const uint8_t CLIENT_LOGIN_REPLAY_MAGIC[4] = {'M', 'C', 'R', '1'};
|
||||
static const size_t CLIENT_LOGIN_REPLAY_RECORD_SIZE = 32 + sizeof(uint32_t);
|
||||
static const size_t CLIENT_LOGIN_REPLAY_TRAILER_SIZE =
|
||||
sizeof(CLIENT_LOGIN_REPLAY_MAGIC) + sizeof(uint32_t);
|
||||
|
||||
// A successful login reserves this many sender-clock seconds in one durable
|
||||
// write. Further monotonically increasing logins inside the reservation need
|
||||
// no flash write. After a reset, the unused tail is deliberately skipped;
|
||||
// this is the availability cost that prevents an already accepted timestamp
|
||||
// from becoming fresh again. One minute bounds that cost while coalescing
|
||||
// rapid reconnects and Bluetooth/Wi-Fi transport changes.
|
||||
static const uint32_t CLIENT_LOGIN_REPLAY_RESERVATION_SECONDS = 60;
|
||||
|
||||
// This is deliberately larger than the live ACL. Historical entries are
|
||||
// privileged tombstones and may not be evicted safely while their password
|
||||
// remains usable. A full store fails closed for a new privileged identity
|
||||
// rather than silently discarding an older replay boundary; low-trust roles do
|
||||
// not allocate records.
|
||||
static const size_t MAX_CLIENT_LOGIN_REPLAY_IDENTITIES = 128;
|
||||
|
||||
struct ClientLoginReplayPlan {
|
||||
bool fresh;
|
||||
bool reservation_needed;
|
||||
uint32_t reservation_ceiling;
|
||||
};
|
||||
|
||||
enum class ClientLoginReplayReservationAction : uint8_t {
|
||||
None,
|
||||
UpdateExisting,
|
||||
CreateNew,
|
||||
};
|
||||
|
||||
// Guest and read-only sessions can consume public or deliberately low-trust
|
||||
// credentials. They still obey the exact in-boot floor and any historical
|
||||
// privileged tombstone, but must not allocate a new permanent identity in the
|
||||
// bounded replay store. Read/write and manager roles retain the durable
|
||||
// pre-allocation reservation which protects a later revocation or ACL
|
||||
// eviction.
|
||||
inline bool clientLoginRoleNeedsDurableReplay(
|
||||
uint8_t permissions,
|
||||
uint8_t role_mask,
|
||||
uint8_t guest_role,
|
||||
uint8_t read_only_role) {
|
||||
const uint8_t role = permissions & role_mask;
|
||||
return role != (guest_role & role_mask)
|
||||
&& role != (read_only_role & role_mask);
|
||||
}
|
||||
|
||||
// Select durable work only after freshness is established. An existing
|
||||
// privileged tombstone remains authoritative even if the identity currently
|
||||
// logs in with a guest/read-only role, so advance that record rather than
|
||||
// silently weakening revoked-role protection. Only a missing low-trust
|
||||
// identity stays RAM-only.
|
||||
inline ClientLoginReplayReservationAction selectClientLoginReplayReservation(
|
||||
bool reservation_needed,
|
||||
bool identity_already_stored,
|
||||
bool role_needs_durable_replay) {
|
||||
if (!reservation_needed) {
|
||||
return ClientLoginReplayReservationAction::None;
|
||||
}
|
||||
if (identity_already_stored) {
|
||||
return ClientLoginReplayReservationAction::UpdateExisting;
|
||||
}
|
||||
return role_needs_durable_replay
|
||||
? ClientLoginReplayReservationAction::CreateNew
|
||||
: ClientLoginReplayReservationAction::None;
|
||||
}
|
||||
|
||||
inline bool clientLoginReplayCanInsert(size_t stored_identity_count) {
|
||||
return stored_identity_count < MAX_CLIENT_LOGIN_REPLAY_IDENTITIES;
|
||||
}
|
||||
|
||||
inline uint32_t reserveClientLoginTimestamp(uint32_t sender_timestamp,
|
||||
uint32_t reservation_seconds) {
|
||||
const uint32_t room = UINT32_MAX - sender_timestamp;
|
||||
return room < reservation_seconds
|
||||
? UINT32_MAX
|
||||
: sender_timestamp + reservation_seconds;
|
||||
}
|
||||
|
||||
// Plan the admission before mutating the ACL. runtime_last_timestamp is
|
||||
// non-zero only when this boot has live state for the identity. Otherwise the
|
||||
// persisted ceiling is the freshness floor (boot, eviction, and revocation all
|
||||
// take this branch). The caller must durably publish reservation_ceiling
|
||||
// before treating a plan with reservation_needed as accepted.
|
||||
inline ClientLoginReplayPlan planClientLoginReplay(
|
||||
uint32_t runtime_last_timestamp,
|
||||
uint32_t persisted_ceiling,
|
||||
uint32_t sender_timestamp,
|
||||
uint32_t reservation_seconds =
|
||||
CLIENT_LOGIN_REPLAY_RESERVATION_SECONDS) {
|
||||
const uint32_t freshness_floor = runtime_last_timestamp != 0
|
||||
? runtime_last_timestamp
|
||||
: persisted_ceiling;
|
||||
if (sender_timestamp == 0 || sender_timestamp <= freshness_floor) {
|
||||
return {false, false, persisted_ceiling};
|
||||
}
|
||||
if (sender_timestamp <= persisted_ceiling) {
|
||||
return {true, false, persisted_ceiling};
|
||||
}
|
||||
return {
|
||||
true,
|
||||
true,
|
||||
reserveClientLoginTimestamp(sender_timestamp, reservation_seconds),
|
||||
};
|
||||
}
|
||||
|
||||
inline uint32_t updateClientLoginReplayCRC(uint32_t crc,
|
||||
const uint8_t* data,
|
||||
size_t length) {
|
||||
while (length-- != 0) {
|
||||
crc ^= *data++;
|
||||
for (uint8_t bit = 0; bit < 8; bit++) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL &
|
||||
(uint32_t)-(int32_t)(crc & 1U));
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
inline bool validateClientLoginReplayImage(const uint8_t* image,
|
||||
size_t size) {
|
||||
if (image == NULL || size < CLIENT_LOGIN_REPLAY_TRAILER_SIZE) return false;
|
||||
const size_t payload_size = size - CLIENT_LOGIN_REPLAY_TRAILER_SIZE;
|
||||
if (payload_size % CLIENT_LOGIN_REPLAY_RECORD_SIZE != 0
|
||||
|| payload_size / CLIENT_LOGIN_REPLAY_RECORD_SIZE
|
||||
> MAX_CLIENT_LOGIN_REPLAY_IDENTITIES) {
|
||||
return false;
|
||||
}
|
||||
if (memcmp(image + payload_size, CLIENT_LOGIN_REPLAY_MAGIC,
|
||||
sizeof(CLIENT_LOGIN_REPLAY_MAGIC)) != 0) {
|
||||
return false;
|
||||
}
|
||||
for (size_t offset = 0; offset < payload_size;
|
||||
offset += CLIENT_LOGIN_REPLAY_RECORD_SIZE) {
|
||||
uint32_t ceiling;
|
||||
memcpy(&ceiling, image + offset + 32, sizeof(ceiling));
|
||||
if (ceiling == 0) return false;
|
||||
}
|
||||
uint32_t stored_crc;
|
||||
memcpy(&stored_crc,
|
||||
image + payload_size + sizeof(CLIENT_LOGIN_REPLAY_MAGIC),
|
||||
sizeof(stored_crc));
|
||||
const uint32_t crc = updateClientLoginReplayCRC(
|
||||
0xFFFFFFFFUL, image, payload_size) ^ 0xFFFFFFFFUL;
|
||||
return stored_crc == crc;
|
||||
}
|
||||
|
||||
template <typename Filesystem>
|
||||
bool removeClientLoginReplayArtifact(Filesystem* fs, const char* path) {
|
||||
if (!fs->exists(path)) return true;
|
||||
fs->remove(path);
|
||||
return !fs->exists(path);
|
||||
}
|
||||
|
||||
template <typename Filesystem, typename Validator>
|
||||
bool recoverClientLoginReplayFiles(Filesystem* fs, Validator is_valid) {
|
||||
if (fs->exists(CLIENT_LOGIN_REPLAY_PRIMARY_PATH)
|
||||
&& is_valid(fs, CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) {
|
||||
// The validated primary is already authoritative. Cleanup failure must
|
||||
// not disable replay protection or make committed state appear absent.
|
||||
removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_TEMP_PATH);
|
||||
removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_BACKUP_PATH);
|
||||
return true;
|
||||
}
|
||||
if (fs->exists(CLIENT_LOGIN_REPLAY_BACKUP_PATH)
|
||||
&& is_valid(fs, CLIENT_LOGIN_REPLAY_BACKUP_PATH)) {
|
||||
if (!removeClientLoginReplayArtifact(fs,
|
||||
CLIENT_LOGIN_REPLAY_PRIMARY_PATH)
|
||||
|| !fs->rename(CLIENT_LOGIN_REPLAY_BACKUP_PATH,
|
||||
CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) {
|
||||
return false;
|
||||
}
|
||||
removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_TEMP_PATH);
|
||||
return true;
|
||||
}
|
||||
// A never-created store is the upgrade/first-boot case. An invalid live or
|
||||
// backup image is different: retain it for diagnosis and fail closed.
|
||||
return !fs->exists(CLIENT_LOGIN_REPLAY_PRIMARY_PATH)
|
||||
&& !fs->exists(CLIENT_LOGIN_REPLAY_BACKUP_PATH)
|
||||
&& removeClientLoginReplayArtifact(fs,
|
||||
CLIENT_LOGIN_REPLAY_TEMP_PATH);
|
||||
}
|
||||
|
||||
template <typename Filesystem, typename Validator>
|
||||
bool publishClientLoginReplayTemp(Filesystem* fs,
|
||||
bool temp_verified,
|
||||
Validator is_valid) {
|
||||
if (!temp_verified) {
|
||||
removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_TEMP_PATH);
|
||||
return false;
|
||||
}
|
||||
if (fs->exists(CLIENT_LOGIN_REPLAY_BACKUP_PATH)) return false;
|
||||
|
||||
const bool had_primary = fs->exists(CLIENT_LOGIN_REPLAY_PRIMARY_PATH);
|
||||
if (had_primary
|
||||
&& !fs->rename(CLIENT_LOGIN_REPLAY_PRIMARY_PATH,
|
||||
CLIENT_LOGIN_REPLAY_BACKUP_PATH)) {
|
||||
return false;
|
||||
}
|
||||
if (!fs->rename(CLIENT_LOGIN_REPLAY_TEMP_PATH,
|
||||
CLIENT_LOGIN_REPLAY_PRIMARY_PATH)
|
||||
|| !is_valid(fs, CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) {
|
||||
removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_PRIMARY_PATH);
|
||||
if (had_primary) {
|
||||
fs->rename(CLIENT_LOGIN_REPLAY_BACKUP_PATH,
|
||||
CLIENT_LOGIN_REPLAY_PRIMARY_PATH);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// The new replay ceiling committed at the successful rename and
|
||||
// validation. Treat stale-backup cleanup as best-effort so callers never
|
||||
// reject a login whose durable reservation is already on disk.
|
||||
removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_BACKUP_PATH);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool clientRoleIsPersisted(uint8_t permissions,
|
||||
uint8_t role_mask,
|
||||
uint8_t guest_role) {
|
||||
return (permissions & role_mask) != (guest_role & role_mask);
|
||||
}
|
||||
|
||||
inline bool isFreshClientLoginTimestamp(bool client_existed,
|
||||
uint32_t sender_timestamp,
|
||||
uint32_t last_timestamp) {
|
||||
return sender_timestamp != 0
|
||||
&& (!client_existed || sender_timestamp > last_timestamp);
|
||||
}
|
||||
|
||||
inline bool successfulClientLoginNeedsPersistence(bool client_existed,
|
||||
uint8_t previous_permissions,
|
||||
uint8_t next_permissions,
|
||||
uint8_t role_mask,
|
||||
uint8_t guest_role,
|
||||
bool persisted_changed) {
|
||||
if (!persisted_changed) return false;
|
||||
const uint8_t guest = guest_role & role_mask;
|
||||
const bool previously_persisted = client_existed
|
||||
&& clientRoleIsPersisted(previous_permissions, role_mask, guest);
|
||||
const bool now_persisted =
|
||||
clientRoleIsPersisted(next_permissions, role_mask, guest);
|
||||
// A downgrade to guest must rewrite the ACL even though the guest itself is
|
||||
// omitted from the file; otherwise the prior persisted role survives reboot.
|
||||
return previously_persisted || now_persisted;
|
||||
}
|
||||
|
||||
// Apply the successful-login state transition and report whether any field
|
||||
// written by ClientACL::save() changed. Timestamps are deliberately excluded
|
||||
// from that result: the exact live value is RAM state, while its reserved
|
||||
// high-water boundary is synchronously owned by the separate replay store.
|
||||
template <typename Client>
|
||||
bool applySuccessfulClientLogin(
|
||||
Client& client,
|
||||
bool client_existed,
|
||||
uint8_t role_permissions,
|
||||
uint8_t role_mask,
|
||||
const uint8_t* shared_secret,
|
||||
uint32_t sender_timestamp,
|
||||
uint32_t activity_timestamp,
|
||||
bool reset_out_path,
|
||||
uint8_t unknown_path) {
|
||||
const uint8_t next_permissions =
|
||||
(uint8_t)((client.permissions & (uint8_t)~role_mask)
|
||||
| (role_permissions & role_mask));
|
||||
bool persisted_changed = !client_existed
|
||||
|| client.permissions != next_permissions
|
||||
|| memcmp(client.shared_secret, shared_secret,
|
||||
sizeof(client.shared_secret)) != 0
|
||||
|| (reset_out_path && client.out_path_len != unknown_path);
|
||||
|
||||
client.last_timestamp = sender_timestamp;
|
||||
client.last_activity = activity_timestamp;
|
||||
client.permissions = next_permissions;
|
||||
memcpy(client.shared_secret, shared_secret, sizeof(client.shared_secret));
|
||||
if (reset_out_path) {
|
||||
client.out_path_len = unknown_path;
|
||||
client.out_path_is_persistable = true;
|
||||
}
|
||||
|
||||
return persisted_changed;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
struct ClientPathUpdateResult {
|
||||
bool changed;
|
||||
bool persistence_needed;
|
||||
};
|
||||
|
||||
struct StoredClientPathView {
|
||||
uint8_t encoded_path_len;
|
||||
const uint8_t* path;
|
||||
};
|
||||
|
||||
inline bool clientPathPersistenceAllowed(bool client_is_persistable,
|
||||
bool replay_freshness_proven) {
|
||||
return client_is_persistable && replay_freshness_proven;
|
||||
}
|
||||
|
||||
inline uint8_t storedClientPathLength(bool path_is_persistable,
|
||||
uint8_t current_path_len,
|
||||
uint8_t unknown_path) {
|
||||
return path_is_persistable ? current_path_len : unknown_path;
|
||||
}
|
||||
|
||||
// A replay-unproven PATH may replace the route used for the remainder of this
|
||||
// boot, but it must not erase an earlier operator-selected route when an
|
||||
// unrelated ACL mutation rewrites /s_contacts. Select the live route only
|
||||
// when it owns durable state; otherwise retain the already-published route (or
|
||||
// serialize unknown for a contact that has never had one).
|
||||
inline StoredClientPathView selectStoredClientPath(
|
||||
bool runtime_path_is_persistable,
|
||||
uint8_t runtime_path_len,
|
||||
const uint8_t* runtime_path,
|
||||
bool prior_path_exists,
|
||||
uint8_t prior_path_len,
|
||||
const uint8_t* prior_path,
|
||||
uint8_t unknown_path,
|
||||
const uint8_t* empty_path) {
|
||||
if (runtime_path_is_persistable) {
|
||||
return {runtime_path_len, runtime_path};
|
||||
}
|
||||
if (prior_path_exists) {
|
||||
return {prior_path_len, prior_path};
|
||||
}
|
||||
return {unknown_path, empty_path};
|
||||
}
|
||||
|
||||
// Packet paths encode the hash width in the upper two bits and the hop count
|
||||
// in the lower six bits. This intentionally matches Packet::writePath()'s byte
|
||||
// count without pulling the firmware-only Packet dependencies into host tests.
|
||||
inline size_t encodedClientPathByteLength(uint8_t encoded_path_len) {
|
||||
const size_t hash_count = encoded_path_len & 63U;
|
||||
const size_t hash_size = (encoded_path_len >> 6) + 1U;
|
||||
return hash_count * hash_size;
|
||||
}
|
||||
|
||||
inline bool isValidEncodedClientPathLength(uint8_t encoded_path_len,
|
||||
size_t capacity) {
|
||||
const size_t hash_size = (encoded_path_len >> 6) + 1U;
|
||||
return hash_size != 4U
|
||||
&& encodedClientPathByteLength(encoded_path_len) <= capacity;
|
||||
}
|
||||
|
||||
// Update the RAM route and report whether the caller owns a persistent change.
|
||||
// FORCE_FLOOD is an explicit operator setting and is never replaced by a
|
||||
// learned route. Bytes beyond the encoded route length are deliberately
|
||||
// ignored: they are stale capacity, not part of the logical path.
|
||||
template <typename Client>
|
||||
ClientPathUpdateResult applyReceivedClientPath(
|
||||
Client& client,
|
||||
const uint8_t* path,
|
||||
uint8_t encoded_path_len,
|
||||
bool client_is_persistable,
|
||||
uint8_t force_flood_path) {
|
||||
if (client.out_path_len == force_flood_path) return {false, false};
|
||||
|
||||
const size_t path_bytes = encodedClientPathByteLength(encoded_path_len);
|
||||
if (!isValidEncodedClientPathLength(
|
||||
encoded_path_len, sizeof(client.out_path))
|
||||
|| (path_bytes != 0 && path == NULL)) {
|
||||
return {false, false};
|
||||
}
|
||||
|
||||
const bool changed = client.out_path_len != encoded_path_len
|
||||
|| (path_bytes != 0
|
||||
&& memcmp(client.out_path, path, path_bytes) != 0);
|
||||
if (!changed) {
|
||||
// A later request may carry the replay/freshness proof that the first copy
|
||||
// lacked. Promote the identical RAM route without requiring it to change
|
||||
// bytes a second time.
|
||||
const bool persistence_upgrade = client_is_persistable
|
||||
&& !client.out_path_is_persistable;
|
||||
if (persistence_upgrade) client.out_path_is_persistable = true;
|
||||
return {persistence_upgrade, persistence_upgrade};
|
||||
}
|
||||
|
||||
if (path_bytes != 0) memcpy(client.out_path, path, path_bytes);
|
||||
client.out_path_len = encoded_path_len;
|
||||
client.out_path_is_persistable = client_is_persistable;
|
||||
return {true, client_is_persistable};
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
+149
-56
@@ -316,6 +316,18 @@ static bool parseUint8Strict(const char* value, uint8_t min_value, uint8_t max_v
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parseOnOffStrict(const char* value, bool& enabled) {
|
||||
if (strcmp(value, "on") == 0) {
|
||||
enabled = true;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(value, "off") == 0) {
|
||||
enabled = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool bwMatches(float bw, float allowed) {
|
||||
float diff = bw - allowed;
|
||||
if (diff < 0.0f) diff = -diff;
|
||||
@@ -915,7 +927,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
File file = fs->open(filename);
|
||||
#endif
|
||||
if (file) {
|
||||
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
|
||||
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
|
||||
&& !defined(RS232_BRIDGE_DEFAULT_ON)
|
||||
bool has_runtime_bridge_uart = false;
|
||||
#endif
|
||||
// Every supported layout contains the fixed 290-byte common core. Reject
|
||||
@@ -1280,7 +1293,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
if (file.available() >= (int)sizeof(_prefs->bridge_uart)) {
|
||||
file.read((uint8_t *)&_prefs->bridge_uart,
|
||||
sizeof(_prefs->bridge_uart));
|
||||
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
|
||||
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
|
||||
&& !defined(RS232_BRIDGE_DEFAULT_ON)
|
||||
has_runtime_bridge_uart = true;
|
||||
#endif
|
||||
if (file.available() >= (int)sizeof(_prefs->bridge_format)) {
|
||||
@@ -1345,7 +1359,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
_prefs->loop_detect = constrain(_prefs->loop_detect, 0, 3); // LOOP_DETECT_OFF..LOOP_DETECT_STRICT
|
||||
|
||||
// sanitise bad bridge pref values
|
||||
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED)
|
||||
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
|
||||
&& !defined(RS232_BRIDGE_DEFAULT_ON)
|
||||
if (!has_runtime_bridge_uart) {
|
||||
// Pre-merge normal repeaters persisted bridge_enabled=1 even though no
|
||||
// bridge was compiled. Fail safe on the first merged boot instead of
|
||||
@@ -2722,13 +2737,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
} else if (!valid_gps_toggle) {
|
||||
strcpy(reply, "gps must be 0 or 1");
|
||||
}
|
||||
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
|
||||
else if (is_gps_toggle && strcmp(value, "1") == 0
|
||||
&& _prefs->bridge_enabled
|
||||
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
|
||||
strcpy(reply, "turn the RS232 bridge off or select another UART first");
|
||||
}
|
||||
#endif
|
||||
else if (_sensors->setSettingValue(key, value)) {
|
||||
if (is_gps_interval) {
|
||||
_prefs->gps_interval = gps_interval;
|
||||
@@ -2737,7 +2745,16 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
_prefs->gps_enabled = strcmp(value, "1") == 0 ? 1 : 0;
|
||||
savePrefs();
|
||||
}
|
||||
strcpy(reply, "ok");
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
if (is_gps_toggle && strcmp(value, "1") == 0
|
||||
&& _callbacks->isBridgeRunning()
|
||||
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
|
||||
strcpy(reply, "saved; UART GPS paused while bridge is enabled");
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
strcpy(reply, "ok");
|
||||
}
|
||||
} else {
|
||||
strcpy(reply, "can't find custom var");
|
||||
}
|
||||
@@ -2770,17 +2787,19 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
handleRegionCmd(command, reply);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (memcmp(command, "gps on", 6) == 0) {
|
||||
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
|
||||
if (_prefs->bridge_enabled
|
||||
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
|
||||
strcpy(reply, "turn the RS232 bridge off or select another UART first");
|
||||
} else
|
||||
#endif
|
||||
if (_sensors->setSettingValue("gps", "1")) {
|
||||
_prefs->gps_enabled = 1;
|
||||
savePrefs();
|
||||
|
||||
if (_prefs->powersaving_enabled) { // Power Saving
|
||||
if (_callbacks->isBridgeRunning()
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)
|
||||
#else
|
||||
&& false
|
||||
#endif
|
||||
) {
|
||||
strcpy(reply, "saved; UART GPS paused while bridge is enabled");
|
||||
} else if (_prefs->powersaving_enabled) { // Power Saving
|
||||
strcpy(reply, "on (powersaving)");
|
||||
} else { // Normal mode
|
||||
strcpy(reply, "ok");
|
||||
@@ -2800,6 +2819,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
LocationProvider * l = _sensors->getLocationProvider();
|
||||
if (!_prefs->gps_enabled) {
|
||||
strcpy(reply, "gps is off");
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
} else if (_callbacks->isBridgeRunning()
|
||||
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
|
||||
strcpy(reply, "gps paused by RS232 bridge");
|
||||
#endif
|
||||
} else if (l != NULL) {
|
||||
l->syncTime();
|
||||
strcpy(reply, "scheduled");
|
||||
@@ -4231,35 +4255,73 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
}
|
||||
#ifdef WITH_BRIDGE
|
||||
} else if (memcmp(config, "bridge.enabled ", 15) == 0) {
|
||||
const bool enable = memcmp(&config[15], "on", 2) == 0;
|
||||
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
|
||||
if (enable && _prefs->gps_enabled
|
||||
&& _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) {
|
||||
strcpy(reply, "Error: turn GPS off or select another UART first");
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
_prefs->bridge_enabled = enable;
|
||||
const bool applied = _callbacks->setBridgeState(_prefs->bridge_enabled);
|
||||
savePrefs();
|
||||
if (applied) {
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
strcpy(reply, enable ? "Error: bridge failed to start; setting saved"
|
||||
: "Error: bridge failed to stop; setting saved");
|
||||
bool enable = false;
|
||||
if (!parseOnOffStrict(&config[15], enable)) {
|
||||
strcpy(reply, "Error: usage set bridge.enabled on|off");
|
||||
} else {
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
if (enable
|
||||
&& _sensors->gpsSerialTransportMayConflict(_prefs->bridge_uart)
|
||||
&& (!_sensors->gpsUsesSerialUart(_prefs->bridge_uart)
|
||||
|| !_sensors->gpsSerialTransportCanYield(_prefs->bridge_uart))) {
|
||||
strcpy(reply, "Error: UART may be driven by GPS; use UART 2 or a no-GPS build");
|
||||
return;
|
||||
}
|
||||
const uint8_t previous_enabled = _prefs->bridge_enabled;
|
||||
const bool previous_running = _callbacks->isBridgeRunning();
|
||||
_prefs->bridge_enabled = enable;
|
||||
const bool applied = _callbacks->setBridgeState(enable);
|
||||
if (applied) {
|
||||
savePrefs();
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
if (enable && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
|
||||
strcpy(reply, "OK - UART GPS paused");
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
strcpy(reply, "OK");
|
||||
}
|
||||
} else {
|
||||
_prefs->bridge_enabled = previous_enabled;
|
||||
bool restored = true;
|
||||
if (_callbacks->isBridgeRunning() != previous_running
|
||||
|| !previous_running) {
|
||||
// The false/false case still calls disable to delete a failed,
|
||||
// non-running heap-backed bridge instance.
|
||||
restored = _callbacks->setBridgeState(previous_running);
|
||||
}
|
||||
strcpy(reply, restored
|
||||
? "Error: bridge state change failed; setting unchanged"
|
||||
: "Error: bridge state change failed; previous runtime state could not be restored");
|
||||
}
|
||||
#else
|
||||
// MQTT and ESP-NOW preserve configured intent across missing WiFi
|
||||
// credentials or transient initialization failure. Their runtime can be
|
||||
// retried later, so do not apply the stricter RS-232 rollback policy.
|
||||
_prefs->bridge_enabled = enable;
|
||||
const bool applied = _callbacks->setBridgeState(enable);
|
||||
savePrefs();
|
||||
strcpy(reply, applied ? "OK"
|
||||
: "Error: bridge runtime change failed; setting saved");
|
||||
#endif
|
||||
}
|
||||
} else if (memcmp(config, "bridge.delay ", 13) == 0) {
|
||||
int delay = _atoi(&config[13]);
|
||||
if (delay >= 0 && delay <= 10000) {
|
||||
_prefs->bridge_delay = (uint16_t)delay;
|
||||
int32_t bridge_delay = 0;
|
||||
if (mesh::cli::parseIntegerStrict(&config[13], bridge_delay)
|
||||
&& bridge_delay >= 0 && bridge_delay <= 10000) {
|
||||
_prefs->bridge_delay = (uint16_t)bridge_delay;
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
strcpy(reply, "Error: delay must be between 0-10000 ms");
|
||||
}
|
||||
} else if (memcmp(config, "bridge.source ", 14) == 0) {
|
||||
_prefs->bridge_pkt_src = memcmp(&config[14], "rx", 2) == 0;
|
||||
const char* source = &config[14];
|
||||
if (strcmp(source, "rx") != 0 && strcmp(source, "tx") != 0) {
|
||||
strcpy(reply, "Error: usage set bridge.source rx|tx");
|
||||
return;
|
||||
}
|
||||
_prefs->bridge_pkt_src = strcmp(source, "rx") == 0;
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
if (_prefs->bridge_pkt_src == 1) {
|
||||
_mqtt_prefs.mqtt_rx_enabled = 1;
|
||||
@@ -4276,24 +4338,37 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
#endif
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
} else if (memcmp(config, "bridge.baud ", 12) == 0) {
|
||||
uint32_t baud = atoi(&config[12]);
|
||||
if (baud >= 9600 && baud <= BRIDGE_MAX_BAUD) {
|
||||
_prefs->bridge_baud = (uint32_t)baud;
|
||||
uint32_t baud = 0;
|
||||
if (mesh::cli::parseUnsignedIntegerStrict(&config[12], baud)
|
||||
&& baud >= 9600 && baud <= BRIDGE_MAX_BAUD) {
|
||||
const uint32_t previous_baud = _prefs->bridge_baud;
|
||||
const bool previous_running = _callbacks->isBridgeRunning();
|
||||
_prefs->bridge_baud = baud;
|
||||
const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge();
|
||||
savePrefs();
|
||||
strcpy(reply, applied ? "OK"
|
||||
: "Error: setting saved; bridge failed to restart");
|
||||
if (applied) {
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
} else {
|
||||
_prefs->bridge_baud = previous_baud;
|
||||
const bool restored = previous_running
|
||||
? _callbacks->restartBridge()
|
||||
: _callbacks->setBridgeState(false);
|
||||
strcpy(reply, restored
|
||||
? "Error: bridge failed to restart; baud unchanged"
|
||||
: "Error: bridge failed to restart; previous baud could not be restored");
|
||||
}
|
||||
} else {
|
||||
sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD);
|
||||
}
|
||||
} else if (memcmp(config, "bridge.uart ", 12) == 0) {
|
||||
const int uart = atoi(&config[12]);
|
||||
int32_t uart = 0;
|
||||
const bool parsed = mesh::cli::parseIntegerStrict(&config[12], uart);
|
||||
const bool supported = uart == WITH_RS232_BRIDGE_UART
|
||||
#ifdef WITH_RS232_BRIDGE_ALT
|
||||
|| uart == WITH_RS232_BRIDGE_ALT_UART
|
||||
#endif
|
||||
;
|
||||
if (!supported) {
|
||||
if (!parsed || !supported) {
|
||||
#ifdef WITH_RS232_BRIDGE_ALT
|
||||
sprintf(reply, "Error: UART must be %d or %d",
|
||||
WITH_RS232_BRIDGE_UART, WITH_RS232_BRIDGE_ALT_UART);
|
||||
@@ -4301,18 +4376,34 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
sprintf(reply, "Error: UART is fixed at %d", WITH_RS232_BRIDGE_UART);
|
||||
#endif
|
||||
}
|
||||
#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
|
||||
else if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART
|
||||
&& _prefs->gps_enabled && _prefs->bridge_enabled) {
|
||||
strcpy(reply, "Error: turn GPS or the bridge off first");
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
if (_prefs->bridge_enabled
|
||||
&& _sensors->gpsSerialTransportMayConflict((uint8_t)uart)
|
||||
&& (!_sensors->gpsUsesSerialUart((uint8_t)uart)
|
||||
|| !_sensors->gpsSerialTransportCanYield((uint8_t)uart))) {
|
||||
strcpy(reply, "Error: UART may be driven by GPS; keep UART 2 or use a no-GPS build");
|
||||
return;
|
||||
}
|
||||
const uint8_t previous_uart = _prefs->bridge_uart;
|
||||
const bool previous_running = _callbacks->isBridgeRunning();
|
||||
_prefs->bridge_uart = (uint8_t)uart;
|
||||
const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge();
|
||||
savePrefs();
|
||||
strcpy(reply, applied ? "OK"
|
||||
: "Error: setting saved; bridge failed to restart");
|
||||
if (!applied) {
|
||||
_prefs->bridge_uart = previous_uart;
|
||||
const bool restored = previous_running
|
||||
? _callbacks->restartBridge()
|
||||
: _callbacks->setBridgeState(false);
|
||||
strcpy(reply, restored
|
||||
? "Error: bridge failed to restart; UART unchanged"
|
||||
: "Error: bridge failed to restart; previous UART could not be restored");
|
||||
} else if (_prefs->bridge_enabled
|
||||
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
|
||||
savePrefs();
|
||||
strcpy(reply, "OK - UART GPS paused");
|
||||
} else {
|
||||
savePrefs();
|
||||
strcpy(reply, "OK");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef WITH_ESPNOW_BRIDGE
|
||||
@@ -4740,10 +4831,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
#ifdef WITH_BRIDGE
|
||||
} else if (configKeyEquals(config, "bridge.enabled")) {
|
||||
sprintf(reply, "> %s", _prefs->bridge_enabled ? "on" : "off");
|
||||
} else if (configKeyEquals(config, "bridge.running")) {
|
||||
sprintf(reply, "> %s", _callbacks->isBridgeRunning() ? "on" : "off");
|
||||
} else if (configKeyEquals(config, "bridge.delay")) {
|
||||
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay);
|
||||
} else if (configKeyEquals(config, "bridge.source")) {
|
||||
sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "logRx" : "logTx");
|
||||
sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "rx" : "tx");
|
||||
#endif
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
} else if (configKeyEquals(config, "bridge.baud")) {
|
||||
|
||||
@@ -556,6 +556,8 @@ public:
|
||||
return false;
|
||||
};
|
||||
|
||||
virtual bool isBridgeRunning() const { return false; }
|
||||
|
||||
virtual void restartBridgeSlot(int slot) {
|
||||
// Default: fall back to full restart
|
||||
restartBridge();
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <esp_wifi.h>
|
||||
#include <time.h>
|
||||
#include "WiFiPowerSave.h"
|
||||
#include "esp32/TlsClockValidity.h"
|
||||
#include "esp32/WiFiRadioPolicy.h"
|
||||
#endif
|
||||
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
|
||||
@@ -1166,12 +1168,21 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command,
|
||||
client.setCACertBundle(rootca_crt_bundle_start);
|
||||
#endif
|
||||
client.setTimeout(8000);
|
||||
bool ok = client.connect(host, port);
|
||||
if (ok) {
|
||||
client.stop();
|
||||
snprintf(reply, 160, "OK: TLS bundle verified %s:%u", host, (unsigned)port);
|
||||
// This diagnostic must distinguish a bad CA bundle from a clock that
|
||||
// cannot validate any certificate yet. It does not mutate the global
|
||||
// SNTP client while the MQTT task is live; normal bridge NTP supplies
|
||||
// the signed wall clock checked here.
|
||||
const time_t tls_now = time(nullptr);
|
||||
if (!mesh::tls_clock::timeIsValid(tls_now)) {
|
||||
strcpy(reply, "ERR: TLS clock is not synchronized");
|
||||
} else {
|
||||
snprintf(reply, 160, "ERR: TLS bundle failed %s:%u", host, (unsigned)port);
|
||||
bool ok = client.connect(host, port);
|
||||
if (ok) {
|
||||
client.stop();
|
||||
snprintf(reply, 160, "OK: TLS bundle verified %s:%u", host, (unsigned)port);
|
||||
} else {
|
||||
snprintf(reply, 160, "ERR: TLS bundle failed %s:%u", host, (unsigned)port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1193,8 +1204,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command,
|
||||
} else if (memcmp(command, "ota check", 9) == 0) {
|
||||
// Check is synchronous so its result lands in this reply, and runs with the
|
||||
// MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only
|
||||
// costs a single TLS handshake (no large JSON doc) - which fits alongside
|
||||
// the live MQTT sessions even on no-PSRAM boards. No bridge bounce needed.
|
||||
// costs one plain-HTTP request (no cert-bundle allocation or large JSON
|
||||
// document). The later update path refetches it over verified HTTPS after
|
||||
// the bridge is down. No bridge bounce is needed for this advisory check.
|
||||
_board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply);
|
||||
} else {
|
||||
// `ota update`: cheap pre-check first (plain HTTP, bridge stays up). Only
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <freertos/task.h>
|
||||
#include <helpers/MQTTDefaults.h>
|
||||
#include <helpers/MQTTPresets.h>
|
||||
#include <helpers/UsbLogging.h>
|
||||
#include <strings.h>
|
||||
|
||||
namespace {
|
||||
@@ -553,8 +554,9 @@ bool CompanionMqttSetupPortal::begin(MQTTPrefs* prefs) {
|
||||
impl->task = nullptr;
|
||||
return false;
|
||||
}
|
||||
Serial.printf("MQTT setup: reconnect to the joined WiFi and open http://%s/\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
mesh::usbLoggingPort().printf(
|
||||
"MQTT setup: reconnect to the joined WiFi and open http://%s/\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -580,9 +582,13 @@ bool CompanionMqttSetupPortal::loop() {
|
||||
|
||||
bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(NVS_NAMESPACE, true)) return false;
|
||||
const uint16_t version = nvs.getUShort(NVS_VERSION_KEY, 0);
|
||||
const size_t length = nvs.getBytesLength(NVS_PREFS_KEY);
|
||||
// The namespace and blob legitimately do not exist on a fresh install.
|
||||
// Avoid Arduino Preferences turning that state into UART error output.
|
||||
if (!nvs.begin(NVS_NAMESPACE, false)) return false;
|
||||
const uint16_t version = nvs.isKey(NVS_VERSION_KEY)
|
||||
? nvs.getUShort(NVS_VERSION_KEY, 0) : 0;
|
||||
const size_t length = nvs.isKey(NVS_PREFS_KEY)
|
||||
? nvs.getBytesLength(NVS_PREFS_KEY) : 0;
|
||||
// Companion stores this struct directly rather than using the observer's
|
||||
// versioned file header. Accept the previously shipped pre-display size and
|
||||
// default its append-only display tail so adding observer UI preferences
|
||||
|
||||
+115
-10
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "ESP32Board.h"
|
||||
#include <target.h>
|
||||
#include "UsbLogging.h"
|
||||
#include "UserGpioPinPolicy.h"
|
||||
|
||||
namespace {
|
||||
@@ -414,16 +415,113 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <Update.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <atomic>
|
||||
#include <helpers/esp32/SntpOperationCoordinator.h>
|
||||
#include <helpers/esp32/TlsClockValidity.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <esp_partition.h>
|
||||
#include <esp_sntp.h>
|
||||
#include <strings.h>
|
||||
#include <time.h>
|
||||
|
||||
// Embedded CA bundle (produced by board_build.embed_files). Weak so non-bundle
|
||||
// builds still link; we check for presence at runtime.
|
||||
extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak));
|
||||
extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak));
|
||||
|
||||
static constexpr uint32_t OTA_NTP_SYNC_WAIT_MS = 15000UL;
|
||||
static constexpr uint32_t OTA_TLS_PROOF_MAX_AGE_MS = 300000UL;
|
||||
static std::atomic<bool> ota_tls_fresh_ntp_received{false};
|
||||
static std::atomic<uint32_t> ota_tls_proof_millis{0};
|
||||
static std::atomic<uint32_t> ota_tls_proof_generation{0};
|
||||
static std::atomic<uint32_t> ota_tls_expected_generation{0};
|
||||
static std::atomic<uint32_t> ota_sntp_operation_generation{0};
|
||||
|
||||
static void ota_clearNtpCallback() {
|
||||
// OperationLease invokes this before publishing the global coordinator as
|
||||
// free, so a stale OTA teardown cannot erase another service's callback.
|
||||
ota_sntp_operation_generation.store(0, std::memory_order_release);
|
||||
sntp_set_time_sync_notification_cb(nullptr);
|
||||
}
|
||||
|
||||
static void ota_noteNtpTime(struct timeval* value) {
|
||||
const uint32_t generation =
|
||||
ota_sntp_operation_generation.load(std::memory_order_acquire);
|
||||
if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return;
|
||||
if (value != nullptr && mesh::tls_clock::timeIsValid(value->tv_sec)) {
|
||||
ota_tls_proof_millis.store(millis(), std::memory_order_release);
|
||||
ota_tls_proof_generation.store(generation, std::memory_order_release);
|
||||
ota_tls_fresh_ntp_received.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
static bool ota_tlsClockProofValid() {
|
||||
const bool fresh = ota_tls_fresh_ntp_received.load(std::memory_order_acquire);
|
||||
const uint32_t proven_at =
|
||||
ota_tls_proof_millis.load(std::memory_order_acquire);
|
||||
const uint32_t proof_generation =
|
||||
ota_tls_proof_generation.load(std::memory_order_acquire);
|
||||
const uint32_t expected_generation =
|
||||
ota_tls_expected_generation.load(std::memory_order_acquire);
|
||||
return mesh::tls_clock::proofGenerationIsValid(
|
||||
fresh, proof_generation, expected_generation)
|
||||
&& mesh::tls_clock::proofIsValid(
|
||||
fresh, WiFi.status() == WL_CONNECTED, time(nullptr))
|
||||
&& mesh::tls_clock::proofAgeIsValid(
|
||||
fresh, millis(), proven_at, OTA_TLS_PROOF_MAX_AGE_MS);
|
||||
}
|
||||
|
||||
static bool ota_prepareTlsClock(char reply[]) {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
strcpy(reply, "ERR: WiFi disconnected before NTP");
|
||||
return false;
|
||||
}
|
||||
|
||||
mesh::sntp_coord::OperationLease sntp_operation(
|
||||
mesh::sntp_coord::processWideCoordinator(), ota_clearNtpCallback);
|
||||
if (!sntp_operation.tryAcquire()) {
|
||||
strcpy(reply, "ERR: NTP busy in another firmware service");
|
||||
return false;
|
||||
}
|
||||
|
||||
// The MQTT bridge has stopped for an actual update, but other firmware
|
||||
// services can still need SNTP. The process-wide lease makes this callback
|
||||
// and configTime() sequence exclusive. A plausible retained RTC is
|
||||
// intentionally not enough: observe a response during this operation.
|
||||
ota_tls_fresh_ntp_received.store(false, std::memory_order_release);
|
||||
ota_tls_proof_millis.store(0, std::memory_order_release);
|
||||
ota_tls_proof_generation.store(0, std::memory_order_release);
|
||||
ota_tls_expected_generation.store(
|
||||
sntp_operation.generation(), std::memory_order_release);
|
||||
ota_sntp_operation_generation.store(
|
||||
sntp_operation.generation(), std::memory_order_release);
|
||||
sntp_set_time_sync_notification_cb(ota_noteNtpTime);
|
||||
sntp_set_sync_status(SNTP_SYNC_STATUS_RESET);
|
||||
mesh::usbLoggingPort().println(
|
||||
"OTA: requesting fresh NTP time before HTTPS");
|
||||
configTime(0, 0, "time.cloudflare.com", "time.google.com", "pool.ntp.org");
|
||||
|
||||
const uint32_t started = millis();
|
||||
while (millis() - started < OTA_NTP_SYNC_WAIT_MS) {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
strcpy(reply, "ERR: WiFi disconnected during NTP");
|
||||
return false;
|
||||
}
|
||||
const time_t now = time(nullptr);
|
||||
if (ota_tlsClockProofValid()) {
|
||||
mesh::usbLoggingPort().printf(
|
||||
"OTA: fresh NTP time %lld received in %lu ms\n",
|
||||
(long long)now, (unsigned long)(millis() - started));
|
||||
return true;
|
||||
}
|
||||
delay(100);
|
||||
}
|
||||
|
||||
strcpy(reply, "ERR: fresh NTP time unavailable");
|
||||
return false;
|
||||
}
|
||||
|
||||
struct OtaHttpResponse {
|
||||
int status;
|
||||
size_t content_length;
|
||||
@@ -513,6 +611,10 @@ static bool ota_openHttp(Client& client, const char* url, bool require_tls,
|
||||
}
|
||||
|
||||
client.setTimeout(20000);
|
||||
if (require_tls && !ota_tlsClockProofValid()) {
|
||||
strcpy(reply, "ERR: TLS clock proof invalid");
|
||||
return false;
|
||||
}
|
||||
if (!client.connect(host, port)) {
|
||||
strcpy(reply, "ERR: HTTP connect failed");
|
||||
return false;
|
||||
@@ -647,7 +749,7 @@ static bool ota_streamFirmware(Client& client, size_t content_length, char reply
|
||||
int decile = (int)((received_total * 10U) / content_length);
|
||||
if (decile != progress_decile) {
|
||||
progress_decile = decile;
|
||||
Serial.printf("OTA: %d%%\n", decile * 10);
|
||||
mesh::usbLoggingPort().printf("OTA: %d%%\n", decile * 10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +890,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char
|
||||
strcpy(reply, "ERR: no embedded cert bundle");
|
||||
return false;
|
||||
}
|
||||
if (!dry_run && !ota_prepareTlsClock(reply)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Fetch this variant's slim manifest ----------------------------------
|
||||
// <OTA_MANIFEST_BASE>/<OTA_VARIANT>.json - a ~180 byte per-variant file, not the
|
||||
@@ -822,13 +927,12 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char
|
||||
mclient.setCACertBundle(rootca_crt_bundle_start);
|
||||
#endif
|
||||
snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT);
|
||||
mesh::usbLoggingPort().printf("OTA: downloading manifest %s\n", murl);
|
||||
if (!ota_fetchManifest(mclient, murl, true, doc, reply)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dry_run) { Serial.print("OTA: checking manifest "); Serial.println(murl); }
|
||||
|
||||
// Copy fields out before the document is reused/cleared.
|
||||
char file_url[200] = {0}, avail_version[40] = {0}, avail_base[40] = {0}, avail_hash[24] = {0};
|
||||
strncpy(file_url, doc["file"] | "", sizeof(file_url) - 1);
|
||||
@@ -931,8 +1035,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char
|
||||
}
|
||||
|
||||
// --- Stream the .bin (the manifest's full URL) into the inactive OTA slot -
|
||||
Serial.printf("OTA: update %s -> %s\n", own_disp, avail_disp);
|
||||
Serial.print("OTA: downloading "); Serial.println(file_url);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"OTA: update %s -> %s\n", own_disp, avail_disp);
|
||||
mesh::usbLoggingPort().printf("OTA: downloading %s\n", file_url);
|
||||
inhibit_sleep = true; // keep awake through the flash
|
||||
|
||||
WiFiClientSecure uclient;
|
||||
@@ -945,32 +1050,32 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char
|
||||
OtaHttpResponse response;
|
||||
if (!ota_openHttp(uclient, file_url, true, response, reply)) {
|
||||
inhibit_sleep = false;
|
||||
Serial.print("OTA: FAILED - "); Serial.println(reply);
|
||||
mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply);
|
||||
return false;
|
||||
}
|
||||
if (response.status != 200) {
|
||||
inhibit_sleep = false;
|
||||
snprintf(reply, 160, "ERR: firmware HTTP %d", response.status);
|
||||
uclient.stop();
|
||||
Serial.print("OTA: FAILED - "); Serial.println(reply);
|
||||
mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply);
|
||||
return false;
|
||||
}
|
||||
if (!response.has_content_length) {
|
||||
inhibit_sleep = false;
|
||||
strcpy(reply, "ERR: firmware size missing");
|
||||
uclient.stop();
|
||||
Serial.print("OTA: FAILED - "); Serial.println(reply);
|
||||
mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply);
|
||||
return false;
|
||||
}
|
||||
if (!ota_streamFirmware(uclient, response.content_length, reply)) {
|
||||
inhibit_sleep = false;
|
||||
uclient.stop();
|
||||
Serial.print("OTA: FAILED - "); Serial.println(reply);
|
||||
mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply);
|
||||
return false;
|
||||
}
|
||||
|
||||
uclient.stop();
|
||||
Serial.println("OTA: write complete, rebooting...");
|
||||
mesh::usbLoggingPort().println("OTA: write complete, rebooting...");
|
||||
delay(250);
|
||||
esp_restart();
|
||||
return true; // unreachable
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace indicator_font {
|
||||
|
||||
// STAGEV2 is deliberately a separate command word from the legacy STAGE
|
||||
// grammar. Old RP2040 services therefore reject it before consuming binary
|
||||
// data, which makes the explicit ERROR COMMAND response safe to fall back on.
|
||||
static constexpr size_t kStageV2ChunkBytes = 512;
|
||||
static constexpr char kStageV2ReadyReply[] = "READY 2 512";
|
||||
static constexpr char kStageV2LegacyUnsupportedReply[] = "ERROR COMMAND";
|
||||
|
||||
enum class StageV2BeginAction : uint8_t {
|
||||
UseAcknowledged,
|
||||
UseLegacy,
|
||||
Fail,
|
||||
};
|
||||
|
||||
inline StageV2BeginAction classifyStageV2BeginReply(bool replied,
|
||||
const char* reply) {
|
||||
if (!replied || reply == nullptr) return StageV2BeginAction::Fail;
|
||||
if (strcmp(reply, kStageV2ReadyReply) == 0) {
|
||||
return StageV2BeginAction::UseAcknowledged;
|
||||
}
|
||||
if (strcmp(reply, kStageV2LegacyUnsupportedReply) == 0) {
|
||||
return StageV2BeginAction::UseLegacy;
|
||||
}
|
||||
return StageV2BeginAction::Fail;
|
||||
}
|
||||
|
||||
inline size_t stageV2ChunkSize(size_t total, size_t acknowledged) {
|
||||
if (acknowledged >= total) return 0;
|
||||
const size_t remaining = total - acknowledged;
|
||||
return remaining < kStageV2ChunkBytes ? remaining : kStageV2ChunkBytes;
|
||||
}
|
||||
|
||||
// Advance only after the receiver has accepted the complete negotiated block.
|
||||
// This keeps cumulative ACK offsets unambiguous and rejects premature/partial
|
||||
// progress observations.
|
||||
inline bool advanceStageV2Offset(size_t total, size_t acknowledged,
|
||||
size_t stored, size_t& next) {
|
||||
const size_t expected = stageV2ChunkSize(total, acknowledged);
|
||||
if (expected == 0 || stored != expected) return false;
|
||||
next = acknowledged + expected; // bounded by total, so this cannot overflow
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool parseStageV2Ack(const char* reply, size_t expectedOffset) {
|
||||
static constexpr char prefix[] = "ACK ";
|
||||
if (reply == nullptr || expectedOffset == 0
|
||||
|| strncmp(reply, prefix, sizeof(prefix) - 1) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* cursor = reply + sizeof(prefix) - 1;
|
||||
if (*cursor < '0' || *cursor > '9') return false;
|
||||
// The RP2040 emits canonical decimal offsets. Reject alternate spellings so
|
||||
// stale or damaged lines cannot be interpreted leniently.
|
||||
if (*cursor == '0' && cursor[1] != 0) return false;
|
||||
|
||||
size_t parsed = 0;
|
||||
const size_t maximum = (size_t)-1;
|
||||
do {
|
||||
const size_t digit = (size_t)(*cursor - '0');
|
||||
if (parsed > (maximum - digit) / 10) return false;
|
||||
parsed = parsed * 10 + digit;
|
||||
++cursor;
|
||||
} while (*cursor >= '0' && *cursor <= '9');
|
||||
|
||||
return *cursor == 0 && parsed == expectedOffset;
|
||||
}
|
||||
|
||||
} // namespace indicator_font
|
||||
} // namespace mesh
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <string.h>
|
||||
#include "ed_25519.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "UsbLogging.h"
|
||||
|
||||
// Base64 URL encoding table (without padding)
|
||||
static const char base64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
@@ -87,7 +88,11 @@ bool JWTHelper::createAuthToken(
|
||||
#if !defined(PORTABLE_MQTT_OBSERVER)
|
||||
int verify_result = ed25519_verify(signature, (const unsigned char*)signingInput, signingInputLen, public_key);
|
||||
if (verify_result != 1) {
|
||||
if (Serial.availableForWrite() > 0) Serial.println("JWTHelper: Signature verification failed!");
|
||||
if (mesh::isUsbLoggingEnabled()
|
||||
&& mesh::usbLoggingPort().availableForWrite() > 0) {
|
||||
mesh::usbLoggingPort().println(
|
||||
"JWTHelper: Signature verification failed!");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
static const uint32_t LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS = 300000UL;
|
||||
|
||||
inline unsigned long nonzeroLazyPersistenceDeadline(unsigned long deadline) {
|
||||
// Zero is the callers' "not pending" sentinel. Preserve a pending write at
|
||||
// the single millis() value where deadline arithmetic wraps exactly to zero.
|
||||
return deadline == 0 ? 1UL : deadline;
|
||||
}
|
||||
|
||||
inline bool armFirstLazyPersistence(unsigned long& pending_deadline,
|
||||
unsigned long new_deadline,
|
||||
bool persistence_needed) {
|
||||
if (!persistence_needed || pending_deadline != 0) return false;
|
||||
pending_deadline = nonzeroLazyPersistenceDeadline(new_deadline);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool scheduleLazyPersistenceMutation(
|
||||
unsigned long& pending_deadline,
|
||||
uint8_t& consecutive_failures,
|
||||
unsigned long new_deadline) {
|
||||
// Once persistence has failed, ordinary mutations must not turn a permanent
|
||||
// filesystem fault back into a five-second wake/write loop. Only a
|
||||
// successful save resets the failure count and its bounded retry deadline.
|
||||
if (consecutive_failures != 0) {
|
||||
if (pending_deadline != 0) return false;
|
||||
pending_deadline = nonzeroLazyPersistenceDeadline(new_deadline);
|
||||
return true;
|
||||
}
|
||||
if (pending_deadline != 0) return false;
|
||||
pending_deadline = nonzeroLazyPersistenceDeadline(new_deadline);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline uint32_t recordLazyPersistenceSaveFailure(
|
||||
uint8_t& consecutive_failures,
|
||||
uint32_t base_delay,
|
||||
uint32_t maximum_delay) {
|
||||
uint32_t delay = base_delay > maximum_delay ? maximum_delay : base_delay;
|
||||
uint8_t remaining_doublings = consecutive_failures;
|
||||
while (remaining_doublings-- != 0 && delay < maximum_delay) {
|
||||
delay = delay > maximum_delay / 2U ? maximum_delay : delay * 2U;
|
||||
}
|
||||
if (consecutive_failures != UINT8_MAX) consecutive_failures++;
|
||||
return delay;
|
||||
}
|
||||
|
||||
inline void resetLazyPersistenceAfterSuccess(
|
||||
unsigned long& pending_deadline,
|
||||
uint8_t& consecutive_failures) {
|
||||
pending_deadline = 0;
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
|
||||
inline void completeLazyPersistenceSave(unsigned long& pending_deadline,
|
||||
bool save_succeeded,
|
||||
unsigned long retry_deadline) {
|
||||
pending_deadline = save_succeeded
|
||||
? 0UL
|
||||
: nonzeroLazyPersistenceDeadline(retry_deadline);
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <stdint.h>
|
||||
|
||||
// Pure timing and state-transition policy used by MQTTBridge's connection
|
||||
@@ -13,6 +14,8 @@ static const uint32_t kStableResetMs = 120000UL;
|
||||
static const uint32_t kCircuitBreakerProbeMs = 1800000UL;
|
||||
static const uint32_t kRenewalThrottleMs = 60000UL;
|
||||
static const uint32_t kSlotStaggerMs = 3000UL;
|
||||
static const uint32_t kNtpRefreshIntervalMs = 86400000UL; // 24 hours
|
||||
static const uint32_t kNtpRetryMs = 5000UL;
|
||||
static const uint8_t kMaxFailuresAtMaxBackoff = 3;
|
||||
static const uint32_t kDefaultJwtLifetimeSecs = 86400UL;
|
||||
static const uint32_t kMaxJwtStaggerSecs = 300UL;
|
||||
@@ -30,6 +33,53 @@ static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
|
||||
return now - then;
|
||||
}
|
||||
|
||||
// A pending short retry takes precedence over the normal daily schedule. The
|
||||
// signed deadline comparison is safe because the retry is only five seconds
|
||||
// away; the daily path uses unsigned elapsed time and therefore survives one
|
||||
// millis() rollover as well.
|
||||
static inline bool ntpRefreshDue(uint32_t now, uint32_t last_sync,
|
||||
uint32_t retry_at) {
|
||||
if (retry_at != 0) {
|
||||
return static_cast<int32_t>(now - retry_at) >= 0;
|
||||
}
|
||||
return elapsedMs(now, last_sync) >= kNtpRefreshIntervalMs;
|
||||
}
|
||||
|
||||
static inline uint32_t ntpRetryAt(uint32_t now) {
|
||||
const uint32_t retry_at = now + kNtpRetryMs;
|
||||
// Zero is the "no retry scheduled" sentinel, so move that one rollover
|
||||
// collision forward by one millisecond.
|
||||
return retry_at == 0 ? 1 : retry_at;
|
||||
}
|
||||
|
||||
static inline uint32_t ntpReconnectRefreshAt(uint32_t now) {
|
||||
// Reconnection invalidates any old short deadline. Schedule the refresh for
|
||||
// the current task loop while preserving zero as the unscheduled sentinel.
|
||||
return now == 0 ? 1 : now;
|
||||
}
|
||||
|
||||
// WiFi events can run on a different task from MQTT maintenance. Keep the
|
||||
// callback to a single atomic edge latch: the MQTT task consumes the edge only
|
||||
// after WiFi is connected and makes every clock/scheduler decision itself.
|
||||
// Coalescing multiple GOT_IP events is intentional; one fresh sample after the
|
||||
// latest reconnect is sufficient.
|
||||
class NtpReconnectLatch {
|
||||
public:
|
||||
NtpReconnectLatch() : _pending(false) {}
|
||||
|
||||
void noteGotIp() {
|
||||
_pending.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool consumeIfConnected(bool wifi_connected) {
|
||||
if (!wifi_connected) return false;
|
||||
return _pending.exchange(false, std::memory_order_acq_rel);
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<bool> _pending;
|
||||
};
|
||||
|
||||
static inline bool reconnectGuardActive(uint32_t now, uint32_t last_reconnect) {
|
||||
return elapsedMs(now, last_reconnect) < kReconnectGuardMs;
|
||||
}
|
||||
@@ -238,6 +288,25 @@ enum class ClockSource : uint8_t {
|
||||
Rtc,
|
||||
};
|
||||
|
||||
// RTCClock carries Unix seconds in a uint32_t. Validate the signed system
|
||||
// time before converting it: time(nullptr) uses -1 for failure, which would
|
||||
// otherwise become UINT32_MAX and look newer than every minimum-epoch check.
|
||||
// Reject values beyond the RTC wire/storage range as well instead of wrapping
|
||||
// them into an apparently plausible earlier date.
|
||||
static inline bool checkedRtcEpoch(int64_t candidate,
|
||||
uint32_t min_valid_epoch,
|
||||
uint32_t& accepted) {
|
||||
accepted = 0;
|
||||
if (candidate < 0
|
||||
|| static_cast<uint64_t>(candidate) > UINT32_MAX) {
|
||||
return false;
|
||||
}
|
||||
const uint32_t converted = static_cast<uint32_t>(candidate);
|
||||
if (converted < min_valid_epoch) return false;
|
||||
accepted = converted;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prefer a plausible system clock, then an RTC. Server validation may not use
|
||||
// a local clock as evidence that the requested NTP host answered.
|
||||
static inline ClockSource chooseFallbackClock(bool validating_server,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <atomic>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// A Stream facade for a transport whose underlying write() may wait for FIFO
|
||||
// space. It never retries or forwards a write larger than one nRF52 TinyUSB TX
|
||||
// FIFO. Print::printf() can report a length larger than its 256-byte scratch
|
||||
// buffer; rejecting such a call here also prevents that stale length from
|
||||
// making the delegate read beyond the scratch buffer.
|
||||
template <size_t MAX_WRITE_SIZE = 256>
|
||||
class WholeRecordNonBlockingStream : public Stream {
|
||||
public:
|
||||
explicit WholeRecordNonBlockingStream(Stream& delegate)
|
||||
: _delegate(delegate) {}
|
||||
|
||||
int available() override { return _delegate.available(); }
|
||||
int read() override { return _delegate.read(); }
|
||||
int peek() override { return _delegate.peek(); }
|
||||
void flush() override { _delegate.flush(); }
|
||||
int availableForWrite() override { return _delegate.availableForWrite(); }
|
||||
|
||||
size_t write(uint8_t value) override {
|
||||
return write(&value, 1);
|
||||
}
|
||||
|
||||
size_t write(const uint8_t* data, size_t size) override {
|
||||
if (data == nullptr || size == 0 || size > MAX_WRITE_SIZE) return 0;
|
||||
|
||||
// Never wait for another application writer. All diagnostic access to the
|
||||
// delegate is routed through this facade; the USB task can only drain the
|
||||
// TX FIFO, so free capacity cannot shrink while this gate is held.
|
||||
if (_writer_busy.test_and_set(std::memory_order_acquire)) return 0;
|
||||
|
||||
size_t written = 0;
|
||||
const int available = _delegate.availableForWrite();
|
||||
if (available >= 0 && static_cast<size_t>(available) >= size) {
|
||||
written = _delegate.write(data, size);
|
||||
}
|
||||
|
||||
_writer_busy.clear(std::memory_order_release);
|
||||
return written;
|
||||
}
|
||||
|
||||
private:
|
||||
Stream& _delegate;
|
||||
std::atomic_flag _writer_busy = ATOMIC_FLAG_INIT;
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -49,14 +49,15 @@ void SensorManager::updateGpsTelemetryCache(float lat, float lon, float altitude
|
||||
}
|
||||
|
||||
void SensorManager::maybeStopGpsForTelemetry(unsigned long now) {
|
||||
if (telemetryGpsActive() && !gps_user_enabled && !gps_acquiring && !gpsTelemetryHoldActive(now)) {
|
||||
if (gps_transport_available && telemetryGpsActive() && !gps_user_enabled
|
||||
&& !gps_acquiring && !gpsTelemetryHoldActive(now)) {
|
||||
telemetryGpsStop();
|
||||
gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL;
|
||||
}
|
||||
}
|
||||
|
||||
void SensorManager::beginGpsTelemetryAcquisition(unsigned long now) {
|
||||
if (!telemetryGpsDetected() || gps_acquiring) return;
|
||||
if (!gps_transport_available || !telemetryGpsDetected() || gps_acquiring) return;
|
||||
|
||||
gps_acquiring = true;
|
||||
gps_acquire_has_fix = false;
|
||||
@@ -83,9 +84,19 @@ void SensorManager::finishGpsTelemetryAcquisition(unsigned long now, bool use_we
|
||||
}
|
||||
|
||||
bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP& telemetry) {
|
||||
if (!(requester_permissions & TELEM_PERM_LOCATION) || !telemetryGpsDetected()) return false;
|
||||
if (!(requester_permissions & TELEM_PERM_LOCATION)
|
||||
|| !telemetryGpsDetected()) return false;
|
||||
|
||||
unsigned long now = millis();
|
||||
if (!gps_transport_available) {
|
||||
// A bridge may temporarily own the GPS UART. Authorized callers can still
|
||||
// receive the last good fix while it is inside the normal freshness bound,
|
||||
// but the query must not create a hold or try to reclaim the UART.
|
||||
if (!gpsTelemetryCacheFresh(now)) return false;
|
||||
telemetry.addGPS(TELEM_CHANNEL_SELF, gps_cache_lat, gps_cache_lon,
|
||||
gps_cache_altitude);
|
||||
return true;
|
||||
}
|
||||
gps_hold_until = now + GPS_TELEMETRY_HOLD_SEC * 1000UL;
|
||||
if (!telemetryGpsActive()) telemetryGpsStart();
|
||||
if (!gpsTelemetryCacheFresh(now) && !gps_acquiring) beginGpsTelemetryAcquisition(now);
|
||||
@@ -96,6 +107,7 @@ bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP&
|
||||
}
|
||||
|
||||
void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude, unsigned long now) {
|
||||
if (!gps_transport_available) return;
|
||||
if (!gps_acquiring) {
|
||||
if (gps_user_enabled || gpsTelemetryHoldActive(now)) {
|
||||
updateGpsTelemetryCache(lat, lon, altitude, now);
|
||||
@@ -129,6 +141,7 @@ void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude,
|
||||
}
|
||||
|
||||
void SensorManager::loopGpsTelemetry(unsigned long now) {
|
||||
if (!gps_transport_available) return;
|
||||
if (!gps_user_enabled && !gpsTelemetryHoldActive(now) && !gps_acquiring) {
|
||||
maybeStopGpsForTelemetry(now);
|
||||
}
|
||||
@@ -146,11 +159,57 @@ void SensorManager::setGpsTelemetryUserEnabled(bool enabled) {
|
||||
gps_user_enabled = enabled;
|
||||
unsigned long now = millis();
|
||||
if (enabled) {
|
||||
if (telemetryGpsDetected() && !telemetryGpsActive()) telemetryGpsStart();
|
||||
if (gps_transport_available && telemetryGpsDetected()
|
||||
&& !telemetryGpsActive()) telemetryGpsStart();
|
||||
} else {
|
||||
maybeStopGpsForTelemetry(now);
|
||||
}
|
||||
}
|
||||
|
||||
void SensorManager::setGpsTelemetryTransportAvailable(bool available) {
|
||||
if (gps_transport_available == available) return;
|
||||
|
||||
gps_transport_available = available;
|
||||
if (!available) {
|
||||
// The UART is no longer ours. Cancel both the short acquisition and the
|
||||
// two-hour remote-query hold so neither can silently reclaim it from a
|
||||
// bridge. Preserve the user's preference and last good cache.
|
||||
gps_acquiring = false;
|
||||
gps_acquire_has_fix = false;
|
||||
gps_hold_until = 0;
|
||||
gps_acquire_started_at = 0;
|
||||
gps_stable_started_at = 0;
|
||||
gps_weighted_lat = 0;
|
||||
gps_weighted_lon = 0;
|
||||
gps_weighted_altitude = 0;
|
||||
gps_weight_sum = 0;
|
||||
gps_weight_count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
gps_next_cache_update_at = 0;
|
||||
if (gps_user_enabled && telemetryGpsDetected() && !telemetryGpsActive()) {
|
||||
telemetryGpsStart();
|
||||
}
|
||||
}
|
||||
|
||||
void SensorManager::resetGpsTelemetryTransportState() {
|
||||
// Hardware discovery may be rerun after a bridge-owned UART was blocked.
|
||||
// Reset transient ownership without invoking a provider callback before the
|
||||
// new probe has established which provider, if any, is present.
|
||||
gps_transport_available = true;
|
||||
gps_acquiring = false;
|
||||
gps_acquire_has_fix = false;
|
||||
gps_hold_until = 0;
|
||||
gps_acquire_started_at = 0;
|
||||
gps_stable_started_at = 0;
|
||||
gps_weighted_lat = 0;
|
||||
gps_weighted_lon = 0;
|
||||
gps_weighted_altitude = 0;
|
||||
gps_weight_sum = 0;
|
||||
gps_weight_count = 0;
|
||||
gps_next_cache_update_at = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
void SensorManager::setTelemetryLocationAccessAvailable(bool available) {
|
||||
|
||||
@@ -16,6 +16,7 @@ class SensorManager {
|
||||
#if ENV_INCLUDE_GPS
|
||||
bool gps_cache_valid = false;
|
||||
bool gps_location_access_available = false;
|
||||
bool gps_transport_available = true;
|
||||
bool gps_user_enabled = false;
|
||||
bool gps_acquiring = false;
|
||||
bool gps_acquire_has_fix = false;
|
||||
@@ -52,7 +53,12 @@ protected:
|
||||
void processGpsTelemetryFix(float lat, float lon, float altitude, unsigned long now);
|
||||
void loopGpsTelemetry(unsigned long now);
|
||||
void setGpsTelemetryUserEnabled(bool enabled);
|
||||
void setGpsTelemetryTransportAvailable(bool available);
|
||||
void resetGpsTelemetryTransportState();
|
||||
bool isGpsTelemetryUserEnabled() const { return gps_user_enabled; }
|
||||
bool isGpsTelemetryTransportAvailable() const {
|
||||
return gps_transport_available;
|
||||
}
|
||||
bool gpsTelemetryReceiverRequired(unsigned long now) const {
|
||||
return gps_acquiring || gpsTelemetryHoldActive(now);
|
||||
}
|
||||
@@ -109,6 +115,31 @@ public:
|
||||
}
|
||||
virtual LocationProvider* getLocationProvider() { return NULL; }
|
||||
virtual void setPowerSavingEnabled(bool enabled) { powersaving_enabled = enabled; }
|
||||
// Runtime bridges may temporarily own the UART used by a GPS receiver. A
|
||||
// sensor manager that actually detected a GPS on that UART overrides these
|
||||
// hooks; I2C GPS providers and boards without GPS remain unaffected.
|
||||
virtual bool gpsUsesSerialUart(uint8_t uart) const {
|
||||
(void)uart;
|
||||
return false;
|
||||
}
|
||||
// A universal image may need to reserve a UART even when a receiver did not
|
||||
// speak during its bounded boot probe. The default has no such uncertainty;
|
||||
// hardware-specific managers can fail closed for a known shared connector.
|
||||
virtual bool gpsSerialTransportMayConflict(uint8_t uart) const {
|
||||
return gpsUsesSerialUart(uart);
|
||||
}
|
||||
// True only when the detected receiver can be made electrically silent on
|
||||
// the selected UART. Stopping the MCU UART alone is not sufficient when a
|
||||
// still-powered GPS continues driving its TX pin.
|
||||
virtual bool gpsSerialTransportCanYield(uint8_t uart) const {
|
||||
(void)uart;
|
||||
return false;
|
||||
}
|
||||
virtual bool setGpsSerialTransportBlocked(uint8_t uart, bool blocked) {
|
||||
(void)uart;
|
||||
(void)blocked;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper functions to manage setting by keys (useful in many places ...)
|
||||
const char* getSettingByKey(const char* key) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Scheduled TempRadio uses wall-clock epochs so independently managed nodes
|
||||
// can enter one common window. The wall clock may legitimately be corrected
|
||||
// backward by mesh/GPS/NTP consensus, however, and that must never extend a
|
||||
// temporary modulation lease. Anchor the accepted end to 64-bit uptime too;
|
||||
// wall time decides when to start, while either clock is allowed to end it.
|
||||
class TempRadioLeaseDeadline {
|
||||
public:
|
||||
static uint64_t fromEpochEnd(uint64_t now_uptime_millis,
|
||||
uint32_t now_epoch,
|
||||
uint32_t end_epoch) {
|
||||
if (end_epoch <= now_epoch) return now_uptime_millis;
|
||||
return now_uptime_millis
|
||||
+ (uint64_t)(end_epoch - now_epoch) * 1000ULL;
|
||||
}
|
||||
|
||||
static bool expired(uint64_t now_uptime_millis,
|
||||
uint64_t hard_end_uptime_millis) {
|
||||
return hard_end_uptime_millis != 0
|
||||
&& now_uptime_millis >= hard_end_uptime_millis;
|
||||
}
|
||||
|
||||
static uint32_t secondsUntil(uint64_t now_uptime_millis,
|
||||
uint64_t hard_end_uptime_millis) {
|
||||
if (hard_end_uptime_millis == 0) return UINT32_MAX;
|
||||
if (now_uptime_millis >= hard_end_uptime_millis) return 0;
|
||||
uint64_t remaining_millis = hard_end_uptime_millis - now_uptime_millis;
|
||||
uint64_t seconds = (remaining_millis + 999ULL) / 1000ULL;
|
||||
return seconds > UINT32_MAX ? UINT32_MAX : (uint32_t)seconds;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// A parameterized TempRadio command changes the modulation used to carry its
|
||||
// own remote-CLI reply. A wall-clock delay cannot prove that the reply has
|
||||
// left a busy outbound queue, so keep the scheduled handoff blocked by the
|
||||
// exact packet object until Dispatcher reports TX completion. This class is
|
||||
// deliberately pointer-only: packet ownership stays with Dispatcher.
|
||||
class TempRadioReplyBarrier {
|
||||
const void* _packet;
|
||||
|
||||
public:
|
||||
TempRadioReplyBarrier() : _packet(nullptr) {}
|
||||
|
||||
void arm(const void* packet) { _packet = packet; }
|
||||
void clear() { _packet = nullptr; }
|
||||
|
||||
bool waiting() const { return _packet != nullptr; }
|
||||
|
||||
bool complete(const void* packet) {
|
||||
if (_packet == nullptr || packet != _packet) return false;
|
||||
_packet = nullptr;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool fail(const void* packet) { return complete(packet); }
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -4,6 +4,10 @@
|
||||
#include <Arduino.h>
|
||||
#include <atomic>
|
||||
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#include "NonBlockingWriteStream.h"
|
||||
#endif
|
||||
|
||||
#if defined(MESH_DUAL_CDC_LOGGING)
|
||||
#if !defined(COMPANION_FEATURE_DEDICATED_USB_LOGGING) || \
|
||||
!COMPANION_FEATURE_DEDICATED_USB_LOGGING
|
||||
@@ -42,11 +46,30 @@ class NullUsbLoggingStream : public Stream {
|
||||
|
||||
static NullUsbLoggingStream null_usb_logging_stream;
|
||||
|
||||
static void setPlatformDebugOutputEnabled(bool enabled) {
|
||||
#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE)
|
||||
// Arduino-ESP32 log_e()/ESP-IDF diagnostics otherwise write straight to
|
||||
// the same UART/CDC stream used by Binary Companion. Keep that low-level
|
||||
// route under the same runtime gate as MeshCore's own diagnostics.
|
||||
Serial.setDebugOutput(enabled);
|
||||
#else
|
||||
(void)enabled;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(MESH_DUAL_CDC_LOGGING)
|
||||
static bool dedicated_usb_logging_port_configured = false;
|
||||
static bool dedicated_usb_logging_port_started = false;
|
||||
static bool dedicated_usb_logging_port_connected = false;
|
||||
static Adafruit_USBD_CDC dedicated_usb_logging_port;
|
||||
static WholeRecordNonBlockingStream<>
|
||||
nonblocking_dedicated_usb_logging_port(dedicated_usb_logging_port);
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
// Single-CDC nRF52 roles need the same protection. In particular, BLE debug
|
||||
// callbacks and packet logging write through usbLoggingPort() without going
|
||||
// through MeshCore's formatted-debug helper.
|
||||
static WholeRecordNonBlockingStream<>
|
||||
nonblocking_primary_usb_logging_port(Serial);
|
||||
#endif
|
||||
|
||||
bool isUsbLoggingEnabled() {
|
||||
@@ -56,6 +79,7 @@ bool isUsbLoggingEnabled() {
|
||||
void setUsbLoggingEnabled(bool enabled) {
|
||||
usb_logging_enabled.store(enabled, std::memory_order_relaxed);
|
||||
usb_logging_preference_known.store(true, std::memory_order_relaxed);
|
||||
setPlatformDebugOutputEnabled(enabled);
|
||||
}
|
||||
|
||||
bool saveUsbLoggingBootPreference(bool enabled) {
|
||||
@@ -64,6 +88,11 @@ bool saveUsbLoggingBootPreference(bool enabled) {
|
||||
}
|
||||
|
||||
void beginUsbLoggingPort() {
|
||||
// setup() calls this once before role preferences are loaded and again
|
||||
// afterwards. The first call silences framework diagnostics on a protected
|
||||
// ESP32 Companion stream; setUsbLoggingEnabled() restores them only when the
|
||||
// saved setting explicitly enables logging.
|
||||
setPlatformDebugOutputEnabled(isUsbLoggingEnabled());
|
||||
#if defined(MESH_DUAL_CDC_LOGGING)
|
||||
if (dedicated_usb_logging_port_started
|
||||
|| !usb_logging_preference_known.load(std::memory_order_relaxed)
|
||||
@@ -109,12 +138,16 @@ void serviceUsbLoggingPort() {
|
||||
Stream& usbLoggingPort() {
|
||||
#if defined(MESH_DUAL_CDC_LOGGING)
|
||||
if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started) {
|
||||
return dedicated_usb_logging_port;
|
||||
return nonblocking_dedicated_usb_logging_port;
|
||||
}
|
||||
return null_usb_logging_stream;
|
||||
#else
|
||||
if (!isUsbLoggingEnabled()) return null_usb_logging_stream;
|
||||
return Serial;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
return nonblocking_primary_usb_logging_port;
|
||||
#else
|
||||
return Serial;
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <freertos/task.h>
|
||||
#include <strings.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/UsbLogging.h>
|
||||
#include <helpers/esp32/WiFiRadioPolicy.h>
|
||||
#include <helpers/esp32/WiFiStationPolicy.h>
|
||||
|
||||
@@ -173,7 +174,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
|
||||
// A submitted configuration takes priority over a background retry of the
|
||||
// previously saved network.
|
||||
impl->recovery_connecting = false;
|
||||
Serial.printf("WiFi setup: connecting to '%s'...\n", ssid);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WiFi setup: connecting to '%s'...\n", ssid);
|
||||
mesh::wifi::beginStation(ssid, password);
|
||||
uint32_t deadline = millis() + CONNECT_TIMEOUT_MS;
|
||||
while (impl->active && *impl->active && WiFi.status() != WL_CONNECTED
|
||||
@@ -187,7 +189,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
|
||||
sendResponse(client, 200, "OK", "text/html",
|
||||
"<!doctype html><meta name=viewport content='width=device-width'><h2>Connection failed</h2>"
|
||||
"<p>Check the SSID and password, then <a href='/'>try again</a>.</p>");
|
||||
Serial.println("WiFi setup: connection failed; setup AP remains active");
|
||||
mesh::usbLoggingPort().println(
|
||||
"WiFi setup: connection failed; setup AP remains active");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,7 +198,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
|
||||
WiFi.disconnect();
|
||||
sendResponse(client, 500, "Internal Server Error", "text/plain",
|
||||
"Connected, but the credentials could not be saved.");
|
||||
Serial.println("WiFi setup: connected, but credential persistence failed");
|
||||
mesh::usbLoggingPort().println(
|
||||
"WiFi setup: connected, but credential persistence failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -218,7 +222,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
|
||||
ip.c_str());
|
||||
#endif
|
||||
sendResponse(client, 200, "OK", "text/html", response);
|
||||
Serial.printf("WiFi setup: connected; IP %s\n", ip.c_str());
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WiFi setup: connected; IP %s\n", ip.c_str());
|
||||
impl->close_ap_at = millis() + AP_CLOSE_DELAY_MS;
|
||||
}
|
||||
|
||||
@@ -272,18 +277,22 @@ static void portalTask(void* arg) {
|
||||
if (impl->recovery_connecting) {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
String ip = WiFi.localIP().toString();
|
||||
Serial.printf("WiFi setup: saved network recovered; IP %s\n", ip.c_str());
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WiFi setup: saved network recovered; IP %s\n", ip.c_str());
|
||||
impl->recovery_connecting = false;
|
||||
break;
|
||||
}
|
||||
if (static_cast<int32_t>(now - impl->recovery_deadline) >= 0) {
|
||||
WiFi.disconnect();
|
||||
impl->recovery_connecting = false;
|
||||
Serial.println("WiFi setup: saved network still unavailable; setup AP remains active");
|
||||
mesh::usbLoggingPort().println(
|
||||
"WiFi setup: saved network still unavailable; setup AP remains active");
|
||||
}
|
||||
} else if (impl->recovery_ssid[0] && impl->recovery_interval_ms
|
||||
&& static_cast<int32_t>(now - impl->next_recovery_at) >= 0) {
|
||||
Serial.printf("WiFi setup: retrying saved network '%s'...\n", impl->recovery_ssid);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WiFi setup: retrying saved network '%s'...\n",
|
||||
impl->recovery_ssid);
|
||||
mesh::wifi::beginStation(
|
||||
impl->recovery_ssid, impl->recovery_password);
|
||||
impl->recovery_connecting = true;
|
||||
@@ -358,8 +367,9 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
|
||||
impl->task = nullptr;
|
||||
return false;
|
||||
}
|
||||
Serial.printf("WiFi setup: join open AP '%s' and open http://%s/\n",
|
||||
impl->ap_name, SETUP_IP.toString().c_str());
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WiFi setup: join open AP '%s' and open http://%s/\n",
|
||||
impl->ap_name, SETUP_IP.toString().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -394,9 +404,14 @@ bool WiFiSetupPortal::loadStoredCredentials(char* ssid, size_t ssid_size,
|
||||
char* password, size_t password_size) {
|
||||
if (!ssid || ssid_size == 0 || !password || password_size == 0) return false;
|
||||
Preferences prefs;
|
||||
if (!prefs.begin("mesh-wifi", true)) return false;
|
||||
String stored_ssid = prefs.getString("ssid", "");
|
||||
String stored_password = prefs.getString("password", "");
|
||||
// Opening read-only reports ESP_ERR_NVS_NOT_FOUND on a normal fresh install.
|
||||
// Read-write creates the namespace once; guard absent String keys because
|
||||
// Preferences::getString() also logs NOT_FOUND instead of returning quietly.
|
||||
if (!prefs.begin("mesh-wifi", false)) return false;
|
||||
String stored_ssid = prefs.isKey("ssid")
|
||||
? prefs.getString("ssid", "") : String();
|
||||
String stored_password = prefs.isKey("password")
|
||||
? prefs.getString("password", "") : String();
|
||||
prefs.end();
|
||||
if (stored_ssid.length() == 0 || stored_ssid.length() >= ssid_size
|
||||
|| stored_password.length() >= password_size
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "../MQTTRuntimeBufferLifecycle.h"
|
||||
#include "../MQTTTopicRouter.h"
|
||||
#include "../TxtDataHelpers.h"
|
||||
#include "../UsbLogging.h"
|
||||
#include "../WiFiPowerSave.h"
|
||||
#include <NTPClient.h>
|
||||
#include <WiFiUdp.h>
|
||||
@@ -25,6 +26,7 @@
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_sntp.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <helpers/esp32/SntpOperationCoordinator.h>
|
||||
#include <helpers/esp32/WiFiStationPolicy.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
@@ -58,6 +60,21 @@ static constexpr size_t kNtpBuiltinFallbackCount =
|
||||
static_assert(MQTTBridge::kMaxNtpServers >= 1 + (int)kNtpBuiltinFallbackCount,
|
||||
"kMaxNtpServers must hold the custom primary plus all built-in fallbacks");
|
||||
|
||||
static constexpr uint32_t MQTT_NTP_REFRESH_TIMEOUT_MS = 15000UL;
|
||||
#ifndef ESP_PLATFORM
|
||||
static constexpr uint32_t MQTT_NTP_NON_ESP_SETTLE_MS = 5000UL;
|
||||
#endif
|
||||
static constexpr unsigned long MQTT_NTP_MIN_VALID_EPOCH = 1767225600UL;
|
||||
|
||||
static bool mqttNtpRefreshDue(uint32_t now, uint32_t last_sync,
|
||||
uint32_t retry_at) {
|
||||
return MQTTConnectionPolicy::ntpRefreshDue(now, last_sync, retry_at);
|
||||
}
|
||||
|
||||
static uint32_t mqttNtpRetryAt(uint32_t now) {
|
||||
return MQTTConnectionPolicy::ntpRetryAt(now);
|
||||
}
|
||||
|
||||
static void formatRadioInfo(float freq, float bw, int sf, int cr,
|
||||
char* dest, size_t dest_size) {
|
||||
char freq_text[20];
|
||||
@@ -664,7 +681,7 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs,
|
||||
_manage_wifi(manage_wifi),
|
||||
_queue_count(0),
|
||||
_last_status_publish(0), _last_status_retry(0), _status_interval(300000),
|
||||
_ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS),
|
||||
_ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_refresh_retry_at(0), _ntp_refresh_started_at(0), _ntp_refresh_pending(false), _ntp_synced(false), _ntp_sync_pending(false), _ntp_reconnect_latch(), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS),
|
||||
_ntp_force_requested(false), _ntp_force_done(false), _ntp_force_result(false),
|
||||
_ntp_diag_requested(false), _ntp_diag_done(false), _ntp_diag_count(0),
|
||||
_ntp_estimate_requested(false), _ntp_estimate_done(false),
|
||||
@@ -847,6 +864,13 @@ void MQTTBridge::begin() {
|
||||
return;
|
||||
}
|
||||
|
||||
_ntp_refresh_retry_at = 0;
|
||||
_ntp_refresh_started_at = 0;
|
||||
_ntp_refresh_pending = false;
|
||||
// Do not clear _ntp_reconnect_latch here. The WiFi event handler remains
|
||||
// registered while MQTT is stopped, so a GOT_IP edge received between end()
|
||||
// and begin() must survive until the restarted task can consume it.
|
||||
|
||||
// A restarted bridge may be using a different preset or custom endpoint.
|
||||
// Clear the old slot description before rebuilding it from _obs below.
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
@@ -1316,10 +1340,9 @@ void MQTTBridge::initializeWiFiInTask() {
|
||||
MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str());
|
||||
setWifiOutage(AlertFaultPolicy::applyWifiGotIp(wifiOutage()));
|
||||
_wifi_reconnect_backoff_attempt = 0;
|
||||
// Set flag to trigger NTP sync from loop() instead of doing it here
|
||||
if (!_ntp_synced && !_ntp_sync_pending) {
|
||||
_ntp_sync_pending = true;
|
||||
}
|
||||
// The 10-second status sampler can miss a short link flap. The event
|
||||
// callback only records the edge; all NTP state remains task-owned.
|
||||
_ntp_reconnect_latch.noteGotIp();
|
||||
break;
|
||||
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: {
|
||||
const uint8_t reason = info.wifi_sta_disconnected.reason;
|
||||
@@ -1348,7 +1371,8 @@ void MQTTBridge::initializeWiFiInTask() {
|
||||
if (_manage_wifi) {
|
||||
mesh::wifi::beginStation(_obs->wifi_ssid, _obs->wifi_password);
|
||||
}
|
||||
} else if (!_ntp_synced && !_ntp_sync_pending) {
|
||||
} else if (!_ntp_synced.load(std::memory_order_acquire)
|
||||
&& !_ntp_sync_pending) {
|
||||
_ntp_sync_pending = true; // already connected but never synced - kick NTP now
|
||||
}
|
||||
|
||||
@@ -1430,6 +1454,23 @@ void MQTTBridge::mqttTaskLoop() {
|
||||
_slots[i].last_reconnect_attempt = 0;
|
||||
}
|
||||
}
|
||||
_ntp_reconnect_latch.noteGotIp();
|
||||
}
|
||||
|
||||
const bool wifi_connected = WiFi.status() == WL_CONNECTED;
|
||||
if (_ntp_reconnect_latch.consumeIfConnected(wifi_connected)) {
|
||||
if (!_ntp_synced.load(std::memory_order_acquire)) {
|
||||
_ntp_sync_pending = true;
|
||||
} else {
|
||||
// A successful reconnect gets one fresh clock sample regardless of how
|
||||
// long the link was down. This also discards a short retry deadline that
|
||||
// may be ambiguous after a very long offline interval. The normal
|
||||
// continuously-connected cadence remains once per day.
|
||||
_ntp_refresh_pending = false;
|
||||
_ntp_refresh_started_at = 0;
|
||||
_ntp_refresh_retry_at =
|
||||
MQTTConnectionPolicy::ntpReconnectRefreshAt(now);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pending NTP sync (triggered from WiFi event handler)
|
||||
@@ -1439,7 +1480,8 @@ void MQTTBridge::mqttTaskLoop() {
|
||||
}
|
||||
|
||||
// Retry NTP every 30s if initial sync failed (slots can't start without valid time)
|
||||
if (!_ntp_synced && WiFi.status() == WL_CONNECTED) {
|
||||
if (!_ntp_synced.load(std::memory_order_acquire)
|
||||
&& WiFi.status() == WL_CONNECTED) {
|
||||
static unsigned long last_ntp_retry = 0;
|
||||
if (now - last_ntp_retry >= 30000) {
|
||||
last_ntp_retry = now;
|
||||
@@ -1481,7 +1523,7 @@ void MQTTBridge::mqttTaskLoop() {
|
||||
|
||||
// Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps.
|
||||
// This avoids wasted TLS handshakes that get rejected due to bad token times.
|
||||
if (_ntp_synced && !_slots_setup_done) {
|
||||
if (_ntp_synced.load(std::memory_order_acquire) && !_slots_setup_done) {
|
||||
_slots_setup_done = true;
|
||||
|
||||
// Redirect mbedTLS allocations to PSRAM to save ~40KB internal heap per TLS connection.
|
||||
@@ -1586,10 +1628,14 @@ void MQTTBridge::mqttTaskLoop() {
|
||||
// Periodic configuration check (throttled to avoid spam)
|
||||
checkConfigurationMismatch();
|
||||
|
||||
// Periodic NTP refresh (every hour) - lightweight, non-blocking.
|
||||
// Periodic NTP refresh (once per day) - lightweight, non-blocking.
|
||||
// Uses async SNTP instead of the heavy syncTimeWithNTP() which blocks Core 0
|
||||
// for up to 20+ seconds with DNS lookups, UDP sockets, and retry loops.
|
||||
if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) {
|
||||
pollNtpRefresh(now);
|
||||
if (WiFi.status() == WL_CONNECTED
|
||||
&& !_ntp_refresh_pending
|
||||
&& mqttNtpRefreshDue(now, _last_ntp_sync,
|
||||
_ntp_refresh_retry_at)) {
|
||||
refreshNTP();
|
||||
}
|
||||
|
||||
@@ -2132,7 +2178,8 @@ void MQTTBridge::maintainSlotConnections() {
|
||||
// JWT tokens require valid timestamps
|
||||
unsigned long clock_sec = current_time;
|
||||
bool can_do_jwt = MQTTConnectionPolicy::jwtClockAvailable(
|
||||
_ntp_synced, static_cast<uint32_t>(clock_sec));
|
||||
_ntp_synced.load(std::memory_order_acquire),
|
||||
static_cast<uint32_t>(clock_sec));
|
||||
|
||||
// Count connected slots to inform reconnect decisions
|
||||
int connected_count = 0;
|
||||
@@ -3008,8 +3055,16 @@ void MQTTBridge::loop() {
|
||||
return;
|
||||
#else
|
||||
unsigned long now = millis();
|
||||
if (handleWiFiConnection(now) && !_ntp_synced) {
|
||||
syncTimeWithNTP();
|
||||
const bool wifi_just_connected = handleWiFiConnection(now);
|
||||
if (wifi_just_connected) {
|
||||
if (!_ntp_synced.load(std::memory_order_acquire)) {
|
||||
syncTimeWithNTP();
|
||||
} else {
|
||||
_ntp_refresh_pending = false;
|
||||
_ntp_refresh_started_at = 0;
|
||||
_ntp_refresh_retry_at =
|
||||
MQTTConnectionPolicy::ntpReconnectRefreshAt((uint32_t)now);
|
||||
}
|
||||
}
|
||||
if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) {
|
||||
_ntp_sync_pending = false;
|
||||
@@ -3022,7 +3077,7 @@ void MQTTBridge::loop() {
|
||||
}
|
||||
|
||||
// Deferred slot setup after NTP sync (non-ESP32 path)
|
||||
if (_ntp_synced && !_slots_setup_done) {
|
||||
if (_ntp_synced.load(std::memory_order_acquire) && !_slots_setup_done) {
|
||||
_slots_setup_done = true;
|
||||
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
||||
if (_slots[i].enabled) {
|
||||
@@ -3055,8 +3110,13 @@ void MQTTBridge::loop() {
|
||||
// Periodic configuration check (throttled to avoid spam)
|
||||
checkConfigurationMismatch();
|
||||
|
||||
// Periodic NTP refresh (every hour) - lightweight, non-blocking.
|
||||
if (WiFi.status() == WL_CONNECTED && millis() - _last_ntp_sync > 3600000) {
|
||||
// Periodic NTP refresh (once per day) - lightweight, non-blocking.
|
||||
const uint32_t ntp_now = millis();
|
||||
pollNtpRefresh(ntp_now);
|
||||
if (WiFi.status() == WL_CONNECTED
|
||||
&& !_ntp_refresh_pending
|
||||
&& mqttNtpRefreshDue(ntp_now, _last_ntp_sync,
|
||||
_ntp_refresh_retry_at)) {
|
||||
refreshNTP();
|
||||
}
|
||||
|
||||
@@ -4037,12 +4097,96 @@ void MQTTBridge::storeRawRadioData(const uint8_t* raw_data, int len, float snr,
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void MQTTBridge::refreshNTP() {
|
||||
// Lightweight periodic refresh: just restart SNTP which runs async in the background.
|
||||
// No blocking DNS, no UDP sockets, no retry loops on the MQTT task loop.
|
||||
// The heavy syncTimeWithNTP() is only used for initial sync and WiFi reconnect recovery.
|
||||
const uint32_t now = millis();
|
||||
if (_ntp_refresh_pending) return;
|
||||
|
||||
// Both task-loop variants call this through mqttNtpRefreshDue(), but keep the
|
||||
// deadline check here as well so a future direct caller cannot spin on the
|
||||
// process-wide SNTP lease.
|
||||
if (_ntp_refresh_retry_at != 0
|
||||
&& !mqttNtpRefreshDue(now, _last_ntp_sync,
|
||||
_ntp_refresh_retry_at)) {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
mesh::sntp_coord::OperationLease sntp_operation(
|
||||
mesh::sntp_coord::processWideCoordinator());
|
||||
if (!sntp_operation.tryAcquire()) {
|
||||
_ntp_refresh_retry_at = mqttNtpRetryAt(now);
|
||||
MQTT_DEBUG_PRINTLN(
|
||||
"NTP refresh deferred; another firmware service owns SNTP");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Restart SNTP and let it run asynchronously. Resetting the status makes the
|
||||
// later COMPLETED state proof of this request rather than an earlier boot
|
||||
// sync. Do not advance _last_ntp_sync yet: an attempted configTime() is not a
|
||||
// successful refresh, and MeshCore consumers read _rtc rather than time().
|
||||
#ifdef ESP_PLATFORM
|
||||
sntp_set_sync_status(SNTP_SYNC_STATUS_RESET);
|
||||
#endif
|
||||
configTime(0, 0, effectiveNtpPrimary(_obs));
|
||||
_last_ntp_sync = millis();
|
||||
MQTT_DEBUG_PRINTLN("NTP refresh triggered (async SNTP)");
|
||||
_ntp_refresh_retry_at = 0;
|
||||
_ntp_refresh_started_at = millis();
|
||||
_ntp_refresh_pending = true;
|
||||
MQTT_DEBUG_PRINTLN("NTP refresh requested (async SNTP)");
|
||||
}
|
||||
|
||||
void MQTTBridge::pollNtpRefresh(uint32_t now) {
|
||||
if (!_ntp_refresh_pending) return;
|
||||
|
||||
const uint32_t elapsed = now - _ntp_refresh_started_at;
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
_ntp_refresh_pending = false;
|
||||
_ntp_refresh_retry_at = mqttNtpRetryAt(now);
|
||||
MQTT_DEBUG_PRINTLN("NTP refresh aborted; WiFi disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
const bool completed =
|
||||
sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED;
|
||||
#else
|
||||
// Other Arduino WiFi stacks do not expose ESP-IDF's completion status. Keep
|
||||
// their historical asynchronous configTime() behavior, but wait before
|
||||
// pulling the system clock into the MeshCore RTC.
|
||||
const bool completed = elapsed >= MQTT_NTP_NON_ESP_SETTLE_MS;
|
||||
#endif
|
||||
|
||||
if (completed) {
|
||||
const time_t raw_system_time = time(nullptr);
|
||||
uint32_t system_time = 0;
|
||||
if (MQTTConnectionPolicy::checkedRtcEpoch(
|
||||
static_cast<int64_t>(raw_system_time),
|
||||
MQTT_NTP_MIN_VALID_EPOCH, system_time)) {
|
||||
if (_rtc) _rtc->setCurrentTime(system_time);
|
||||
_ntp_synced.store(true, std::memory_order_release);
|
||||
_last_ntp_sync = now;
|
||||
_ntp_refresh_retry_at = 0;
|
||||
_ntp_refresh_pending = false;
|
||||
MQTT_DEBUG_PRINTLN(
|
||||
"NTP refresh completed; MeshCore RTC updated: %lu", system_time);
|
||||
return;
|
||||
}
|
||||
|
||||
// A completion carrying an implausible time cannot become authoritative.
|
||||
// The ESP status is edge-like and may reset after it is read, so retry the
|
||||
// whole operation instead of waiting on a completion that was consumed.
|
||||
_ntp_refresh_pending = false;
|
||||
_ntp_refresh_retry_at = mqttNtpRetryAt(now);
|
||||
MQTT_DEBUG_PRINTLN(
|
||||
"NTP refresh returned implausible time %lld; retry scheduled",
|
||||
(long long)raw_system_time);
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed >= MQTT_NTP_REFRESH_TIMEOUT_MS) {
|
||||
_ntp_refresh_pending = false;
|
||||
_ntp_refresh_retry_at = mqttNtpRetryAt(now);
|
||||
MQTT_DEBUG_PRINTLN("NTP refresh timed out; retry scheduled");
|
||||
}
|
||||
}
|
||||
|
||||
bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
|
||||
@@ -4052,7 +4196,8 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
|
||||
}
|
||||
|
||||
unsigned long now = millis();
|
||||
if (!force && _ntp_synced && (now - _last_ntp_sync) < 5000) {
|
||||
if (!force && _ntp_synced.load(std::memory_order_acquire)
|
||||
&& (now - _last_ntp_sync) < 5000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4077,8 +4222,9 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
|
||||
|
||||
bool ntp_ok = false;
|
||||
unsigned long epochTime = 0;
|
||||
const unsigned long kMinValidEpoch = 1767225600; // 2026-01-01 00:00:00 UTC
|
||||
const unsigned long kMinValidEpoch = MQTT_NTP_MIN_VALID_EPOCH;
|
||||
const char* ntp_server_used = nullptr;
|
||||
bool sntp_fallback_configured = false;
|
||||
|
||||
_ntp_client.begin();
|
||||
const int kMaxNtpRetriesPerServer = 2;
|
||||
@@ -4121,6 +4267,13 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
|
||||
for (int s = 0; s < server_count && !ntp_ok; s++) {
|
||||
const char* server = servers[s];
|
||||
MQTT_DEBUG_PRINTLN("SNTP fallback trying %s...", server);
|
||||
mesh::sntp_coord::OperationLease sntp_operation(
|
||||
mesh::sntp_coord::processWideCoordinator());
|
||||
if (!sntp_operation.tryAcquire()) {
|
||||
MQTT_DEBUG_PRINTLN(
|
||||
"SNTP fallback deferred; another firmware service owns SNTP");
|
||||
break;
|
||||
}
|
||||
// configTime() already stops and reconfigures an active SNTP client on
|
||||
// both Arduino-ESP32 2.x and 3.x. Avoid the legacy unprefixed stop APIs,
|
||||
// which are no longer exported by esp_sntp.h in Arduino 3.x.
|
||||
@@ -4129,15 +4282,20 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
delay(500);
|
||||
if (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED) continue;
|
||||
epochTime = (unsigned long)time(nullptr);
|
||||
if (epochTime >= kMinValidEpoch) {
|
||||
const time_t raw_system_time = time(nullptr);
|
||||
uint32_t checked_system_time = 0;
|
||||
if (MQTTConnectionPolicy::checkedRtcEpoch(
|
||||
static_cast<int64_t>(raw_system_time), kMinValidEpoch,
|
||||
checked_system_time)) {
|
||||
epochTime = checked_system_time;
|
||||
ntp_ok = true;
|
||||
ntp_server_used = server;
|
||||
sntp_fallback_configured = true;
|
||||
MQTT_DEBUG_PRINTLN("SNTP fallback succeeded on %s: %lu", server, epochTime);
|
||||
} else {
|
||||
MQTT_DEBUG_PRINTLN(
|
||||
"SNTP fallback: %s synced an implausible epoch %lu", server,
|
||||
epochTime);
|
||||
"SNTP fallback: %s synced an implausible epoch %lld", server,
|
||||
(long long)raw_system_time);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -4146,7 +4304,9 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
|
||||
#endif
|
||||
|
||||
if (!ntp_ok) {
|
||||
const unsigned long system_time = (unsigned long)time(nullptr);
|
||||
uint32_t system_time = 0;
|
||||
MQTTConnectionPolicy::checkedRtcEpoch(
|
||||
static_cast<int64_t>(time(nullptr)), kMinValidEpoch, system_time);
|
||||
const unsigned long rtc_time =
|
||||
_rtc ? (unsigned long)_rtc->getCurrentTime() : 0;
|
||||
const MQTTConnectionPolicy::ClockSource source =
|
||||
@@ -4171,14 +4331,36 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
|
||||
accepted.tv_usec = 0;
|
||||
settimeofday(&accepted, nullptr);
|
||||
|
||||
if (ntp_server_used) configTime(0, 0, ntp_server_used);
|
||||
if (ntp_server_used && !sntp_fallback_configured) {
|
||||
// A direct NTPClient response has already made the clock authoritative.
|
||||
// Re-arm background SNTP without letting this asynchronous convenience
|
||||
// race a callback-owning firmware service. Failure here does not discard
|
||||
// the fresh direct response; the task loop retries the re-arm shortly.
|
||||
mesh::sntp_coord::OperationLease sntp_operation(
|
||||
mesh::sntp_coord::processWideCoordinator());
|
||||
if (sntp_operation.tryAcquire()) {
|
||||
configTime(0, 0, ntp_server_used);
|
||||
_ntp_refresh_retry_at = 0;
|
||||
} else {
|
||||
_ntp_refresh_retry_at = mqttNtpRetryAt(millis());
|
||||
MQTT_DEBUG_PRINTLN(
|
||||
"Background SNTP re-arm deferred; another firmware service owns SNTP");
|
||||
}
|
||||
} else if (sntp_fallback_configured) {
|
||||
// The successful fallback's configTime() remains the active async client.
|
||||
_ntp_refresh_retry_at = 0;
|
||||
}
|
||||
|
||||
if (_rtc) {
|
||||
_rtc->setCurrentTime(epochTime);
|
||||
}
|
||||
|
||||
bool was_ntp_synced = _ntp_synced;
|
||||
_ntp_synced = true;
|
||||
bool was_ntp_synced = _ntp_synced.load(std::memory_order_acquire);
|
||||
_ntp_synced.store(true, std::memory_order_release);
|
||||
// A blocking/direct sync already copied its accepted epoch into _rtc, so
|
||||
// it supersedes any older asynchronous periodic request.
|
||||
_ntp_refresh_pending = false;
|
||||
_ntp_refresh_started_at = 0;
|
||||
_last_ntp_sync = millis();
|
||||
sync_in_progress = false;
|
||||
|
||||
@@ -4361,20 +4543,22 @@ bool MQTTBridge::ntpDiag(char* reply, size_t reply_size, bool verbose) {
|
||||
if (_ntp_diag_results[i].ok) ok_count++;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
if (verbose && mesh::isUsbLoggingEnabled()) {
|
||||
// Detailed table to the serial console; reply carries a short summary (the operator
|
||||
// sees the table above the "-> <reply>" line). Mirrors the dumpLogFile() convention.
|
||||
Serial.printf("NTP diag - %d server(s):\r\n", _ntp_diag_count);
|
||||
Stream& output = mesh::usbLoggingPort();
|
||||
output.printf("NTP diag - %d server(s):\r\n", _ntp_diag_count);
|
||||
for (int i = 0; i < _ntp_diag_count; i++) {
|
||||
const NtpDiagResult& r = _ntp_diag_results[i];
|
||||
if (r.ok) {
|
||||
time_t t = (time_t)r.epoch;
|
||||
struct tm* tmv = gmtime(&t);
|
||||
Serial.printf(" %-20s OK %04d-%02d-%02d %02d:%02d:%02d UTC\r\n",
|
||||
r.server, tmv->tm_year + 1900, tmv->tm_mon + 1, tmv->tm_mday,
|
||||
tmv->tm_hour, tmv->tm_min, tmv->tm_sec);
|
||||
output.printf(
|
||||
" %-20s OK %04d-%02d-%02d %02d:%02d:%02d UTC\r\n",
|
||||
r.server, tmv->tm_year + 1900, tmv->tm_mon + 1,
|
||||
tmv->tm_mday, tmv->tm_hour, tmv->tm_min, tmv->tm_sec);
|
||||
} else {
|
||||
Serial.printf(" %-20s FAIL\r\n", r.server);
|
||||
output.printf(" %-20s FAIL\r\n", r.server);
|
||||
}
|
||||
}
|
||||
snprintf(reply, reply_size, "> NTP diag: %d/%d OK (see console)", ok_count, _ntp_diag_count);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <WiFiUdp.h>
|
||||
#include <Timezone.h>
|
||||
#include "helpers/JWTHelper.h"
|
||||
#include "helpers/MQTTConnectionPolicy.h"
|
||||
#include "helpers/MQTTPacketFilter.h"
|
||||
#include "helpers/MQTTPresets.h"
|
||||
#include "helpers/MQTTLifecycle.h"
|
||||
@@ -221,8 +222,12 @@ private:
|
||||
WiFiUDP _ntp_udp;
|
||||
NTPClient _ntp_client;
|
||||
unsigned long _last_ntp_sync;
|
||||
volatile bool _ntp_synced;
|
||||
bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler
|
||||
unsigned long _ntp_refresh_retry_at;
|
||||
unsigned long _ntp_refresh_started_at;
|
||||
bool _ntp_refresh_pending;
|
||||
std::atomic<bool> _ntp_synced;
|
||||
bool _ntp_sync_pending; // Owned by the MQTT task; the WiFi callback only records a GOT_IP edge
|
||||
MQTTConnectionPolicy::NtpReconnectLatch _ntp_reconnect_latch;
|
||||
bool _slots_setup_done; // Deferred: slots set up after NTP sync
|
||||
// WiFi.onEvent() handler registered once and never removed by end(); the bridge
|
||||
// object is reused across restarts, so re-registering would leak handlers and
|
||||
@@ -507,7 +512,8 @@ private:
|
||||
void queuePacket(mesh::Packet* packet, bool is_tx);
|
||||
void dequeuePacket();
|
||||
bool isAnySlotConnected();
|
||||
void refreshNTP(); // Lightweight periodic NTP refresh (non-blocking)
|
||||
void refreshNTP(); // Start a lightweight daily/reconnect NTP refresh (non-blocking)
|
||||
void pollNtpRefresh(uint32_t now); // Copy a completed async refresh into MeshCore's RTC
|
||||
void runNtpDiagProbe(); // Probe every server for connectivity; never sets the clock. Core 0 only.
|
||||
void runNtpEstimateProbe(); // Query the first usable server without changing any clock. Core 0 only.
|
||||
// Populates dst_out/std_out with TimeChangeRules for the given IANA or
|
||||
@@ -700,10 +706,12 @@ public:
|
||||
* Performs blocking NTP I/O and must only be called from the MQTT task (Core 0).
|
||||
* Other tasks (e.g. the CLI on Core 1) must use requestForcedNtpSync() instead. */
|
||||
bool syncTimeWithNTP(bool force = false, bool primary_only = false);
|
||||
/** Request a forced NTP sync from another task (e.g. CLI on Core 1). Marshals the
|
||||
* work onto the MQTT task so all NTP I/O stays on Core 0, then blocks up to
|
||||
* timeout_ms for the result. Returns true if the sync succeeded, false on failure,
|
||||
* timeout, or if the bridge is not running. */
|
||||
/** Request a forced clock refresh from another task (e.g. CLI on Core 1).
|
||||
* Marshals NTP I/O onto the MQTT task, then blocks up to timeout_ms for the
|
||||
* result. This validation request tests only the configured primary server
|
||||
* and never accepts a retained RTC/system clock as success, so a true result
|
||||
* from a nonzero-timeout call proves a fresh NTP response. A zero timeout is
|
||||
* fire-and-forget and proves only that the request was queued. */
|
||||
bool requestForcedNtpSync(uint32_t timeout_ms = 30000);
|
||||
/** Probe every configured NTP server (custom primary + built-in fallbacks) for
|
||||
* connectivity and report each server's reported time WITHOUT touching the system
|
||||
@@ -716,8 +724,11 @@ public:
|
||||
bool requestNtpTimeEstimate();
|
||||
/** Poll/consume the queued estimate. finished=false means it is still pending. */
|
||||
bool takeNtpTimeEstimate(uint32_t& epoch, bool& finished);
|
||||
/** True after this bridge has successfully set the RTC from NTP this boot. */
|
||||
bool hasNtpTime() const { return _ntp_synced; }
|
||||
/** True after the bridge established a usable clock this boot. This may be a
|
||||
* plausible retained RTC/system clock accepted after NTP was unavailable. */
|
||||
bool hasNtpTime() const {
|
||||
return _ntp_synced.load(std::memory_order_acquire);
|
||||
}
|
||||
static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs);
|
||||
/** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err,
|
||||
* outbox size, free/max heap, queue depth). */
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace sntp_coord {
|
||||
|
||||
using CleanupHook = void (*)();
|
||||
|
||||
// ESP-IDF exposes one process-wide SNTP client and one notification callback.
|
||||
// Keep callback installation/configTime() sequences mutually exclusive across
|
||||
// otherwise independent firmware features. Acquisition is deliberately
|
||||
// non-blocking: a caller can retry through its own bounded policy instead of
|
||||
// deadlocking behind a failed network operation.
|
||||
class OperationCoordinator {
|
||||
public:
|
||||
OperationCoordinator() : owner_(0), next_generation_(1) {}
|
||||
|
||||
bool owns(uint32_t generation) const {
|
||||
return generation != 0
|
||||
&& owner_.load(std::memory_order_acquire) == generation;
|
||||
}
|
||||
|
||||
private:
|
||||
friend class OperationLease;
|
||||
|
||||
bool tryAcquire(uint32_t& generation) {
|
||||
if (generation != 0) return false;
|
||||
|
||||
uint32_t candidate = 0;
|
||||
do {
|
||||
candidate = next_generation_.fetch_add(1, std::memory_order_relaxed);
|
||||
} while (candidate == 0);
|
||||
|
||||
uint32_t expected = 0;
|
||||
if (!owner_.compare_exchange_strong(
|
||||
expected, candidate, std::memory_order_acq_rel,
|
||||
std::memory_order_acquire)) {
|
||||
return false;
|
||||
}
|
||||
generation = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool release(uint32_t generation, CleanupHook cleanup) {
|
||||
// Run the callback cleanup before publishing the coordinator as free. This
|
||||
// prevents a new owner from installing its callback and then having a stale
|
||||
// owner's teardown clear it. Only OperationLease can release a generation.
|
||||
if (!owns(generation)) return false;
|
||||
if (cleanup != nullptr) cleanup();
|
||||
uint32_t expected = generation;
|
||||
return owner_.compare_exchange_strong(
|
||||
expected, 0, std::memory_order_acq_rel, std::memory_order_acquire);
|
||||
}
|
||||
|
||||
std::atomic<uint32_t> owner_;
|
||||
std::atomic<uint32_t> next_generation_;
|
||||
};
|
||||
|
||||
class OperationLease {
|
||||
public:
|
||||
explicit OperationLease(OperationCoordinator& coordinator,
|
||||
CleanupHook cleanup = nullptr)
|
||||
: coordinator_(coordinator), cleanup_(cleanup), generation_(0) {}
|
||||
|
||||
~OperationLease() { release(); }
|
||||
|
||||
bool tryAcquire() { return coordinator_.tryAcquire(generation_); }
|
||||
|
||||
bool owns() const { return coordinator_.owns(generation_); }
|
||||
|
||||
uint32_t generation() const { return generation_; }
|
||||
|
||||
bool release() {
|
||||
if (generation_ == 0) return false;
|
||||
const uint32_t releasing = generation_;
|
||||
// Make repeated release attempts inert even if cleanup code indirectly
|
||||
// observes this lease.
|
||||
generation_ = 0;
|
||||
return coordinator_.release(releasing, cleanup_);
|
||||
}
|
||||
|
||||
OperationLease(const OperationLease&) = delete;
|
||||
OperationLease& operator=(const OperationLease&) = delete;
|
||||
|
||||
private:
|
||||
OperationCoordinator& coordinator_;
|
||||
CleanupHook cleanup_;
|
||||
uint32_t generation_;
|
||||
};
|
||||
|
||||
// A function-local static in an external-linkage inline function denotes one
|
||||
// object program-wide under the C++ ODR, including the Arduino-ESP32 2.x C++11
|
||||
// toolchain. Both the font repair and pull-OTA paths use this exact instance.
|
||||
inline OperationCoordinator& processWideCoordinator() {
|
||||
static OperationCoordinator coordinator;
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
} // namespace sntp_coord
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace tls_clock {
|
||||
|
||||
// A certificate-validating TLS handshake must not treat an uninitialised or
|
||||
// wrapped wall clock as usable. The fresh-proof bit is deliberately supplied by
|
||||
// caller: a plausible retained RTC alone is not evidence that NTP answered in
|
||||
// the operation which is about to download trusted content.
|
||||
static constexpr time_t kMinimumValidEpoch = (time_t)1767225600; // 2026-01-01 UTC
|
||||
|
||||
constexpr bool timeIsValid(time_t now) {
|
||||
return now >= kMinimumValidEpoch;
|
||||
}
|
||||
|
||||
constexpr bool proofIsValid(bool fresh_proof, bool wifi_connected,
|
||||
time_t now) {
|
||||
return fresh_proof && wifi_connected && timeIsValid(now);
|
||||
}
|
||||
|
||||
// `now - proven_at` is the standard modulo-uint32_t elapsed-time calculation,
|
||||
// so a proof remains valid across the one millis() rollover that a bounded
|
||||
// operation can cross. Callers must reset the fresh-proof/generation state
|
||||
// before every operation and must not retain a proof for a complete 2^32-ms
|
||||
// cycle, where a 32-bit elapsed clock necessarily becomes ambiguous.
|
||||
constexpr bool proofAgeIsValid(bool fresh_proof, uint32_t now,
|
||||
uint32_t proven_at,
|
||||
uint32_t maximum_age_millis) {
|
||||
return fresh_proof && now - proven_at <= maximum_age_millis;
|
||||
}
|
||||
|
||||
// A callback can already be in flight when its owner unregisters it. Binding
|
||||
// the proof to the lease generation makes such a late callback fail closed if
|
||||
// a later operation of the same feature has already started.
|
||||
constexpr bool proofGenerationIsValid(bool fresh_proof,
|
||||
uint32_t proof_generation,
|
||||
uint32_t expected_generation) {
|
||||
return fresh_proof && expected_generation != 0
|
||||
&& proof_generation == expected_generation;
|
||||
}
|
||||
|
||||
} // namespace tls_clock
|
||||
} // namespace mesh
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
#include "WebConfigHtml.h"
|
||||
#include "helpers/CLICommandUtils.h"
|
||||
#include "helpers/UsbLogging.h"
|
||||
#include "helpers/WebConfigKeys.h"
|
||||
#include "helpers/WiFiPowerSave.h"
|
||||
#include "helpers/esp32/WiFiRadioPolicy.h"
|
||||
@@ -389,8 +390,11 @@ WebConfigServer::~WebConfigServer() {
|
||||
|
||||
bool WebConfigServer::loadEnabled(bool default_value) {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin("mesh-webui", true)) return default_value;
|
||||
bool enabled = nvs.getBool("enabled", default_value);
|
||||
// A missing namespace is the normal erased-device state. Read-write creates
|
||||
// it once instead of making Arduino Preferences log NOT_FOUND to Serial.
|
||||
if (!nvs.begin("mesh-webui", false)) return default_value;
|
||||
bool enabled = nvs.isKey("enabled")
|
||||
? nvs.getBool("enabled", default_value) : default_value;
|
||||
nvs.end();
|
||||
return enabled;
|
||||
}
|
||||
@@ -405,8 +409,9 @@ bool WebConfigServer::saveEnabled(bool enabled) {
|
||||
|
||||
bool WebConfigServer::loadCliEnabled(bool default_value) {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin("mesh-webui", true)) return default_value;
|
||||
bool enabled = nvs.getBool("cli", default_value);
|
||||
if (!nvs.begin("mesh-webui", false)) return default_value;
|
||||
bool enabled = nvs.isKey("cli")
|
||||
? nvs.getBool("cli", default_value) : default_value;
|
||||
nvs.end();
|
||||
return enabled;
|
||||
}
|
||||
@@ -426,11 +431,16 @@ bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len,
|
||||
ssid[0] = 0;
|
||||
password[0] = 0;
|
||||
Preferences nvs;
|
||||
if (!nvs.begin("mesh-wifi", true)) return false;
|
||||
String stored_ssid = nvs.getString("ssid", "");
|
||||
String stored_password = nvs.getString("password", "");
|
||||
uint8_t stored_ps = nvs.getUChar(
|
||||
"powersave", mesh::wifi::kDefaultPowerSave);
|
||||
if (!nvs.begin("mesh-wifi", false)) return false;
|
||||
// Preferences::getString() logs an error for a missing key even when the
|
||||
// caller supplied a default. Missing credentials are expected on first boot.
|
||||
String stored_ssid = nvs.isKey("ssid")
|
||||
? nvs.getString("ssid", "") : String();
|
||||
String stored_password = nvs.isKey("password")
|
||||
? nvs.getString("password", "") : String();
|
||||
uint8_t stored_ps = nvs.isKey("powersave")
|
||||
? nvs.getUChar("powersave", mesh::wifi::kDefaultPowerSave)
|
||||
: mesh::wifi::kDefaultPowerSave;
|
||||
nvs.end();
|
||||
if (stored_ssid.length() >= ssid_len
|
||||
|| stored_password.length() >= password_len
|
||||
@@ -779,12 +789,13 @@ bool WebConfigServer::startSetupMode(char reply[]) {
|
||||
const esp_err_t sta_protocol_result = esp_wifi_set_protocol(
|
||||
WIFI_IF_STA, mesh::wifi::kProtocolMask);
|
||||
if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break;
|
||||
Serial.printf("WebConfig protocol reset failed: AP=%d STA=%d\n",
|
||||
(int)ap_protocol_result, (int)sta_protocol_result);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig protocol reset failed: AP=%d STA=%d\n",
|
||||
(int)ap_protocol_result, (int)sta_protocol_result);
|
||||
ap_ok = false;
|
||||
}
|
||||
|
||||
Serial.printf(
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig AP attempt %u failed: mode_ok=%d disconnect_ok=%d mode=%d heap=%u largest=%u\n",
|
||||
(unsigned)attempt, mode_ok, disconnect_ok, (int)WiFi.getMode(),
|
||||
(unsigned)ESP.getFreeHeap(),
|
||||
@@ -969,8 +980,9 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
break;
|
||||
case WebConfigBatch::StopAction::Warn:
|
||||
_stop_warned = true;
|
||||
Serial.printf("WC: stop waiting for %lu handler(s); retaining session safely\n",
|
||||
(unsigned long)refs);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: stop waiting for %lu handler(s); retaining session safely\n",
|
||||
(unsigned long)refs);
|
||||
break;
|
||||
case WebConfigBatch::StopAction::Wait:
|
||||
break;
|
||||
@@ -993,9 +1005,12 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
createServer();
|
||||
_connect_deadline = 0;
|
||||
_last_activity = now;
|
||||
Serial.printf("WebConfig ready: http://%s/\n", WiFi.localIP().toString().c_str());
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig ready: http://%s/\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
} else if (_connect_deadline && (int32_t)(now - _connect_deadline) >= 0) {
|
||||
Serial.printf("WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid);
|
||||
const bool retry_saved_wifi = _wifi_ssid[0] != 0;
|
||||
// Keep the WiFi driver running while changing from STA to AP+STA.
|
||||
// Powering it off here and starting an AP immediately can race the
|
||||
@@ -1008,7 +1023,7 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
if (startSetupMode(ignored)) {
|
||||
_retry_saved_wifi_in_setup = retry_saved_wifi;
|
||||
}
|
||||
Serial.println(ignored);
|
||||
mesh::usbLoggingPort().println(ignored);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1024,8 +1039,9 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
_wifi_reconnect_tracker.noteDisconnected(now);
|
||||
if (_wifi_reconnect_tracker.retryDue(now)) {
|
||||
_wifi_reconnect_tracker.noteAttempt(now);
|
||||
Serial.printf("WebConfig: WiFi still unavailable; retrying '%s'\n",
|
||||
_wifi_ssid);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: WiFi still unavailable; retrying '%s'\n",
|
||||
_wifi_ssid);
|
||||
WiFi.mode(WIFI_STA);
|
||||
mesh::wifi::setStationAutoReconnect(true);
|
||||
WiFi.disconnect(false, false);
|
||||
@@ -1061,20 +1077,23 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
_wifi_reconnect_tracker.noteConnected();
|
||||
_mode = MODE_LAN;
|
||||
_last_activity = now;
|
||||
Serial.printf("WebConfig: saved WiFi recovered; ready at http://%s/\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: saved WiFi recovered; ready at http://%s/\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
} else if (_owns_wifi && _setup_reconnect_in_progress
|
||||
&& (int32_t)(now - _setup_reconnect_deadline) >= 0) {
|
||||
WiFi.disconnect(false, false);
|
||||
_setup_reconnect_in_progress = false;
|
||||
_setup_reconnect_deadline = 0;
|
||||
Serial.println("WebConfig: saved WiFi still unavailable; setup AP remains active");
|
||||
mesh::usbLoggingPort().println(
|
||||
"WebConfig: saved WiFi still unavailable; setup AP remains active");
|
||||
} else if (_owns_wifi && !_setup_reconnect_in_progress
|
||||
&& _wifi_reconnect_tracker.retryDue(now)) {
|
||||
_wifi_reconnect_tracker.noteAttempt(now);
|
||||
_setup_reconnect_in_progress = true;
|
||||
_setup_reconnect_deadline = now + 20000UL;
|
||||
Serial.printf("WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid);
|
||||
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
|
||||
}
|
||||
}
|
||||
@@ -1087,15 +1106,18 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
}
|
||||
|
||||
if (WebConfigBatch::rebootDue(_reboot_at, now)) {
|
||||
Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback");
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: rebooting now (%s)\n",
|
||||
_batch_reboot_armed ? "confirmed" : "fallback");
|
||||
_cb->rebootNow(); // does not return
|
||||
}
|
||||
|
||||
if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) {
|
||||
_diag_last = now;
|
||||
Serial.printf("WC: diag sta=%d heap=%u batch=%d/%d state=%d\n",
|
||||
(int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(),
|
||||
(int)_batch_next, (int)_batch_count, (int)_batch_state);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: diag sta=%d heap=%u batch=%d/%d state=%d\n",
|
||||
(int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(),
|
||||
(int)_batch_next, (int)_batch_count, (int)_batch_state);
|
||||
}
|
||||
|
||||
// Refresh the stats snapshot only while a client is actually polling.
|
||||
@@ -1113,7 +1135,7 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
_mode == MODE_SETUP, _wifi_ssid[0] != 0, now,
|
||||
_setup_started_at,
|
||||
(uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS)) {
|
||||
Serial.printf(
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: WiFi still unconfigured after %lu minutes; powering off until reboot or explicit restart\n",
|
||||
(unsigned long)((uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS / 60000UL));
|
||||
requestStop();
|
||||
@@ -1144,7 +1166,8 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
_setup_reconnect_deadline = 0;
|
||||
_mode = MODE_LAN;
|
||||
_last_activity = now;
|
||||
Serial.println("WebConfig: setup AP idle; saved WiFi recovery continues");
|
||||
mesh::usbLoggingPort().println(
|
||||
"WebConfig: setup AP idle; saved WiFi recovery continues");
|
||||
} else {
|
||||
requestStop();
|
||||
}
|
||||
@@ -1179,8 +1202,9 @@ void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) {
|
||||
_setup_wifi_handoff_pending = false;
|
||||
_setup_wifi_handoff_deadline = 0;
|
||||
}
|
||||
Serial.printf("WebConfig: joined '%s' at %s; waiting for browser handoff\n",
|
||||
_wifi_ssid, ip);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: joined '%s' at %s; waiting for browser handoff\n",
|
||||
_wifi_ssid, ip);
|
||||
finishBatch(now);
|
||||
return;
|
||||
}
|
||||
@@ -1207,8 +1231,9 @@ void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) {
|
||||
_setup_wifi_handoff_deadline = 0;
|
||||
_setup_wifi_handoff_ip[0] = 0;
|
||||
}
|
||||
Serial.printf("WebConfig: could not join '%s'; setup AP remains active\n",
|
||||
_wifi_ssid);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: could not join '%s'; setup AP remains active\n",
|
||||
_wifi_ssid);
|
||||
finishBatch(now);
|
||||
}
|
||||
|
||||
@@ -1314,11 +1339,14 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
// `set wifi.pwd` or `password` from the terminal must not reach the serial
|
||||
// log, which is a different audience from the browser session.
|
||||
if (_batch_kind == BATCH_CLI) {
|
||||
Serial.printf("WC: cli %d/%d took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
(unsigned long)(_batch_last_cmd - t0));
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: cli %d/%d took %lums\n", (int)_batch_next,
|
||||
(int)_batch_count, (unsigned long)(_batch_last_cmd - t0));
|
||||
} else {
|
||||
Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count,
|
||||
e.key, (unsigned long)(_batch_last_cmd - t0));
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next,
|
||||
(int)_batch_count, e.key,
|
||||
(unsigned long)(_batch_last_cmd - t0));
|
||||
}
|
||||
if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) {
|
||||
return; // more commands next tick
|
||||
@@ -1368,8 +1396,8 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
// that will be shown to the operator before the setup AP is shut down.
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
WiFi.setAutoReconnect(false);
|
||||
Serial.printf("WebConfig: testing saved WiFi '%s' before reboot\n",
|
||||
_wifi_ssid);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WebConfig: testing saved WiFi '%s' before reboot\n", _wifi_ssid);
|
||||
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
|
||||
return;
|
||||
}
|
||||
@@ -1384,7 +1412,8 @@ void WebConfigServer::drainBatch(uint32_t now) {
|
||||
// Distinguishes "client stopped sending" from "server stopped accepting" when
|
||||
// a save's confirmation polls go missing on hardware.
|
||||
static void wcLogReq(AsyncWebServerRequest* r) {
|
||||
Serial.printf("WC: http %s %s\n", r->methodToString(), r->url().c_str());
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: http %s %s\n", r->methodToString(), r->url().c_str());
|
||||
}
|
||||
|
||||
void WebConfigServer::attachRoutes() {
|
||||
@@ -1943,7 +1972,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
|
||||
uint32_t du = millis() + 60000;
|
||||
if (du == 0) du = 1;
|
||||
_diag_until = du;
|
||||
Serial.printf("WC: config POST accepted, %d cmds, reboot=%d\n", count, (int)reboot_after);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: config POST accepted, %d cmds, reboot=%d\n",
|
||||
count, (int)reboot_after);
|
||||
|
||||
StaticJsonDocument<96> ack;
|
||||
ack["state"] = "pending";
|
||||
@@ -1955,8 +1986,16 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
|
||||
}
|
||||
|
||||
void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
if (_mode == MODE_OFF) { Serial.println("WC: result read -> 503 (mode off)"); req->send(503); return; }
|
||||
if (!checkAuth(req)) { Serial.println("WC: result read -> 401"); req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
|
||||
if (_mode == MODE_OFF) {
|
||||
mesh::usbLoggingPort().println("WC: result read -> 503 (mode off)");
|
||||
req->send(503);
|
||||
return;
|
||||
}
|
||||
if (!checkAuth(req)) {
|
||||
mesh::usbLoggingPort().println("WC: result read -> 401");
|
||||
req->send(401, "application/json", "{\"error\":\"auth\"}");
|
||||
return;
|
||||
}
|
||||
if (!req->hasParam("reqid")) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
|
||||
return;
|
||||
@@ -1969,7 +2008,9 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
|
||||
// Entry print BEFORE the lock (racy state read is fine for diag): if this
|
||||
// fires but no branch print follows, the handler is blocked on _mux.
|
||||
Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: result entry mode=%d state=%d\n",
|
||||
(int)_mode, (int)_batch_state);
|
||||
WCLock lock(_mux);
|
||||
// A CLI sequence occupying the shared slot is not a config save, whatever the
|
||||
// reqid says: its entries have no `key` and its results belong to the
|
||||
@@ -1979,7 +2020,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
const WebConfigBatch::ResultOutcome outcome =
|
||||
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
|
||||
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
|
||||
Serial.println("WC: result read -> idle");
|
||||
mesh::usbLoggingPort().println("WC: result read -> idle");
|
||||
StaticJsonDocument<64> idle;
|
||||
idle["state"] = "idle";
|
||||
idle["reqid"] = requested_reqid;
|
||||
@@ -2001,9 +2042,10 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
req->send(200, "application/json", out);
|
||||
return;
|
||||
}
|
||||
Serial.printf("WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n",
|
||||
(int)_batch_reboot, (int)_batch_reboot_armed,
|
||||
(int)_batch_all_ok);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n",
|
||||
(int)_batch_reboot, (int)_batch_reboot_armed,
|
||||
(int)_batch_all_ok);
|
||||
DynamicJsonDocument doc(6144);
|
||||
doc["state"] = "done";
|
||||
doc["reboot"] =
|
||||
@@ -2206,7 +2248,9 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
|
||||
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
|
||||
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
|
||||
Serial.printf("WC: cli POST accepted, %d cmds, reboot=%d\n", count, (int)defer_reboot);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"WC: cli POST accepted, %d cmds, reboot=%d\n",
|
||||
count, (int)defer_reboot);
|
||||
|
||||
StaticJsonDocument<96> ack;
|
||||
ack["state"] = "running";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <helpers/WiFiOtaSeederPolicy.h>
|
||||
#include <helpers/WiFiOtaSeederStatus.h>
|
||||
#include <helpers/UsbLogging.h>
|
||||
#include <helpers/ota/FolderMotaStore.h>
|
||||
#include <helpers/ota/MotaSourceSerial.h>
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
@@ -60,8 +61,9 @@ void WiFiOtaSeeder::loop() {
|
||||
case ListenerAction::Start:
|
||||
seeder_server.begin();
|
||||
listener_active = true;
|
||||
Serial.printf("OTA seeder listening on :%u (motatool serve --tcp)\n",
|
||||
static_cast<unsigned>(OTA_SEEDER_TCP_PORT));
|
||||
mesh::usbLoggingPort().printf(
|
||||
"OTA seeder listening on :%u (motatool serve --tcp)\n",
|
||||
static_cast<unsigned>(OTA_SEEDER_TCP_PORT));
|
||||
break;
|
||||
case ListenerAction::Stop:
|
||||
stop();
|
||||
@@ -90,7 +92,8 @@ void WiFiOtaSeeder::loop() {
|
||||
if (!WiFiOtaSeederPolicy::canAttachTcpFolder(context.folder_active,
|
||||
tcp_folder_attached)) {
|
||||
incoming.stop();
|
||||
Serial.println("OTA seeder rejected TCP client: another folder link is active");
|
||||
mesh::usbLoggingPort().println(
|
||||
"OTA seeder rejected TCP client: another folder link is active");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,7 +102,8 @@ void WiFiOtaSeeder::loop() {
|
||||
if (!context.attach_folder_source(&seeder_source, OtaContext::FOLDER_LINK_TCP,
|
||||
"tcp", attach_reply, sizeof(attach_reply))) {
|
||||
seeder_client.stop();
|
||||
Serial.printf("OTA seeder rejected TCP client: %s\n", attach_reply);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"OTA seeder rejected TCP client: %s\n", attach_reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +118,8 @@ void WiFiOtaSeeder::loop() {
|
||||
context.manager.resumeStaged(context.manager.fetchManifestId());
|
||||
}
|
||||
context.manager.announce();
|
||||
Serial.printf("OTA seeder client connected (%s)\n", link_info);
|
||||
mesh::usbLoggingPort().printf(
|
||||
"OTA seeder client connected (%s)\n", link_info);
|
||||
}
|
||||
|
||||
void WiFiOtaSeeder::stop() {
|
||||
|
||||
@@ -54,8 +54,12 @@ struct EspNowBootChannelState {
|
||||
inline uint8_t loadConfiguredEspNowChannel() {
|
||||
uint8_t channel = kDefaultEspNowChannel;
|
||||
Preferences prefs;
|
||||
if (prefs.begin("mesh-wifi", true)) {
|
||||
channel = prefs.getUChar("espnow_ch", kDefaultEspNowChannel);
|
||||
// A missing namespace is the normal first-boot state. Read-write creates it
|
||||
// without Arduino Preferences emitting ESP_ERR_NVS_NOT_FOUND on Serial.
|
||||
if (prefs.begin("mesh-wifi", false)) {
|
||||
if (prefs.isKey("espnow_ch")) {
|
||||
channel = prefs.getUChar("espnow_ch", kDefaultEspNowChannel);
|
||||
}
|
||||
prefs.end();
|
||||
}
|
||||
return validEspNowChannelOrDefault(channel, kDefaultEspNowChannel);
|
||||
|
||||
@@ -133,7 +133,9 @@ bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out);
|
||||
|
||||
inline uint8_t ota_nrf52_boot_result_or_zero(uint8_t value) {
|
||||
return ((value >= 0x90u && value <= 0x9Fu) ||
|
||||
(value >= 0xB0u && value <= 0xBCu) ||
|
||||
// B0..BC are the ordinary apply progress/failure/success results;
|
||||
// SD-backed OTAFIX additionally uses BD for an authorization failure.
|
||||
(value >= 0xB0u && value <= 0xBDu) ||
|
||||
(value >= 0xC0u && value <= 0xCFu)) ? value : 0u;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "EnvironmentSensorManager.h"
|
||||
#include "I2CAddressClaimPolicy.h"
|
||||
#include "EnvironmentI2CConfig.h"
|
||||
#include "NmeaSentenceProbe.h"
|
||||
|
||||
#include <Wire.h>
|
||||
|
||||
@@ -9,7 +11,122 @@
|
||||
#define TELEM_WIRE &Wire // Use default I2C bus for Environment Sensors
|
||||
#endif
|
||||
|
||||
#ifdef NRF52_PLATFORM
|
||||
static bool isValidNrfI2cPinPair(int32_t sda, int32_t scl) {
|
||||
if (!mesh::isValidI2cPinPair(sda, scl, PINS_COUNT)) return false;
|
||||
#ifdef NRF_P1
|
||||
static const uint32_t MAX_PHYSICAL_NRF_PIN = 48;
|
||||
#else
|
||||
static const uint32_t MAX_PHYSICAL_NRF_PIN = 32;
|
||||
#endif
|
||||
return digitalPinToPinName(static_cast<uint32_t>(sda))
|
||||
< MAX_PHYSICAL_NRF_PIN
|
||||
&& digitalPinToPinName(static_cast<uint32_t>(scl))
|
||||
< MAX_PHYSICAL_NRF_PIN;
|
||||
}
|
||||
#endif
|
||||
|
||||
// The pinned Adafruit nRF52 Wire core has no transaction timeout. Reject a
|
||||
// bus which is already held low, and make one bounded standard nine-clock
|
||||
// recovery attempt, before entering this manager's blocking transaction loops.
|
||||
// This cannot protect I2C users which ran earlier in boot or cure a peripheral
|
||||
// which wedges mid-transaction, but it prevents discovery from entering a bus
|
||||
// which is already visibly stuck and limits the number of exposed transactions.
|
||||
static bool ensureI2cBusReleased(TwoWire* wire) {
|
||||
#ifdef NRF52_PLATFORM
|
||||
// Board::begin() may remap the primary Wire instance away from the variant's
|
||||
// PIN_WIRE_* defaults (ProMicro is one example). Use the same board-level
|
||||
// pins first so recovery never probes or drives an unrelated GPIO.
|
||||
int32_t sda = -1;
|
||||
int32_t scl = -1;
|
||||
#if ENV_HAS_SECONDARY_I2C
|
||||
if (wire == &Wire1) {
|
||||
sda = ENV_PIN_SDA;
|
||||
scl = ENV_PIN_SCL;
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL)
|
||||
sda = PIN_BOARD_SDA;
|
||||
scl = PIN_BOARD_SCL;
|
||||
#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL)
|
||||
sda = PIN_WIRE_SDA;
|
||||
scl = PIN_WIRE_SCL;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (!isValidNrfI2cPinPair(sda, scl)) return false;
|
||||
|
||||
if (digitalRead(sda) == HIGH && digitalRead(scl) == HIGH) return true;
|
||||
|
||||
wire->end();
|
||||
auto releaseLine = [](int32_t pin) {
|
||||
// OUTPUT_S0D1 is nRF open drain: drive zero, disconnect for one. The input
|
||||
// pull-up then observes the external bus without ever driving it high.
|
||||
digitalWrite(pin, HIGH);
|
||||
pinMode(pin, INPUT_PULLUP);
|
||||
};
|
||||
auto driveLineLow = [](int32_t pin) {
|
||||
// Prime the output latch before enabling an output, avoiding a brief
|
||||
// push-pull high pulse if the previous latch happened to contain one.
|
||||
digitalWrite(pin, LOW);
|
||||
pinMode(pin, OUTPUT_S0D1);
|
||||
};
|
||||
auto waitLineHigh = [](int32_t pin) {
|
||||
for (uint8_t wait = 0; wait < 20; wait++) {
|
||||
if (digitalRead(pin) == HIGH) return true;
|
||||
delayMicroseconds(5);
|
||||
}
|
||||
return digitalRead(pin) == HIGH;
|
||||
};
|
||||
|
||||
releaseLine(sda);
|
||||
releaseLine(scl);
|
||||
delayMicroseconds(10);
|
||||
|
||||
// A secondary may have stopped halfway through a byte. Clock it to a byte
|
||||
// boundary while never driving either open-drain line high.
|
||||
if (digitalRead(scl) == HIGH) {
|
||||
for (uint8_t pulse = 0; pulse < 9 && digitalRead(sda) == LOW; pulse++) {
|
||||
driveLineLow(scl);
|
||||
delayMicroseconds(5);
|
||||
releaseLine(scl);
|
||||
if (!waitLineHigh(scl)) break;
|
||||
}
|
||||
|
||||
if (waitLineHigh(scl)) {
|
||||
// Generate a STOP (SDA low-to-high while SCL is confirmed high).
|
||||
driveLineLow(sda);
|
||||
delayMicroseconds(5);
|
||||
releaseLine(scl);
|
||||
if (waitLineHigh(scl)) {
|
||||
releaseLine(sda);
|
||||
delayMicroseconds(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if ENV_HAS_SECONDARY_I2C
|
||||
if (wire == &Wire1) {
|
||||
Wire1.setPins(static_cast<uint8_t>(sda), static_cast<uint8_t>(scl));
|
||||
Wire1.begin();
|
||||
Wire1.setClock(100000);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
Wire.setPins(static_cast<uint8_t>(sda), static_cast<uint8_t>(scl));
|
||||
Wire.begin();
|
||||
}
|
||||
delayMicroseconds(10);
|
||||
return digitalRead(sda) == HIGH && digitalRead(scl) == HIGH;
|
||||
#else
|
||||
(void)wire;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EnvironmentSensorManager::i2c_probe(TwoWire& wire, uint8_t addr) {
|
||||
if (!mesh::isValidI2cPeripheralAddress(addr)) return false;
|
||||
wire.beginTransmission(addr);
|
||||
uint8_t error = wire.endTransmission();
|
||||
return error == 0;
|
||||
@@ -189,11 +306,105 @@ static uint32_t gpsResetPin = -1;
|
||||
static bool i2cGPSFlag = false;
|
||||
static bool serialGPSFlag = false;
|
||||
#ifndef TELEM_RAK12500_ADDRESS
|
||||
#ifdef GPS_ADDRESS
|
||||
#define TELEM_RAK12500_ADDRESS GPS_ADDRESS
|
||||
#else
|
||||
#define TELEM_RAK12500_ADDRESS 0x42 //RAK12500 Ublox GPS via i2c
|
||||
#endif
|
||||
#endif
|
||||
#include <SparkFun_u-blox_GNSS_Arduino_Library.h>
|
||||
static SFE_UBLOX_GNSS ublox_GNSS;
|
||||
|
||||
#ifndef RAK_UART_GPS_PROBE_TIMEOUT_MS
|
||||
#define RAK_UART_GPS_PROBE_TIMEOUT_MS 1200UL
|
||||
#endif
|
||||
|
||||
static uint8_t rakGpsControlActiveLevel() {
|
||||
#ifdef PIN_GPS_EN_ACTIVE
|
||||
return PIN_GPS_EN_ACTIVE;
|
||||
#else
|
||||
return HIGH;
|
||||
#endif
|
||||
}
|
||||
|
||||
static void setRakGpsControl(uint8_t pin, bool shared_power_rail,
|
||||
bool enabled) {
|
||||
pinMode(pin, OUTPUT);
|
||||
if (shared_power_rail) {
|
||||
// WB_IO2 controls every 3V3_S peripheral, not just the GPS. It must never
|
||||
// be dropped as a GPS power-saving operation.
|
||||
digitalWrite(pin, HIGH);
|
||||
return;
|
||||
}
|
||||
const uint8_t active = rakGpsControlActiveLevel();
|
||||
digitalWrite(pin, enabled ? active : static_cast<uint8_t>(!active));
|
||||
}
|
||||
|
||||
static bool serialHasValidGpsSentence(Stream& serial, uint32_t timeout_ms) {
|
||||
mesh::NmeaSentenceProbe probe;
|
||||
const uint32_t started = millis();
|
||||
do {
|
||||
while (serial.available()) {
|
||||
if (probe.ingest(static_cast<uint8_t>(serial.read()))) return true;
|
||||
}
|
||||
delay(5);
|
||||
} while (static_cast<uint32_t>(millis() - started) < timeout_ms);
|
||||
return false;
|
||||
}
|
||||
|
||||
#if ENV_INCLUDE_INA3221
|
||||
static mesh::I2cRegisterProbeStatus readI2cRegister16(TwoWire* wire,
|
||||
uint8_t address,
|
||||
uint8_t reg,
|
||||
uint16_t& value) {
|
||||
wire->beginTransmission(address);
|
||||
if (wire->write(reg) != 1) {
|
||||
return mesh::I2cRegisterProbeStatus::Inconclusive;
|
||||
}
|
||||
const uint8_t error = wire->endTransmission(false);
|
||||
if (error == 2) return mesh::I2cRegisterProbeStatus::NoResponse;
|
||||
if (error != 0) return mesh::I2cRegisterProbeStatus::Inconclusive;
|
||||
if (wire->requestFrom(address, static_cast<uint8_t>(2)) != 2) {
|
||||
return mesh::I2cRegisterProbeStatus::Inconclusive;
|
||||
}
|
||||
value = static_cast<uint16_t>(wire->read()) << 8;
|
||||
value |= static_cast<uint8_t>(wire->read());
|
||||
return mesh::I2cRegisterProbeStatus::Success;
|
||||
}
|
||||
|
||||
static mesh::I2cIdentityProbeResult probeIna3221Identity(TwoWire* wire,
|
||||
uint8_t address) {
|
||||
uint16_t manufacturer = 0;
|
||||
const mesh::I2cRegisterProbeStatus manufacturer_result = readI2cRegister16(
|
||||
wire, address, INA3221_REG_MANUFACTURER_ID, manufacturer);
|
||||
if (manufacturer_result != mesh::I2cRegisterProbeStatus::Success) {
|
||||
return mesh::classifyIna3221Identity(
|
||||
manufacturer_result, manufacturer,
|
||||
mesh::I2cRegisterProbeStatus::Inconclusive, 0,
|
||||
INA3221_MANUFACTURER_ID, INA3221_DIE_ID);
|
||||
}
|
||||
if (manufacturer != INA3221_MANUFACTURER_ID) {
|
||||
// Require two successful non-INA reads. The bytes exposed by u-blox at
|
||||
// 0xFE/0xFF are dynamic, so their values need not equal one another. This
|
||||
// still cannot make a physical address collision safe.
|
||||
uint16_t repeated_manufacturer = 0;
|
||||
const mesh::I2cRegisterProbeStatus repeated_result = readI2cRegister16(
|
||||
wire, address, INA3221_REG_MANUFACTURER_ID, repeated_manufacturer);
|
||||
return mesh::classifyIna3221Identity(
|
||||
manufacturer_result, manufacturer,
|
||||
repeated_result, repeated_manufacturer,
|
||||
INA3221_MANUFACTURER_ID, INA3221_DIE_ID);
|
||||
}
|
||||
|
||||
uint16_t die = 0;
|
||||
const mesh::I2cRegisterProbeStatus die_result = readI2cRegister16(
|
||||
wire, address, INA3221_REG_DIE_ID, die);
|
||||
return mesh::classifyIna3221Identity(
|
||||
manufacturer_result, manufacturer, die_result, die,
|
||||
INA3221_MANUFACTURER_ID, INA3221_DIE_ID);
|
||||
}
|
||||
#endif
|
||||
|
||||
class RAK12500LocationProvider : public LocationProvider {
|
||||
long _lat = 0;
|
||||
long _lng = 0;
|
||||
@@ -221,7 +432,23 @@ public:
|
||||
bool isValid() override { return _fix; }
|
||||
long getTimestamp() override { return _epoch; }
|
||||
void sendSentence(const char * sentence) override { }
|
||||
void reset() override { }
|
||||
void reset() override {
|
||||
// Discovery can replace and later reselect this singleton provider. Never
|
||||
// expose the previous receiver's fix or time samples while the new device
|
||||
// is still acquiring.
|
||||
_lat = 0;
|
||||
_lng = 0;
|
||||
_alt = 0;
|
||||
_sats = 0;
|
||||
_epoch = 0;
|
||||
_fix = false;
|
||||
_next_time_check = 0;
|
||||
_last_time_sync = 0;
|
||||
_valid_time_samples = 0;
|
||||
_time_sync_needed = true;
|
||||
_time_sync_applied = false;
|
||||
_last_valid_time_sync = 0;
|
||||
}
|
||||
void begin() override { }
|
||||
void stop() override { }
|
||||
void loop() override {
|
||||
@@ -271,20 +498,6 @@ public:
|
||||
static RAK12500LocationProvider RAK12500_provider;
|
||||
#endif
|
||||
|
||||
// ============================================================
|
||||
// I2C bus scanner
|
||||
// Probes every valid address and records which ones ACK.
|
||||
// This runs before any sensor library is touched, so a missing
|
||||
// or misbehaving device cannot stall or crash the boot sequence.
|
||||
// ============================================================
|
||||
|
||||
static void scanI2CBus(TwoWire* wire, bool found[128]) {
|
||||
for (uint8_t addr = 0x08; addr < 0x78; addr++) {
|
||||
wire->beginTransmission(addr);
|
||||
found[addr] = (wire->endTransmission() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Per-sensor init and query functions
|
||||
//
|
||||
@@ -630,14 +843,15 @@ static void query_bme680_bsec(uint8_t ch, uint8_t, CayenneLPP& lpp) {
|
||||
// ============================================================
|
||||
|
||||
struct SensorDef {
|
||||
uint8_t address;
|
||||
uint32_t address; // preserve invalid build overrides until validation
|
||||
const char* name;
|
||||
uint8_t (*init)(TwoWire* wire, uint8_t address);
|
||||
void (*query)(uint8_t channel, uint8_t sub_channel, CayenneLPP& telemetry);
|
||||
bool (*query_voltage)(uint8_t sub_channel, float& voltage);
|
||||
};
|
||||
|
||||
#define TELEM_BOSCH_ALT_ADDR(addr) ((uint8_t)((addr) == 0x76 ? 0x77 : 0x76))
|
||||
#define TELEM_BOSCH_ALT_ADDR(addr) \
|
||||
((addr) == 0x76 ? 0x77U : ((addr) == 0x77 ? 0x76U : (addr)))
|
||||
|
||||
static const SensorDef SENSOR_TABLE[] = {
|
||||
#if ENV_INCLUDE_AHTX0
|
||||
@@ -704,13 +918,15 @@ static const SensorDef SENSOR_TABLE[] = {
|
||||
static const size_t SENSOR_TABLE_SIZE = (sizeof(SENSOR_TABLE) / sizeof(SENSOR_TABLE[0])) - 1;
|
||||
|
||||
// ============================================================
|
||||
// begin() - scan the I2C bus, then initialize only what was
|
||||
// found. A sensor whose address does not ACK during the scan
|
||||
// is never touched by a library call, preventing hangs or
|
||||
// crashes caused by absent or misbehaving hardware.
|
||||
// begin() - initialize an optional board GPS, then scan the I2C bus and invoke
|
||||
// only table-driven sensor initializers whose address ACKed. GPS uses a
|
||||
// device-specific probe outside SENSOR_TABLE; an address it positively claims
|
||||
// is not handed to another driver.
|
||||
// ============================================================
|
||||
|
||||
bool EnvironmentSensorManager::begin() {
|
||||
_active_sensor_count = 0;
|
||||
|
||||
#if ENV_INCLUDE_GPS
|
||||
#ifdef RAK_WISBLOCK_GPS
|
||||
// A volatile read anchors the externally linked marker even under LTO.
|
||||
@@ -725,45 +941,82 @@ bool EnvironmentSensorManager::begin() {
|
||||
|
||||
#if ENV_HAS_SECONDARY_I2C
|
||||
#ifdef NRF52_PLATFORM
|
||||
Wire1.setPins(ENV_PIN_SDA, ENV_PIN_SCL);
|
||||
Wire1.setClock(100000);
|
||||
if (!isValidNrfI2cPinPair(ENV_PIN_SDA, ENV_PIN_SCL)) {
|
||||
MESH_DEBUG_PRINTLN("Second I2C skipped: invalid SDA/SCL pin pair");
|
||||
return true;
|
||||
}
|
||||
Wire1.setPins(static_cast<uint8_t>(ENV_PIN_SDA),
|
||||
static_cast<uint8_t>(ENV_PIN_SCL));
|
||||
Wire1.begin();
|
||||
Wire1.setClock(100000);
|
||||
#else
|
||||
Wire1.begin(ENV_PIN_SDA, ENV_PIN_SCL, 100000);
|
||||
#endif
|
||||
MESH_DEBUG_PRINTLN("Second I2C initialized on pins SDA: %d SCL: %d", ENV_PIN_SDA, ENV_PIN_SCL);
|
||||
#endif
|
||||
|
||||
_active_sensor_count = 0;
|
||||
// Avoid touching a shared I2C bus when no environmental drivers were built.
|
||||
if (SENSOR_TABLE_SIZE == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scan the I2C bus before touching any sensor library.
|
||||
if (!ensureI2cBusReleased(TELEM_WIRE)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"I2C discovery skipped: bus pins unavailable or SDA/SCL remained low");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Probe only unique addresses compiled into this image before invoking any
|
||||
// table-driven sensor library. The old all-address scan performed 112
|
||||
// blocking Wire transactions even when only a few drivers were present.
|
||||
bool detected[128] = {};
|
||||
scanI2CBus(TELEM_WIRE, detected);
|
||||
bool probed[128] = {};
|
||||
for (size_t i = 0; i < SENSOR_TABLE_SIZE; i++) {
|
||||
const uint32_t configured_address = SENSOR_TABLE[i].address;
|
||||
if (!mesh::isValidI2cPeripheralAddress(configured_address)) {
|
||||
MESH_DEBUG_PRINTLN("Skipping invalid I2C sensor address %lX",
|
||||
(unsigned long)configured_address);
|
||||
continue;
|
||||
}
|
||||
const uint8_t address = static_cast<uint8_t>(configured_address);
|
||||
if (!probed[address]) {
|
||||
probed[address] = true;
|
||||
detected[address] = i2c_probe(*TELEM_WIRE, address);
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the sensor table and initialize only detected devices.
|
||||
for (size_t i = 0; i < SENSOR_TABLE_SIZE && _active_sensor_count < MAX_ACTIVE_SENSORS; i++) {
|
||||
const SensorDef& def = SENSOR_TABLE[i];
|
||||
if (!mesh::isValidI2cPeripheralAddress(def.address)) continue;
|
||||
const uint8_t address = static_cast<uint8_t>(def.address);
|
||||
#ifdef RAK_WISBLOCK_GPS
|
||||
if (mesh::shouldSkipSensorAtClaimedGpsAddress(
|
||||
i2cGPSFlag, TELEM_WIRE == &Wire,
|
||||
address, TELEM_RAK12500_ADDRESS)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Skipping %s at I2C address %02X: address is claimed by RAK12500 GPS",
|
||||
def.name, address);
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
// One static driver instance per type: an alternate address is a fallback, not a second device.
|
||||
bool already_active = false;
|
||||
for (int j = 0; j < _active_sensor_count; j++) {
|
||||
if (_active_sensors[j].query == def.query) { already_active = true; break; }
|
||||
}
|
||||
if (already_active) continue;
|
||||
if (!detected[def.address]) {
|
||||
MESH_DEBUG_PRINTLN("%s not detected at I2C address %02X", def.name, def.address);
|
||||
if (!detected[address]) {
|
||||
MESH_DEBUG_PRINTLN("%s not detected at I2C address %02X", def.name, address);
|
||||
continue;
|
||||
}
|
||||
uint8_t n = def.init(TELEM_WIRE, def.address);
|
||||
uint8_t n = def.init(TELEM_WIRE, address);
|
||||
if (n == 0) {
|
||||
MESH_DEBUG_PRINTLN("%s found at %02X but failed to initialize", def.name, def.address);
|
||||
MESH_DEBUG_PRINTLN("%s found at %02X but failed to initialize", def.name, address);
|
||||
continue;
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("Found %s at address: %02X", def.name, def.address);
|
||||
detected[def.address] = false; // consumed; later entries must not re-claim this device
|
||||
MESH_DEBUG_PRINTLN("Found %s at address: %02X", def.name, address);
|
||||
detected[address] = false; // consumed; later entries must not re-claim this device
|
||||
for (uint8_t sub = 0; sub < n && _active_sensor_count < MAX_ACTIVE_SENSORS; sub++) {
|
||||
_active_sensors[_active_sensor_count++] = {
|
||||
def.query, def.query_voltage, sub
|
||||
@@ -858,16 +1111,13 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val
|
||||
if (gps_detected && strcmp(name, "gps") == 0) {
|
||||
bool enabled = strcmp(value, "0") != 0;
|
||||
#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)
|
||||
// RAK3401 must keep 3V3_S on for its radio FEM. RAK12501/L76K has no
|
||||
// separately wired standby control, so claiming it is off would only stop
|
||||
// parsing while the receiver continued drawing full power.
|
||||
if (!enabled && serialGPSFlag) {
|
||||
setGpsTelemetryUserEnabled(true);
|
||||
return false;
|
||||
}
|
||||
if (enabled && serialGPSFlag) {
|
||||
// A UART L76K cannot remove power from the shared 3V3_S rail. "Off" stops
|
||||
// parsing and releases the MCU UART, but the still-powered receiver keeps
|
||||
// driving its TX pin. It therefore does not make that pin safe for an
|
||||
// external UART bridge.
|
||||
if (serialGPSFlag) {
|
||||
_location->setGPSPowerSaving(false);
|
||||
setGpsTelemetryUserEnabled(true);
|
||||
setGpsTelemetryUserEnabled(enabled);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
@@ -893,7 +1143,11 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) {
|
||||
#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)
|
||||
if (serialGPSFlag) {
|
||||
_location->setGPSPowerSaving(false);
|
||||
if (!gps_active) start_gps();
|
||||
if (gps_user_enabled) {
|
||||
if (!gps_active) start_gps();
|
||||
} else if (gps_active) {
|
||||
stop_gps();
|
||||
}
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
@@ -916,6 +1170,17 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) {
|
||||
#if ENV_INCLUDE_GPS
|
||||
void EnvironmentSensorManager::initBasicGPS() {
|
||||
|
||||
// A repeated discovery pass must not steal a UART which an active bridge
|
||||
// already owns. The bridge release path explicitly restores availability.
|
||||
if (gps_serial_transport_blocked) {
|
||||
MESH_DEBUG_PRINTLN("GPS discovery skipped: serial transport is owned by bridge");
|
||||
return;
|
||||
}
|
||||
|
||||
resetGpsTelemetryTransportState();
|
||||
gps_serial_transport = false;
|
||||
gps_serial_transport_blocked = false;
|
||||
|
||||
Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX);
|
||||
|
||||
#ifdef GPS_BAUD_RATE
|
||||
@@ -943,6 +1208,7 @@ void EnvironmentSensorManager::initBasicGPS() {
|
||||
#endif
|
||||
|
||||
if (gps_detected) {
|
||||
gps_serial_transport = true;
|
||||
MESH_DEBUG_PRINTLN("GPS detected");
|
||||
#ifdef PERSISTANT_GPS
|
||||
gps_active = true;
|
||||
@@ -960,6 +1226,26 @@ void EnvironmentSensorManager::initBasicGPS() {
|
||||
// or make a new location provider ...
|
||||
#ifdef RAK_WISBLOCK_GPS
|
||||
void EnvironmentSensorManager::rakGPSInit() {
|
||||
// Preserve the established owner across repeated begin() calls. Clearing
|
||||
// this flag and starting Serial1 here would silently take UART1 back from an
|
||||
// active bridge.
|
||||
if (gps_serial_transport_blocked) {
|
||||
MESH_DEBUG_PRINTLN("RAK GPS discovery skipped: UART1 is owned by bridge");
|
||||
return;
|
||||
}
|
||||
|
||||
// A repeated begin() must not retain ownership or a provider from an older
|
||||
// hardware probe.
|
||||
i2cGPSFlag = false;
|
||||
serialGPSFlag = false;
|
||||
resetGpsTelemetryTransportState();
|
||||
gpsResetPin = static_cast<uint32_t>(-1);
|
||||
gps_active = false;
|
||||
gps_detected = false;
|
||||
gps_serial_transport = false;
|
||||
gps_serial_transport_blocked = false;
|
||||
_location = _configured_location;
|
||||
|
||||
Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX);
|
||||
|
||||
#ifdef GPS_BAUD_RATE
|
||||
@@ -968,9 +1254,18 @@ void EnvironmentSensorManager::rakGPSInit() {
|
||||
Serial1.begin(9600);
|
||||
#endif
|
||||
|
||||
// search for the correct IO standby pin depending on socket used
|
||||
if (gpsIsAwake(WB_IO2)) {
|
||||
_location->setPinEn(WB_IO2); // WB_IO2 is the power switch for all sensor and IO slots
|
||||
// A RAK-derived board with a real GPS-enable pin must use it. Stock WisBlock
|
||||
// bases have no dedicated GPS switch, so their fallback is shared WB_IO2.
|
||||
#if defined(PIN_GPS_EN) && PIN_GPS_EN >= 0
|
||||
const uint8_t gps_control_pin = PIN_GPS_EN;
|
||||
const bool shared_power_rail = false;
|
||||
#else
|
||||
const uint8_t gps_control_pin = WB_IO2;
|
||||
const bool shared_power_rail = true;
|
||||
#endif
|
||||
|
||||
if (gpsIsAwake(gps_control_pin, shared_power_rail)) {
|
||||
_location->setPinEn(gps_control_pin);
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("No GPS found");
|
||||
gps_active = false;
|
||||
@@ -993,28 +1288,69 @@ void EnvironmentSensorManager::rakGPSInit() {
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin) {
|
||||
#if defined(ETHERNET_ENABLED) && defined(RAK_BOARD)
|
||||
if (ioPin == WB_IO2) {
|
||||
// WB_IO2 powers the Ethernet module on RAK baseboards.
|
||||
return false;
|
||||
bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin,
|
||||
bool shared_power_rail) {
|
||||
if (!shared_power_rail) {
|
||||
setRakGpsControl(ioPin, false, false);
|
||||
delay(500);
|
||||
}
|
||||
#endif
|
||||
|
||||
// RAK3401 shares WB_IO2 with the radio PA supply. Probing a GPS must never
|
||||
// pulse that rail low or turn the pin back into an input.
|
||||
#ifndef RAK_3401
|
||||
// set initial waking state
|
||||
pinMode(ioPin, OUTPUT);
|
||||
digitalWrite(ioPin, LOW);
|
||||
delay(500);
|
||||
digitalWrite(ioPin, HIGH);
|
||||
#endif
|
||||
setRakGpsControl(ioPin, shared_power_rail, true);
|
||||
// give the receiver time to become responsive
|
||||
delay(500);
|
||||
|
||||
// Try to init RAK12500 on I2C
|
||||
if (ublox_GNSS.begin(Wire) == true) {
|
||||
// Prove the UART device with a complete checksum-valid GPS sentence. This
|
||||
// must precede I2C probing so a UART GPS can coexist with INA3221 at 0x42.
|
||||
if (serialHasValidGpsSentence(Serial1, RAK_UART_GPS_PROBE_TIMEOUT_MS)) {
|
||||
MESH_DEBUG_PRINTLN("RAK12501 UART GPS identified with pin %i", ioPin);
|
||||
gpsResetPin = ioPin;
|
||||
serialGPSFlag = true;
|
||||
gps_serial_transport = true;
|
||||
gps_active = true;
|
||||
gps_detected = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ensureI2cBusReleased(&Wire)) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Skipping RAK12500 probe: I2C pins unavailable or bus remains low");
|
||||
setRakGpsControl(ioPin, shared_power_rail, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t configured_gps_address =
|
||||
static_cast<uint32_t>(TELEM_RAK12500_ADDRESS);
|
||||
if (!mesh::isValidI2cPeripheralAddress(configured_gps_address)) {
|
||||
MESH_DEBUG_PRINTLN("Skipping invalid RAK12500 I2C address");
|
||||
setRakGpsControl(ioPin, shared_power_rail, false);
|
||||
return false;
|
||||
}
|
||||
const uint8_t gps_i2c_address =
|
||||
static_cast<uint8_t>(configured_gps_address);
|
||||
bool probe_i2c_gps = true;
|
||||
#if ENV_INCLUDE_INA3221
|
||||
// Inspect the actual device at the GPS address. This must not depend on the
|
||||
// configured INA address or telemetry bus: an unstrapped INA3221 can still
|
||||
// physically remain at 0x42 when firmware expects it at 0x43.
|
||||
const mesh::I2cIdentityProbeResult ina_identity =
|
||||
probeIna3221Identity(&Wire, gps_i2c_address);
|
||||
probe_i2c_gps = mesh::shouldProbeI2cGps(ina_identity);
|
||||
if (!probe_i2c_gps) {
|
||||
if (ina_identity == mesh::I2cIdentityProbeResult::Match) {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Skipping RAK12500 probe at I2C address %02X: INA3221 identity confirmed",
|
||||
gps_i2c_address);
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN(
|
||||
"Skipping RAK12500 probe at I2C address %02X: INA identity read was inconclusive",
|
||||
gps_i2c_address);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// After two successful non-INA manufacturer reads, use the u-blox-specific
|
||||
// probe. No software can make two physical devices at one address safe.
|
||||
if (probe_i2c_gps
|
||||
&& ublox_GNSS.begin(Wire, gps_i2c_address) == true) {
|
||||
MESH_DEBUG_PRINTLN("RAK12500 GPS init correctly with pin %i", ioPin);
|
||||
ublox_GNSS.setI2COutput(COM_TYPE_UBX);
|
||||
ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_GPS);
|
||||
@@ -1028,24 +1364,17 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin) {
|
||||
ublox_GNSS.saveConfigSelective(VAL_CFG_SUBSEC_IOPORT);
|
||||
gpsResetPin = ioPin;
|
||||
i2cGPSFlag = true;
|
||||
gps_serial_transport = false;
|
||||
gps_active = true;
|
||||
gps_detected = true;
|
||||
|
||||
RAK12500_provider.setRTCClock(_location->getRTCClock());
|
||||
RAK12500_provider.reset();
|
||||
_location = &RAK12500_provider;
|
||||
return true;
|
||||
} else if (Serial1.available()) { // RAK12501 (L76K) on UART
|
||||
MESH_DEBUG_PRINTLN("Serial GPS init correctly and is turned on");
|
||||
gpsResetPin = ioPin;
|
||||
serialGPSFlag = true;
|
||||
gps_active = true;
|
||||
gps_detected = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifndef RAK_3401
|
||||
pinMode(ioPin, INPUT);
|
||||
#endif
|
||||
setRakGpsControl(ioPin, shared_power_rail, false);
|
||||
MESH_DEBUG_PRINTLN("GPS did not init with this IO pin... try the next");
|
||||
return false;
|
||||
}
|
||||
@@ -1059,7 +1388,15 @@ void EnvironmentSensorManager::armGpsPowerSavingCycle() {
|
||||
}
|
||||
|
||||
void EnvironmentSensorManager::start_gps() {
|
||||
if (gps_active) return;
|
||||
if (gps_active || gps_serial_transport_blocked) return;
|
||||
if (gps_serial_transport) {
|
||||
Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX);
|
||||
#ifdef GPS_BAUD_RATE
|
||||
Serial1.begin(GPS_BAUD_RATE);
|
||||
#else
|
||||
Serial1.begin(9600);
|
||||
#endif
|
||||
}
|
||||
gps_active = true;
|
||||
#ifdef RAK_WISBLOCK_GPS
|
||||
#ifdef FORCE_GPS_ALIVE
|
||||
@@ -1067,8 +1404,7 @@ void EnvironmentSensorManager::start_gps() {
|
||||
// an I2C u-blox receiver can also be explicitly returned to full power.
|
||||
if (i2cGPSFlag) ublox_GNSS.powerSaveMode(false);
|
||||
#else
|
||||
pinMode(gpsResetPin, OUTPUT);
|
||||
digitalWrite(gpsResetPin, HIGH); // WB_IO2 is the shared sensor power rail
|
||||
setRakGpsControl(gpsResetPin, false, true);
|
||||
#endif
|
||||
#else
|
||||
_location->begin();
|
||||
@@ -1095,12 +1431,15 @@ void EnvironmentSensorManager::stop_gps() {
|
||||
#ifdef RAK_WISBLOCK_GPS
|
||||
#ifdef FORCE_GPS_ALIVE
|
||||
// Keep the shared rail alive for the RAK3401 radio FEM. The I2C u-blox can
|
||||
// still enter its internal power-save mode. UART L76K builds reject GPS off
|
||||
// above because that module has no independent standby connection.
|
||||
if (i2cGPSFlag) ublox_GNSS.powerSaveMode(true);
|
||||
// still enter its internal power-save mode. Ending Serial1 stops the MCU
|
||||
// parser, but a UART L76K remains powered and continues driving its TX pin.
|
||||
if (i2cGPSFlag) {
|
||||
ublox_GNSS.powerSaveMode(true);
|
||||
} else if (serialGPSFlag) {
|
||||
Serial1.end();
|
||||
}
|
||||
#else
|
||||
pinMode(gpsResetPin, OUTPUT);
|
||||
digitalWrite(gpsResetPin, LOW); // WB_IO2
|
||||
setRakGpsControl(gpsResetPin, false, false);
|
||||
#endif
|
||||
#else
|
||||
_location->stop();
|
||||
@@ -1112,6 +1451,83 @@ void EnvironmentSensorManager::stop_gps() {
|
||||
}
|
||||
#endif // ENV_INCLUDE_GPS
|
||||
|
||||
bool EnvironmentSensorManager::gpsUsesSerialUart(uint8_t uart) const {
|
||||
#if ENV_INCLUDE_GPS
|
||||
return uart == 1 && gps_detected && gps_serial_transport;
|
||||
#else
|
||||
(void)uart;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EnvironmentSensorManager::gpsSerialTransportMayConflict(
|
||||
uint8_t uart) const {
|
||||
#if ENV_INCLUDE_GPS
|
||||
#if defined(RAK_WISBLOCK_GPS) \
|
||||
&& defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART)
|
||||
// Silence is not proof of physical absence: a cold RAK12501/L76K can start
|
||||
// emitting NMEA after the bounded boot probe. Reserve the declared shared
|
||||
// connector in the merged image. Exact legacy Serial1 bridge targets omit
|
||||
// GPS support and this reservation; the merged image retains UART2.
|
||||
if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) return true;
|
||||
#endif
|
||||
return gpsUsesSerialUart(uart);
|
||||
#else
|
||||
(void)uart;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EnvironmentSensorManager::gpsSerialTransportCanYield(uint8_t uart) const {
|
||||
#if ENV_INCLUDE_GPS
|
||||
if (!gpsUsesSerialUart(uart)) return false;
|
||||
#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)
|
||||
// RAK12501 exposes no independently controlled standby/power pin. WB_IO2 is
|
||||
// the shared 3V3_S rail and must remain high, so its L76K keeps transmitting
|
||||
// NMEA after Serial1.end(). Two TX drivers on UART1 would electrically
|
||||
// contend; require UART2 or physical removal/isolation of this GPS instead.
|
||||
return false;
|
||||
#elif defined(RAK_WISBLOCK_GPS)
|
||||
// This profile is allowed to remove power with its validated GPS control.
|
||||
return true;
|
||||
#endif
|
||||
// Generic NMEA receivers may share the same serial API but are safe to hand
|
||||
// off only when the provider has a real enable pin that stop() deasserts.
|
||||
return _location != NULL && _location->getPinEn() >= 0;
|
||||
#else
|
||||
(void)uart;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EnvironmentSensorManager::setGpsSerialTransportBlocked(uint8_t uart,
|
||||
bool blocked) {
|
||||
#if ENV_INCLUDE_GPS
|
||||
if (!gpsUsesSerialUart(uart)) return false;
|
||||
if (gps_serial_transport_blocked == blocked) return true;
|
||||
if (blocked && !gpsSerialTransportCanYield(uart)) return false;
|
||||
|
||||
if (blocked) {
|
||||
// Cancel remote-query acquisition/hold state before releasing the UART.
|
||||
setGpsTelemetryTransportAvailable(false);
|
||||
gps_serial_transport_blocked = true;
|
||||
if (gps_active) stop_gps();
|
||||
Serial1.end();
|
||||
} else {
|
||||
gps_serial_transport_blocked = false;
|
||||
// Restoring availability restarts the receiver only when the user's GPS
|
||||
// preference is on. Otherwise a future authorized location query may
|
||||
// acquire it on demand.
|
||||
setGpsTelemetryTransportAvailable(true);
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
(void)uart;
|
||||
(void)blocked;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC)
|
||||
void EnvironmentSensorManager::loop() {
|
||||
|
||||
|
||||
@@ -22,9 +22,12 @@ protected:
|
||||
|
||||
bool gps_detected = false;
|
||||
bool gps_active = false;
|
||||
bool gps_serial_transport = false;
|
||||
bool gps_serial_transport_blocked = false;
|
||||
|
||||
#if ENV_INCLUDE_GPS
|
||||
LocationProvider* _location;
|
||||
LocationProvider* _configured_location;
|
||||
void armGpsPowerSavingCycle();
|
||||
void start_gps();
|
||||
void stop_gps();
|
||||
@@ -35,13 +38,14 @@ protected:
|
||||
void initBasicGPS();
|
||||
#ifdef RAK_BOARD
|
||||
void rakGPSInit();
|
||||
bool gpsIsAwake(uint8_t ioPin);
|
||||
bool gpsIsAwake(uint8_t ioPin, bool shared_power_rail);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
public:
|
||||
#if ENV_INCLUDE_GPS
|
||||
EnvironmentSensorManager(LocationProvider &location): _location(&location){};
|
||||
EnvironmentSensorManager(LocationProvider &location):
|
||||
_location(&location), _configured_location(&location) {};
|
||||
LocationProvider* getLocationProvider() { return _location; }
|
||||
#else
|
||||
EnvironmentSensorManager(){};
|
||||
@@ -61,4 +65,8 @@ public:
|
||||
const char* getSettingValue(int i) const override;
|
||||
bool setSettingValue(const char* name, const char* value) override;
|
||||
void setPowerSavingEnabled(bool enabled) override;
|
||||
bool gpsUsesSerialUart(uint8_t uart) const override;
|
||||
bool gpsSerialTransportMayConflict(uint8_t uart) const override;
|
||||
bool gpsSerialTransportCanYield(uint8_t uart) const override;
|
||||
bool setGpsSerialTransportBlocked(uint8_t uart, bool blocked) override;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
enum class I2cIdentityProbeResult : uint8_t {
|
||||
NoResponse,
|
||||
Match,
|
||||
NotMatch,
|
||||
Inconclusive,
|
||||
};
|
||||
|
||||
enum class I2cRegisterProbeStatus : uint8_t {
|
||||
NoResponse,
|
||||
Success,
|
||||
Inconclusive,
|
||||
};
|
||||
|
||||
inline bool isValidI2cPeripheralAddress(uint32_t address) {
|
||||
// 0x00..0x07 and 0x78..0x7f are reserved in the 7-bit address space.
|
||||
return address >= 0x08 && address <= 0x77;
|
||||
}
|
||||
|
||||
inline bool isValidI2cPinPair(int32_t sda, int32_t scl,
|
||||
uint32_t pin_count) {
|
||||
return sda >= 0 && scl >= 0
|
||||
&& static_cast<uint32_t>(sda) < pin_count
|
||||
&& static_cast<uint32_t>(scl) < pin_count
|
||||
&& sda != scl;
|
||||
}
|
||||
|
||||
inline I2cIdentityProbeResult classifyIna3221Identity(
|
||||
I2cRegisterProbeStatus first_status, uint16_t first_value,
|
||||
I2cRegisterProbeStatus second_status, uint16_t second_value,
|
||||
uint16_t expected_manufacturer, uint16_t expected_die) {
|
||||
if (first_status == I2cRegisterProbeStatus::NoResponse) {
|
||||
return I2cIdentityProbeResult::NoResponse;
|
||||
}
|
||||
if (first_status != I2cRegisterProbeStatus::Success
|
||||
|| second_status != I2cRegisterProbeStatus::Success) {
|
||||
return I2cIdentityProbeResult::Inconclusive;
|
||||
}
|
||||
if (first_value == expected_manufacturer) {
|
||||
return second_value == expected_die
|
||||
? I2cIdentityProbeResult::Match
|
||||
: I2cIdentityProbeResult::Inconclusive;
|
||||
}
|
||||
return second_value != expected_manufacturer
|
||||
? I2cIdentityProbeResult::NotMatch
|
||||
: I2cIdentityProbeResult::Inconclusive;
|
||||
}
|
||||
|
||||
// Once a device-specific probe has positively identified the I2C GPS, do not
|
||||
// hand the same address to a table-driven sensor initializer. An ACK scan can
|
||||
// prove only that something answered; it cannot distinguish two devices wired
|
||||
// to the same address or make that electrical configuration safe.
|
||||
inline bool shouldSkipSensorAtClaimedGpsAddress(bool gps_claimed,
|
||||
bool same_bus,
|
||||
uint8_t sensor_address,
|
||||
uint8_t gps_address) {
|
||||
return gps_claimed && same_bus && sensor_address == gps_address;
|
||||
}
|
||||
|
||||
// A universal RAK image may support either an INA3221 or an I2C u-blox GPS at
|
||||
// the GPS address. Check the actual device there first, independent of where
|
||||
// the telemetry table expected an INA3221. An incomplete identity transaction
|
||||
// is not proof that the device is safe to receive u-blox traffic.
|
||||
inline bool shouldProbeI2cGps(I2cIdentityProbeResult identity) {
|
||||
// Absence/NACK can be transient while an INA powers up. Only a stable,
|
||||
// definitive non-INA result permits the device-specific u-blox exchange.
|
||||
return identity == I2cIdentityProbeResult::NotMatch;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Bounded, allocation-free proof that a UART stream contains a standard GPS
|
||||
// NMEA sentence. Merely seeing one byte is not enough: RS-232 traffic or line
|
||||
// noise must not be allowed to claim the GPS UART.
|
||||
class NmeaSentenceProbe {
|
||||
public:
|
||||
bool ingest(uint8_t byte) {
|
||||
if (_found) return true;
|
||||
|
||||
if (byte == '$') {
|
||||
resetSentence();
|
||||
_state = State::Body;
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (_state) {
|
||||
case State::Idle:
|
||||
return false;
|
||||
case State::Body:
|
||||
if (byte == '*') {
|
||||
if (_body_length < 6 || !_header_valid) {
|
||||
resetSentence();
|
||||
} else {
|
||||
_state = State::ChecksumHigh;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (byte < 0x20 || byte > 0x7e || _body_length >= kMaxBodyLength) {
|
||||
resetSentence();
|
||||
return false;
|
||||
}
|
||||
validateHeaderByte(byte);
|
||||
_checksum ^= byte;
|
||||
++_body_length;
|
||||
return false;
|
||||
case State::ChecksumHigh: {
|
||||
const int nibble = hexNibble(byte);
|
||||
if (nibble < 0) {
|
||||
resetSentence();
|
||||
} else {
|
||||
_expected_checksum = static_cast<uint8_t>(nibble << 4);
|
||||
_state = State::ChecksumLow;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case State::ChecksumLow: {
|
||||
const int nibble = hexNibble(byte);
|
||||
if (nibble >= 0) {
|
||||
_expected_checksum |= static_cast<uint8_t>(nibble);
|
||||
_found = _header_valid && recognizedTalker()
|
||||
&& _expected_checksum == _checksum;
|
||||
}
|
||||
resetSentence();
|
||||
return _found;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool found() const { return _found; }
|
||||
|
||||
private:
|
||||
enum class State : uint8_t { Idle, Body, ChecksumHigh, ChecksumLow };
|
||||
static constexpr size_t kMaxBodyLength = 79;
|
||||
|
||||
State _state = State::Idle;
|
||||
uint8_t _checksum = 0;
|
||||
uint8_t _expected_checksum = 0;
|
||||
size_t _body_length = 0;
|
||||
char _header[5] = {};
|
||||
bool _header_valid = true;
|
||||
bool _found = false;
|
||||
|
||||
static int hexNibble(uint8_t byte) {
|
||||
if (byte >= '0' && byte <= '9') return byte - '0';
|
||||
if (byte >= 'A' && byte <= 'F') return byte - 'A' + 10;
|
||||
if (byte >= 'a' && byte <= 'f') return byte - 'a' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void validateHeaderByte(uint8_t byte) {
|
||||
if (_body_length < 5) {
|
||||
if (byte < 'A' || byte > 'Z') _header_valid = false;
|
||||
_header[_body_length] = static_cast<char>(byte);
|
||||
} else if (_body_length == 5 && byte != ',') {
|
||||
_header_valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool recognizedTalker() const {
|
||||
if (_header[0] == 'G') {
|
||||
switch (_header[1]) {
|
||||
case 'A': // Galileo
|
||||
case 'B': // BeiDou
|
||||
case 'L': // GLONASS
|
||||
case 'N': // combined GNSS
|
||||
case 'P': // GPS
|
||||
case 'Q': // QZSS
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return _header[0] == 'B' && _header[1] == 'D';
|
||||
}
|
||||
|
||||
void resetSentence() {
|
||||
_state = State::Idle;
|
||||
_checksum = 0;
|
||||
_expected_checksum = 0;
|
||||
_body_length = 0;
|
||||
_header_valid = true;
|
||||
for (char& byte : _header) byte = 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ui {
|
||||
|
||||
// Draw complete text across a bounded number of rows. Lines may break in the
|
||||
// middle of a word (which is important for SSIDs, where every space is
|
||||
// significant), but never in the middle of a UTF-8 codepoint. If the text
|
||||
// cannot fit in max_lines, only the final row is ellipsized.
|
||||
inline int drawTextWrapped(DisplayDriver& display, int x, int y, int max_width,
|
||||
int line_height, int max_lines, const char* str) {
|
||||
if (str == nullptr || str[0] == 0 || max_width <= 0
|
||||
|| line_height <= 0 || max_lines <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int lines = 0;
|
||||
size_t offset = 0;
|
||||
while (str[offset] != 0 && lines < max_lines) {
|
||||
// A display row is necessarily much shorter than this, but retain the
|
||||
// same generous bound used by DisplayDriver::drawTextEllipsized().
|
||||
char row[256];
|
||||
size_t row_length = 0;
|
||||
|
||||
while (str[offset + row_length] != 0) {
|
||||
size_t codepoint_length = 1;
|
||||
const uint8_t first = (uint8_t)str[offset + row_length];
|
||||
if (first >= 0xC2 && first <= 0xDF) {
|
||||
codepoint_length = 2;
|
||||
} else if (first >= 0xE0 && first <= 0xEF) {
|
||||
codepoint_length = 3;
|
||||
} else if (first >= 0xF0 && first <= 0xF4) {
|
||||
codepoint_length = 4;
|
||||
}
|
||||
for (size_t i = 1; i < codepoint_length; ++i) {
|
||||
if (str[offset + row_length + i] == 0
|
||||
|| ((uint8_t)str[offset + row_length + i] & 0xC0) != 0x80) {
|
||||
codepoint_length = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const size_t candidate_length = row_length + codepoint_length;
|
||||
if (candidate_length >= sizeof(row)) break;
|
||||
memcpy(row, str + offset, candidate_length);
|
||||
row[candidate_length] = 0;
|
||||
if (display.getTextWidth(row) > max_width) break;
|
||||
row_length = candidate_length;
|
||||
}
|
||||
|
||||
// Ensure forward progress even for a glyph wider than the viewport.
|
||||
if (row_length == 0) {
|
||||
row_length = 1;
|
||||
while (str[offset + row_length] != 0
|
||||
&& ((uint8_t)str[offset + row_length] & 0xC0) == 0x80
|
||||
&& row_length + 1 < sizeof(row)) {
|
||||
++row_length;
|
||||
}
|
||||
}
|
||||
|
||||
if (lines + 1 == max_lines && str[offset + row_length] != 0) {
|
||||
display.drawTextEllipsized(x, y + lines * line_height, max_width,
|
||||
str + offset);
|
||||
++lines;
|
||||
break;
|
||||
}
|
||||
|
||||
memcpy(row, str + offset, row_length);
|
||||
row[row_length] = 0;
|
||||
display.setCursor(x, y + lines * line_height);
|
||||
display.print(row);
|
||||
offset += row_length;
|
||||
++lines;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace mesh
|
||||
@@ -14,8 +14,13 @@ pio test -e native # all suites except KISS modem
|
||||
pio test -e native_kiss_modem # KISS modem suite
|
||||
pio test -e native -f test_webconfig_keys # a single suite
|
||||
python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract
|
||||
python3 test/test_indicator_font_recovery.py # Indicator TLS/SD font recovery contract
|
||||
python3 test/test_companion_terminal_profile.py # Companion CLI capability gates
|
||||
python3 test/test_client_login_profile_contract.py # ACL login ordering/role contract
|
||||
python3 test/test_esp32_full_partition.py # Full partition-preservation policy
|
||||
python3 test/test_esp32_usb_serial_hygiene.py # Single-TTY diagnostics/NVS contract
|
||||
python3 test/test_temp_radio_reply_delivery_contract.py # TempRadio ACK path/barrier integration
|
||||
python3 test/test_tls_download_clock_gates.py # Fresh-NTP/TLS download integration contract
|
||||
```
|
||||
|
||||
A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions
|
||||
@@ -44,6 +49,7 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
|
||||
| `test_mqtt_prefs_json_import` | `src/helpers/MQTTPrefsJsonImport.h`, `src/helpers/MQTTPrefsSerializer.h` | one-time observer `/mqtt.json` v1 import grammar, strict schema/repair rules, binary-first precedence, future/artifact preservation, display-tail encoding, and atomic commit failure routing |
|
||||
| `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | portrait logical-to-physical mapping, span coverage, fitted-width conversion, text scaling, and visible-frame signatures |
|
||||
| `test_indicator_font_stage_v2_protocol` | `src/helpers/IndicatorFontStageV2Protocol.h` | fail-closed STAGEV2 negotiation and legacy fallback; exact cumulative ACK parsing; 512-byte block boundaries, short final blocks, and real font-asset transfer geometry |
|
||||
| `test_radio_activity_window` | `src/helpers/RadioActivityWindow.h` | rolling minute buckets, derived rates, expiry, warm-up, silence, saturation, and `millis()` rollover |
|
||||
| `test_observer_dashboard` | `src/helpers/ui/ObserverDashboard.h` | landscape and portrait dashboard layout bounds, compact formatting, graph scaling, row signatures, and partial repaint policy |
|
||||
| `test_touch_tap_detector` | `src/helpers/ui/TouchTapDetector.h` | debounced one-shot taps, bounce and long-press handling, minimum gaps, reset, and `millis()` rollover |
|
||||
@@ -55,6 +61,8 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_cli_command_utils` | `src/helpers/CLICommandUtils.h`, `src/helpers/ContactListOrder.h`, `src/helpers/TerminalCommandTracker.h`, `src/helpers/TerminalDisplayFilter.h`, `src/helpers/WiFiChannelPolicy.h`, `src/helpers/bridges/ESPNowBridgeFormat.h` | terminal verb/argument/path parsing; routed receive labels; quiet display defaults and independent emergency filtering; favorite-first contact ordering; standalone WiFi validation including 64-hex WPA/WPA2 PSKs; strict ESP-NOW channel and bridge-format parsing/range/fallback; single-command reply matching, round-trip timing, and rollover-safe expiration |
|
||||
| `test_espnow_raw_fragmentation` | `src/helpers/ESPNowRawFragmentation.h` | byte-exact legacy raw frames; 251-255-byte two-frame encoding; CRC-32 integrity; source-MAC-keyed bounded reassembly; malformed, duplicate, out-of-order, timeout, rollover, and capacity handling |
|
||||
| `test_gps_time_validation` | `src/helpers/sensors/GpsTimeValidation.h` | complete NMEA UTC validation; leap years and calendar bounds; rejection of receiver-default and signed-32-bit-out-of-range dates |
|
||||
| `test_i2c_address_claim_policy` | `src/helpers/sensors/I2CAddressClaimPolicy.h`, `src/helpers/sensors/NmeaSentenceProbe.h` | a positively identified I2C GPS owns only its address on its bus; an INA3221 identity match blocks conflicting u-blox writes; UART GPS detection requires a complete checksum-valid GPS NMEA sentence |
|
||||
| `test_gps_transport_ownership` | `src/helpers/SensorManager.cpp` | temporary bridge ownership cancels GPS acquisition/holds, preserves user preference/cache, and restores GPS only after the UART is released |
|
||||
| `test_identity_generation` | `src/helpers/IdentityGeneration.h` | reserved-prefix rejection; bounded retries; final provisioned attempt; fail-closed exhaustion |
|
||||
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h`, `src/helpers/RemoteCliTimeout.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; 300% response timeout; empty-response completion; on-air truncation and clearing |
|
||||
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h`, `src/helpers/CompanionHardwareCommandCompat.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing; command `0x42` framed-CLI disambiguation and deprecated hardware-alias mapping |
|
||||
@@ -64,6 +72,11 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion |
|
||||
| `test_ble_mota_control` | `src/helpers/BleMotaStream.h`, `CompanionMotaControl.h` | encrypted mOTA channel ring buffering, overflow fail-closed behavior, request gating, and strict rejection of injected or USB-ownership control commands |
|
||||
| `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup |
|
||||
| `test_client_login_persistence` | `src/helpers/ClientLoginPersistence.h`, `LazyPersistence.h` | pre-allocation durable replay reservations; reboot, ACL-eviction, and revoked-admin tombstones; bounded-store policy (low-trust no-insert, privileged fail-closed); atomic replay-file recovery, including post-commit cleanup failure; preauthorized transient refresh; admin/guest promotion and downgrade; force-flood preservation; role masking and fixed-size secret copy |
|
||||
| `test_client_path_persistence` | `src/helpers/ClientPathPersistence.h`, `LazyPersistence.h` | encoded path identity and byte lengths; unknown, zero-hop, force-flood, and nonpersistent clients; replay-unqualified RAM-only routes preserve an earlier operator route across unrelated save/reload |
|
||||
| `test_temp_radio_reply_barrier` | `src/helpers/TempRadioReplyBarrier.h`, `TempRadioLeaseDeadline.h` | exact single-copy queued reply completion/failure handoff; suppression of untracked alternate/retry copies; rejection of foreign callbacks; cancellation; monotonic hard expiry that a backward wall-clock correction cannot extend; bounded remaining-time reporting |
|
||||
| `test_lazy_persistence` | `src/helpers/LazyPersistence.h` | first-write scheduling without postponement, zero-sentinel rollover preservation, capped exponential save-failure backoff that mutations cannot defeat, and reset only after success |
|
||||
| `test_client_acl_file_transaction` | `src/helpers/ClientACLFileTransaction.h` | verified temp publication, preservation of the prior ACL on verification/rename failure, post-commit cleanup-failure handling, and recovery at every temp/backup/primary boundary |
|
||||
| `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation |
|
||||
| `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, and FEM preferences; one-time migration of the regressed power-saving default |
|
||||
| `test_config_serializer` | `src/helpers/ConfigSerializer.cpp`, Companion `NodePrefs` | escaped config save/load, whitespace and malformed input, unknown fields, and FEM/ESP-NOW bridge-format preference round trips |
|
||||
@@ -88,6 +101,7 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing |
|
||||
| `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 |
|
||||
| `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting |
|
||||
| `test_tls_clock_validity` | `src/helpers/esp32/TlsClockValidity.h` | signed wall-clock minimum and the fresh-proof/WiFi/time conjunction required before certificate-validating downloads |
|
||||
| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO, generic internal, and exact MeshTower V2 SD embedded identity, vector, capability, explicit-confirmation, codec-isolation, scratch-headroom/shared-slot no-EndF gates, and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
|
||||
| `test_trace_retry` | `src/Mesh.cpp`, `RTCClock`, `ClockSyncUtils.h`, retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing; backward RTC correction; clock consensus/path policy and the 10-minute default drift threshold |
|
||||
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
class CayenneLPP {
|
||||
public:
|
||||
explicit CayenneLPP(size_t) {}
|
||||
void addGPS(uint8_t, float latitude, float longitude, float altitude) {
|
||||
gps_count++;
|
||||
last_latitude = latitude;
|
||||
last_longitude = longitude;
|
||||
last_altitude = altitude;
|
||||
}
|
||||
const uint8_t* getBuffer() const { return nullptr; }
|
||||
uint16_t getSize() const { return 0; }
|
||||
|
||||
uint16_t gps_count = 0;
|
||||
float last_latitude = 0;
|
||||
float last_longitude = 0;
|
||||
float last_altitude = 0;
|
||||
};
|
||||
@@ -26,6 +26,9 @@ TEST(BleMotaControl, AllowsOnlyBoundedOtaSessionCommands) {
|
||||
EXPECT_FALSE(allowed("otafolder status"));
|
||||
EXPECT_FALSE(allowed("ota folder on"));
|
||||
EXPECT_FALSE(allowed("ota folder off"));
|
||||
// Sharing local command routing with the text/rescue CLI must not expand
|
||||
// the deliberately narrow Bluetooth mOTA control allowlist.
|
||||
EXPECT_FALSE(allowed("set pin 123456"));
|
||||
EXPECT_FALSE(allowed("reboot"));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,30 @@ fail() {
|
||||
[ "$OPTION3_BUILD_WORKERS" -eq 1 ] \
|
||||
|| fail "logging matrix permits concurrent PlatformIO target builds"
|
||||
|
||||
# Reduced RAK profiles all retain the compact INA set, but GPS depends on
|
||||
# whether the bridge already owns Serial1. Keep those independent contracts so
|
||||
# a valid Serial1 image cannot fail release qualification for an absent GPS
|
||||
# marker, and so its manifest never advertises impossible hardware support.
|
||||
for rak_target in \
|
||||
RAK_3401_repeater_lora_ota_no_external_sensors \
|
||||
RAK_4631_repeater_lora_ota_no_external_sensors \
|
||||
RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors \
|
||||
RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors; do
|
||||
is_rak_i2c_voltage_monitor_ota_target "$rak_target" \
|
||||
|| fail "$rak_target lost the retained INA contract"
|
||||
done
|
||||
for rak_target in \
|
||||
RAK_3401_repeater_lora_ota_no_external_sensors \
|
||||
RAK_4631_repeater_lora_ota_no_external_sensors \
|
||||
RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors; do
|
||||
is_rak_gps_retaining_ota_target "$rak_target" \
|
||||
|| fail "$rak_target lost its compatible GPS contract"
|
||||
done
|
||||
if is_rak_gps_retaining_ota_target \
|
||||
RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors; then
|
||||
fail "RAK4631 Serial1 RS-232 target incorrectly promises GPS"
|
||||
fi
|
||||
|
||||
# The expanded-profile marker applied below must select a real, nonzero
|
||||
# boot-local setup window. Keep the build overlay, WebConfig compile-time
|
||||
# mapping, and pure timing policy tied together.
|
||||
@@ -351,6 +375,8 @@ for full_env in "${SUPPORTED_PIO_ENVS[@]}"; do
|
||||
[ "${MESHCORE_ESP32_FULL_PARTITION_TABLE:-}" \
|
||||
= "variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv" ] \
|
||||
|| fail "$full_env omitted the Indicator preserve-SPIFFS table"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *"INDICATOR_WIFI_FONT_RECOVERY=1"* ]] \
|
||||
|| fail "$full_env omitted WiFi font recovery"
|
||||
;;
|
||||
*)
|
||||
[ -z "${MESHCORE_ESP32_FULL_PARTITION_TABLE:-}" ] \
|
||||
@@ -551,6 +577,60 @@ fi
|
||||
uses_merged_standard_usb_logging esp_repeater \
|
||||
|| fail "standard ESP32 target omitted logging because FULL also exists"
|
||||
|
||||
# A standard ESP32 field image may omit LoRa OTA for size, but it must never
|
||||
# also omit the compact browser updater. This is the exact profile combination
|
||||
# that previously produced Heltec V4 repeaters where both `ota` and `start ota`
|
||||
# were dead ends.
|
||||
verify_esp32_field_browser_ota() {
|
||||
local env_name=$1
|
||||
local PLATFORMIO_BUILD_FLAGS=""
|
||||
local PLATFORMIO_BUILD_UNFLAGS=""
|
||||
local BUILD_PROFILE_FOR_TARGET=standard
|
||||
local ESP32_FULL_BUILD=0
|
||||
local -a BUILD_CAPABILITIES=()
|
||||
local -a BUILD_REDUCTIONS=()
|
||||
local -a BUILD_EXPECTATIONS=()
|
||||
local capabilities reductions expectations
|
||||
|
||||
PIO_ENV_PLATFORM_BY_NAME[$env_name]=ESP32_PLATFORM
|
||||
PIO_ENV_OTA_BY_NAME[$env_name]=0
|
||||
apply_esp32_lora_ota_size_profile "$env_name"
|
||||
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *-DLIGHTWEIGHT_WIFI_OTA=1* ]] \
|
||||
|| fail "$env_name omitted its browser OTA fail-safe"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *-UDISABLE_WIFI_OTA* ]] \
|
||||
|| fail "$env_name did not remove a board-level WiFi OTA disable"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" != *-DDISABLE_WIFI_OTA=1* ]] \
|
||||
|| fail "$env_name still explicitly disables browser OTA"
|
||||
capabilities=" ${BUILD_CAPABILITIES[*]} "
|
||||
[[ "$capabilities" == *" web.lightweight_browser_ota "* ]] \
|
||||
|| fail "$env_name did not declare browser OTA"
|
||||
reductions=" ${BUILD_REDUCTIONS[*]} "
|
||||
[[ "$reductions" != *"web.browser_ota omitted"* ]] \
|
||||
|| fail "$env_name still records browser OTA as omitted"
|
||||
|
||||
declare_build_capability_contract "$env_name" ESP32_PLATFORM
|
||||
expectations=" ${BUILD_EXPECTATIONS[*]} "
|
||||
[[ "$expectations" == *"web.lightweight_browser_ota=MeshCore firmware update"* ]] \
|
||||
|| fail "$env_name does not verify browser OTA in the linked image"
|
||||
}
|
||||
|
||||
verify_esp32_field_browser_ota heltec_v4_repeater
|
||||
verify_esp32_field_browser_ota heltec_v4_room_server
|
||||
verify_esp32_field_browser_ota heltec_v4_sensor
|
||||
|
||||
# Personal/attached roles can retain their established transport policy; the
|
||||
# fail-safe is deliberately scoped to remotely installed field/server images.
|
||||
PIO_ENV_PLATFORM_BY_NAME[heltec_v4_terminal_chat]=ESP32_PLATFORM
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
PLATFORMIO_BUILD_UNFLAGS=""
|
||||
BUILD_PROFILE_FOR_TARGET=standard
|
||||
BUILD_CAPABILITIES=()
|
||||
BUILD_REDUCTIONS=()
|
||||
apply_esp32_lora_ota_size_profile heltec_v4_terminal_chat
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *-DDISABLE_WIFI_OTA=1* ]] \
|
||||
|| fail "attached terminal unexpectedly changed its WiFi policy"
|
||||
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
MESHDEBUG_OVERRIDE=""
|
||||
PACKET_LOGGING_OVERRIDE=""
|
||||
@@ -816,6 +896,17 @@ expectations=" ${BUILD_EXPECTATIONS[*]} "
|
||||
[[ "$expectations" != *"Full Companion terminal listening"* ]] \
|
||||
|| fail "ESP32 Full contract still depends on optional debug logging"
|
||||
|
||||
BUILD_CAPABILITIES=()
|
||||
BUILD_REDUCTIONS=()
|
||||
BUILD_EXPECTATIONS=()
|
||||
declare_build_capability_contract \
|
||||
SenseCapIndicator-LoRa_companion_radio_full ESP32_PLATFORM
|
||||
expectations=" ${BUILD_EXPECTATIONS[*]} "
|
||||
[[ "$expectations" == *"indicator.font_recovery_ntp_gate=requesting fresh NTP time before download"* ]] \
|
||||
|| fail "Indicator Full contract omitted the fresh-NTP font-download gate"
|
||||
[[ "$expectations" == *"indicator.font_recovery_tls=opening TLS connection to"* ]] \
|
||||
|| fail "Indicator Full contract omitted the TLS font-recovery marker"
|
||||
|
||||
pio_env_option_contains() {
|
||||
[ "$1" = Heltec_v3_companion_radio_full ] \
|
||||
&& [ "$2" = build_flags ] \
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for serial-zero roots in the ESP32 CA bundle."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
import warnings
|
||||
|
||||
from cryptography.utils import CryptographyDeprecationWarning
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "generate_cert_bundle.py"
|
||||
|
||||
NON_POSITIVE_SERIAL_WARNING = (
|
||||
"Parsed a serial number which wasn't positive (i.e., it was negative or zero), "
|
||||
"which is disallowed by RFC 5280. Loading this certificate will cause an "
|
||||
"exception in a future release of cryptography."
|
||||
)
|
||||
|
||||
# Go Daddy Root Certificate Authority - G2, one of the serial-zero roots in
|
||||
# Mozilla and Adafruit trust bundles.
|
||||
SERIAL_ZERO_ROOT = """-----BEGIN CERTIFICATE-----
|
||||
MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx
|
||||
EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT
|
||||
EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp
|
||||
ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz
|
||||
NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH
|
||||
EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE
|
||||
AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw
|
||||
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD
|
||||
E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH
|
||||
/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy
|
||||
DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh
|
||||
GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR
|
||||
tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA
|
||||
AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE
|
||||
FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX
|
||||
WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu
|
||||
9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr
|
||||
gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo
|
||||
2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO
|
||||
LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
|
||||
4uJEvlz36hz1
|
||||
-----END CERTIFICATE-----
|
||||
"""
|
||||
|
||||
|
||||
class FakeEnvironment:
|
||||
def subst(self, value):
|
||||
if value != "$PIOENV":
|
||||
raise AssertionError(f"unexpected substitution: {value}")
|
||||
return "certificate_filter_test"
|
||||
|
||||
def GetProjectOption(self, name):
|
||||
if name != "board_ssl_cert_source":
|
||||
raise AssertionError(f"unexpected project option: {name}")
|
||||
# An unrecognized source skips the download path. The fixture is then
|
||||
# loaded through the script's supported extra-certificate path.
|
||||
return "fixture-only"
|
||||
|
||||
def Execute(self, _command):
|
||||
raise AssertionError("test requires cryptography to be preinstalled")
|
||||
|
||||
|
||||
class CertificateBundleWarningFilterTest(unittest.TestCase):
|
||||
def test_serial_zero_root_is_retained_and_filter_is_narrow(self):
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
temporary = Path(temporary_directory)
|
||||
certificates = temporary / "ssl_certs"
|
||||
certificates.mkdir()
|
||||
(certificates / "serial_zero.pem").write_text(
|
||||
SERIAL_ZERO_ROOT, encoding="utf-8"
|
||||
)
|
||||
|
||||
previous_directory = Path.cwd()
|
||||
stderr = io.StringIO()
|
||||
try:
|
||||
os.chdir(temporary)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
runpy.run_path(
|
||||
str(SCRIPT),
|
||||
init_globals={
|
||||
"Import": lambda _name: None,
|
||||
"env": FakeEnvironment(),
|
||||
},
|
||||
)
|
||||
|
||||
warnings.warn(
|
||||
NON_POSITIVE_SERIAL_WARNING,
|
||||
CryptographyDeprecationWarning,
|
||||
)
|
||||
warnings.warn(
|
||||
"unrelated cryptography deprecation",
|
||||
CryptographyDeprecationWarning,
|
||||
)
|
||||
finally:
|
||||
os.chdir(previous_directory)
|
||||
|
||||
self.assertEqual(len(caught), 1)
|
||||
self.assertEqual(
|
||||
str(caught[0].message), "unrelated cryptography deprecation"
|
||||
)
|
||||
self.assertIs(
|
||||
caught[0].category, CryptographyDeprecationWarning
|
||||
)
|
||||
|
||||
output = temporary / "src" / "certs" / "x509_crt_bundle.bin"
|
||||
bundle = output.read_bytes()
|
||||
self.assertEqual(struct.unpack(">H", bundle[:2])[0], 1)
|
||||
self.assertGreater(len(bundle), 256)
|
||||
self.assertIn(
|
||||
"Successfully added 1 certificates in total",
|
||||
stderr.getvalue(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,295 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
|
||||
#include <helpers/ClientACLFileTransaction.h>
|
||||
#include <helpers/ClientACLFileIntegrity.h>
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeFilesystem {
|
||||
public:
|
||||
std::set<std::string> files;
|
||||
std::set<std::string> valid_files;
|
||||
std::map<std::string, std::vector<uint8_t> > images;
|
||||
std::string fail_from;
|
||||
std::string fail_to;
|
||||
std::string fail_remove;
|
||||
|
||||
bool exists(const char* path) const {
|
||||
return files.count(path) != 0;
|
||||
}
|
||||
|
||||
bool remove(const char* path) {
|
||||
if (fail_remove == path) return false;
|
||||
images.erase(path);
|
||||
valid_files.erase(path);
|
||||
return files.erase(path) != 0;
|
||||
}
|
||||
|
||||
bool rename(const char* from, const char* to) {
|
||||
if (fail_from == from && fail_to == to) return false;
|
||||
if (!exists(from) || exists(to)) return false;
|
||||
files.erase(from);
|
||||
files.insert(to);
|
||||
if (valid_files.erase(from) != 0) valid_files.insert(to);
|
||||
std::map<std::string, std::vector<uint8_t> >::iterator image =
|
||||
images.find(from);
|
||||
if (image != images.end()) {
|
||||
images[to] = image->second;
|
||||
images.erase(image);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
bool isValid(FakeFilesystem* fs, const char* path) {
|
||||
return fs->valid_files.count(path) != 0;
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, FailedVerificationPreservesPrimary) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH};
|
||||
|
||||
EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, false));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, VerifiedTempPublishesThroughBackup) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH};
|
||||
|
||||
EXPECT_TRUE(mesh::publishVerifiedClientACLTemp(&fs, true));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, PrimaryRenameFailureLeavesOldImage) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH};
|
||||
fs.fail_from = mesh::CLIENT_ACL_PRIMARY_PATH;
|
||||
fs.fail_to = mesh::CLIENT_ACL_BACKUP_PATH;
|
||||
|
||||
EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, true));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, PublishFailureRestoresBackup) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH};
|
||||
fs.fail_from = mesh::CLIENT_ACL_TEMP_PATH;
|
||||
fs.fail_to = mesh::CLIENT_ACL_PRIMARY_PATH;
|
||||
|
||||
EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, true));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, BootRecoveryKeepsPublishedPrimary) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH,
|
||||
mesh::CLIENT_ACL_TEMP_PATH,
|
||||
mesh::CLIENT_ACL_BACKUP_PATH,
|
||||
};
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientACLFiles(&fs));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, BootRecoveryRestoresInterruptedPublish) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {mesh::CLIENT_ACL_TEMP_PATH, mesh::CLIENT_ACL_BACKUP_PATH};
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientACLFiles(&fs));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, CorruptPrimaryFallsBackToValidBackup) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_BACKUP_PATH};
|
||||
fs.valid_files = {mesh::CLIENT_ACL_BACKUP_PATH};
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, isValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.valid_files.count(mesh::CLIENT_ACL_PRIMARY_PATH) != 0);
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, CorruptPublishedPrimaryRestoresOldImage) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH};
|
||||
fs.valid_files = {mesh::CLIENT_ACL_PRIMARY_PATH};
|
||||
|
||||
EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, true, isValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.valid_files.count(mesh::CLIENT_ACL_PRIMARY_PATH) != 0);
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, PublishedPrimarySurvivesBackupCleanupFailure) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH};
|
||||
fs.valid_files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH};
|
||||
fs.fail_remove = mesh::CLIENT_ACL_BACKUP_PATH;
|
||||
|
||||
EXPECT_TRUE(mesh::publishVerifiedClientACLTemp(&fs, true, isValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.valid_files.count(mesh::CLIENT_ACL_PRIMARY_PATH) != 0);
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
|
||||
// A later recovery pass can finish the best-effort cleanup without
|
||||
// changing which image is authoritative.
|
||||
fs.fail_remove.clear();
|
||||
EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, isValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, ValidPrimaryLoadsWhenCleanupCannotFinish) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH,
|
||||
mesh::CLIENT_ACL_TEMP_PATH,
|
||||
mesh::CLIENT_ACL_BACKUP_PATH,
|
||||
};
|
||||
fs.valid_files = {mesh::CLIENT_ACL_PRIMARY_PATH};
|
||||
fs.fail_remove = mesh::CLIENT_ACL_BACKUP_PATH;
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, isValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
}
|
||||
|
||||
std::vector<uint8_t> crcImage(size_t records) {
|
||||
std::vector<uint8_t> image(records * 201, 0x5A);
|
||||
uint32_t crc = mesh::updateClientACLCRC(
|
||||
0xFFFFFFFFUL, image.data(), image.size()) ^ 0xFFFFFFFFUL;
|
||||
image.insert(image.end(), mesh::CLIENT_ACL_CRC_MAGIC,
|
||||
mesh::CLIENT_ACL_CRC_MAGIC + 4);
|
||||
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(&crc);
|
||||
image.insert(image.end(), bytes, bytes + sizeof(crc));
|
||||
return image;
|
||||
}
|
||||
|
||||
bool realImageIsValid(FakeFilesystem* fs, const char* path) {
|
||||
std::map<std::string, std::vector<uint8_t> >::const_iterator found =
|
||||
fs->images.find(path);
|
||||
if (found == fs->images.end()) return false;
|
||||
const mesh::ClientACLImageKind kind = mesh::classifyClientACLImage(
|
||||
found->second.data(), found->second.size(), 201, 136);
|
||||
const bool publication_in_progress = std::string(path)
|
||||
== mesh::CLIENT_ACL_PRIMARY_PATH
|
||||
&& fs->exists(mesh::CLIENT_ACL_BACKUP_PATH);
|
||||
return publication_in_progress ? kind == mesh::CLIENT_ACL_IMAGE_CRC
|
||||
: kind != mesh::CLIENT_ACL_IMAGE_INVALID;
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, RealCRCClassifierRejectsTornTrailer) {
|
||||
std::vector<uint8_t> image = crcImage(2);
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_CRC);
|
||||
|
||||
image.resize(image.size() - 8); // exactly 201*N: ambiguous standalone legacy
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_LEGACY);
|
||||
// Recovery resolves this ambiguity from transaction state: if a backup is
|
||||
// present, only CRC kind is acceptable for the newly published primary.
|
||||
EXPECT_TRUE(mesh::classifyClientACLImage(
|
||||
image.data(), image.size(), 201, 136) != mesh::CLIENT_ACL_IMAGE_CRC);
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, RealClassifierCoversRecordBoundaryTruncations) {
|
||||
std::vector<uint8_t> image = crcImage(4);
|
||||
image.resize(3 * 201);
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_LEGACY);
|
||||
|
||||
image.resize(2 * 136);
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_LEGACY);
|
||||
|
||||
image.resize(2 * 136 - 1);
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_INVALID);
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, TornTrailerRestoresRealValidatedBackup) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_BACKUP_PATH};
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH] = crcImage(2);
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].resize(2 * 201);
|
||||
fs.images[mesh::CLIENT_ACL_BACKUP_PATH] = std::vector<uint8_t>(136, 0x33);
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, realImageIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
EXPECT_EQ(fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 136u);
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, TornTrailerRestoresCRCValidatedBackup) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_BACKUP_PATH};
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH] = crcImage(2);
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].resize(2 * 201);
|
||||
fs.images[mesh::CLIENT_ACL_BACKUP_PATH] = crcImage(1);
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, realImageIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].data(),
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_CRC);
|
||||
}
|
||||
|
||||
TEST(ClientACLFileTransaction, StandaloneNoCRCLayoutsMigrateOnNextPublish) {
|
||||
const size_t standalone_sizes[] = {136, 201};
|
||||
for (size_t standalone_size : standalone_sizes) {
|
||||
FakeFilesystem fs;
|
||||
fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH};
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH] =
|
||||
std::vector<uint8_t>(standalone_size, 0x33);
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, realImageIsValid));
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].data(),
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_LEGACY);
|
||||
|
||||
fs.files.insert(mesh::CLIENT_ACL_TEMP_PATH);
|
||||
fs.images[mesh::CLIENT_ACL_TEMP_PATH] = crcImage(1);
|
||||
EXPECT_TRUE(mesh::publishVerifiedClientACLTemp(
|
||||
&fs, true, realImageIsValid));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH));
|
||||
EXPECT_EQ(mesh::classifyClientACLImage(
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].data(),
|
||||
fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 201, 136),
|
||||
mesh::CLIENT_ACL_IMAGE_CRC);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <helpers/ClientLoginPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
|
||||
namespace {
|
||||
|
||||
static constexpr uint8_t ROLE_MASK = 0x07;
|
||||
static constexpr uint8_t ROLE_GUEST = 0x00;
|
||||
static constexpr uint8_t ROLE_READ_ONLY = 0x01;
|
||||
static constexpr uint8_t ROLE_READ_WRITE = 0x02;
|
||||
static constexpr uint8_t ROLE_ADMIN = 0x03;
|
||||
static constexpr uint8_t UNKNOWN_PATH = 0xFF;
|
||||
static constexpr uint8_t FORCE_FLOOD_PATH = 0xFE;
|
||||
|
||||
struct ClientState {
|
||||
uint8_t permissions = ROLE_ADMIN;
|
||||
uint8_t out_path_len = UNKNOWN_PATH;
|
||||
bool out_path_is_persistable = true;
|
||||
uint8_t shared_secret[4] = {1, 2, 3, 4};
|
||||
uint32_t last_timestamp = 10;
|
||||
uint32_t last_activity = 20;
|
||||
};
|
||||
|
||||
bool applyLogin(ClientState& client, bool existed, const uint8_t* secret,
|
||||
uint8_t role = ROLE_ADMIN, bool is_flood = false) {
|
||||
return mesh::applySuccessfulClientLogin(
|
||||
client, existed, role, ROLE_MASK, secret,
|
||||
100, 200, is_flood, UNKNOWN_PATH);
|
||||
}
|
||||
|
||||
bool loginNeedsSave(bool existed, uint8_t previous, uint8_t next,
|
||||
bool changed) {
|
||||
return mesh::successfulClientLoginNeedsPersistence(
|
||||
existed, previous, next, ROLE_MASK, 0, changed);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, UnchangedAdminLoginRefreshesOnlyTransientState) {
|
||||
ClientState client;
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
|
||||
EXPECT_FALSE(applyLogin(client, true, same_secret));
|
||||
EXPECT_EQ(client.last_timestamp, 100u);
|
||||
EXPECT_EQ(client.last_activity, 200u);
|
||||
EXPECT_EQ(client.permissions, ROLE_ADMIN);
|
||||
EXPECT_EQ(client.out_path_len, UNKNOWN_PATH);
|
||||
EXPECT_EQ(memcmp(client.shared_secret, same_secret, 4), 0);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, NewAdminNeedsPersistenceAndArmsFirstWrite) {
|
||||
ClientState client;
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
unsigned long pending = 0;
|
||||
|
||||
const bool changed = applyLogin(client, false, same_secret);
|
||||
EXPECT_TRUE(changed);
|
||||
EXPECT_TRUE(loginNeedsSave(false, 0, ROLE_ADMIN, changed));
|
||||
EXPECT_TRUE(mesh::armFirstLazyPersistence(
|
||||
pending, 5000,
|
||||
loginNeedsSave(false, 0, ROLE_ADMIN, changed)));
|
||||
EXPECT_EQ(pending, 5000UL);
|
||||
EXPECT_EQ(client.last_timestamp, 100u);
|
||||
EXPECT_EQ(client.last_activity, 200u);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, NewGuestStaysRuntimeOnly) {
|
||||
ClientState client;
|
||||
client.permissions = 0;
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
unsigned long pending = 0;
|
||||
|
||||
const bool changed = applyLogin(client, false, same_secret, 0);
|
||||
EXPECT_TRUE(changed);
|
||||
EXPECT_EQ(client.permissions & ROLE_MASK, 0);
|
||||
EXPECT_FALSE(loginNeedsSave(false, 0, 0, changed));
|
||||
EXPECT_FALSE(mesh::armFirstLazyPersistence(
|
||||
pending, 5000, loginNeedsSave(false, 0, 0, changed)));
|
||||
EXPECT_EQ(pending, 0UL);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, GuestPromotionToAdminNeedsPersistence) {
|
||||
ClientState client;
|
||||
client.permissions = 0;
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
|
||||
const bool changed = applyLogin(client, true, same_secret, ROLE_ADMIN);
|
||||
EXPECT_TRUE(changed);
|
||||
EXPECT_TRUE(loginNeedsSave(true, 0, ROLE_ADMIN, changed));
|
||||
EXPECT_EQ(client.permissions, ROLE_ADMIN);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, AdminDowngradeToGuestRemovesPersistedEntry) {
|
||||
ClientState client;
|
||||
client.permissions = 0x83;
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
|
||||
const bool changed = applyLogin(client, true, same_secret, 0);
|
||||
EXPECT_TRUE(changed);
|
||||
EXPECT_EQ(client.permissions & ROLE_MASK, 0);
|
||||
EXPECT_EQ(client.permissions, 0x80);
|
||||
EXPECT_FALSE(mesh::clientRoleIsPersisted(
|
||||
client.permissions, ROLE_MASK, 0));
|
||||
EXPECT_TRUE(loginNeedsSave(true, ROLE_ADMIN, 0, changed));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, GuestUpperPermissionBitsNeverMakeItPersistent) {
|
||||
EXPECT_FALSE(mesh::clientRoleIsPersisted(0x00, ROLE_MASK, 0));
|
||||
EXPECT_FALSE(mesh::clientRoleIsPersisted(0x80, ROLE_MASK, 0));
|
||||
EXPECT_TRUE(mesh::clientRoleIsPersisted(0x81, ROLE_MASK, 0));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, PreauthorizedLoginStillRefreshesReplayState) {
|
||||
ClientState client;
|
||||
client.permissions = 1;
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
|
||||
EXPECT_FALSE(applyLogin(client, true, same_secret, 1));
|
||||
EXPECT_EQ(client.permissions, 1);
|
||||
EXPECT_EQ(client.last_timestamp, 100u);
|
||||
EXPECT_EQ(client.last_activity, 200u);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, PersistedRoleOrSecretChangesNeedPersistence) {
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
const uint8_t changed_secret[4] = {4, 3, 2, 1};
|
||||
|
||||
ClientState role_changed;
|
||||
role_changed.permissions = 0xA1;
|
||||
EXPECT_TRUE(applyLogin(role_changed, true, same_secret, 0xFB));
|
||||
EXPECT_EQ(role_changed.permissions, 0xA3);
|
||||
|
||||
ClientState secret_changed;
|
||||
EXPECT_TRUE(applyLogin(secret_changed, true, changed_secret));
|
||||
EXPECT_EQ(memcmp(secret_changed.shared_secret, changed_secret, 4), 0);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, FloodPathResetPersistsOnlyWhenValueChanges) {
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
|
||||
ClientState known_path;
|
||||
known_path.out_path_len = 2;
|
||||
EXPECT_TRUE(applyLogin(
|
||||
known_path, true, same_secret, ROLE_ADMIN, true));
|
||||
EXPECT_EQ(known_path.out_path_len, UNKNOWN_PATH);
|
||||
EXPECT_TRUE(known_path.out_path_is_persistable);
|
||||
|
||||
ClientState already_unknown;
|
||||
EXPECT_FALSE(applyLogin(
|
||||
already_unknown, true, same_secret, ROLE_ADMIN, true));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, FloodLoginNeverDestroysForceFlood) {
|
||||
const uint8_t same_secret[4] = {1, 2, 3, 4};
|
||||
ClientState client;
|
||||
client.out_path_len = FORCE_FLOOD_PATH;
|
||||
|
||||
const bool reset_out_path = client.out_path_len != FORCE_FLOOD_PATH;
|
||||
EXPECT_FALSE(mesh::applySuccessfulClientLogin(
|
||||
client, true, ROLE_ADMIN, ROLE_MASK, same_secret,
|
||||
100, 200, reset_out_path, UNKNOWN_PATH));
|
||||
EXPECT_EQ(client.out_path_len, FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(client.out_path_is_persistable);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, ReplayValidationPrecedesAllocation) {
|
||||
EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(false, 0, 0));
|
||||
EXPECT_TRUE(mesh::isFreshClientLoginTimestamp(false, 1, 0));
|
||||
|
||||
EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(true, 0, 50));
|
||||
EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(true, 49, 50));
|
||||
EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(true, 50, 50));
|
||||
EXPECT_TRUE(mesh::isFreshClientLoginTimestamp(true, 51, 50));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, DurableReservationSurvivesReboot) {
|
||||
const auto first = mesh::planClientLoginReplay(0, 0, 100);
|
||||
ASSERT_TRUE(first.fresh);
|
||||
ASSERT_TRUE(first.reservation_needed);
|
||||
EXPECT_EQ(first.reservation_ceiling, 160u);
|
||||
|
||||
// Once this boot has accepted 100, reconnects advance against the exact RAM
|
||||
// floor and consume the already durable reservation without another write.
|
||||
const auto reconnect = mesh::planClientLoginReplay(
|
||||
100, first.reservation_ceiling, 101);
|
||||
EXPECT_TRUE(reconnect.fresh);
|
||||
EXPECT_FALSE(reconnect.reservation_needed);
|
||||
|
||||
// A reboot has no exact RAM floor. It deliberately skips the unused tail,
|
||||
// so neither accepted timestamp can become fresh again.
|
||||
const auto replay_after_reboot = mesh::planClientLoginReplay(
|
||||
0, first.reservation_ceiling, 101);
|
||||
EXPECT_FALSE(replay_after_reboot.fresh);
|
||||
const auto after_reserved_tail = mesh::planClientLoginReplay(
|
||||
0, first.reservation_ceiling, 161);
|
||||
EXPECT_TRUE(after_reserved_tail.fresh);
|
||||
EXPECT_TRUE(after_reserved_tail.reservation_needed);
|
||||
EXPECT_EQ(after_reserved_tail.reservation_ceiling, 221u);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, EvictionCannotEraseReplayFloor) {
|
||||
const uint32_t tombstone_ceiling = 460;
|
||||
|
||||
// Eviction removes the live ClientInfo and therefore its exact timestamp.
|
||||
// The absent runtime floor must fall back to the per-identity tombstone.
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
0, tombstone_ceiling, 400).fresh);
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
0, tombstone_ceiling, tombstone_ceiling).fresh);
|
||||
EXPECT_TRUE(mesh::planClientLoginReplay(
|
||||
0, tombstone_ceiling, tombstone_ceiling + 1).fresh);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, RevokedAdminReplayUsesTombstoneNotRole) {
|
||||
const uint32_t revoked_admin_floor = 1060;
|
||||
|
||||
// Role removal is intentionally absent from the planner: replay history is
|
||||
// keyed by public identity and outlives the ACL record/permissions.
|
||||
const uint8_t current_role = 0; // guest/deleted
|
||||
(void)current_role;
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
0, revoked_admin_floor, 1000).fresh);
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
0, revoked_admin_floor, 1001).fresh);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, ReservationSaturatesWithoutWrapping) {
|
||||
EXPECT_EQ(mesh::reserveClientLoginTimestamp(UINT32_MAX - 10, 60),
|
||||
UINT32_MAX);
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
UINT32_MAX, UINT32_MAX, UINT32_MAX).fresh);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, GuestAndReadOnlyDoNotAllocateDurableIdentity) {
|
||||
EXPECT_FALSE(mesh::clientLoginRoleNeedsDurableReplay(
|
||||
ROLE_GUEST, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY));
|
||||
EXPECT_FALSE(mesh::clientLoginRoleNeedsDurableReplay(
|
||||
ROLE_READ_ONLY, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY));
|
||||
// Unrelated upper permission bits do not turn a low-trust role into a
|
||||
// durable replay-store consumer.
|
||||
EXPECT_FALSE(mesh::clientLoginRoleNeedsDurableReplay(
|
||||
0x81, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY));
|
||||
|
||||
const auto first_guest = mesh::planClientLoginReplay(
|
||||
0, 0, 100);
|
||||
EXPECT_TRUE(first_guest.fresh);
|
||||
EXPECT_TRUE(first_guest.reservation_needed);
|
||||
|
||||
// A full durable store is irrelevant because policy selects no write for a
|
||||
// missing low-trust identity.
|
||||
EXPECT_EQ(mesh::selectClientLoginReplayReservation(
|
||||
first_guest.reservation_needed, false, false),
|
||||
mesh::ClientLoginReplayReservationAction::None);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, LowTrustLoginStillEnforcesExistingFloors) {
|
||||
const uint32_t privileged_tombstone = 460;
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
0, privileged_tombstone, 400).fresh);
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
0, privileged_tombstone, privileged_tombstone).fresh);
|
||||
|
||||
const auto newer = mesh::planClientLoginReplay(
|
||||
0, privileged_tombstone, privileged_tombstone + 1);
|
||||
EXPECT_TRUE(newer.fresh);
|
||||
EXPECT_TRUE(newer.reservation_needed);
|
||||
EXPECT_EQ(mesh::selectClientLoginReplayReservation(
|
||||
newer.reservation_needed, true, false),
|
||||
mesh::ClientLoginReplayReservationAction::UpdateExisting);
|
||||
|
||||
// Once admitted, the exact RAM floor still rejects same-boot replay even
|
||||
// for a missing low-trust identity which deliberately performs no write.
|
||||
EXPECT_FALSE(mesh::planClientLoginReplay(
|
||||
privileged_tombstone + 1, privileged_tombstone,
|
||||
privileged_tombstone + 1).fresh);
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, FullStoreFailsOnlyNewPrivilegedIdentity) {
|
||||
for (uint8_t role : {ROLE_READ_WRITE, ROLE_ADMIN, (uint8_t)4, (uint8_t)5}) {
|
||||
EXPECT_TRUE(mesh::clientLoginRoleNeedsDurableReplay(
|
||||
role, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY));
|
||||
}
|
||||
|
||||
const auto privileged = mesh::planClientLoginReplay(0, 0, 100);
|
||||
ASSERT_TRUE(privileged.fresh);
|
||||
ASSERT_TRUE(privileged.reservation_needed);
|
||||
EXPECT_EQ(mesh::selectClientLoginReplayReservation(
|
||||
privileged.reservation_needed, false, true),
|
||||
mesh::ClientLoginReplayReservationAction::CreateNew);
|
||||
EXPECT_FALSE(mesh::clientLoginReplayCanInsert(
|
||||
mesh::MAX_CLIENT_LOGIN_REPLAY_IDENTITIES));
|
||||
EXPECT_TRUE(mesh::clientLoginReplayCanInsert(
|
||||
mesh::MAX_CLIENT_LOGIN_REPLAY_IDENTITIES - 1));
|
||||
|
||||
EXPECT_EQ(mesh::selectClientLoginReplayReservation(
|
||||
privileged.reservation_needed, true, true),
|
||||
mesh::ClientLoginReplayReservationAction::UpdateExisting);
|
||||
EXPECT_EQ(mesh::selectClientLoginReplayReservation(
|
||||
privileged.reservation_needed, true, false),
|
||||
mesh::ClientLoginReplayReservationAction::UpdateExisting);
|
||||
EXPECT_EQ(mesh::selectClientLoginReplayReservation(
|
||||
privileged.reservation_needed, false, false),
|
||||
mesh::ClientLoginReplayReservationAction::None);
|
||||
EXPECT_EQ(mesh::selectClientLoginReplayReservation(
|
||||
false, false, true),
|
||||
mesh::ClientLoginReplayReservationAction::None);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> replayImage(
|
||||
const std::vector<std::pair<std::vector<uint8_t>, uint32_t> >& records) {
|
||||
std::vector<uint8_t> image;
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
for (const auto& record : records) {
|
||||
EXPECT_EQ(record.first.size(), 32u);
|
||||
image.insert(image.end(), record.first.begin(), record.first.end());
|
||||
crc = mesh::updateClientLoginReplayCRC(
|
||||
crc, record.first.data(), record.first.size());
|
||||
const uint8_t* floor =
|
||||
reinterpret_cast<const uint8_t*>(&record.second);
|
||||
image.insert(image.end(), floor, floor + sizeof(record.second));
|
||||
crc = mesh::updateClientLoginReplayCRC(
|
||||
crc, floor, sizeof(record.second));
|
||||
}
|
||||
image.insert(image.end(), mesh::CLIENT_LOGIN_REPLAY_MAGIC,
|
||||
mesh::CLIENT_LOGIN_REPLAY_MAGIC + 4);
|
||||
const uint32_t final_crc = crc ^ 0xFFFFFFFFUL;
|
||||
const uint8_t* trailer =
|
||||
reinterpret_cast<const uint8_t*>(&final_crc);
|
||||
image.insert(image.end(), trailer, trailer + sizeof(final_crc));
|
||||
return image;
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, ReplayImageIntegrityRejectsTornOrChangedState) {
|
||||
std::vector<uint8_t> key(32, 0xA5);
|
||||
std::vector<uint8_t> image = replayImage({{key, 160}});
|
||||
ASSERT_TRUE(mesh::validateClientLoginReplayImage(
|
||||
image.data(), image.size()));
|
||||
|
||||
std::vector<uint8_t> changed = image;
|
||||
changed[32] ^= 1;
|
||||
EXPECT_FALSE(mesh::validateClientLoginReplayImage(
|
||||
changed.data(), changed.size()));
|
||||
image.pop_back();
|
||||
EXPECT_FALSE(mesh::validateClientLoginReplayImage(
|
||||
image.data(), image.size()));
|
||||
}
|
||||
|
||||
class ReplayFakeFilesystem {
|
||||
public:
|
||||
std::set<std::string> files;
|
||||
std::set<std::string> valid_files;
|
||||
std::string fail_from;
|
||||
std::string fail_to;
|
||||
std::string fail_remove;
|
||||
|
||||
bool exists(const char* path) const {
|
||||
return files.count(path) != 0;
|
||||
}
|
||||
bool remove(const char* path) {
|
||||
if (fail_remove == path) return false;
|
||||
valid_files.erase(path);
|
||||
return files.erase(path) != 0;
|
||||
}
|
||||
bool rename(const char* from, const char* to) {
|
||||
if (fail_from == from && fail_to == to) return false;
|
||||
if (!exists(from) || exists(to)) return false;
|
||||
files.erase(from);
|
||||
files.insert(to);
|
||||
if (valid_files.erase(from) != 0) valid_files.insert(to);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
bool replayFileIsValid(ReplayFakeFilesystem* fs, const char* path) {
|
||||
return fs->valid_files.count(path) != 0;
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, InterruptedReplayPublishRestoresOldFloorImage) {
|
||||
ReplayFakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH,
|
||||
};
|
||||
fs.valid_files = {
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH,
|
||||
};
|
||||
fs.fail_from = mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH;
|
||||
fs.fail_to = mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH;
|
||||
|
||||
EXPECT_FALSE(mesh::publishClientLoginReplayTemp(
|
||||
&fs, true, replayFileIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.valid_files.count(
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) != 0);
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, PublishedReplaySurvivesBackupCleanupFailure) {
|
||||
ReplayFakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH,
|
||||
};
|
||||
fs.valid_files = {
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH,
|
||||
};
|
||||
fs.fail_remove = mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH;
|
||||
|
||||
EXPECT_TRUE(mesh::publishClientLoginReplayTemp(
|
||||
&fs, true, replayFileIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.valid_files.count(
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) != 0);
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH));
|
||||
|
||||
fs.fail_remove.clear();
|
||||
EXPECT_TRUE(mesh::recoverClientLoginReplayFiles(
|
||||
&fs, replayFileIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, ValidReplayLoadsWhenCleanupCannotFinish) {
|
||||
ReplayFakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH,
|
||||
};
|
||||
fs.valid_files = {mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH};
|
||||
fs.fail_remove = mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH;
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientLoginReplayFiles(
|
||||
&fs, replayFileIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, BootRecoveryRestoresValidReplayBackup) {
|
||||
ReplayFakeFilesystem fs;
|
||||
fs.files = {
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH,
|
||||
mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH,
|
||||
};
|
||||
fs.valid_files = {mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH};
|
||||
|
||||
EXPECT_TRUE(mesh::recoverClientLoginReplayFiles(
|
||||
&fs, replayFileIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH));
|
||||
EXPECT_TRUE(fs.valid_files.count(
|
||||
mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) != 0);
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH));
|
||||
EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH));
|
||||
}
|
||||
|
||||
TEST(ClientLoginPersistence, CorruptReplayStateFailsClosed) {
|
||||
ReplayFakeFilesystem fs;
|
||||
fs.files = {mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH};
|
||||
|
||||
EXPECT_FALSE(mesh::recoverClientLoginReplayFiles(
|
||||
&fs, replayFileIsValid));
|
||||
EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static regression contract for ClientACL-backed login handlers."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILES = (
|
||||
(ROOT / "examples/simple_repeater/MyMesh.cpp", 4, "perms"),
|
||||
(ROOT / "examples/simple_sensor/SensorMesh.cpp", 4, "role_permissions"),
|
||||
(ROOT / "examples/simple_room_server/MyMesh.cpp", 8, "perm"),
|
||||
)
|
||||
|
||||
|
||||
def handler_body(path: Path) -> str:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
marker = "handleLoginReq" if "simple_room_server" not in str(path) else "onAnonDataRecv"
|
||||
start = source.index(marker)
|
||||
end = source.index("RESP_SERVER_LOGIN_OK", start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
for profile, minimum_anon_length, login_permissions in PROFILES:
|
||||
body = handler_body(profile)
|
||||
freshness = body.index("authorizeLoginTimestamp")
|
||||
allocation = body.index("putClient")
|
||||
assert freshness < allocation, (
|
||||
f"{profile}: durable replay authorization must precede allocation"
|
||||
)
|
||||
assert "applySuccessfulClientLogin" in body, f"{profile}: unmasked role transition"
|
||||
assert "PERM_ACL_ROLE_MASK" in body, f"{profile}: missing role mask"
|
||||
assert "OUT_PATH_FORCE_FLOOD" in body, f"{profile}: flood login destroys force-flood"
|
||||
authorization = body[
|
||||
freshness : body.index(")", freshness) + 1
|
||||
]
|
||||
assert re.search(
|
||||
rf"previous_timestamp\s*,\s*{re.escape(login_permissions)}\s*\)",
|
||||
authorization,
|
||||
), f"{profile}: replay admission is not bound to the authenticated role"
|
||||
|
||||
source = profile.read_text(encoding="utf-8")
|
||||
anon_start = source.index("onAnonDataRecv")
|
||||
first_field_read = source.index("memcpy(", anon_start)
|
||||
length_guard = source.index(f"len < {minimum_anon_length}", anon_start)
|
||||
assert length_guard < first_field_read, (
|
||||
f"{profile}: anonymous request fields read before minimum-length guard"
|
||||
)
|
||||
|
||||
print("client login profile contract: PASS")
|
||||
@@ -0,0 +1,245 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
|
||||
namespace {
|
||||
|
||||
static constexpr uint8_t UNKNOWN_PATH = 0xFF;
|
||||
static constexpr uint8_t FORCE_FLOOD_PATH = 0xFE;
|
||||
|
||||
struct ClientState {
|
||||
uint8_t permissions = 3;
|
||||
uint8_t out_path_len = UNKNOWN_PATH;
|
||||
uint8_t out_path[64] = {};
|
||||
bool out_path_is_persistable = true;
|
||||
};
|
||||
|
||||
TEST(ClientPathPersistence, UnknownToLearnedPathNeedsPersistence) {
|
||||
ClientState client;
|
||||
const uint8_t path[] = {0x10, 0x11, 0x12, 0x13};
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, path, 0x42, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(result.changed);
|
||||
EXPECT_TRUE(result.persistence_needed);
|
||||
EXPECT_TRUE(client.out_path_is_persistable);
|
||||
EXPECT_EQ(client.out_path_len, 0x42);
|
||||
EXPECT_EQ(memcmp(client.out_path, path, sizeof(path)), 0);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, SameEncodedLengthAndBytesIsUnchanged) {
|
||||
ClientState client;
|
||||
client.out_path_len = 0x42;
|
||||
const uint8_t path[] = {0x10, 0x11, 0x12, 0x13};
|
||||
memcpy(client.out_path, path, sizeof(path));
|
||||
client.out_path[10] = 0xA5; // stale capacity is not part of the path
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, path, 0x42, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_FALSE(result.changed);
|
||||
EXPECT_FALSE(result.persistence_needed);
|
||||
EXPECT_TRUE(client.out_path_is_persistable);
|
||||
EXPECT_EQ(client.out_path[10], 0xA5);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, SameEncodedLengthDifferentBytesChanges) {
|
||||
ClientState client;
|
||||
client.out_path_len = 2;
|
||||
client.out_path[0] = 0x10;
|
||||
client.out_path[1] = 0x11;
|
||||
const uint8_t path[] = {0x10, 0x22};
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, path, 2, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(result.changed);
|
||||
EXPECT_TRUE(result.persistence_needed);
|
||||
EXPECT_EQ(client.out_path[1], 0x22);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, DifferentEncodingChangesEvenAtSameByteLength) {
|
||||
ClientState client;
|
||||
client.out_path_len = 2; // two one-byte hashes
|
||||
client.out_path[0] = 0x10;
|
||||
client.out_path[1] = 0x11;
|
||||
const uint8_t path[] = {0x10, 0x11};
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, path, 0x41, true, FORCE_FLOOD_PATH); // one two-byte hash
|
||||
EXPECT_TRUE(result.changed);
|
||||
EXPECT_TRUE(result.persistence_needed);
|
||||
EXPECT_EQ(client.out_path_len, 0x41);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, EncodingValidationMatchesPacketPathRules) {
|
||||
EXPECT_TRUE(mesh::isValidEncodedClientPathLength(0, 64));
|
||||
EXPECT_TRUE(mesh::isValidEncodedClientPathLength(0x5F, 64));
|
||||
EXPECT_FALSE(mesh::isValidEncodedClientPathLength(0x61, 64));
|
||||
EXPECT_FALSE(mesh::isValidEncodedClientPathLength(0xC0, 64));
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, ZeroHopIsARealLogicalPath) {
|
||||
ClientState client;
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, nullptr, 0, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(result.changed);
|
||||
EXPECT_TRUE(result.persistence_needed);
|
||||
EXPECT_EQ(client.out_path_len, 0);
|
||||
|
||||
const auto repeated = mesh::applyReceivedClientPath(
|
||||
client, nullptr, 0, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_FALSE(repeated.changed);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, ForceFloodIsNeverOverwritten) {
|
||||
ClientState client;
|
||||
client.out_path_len = FORCE_FLOOD_PATH;
|
||||
client.out_path[0] = 0x55;
|
||||
const uint8_t path[] = {0x10};
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, path, 1, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_FALSE(result.changed);
|
||||
EXPECT_FALSE(result.persistence_needed);
|
||||
EXPECT_EQ(client.out_path_len, FORCE_FLOOD_PATH);
|
||||
EXPECT_EQ(client.out_path[0], 0x55);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, NonpersistentClientStillGetsLatestRamPath) {
|
||||
ClientState client;
|
||||
client.permissions = 0;
|
||||
const uint8_t path[] = {0x33};
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, path, 1, false, FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(result.changed);
|
||||
EXPECT_FALSE(result.persistence_needed);
|
||||
EXPECT_EQ(client.out_path_len, 1);
|
||||
EXPECT_EQ(client.out_path[0], 0x33);
|
||||
EXPECT_FALSE(client.out_path_is_persistable);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, NoReplayProofForcesRamOnlyPath) {
|
||||
ClientState client;
|
||||
const uint8_t path[] = {0x44};
|
||||
EXPECT_FALSE(mesh::clientPathPersistenceAllowed(true, false));
|
||||
|
||||
const auto result = mesh::applyReceivedClientPath(
|
||||
client, path, 1,
|
||||
mesh::clientPathPersistenceAllowed(true, false),
|
||||
FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(result.changed);
|
||||
EXPECT_FALSE(result.persistence_needed);
|
||||
EXPECT_EQ(client.out_path[0], 0x44);
|
||||
EXPECT_FALSE(client.out_path_is_persistable);
|
||||
EXPECT_EQ(mesh::storedClientPathLength(
|
||||
client.out_path_is_persistable, client.out_path_len, UNKNOWN_PATH),
|
||||
UNKNOWN_PATH);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, IdenticalProvenPathPromotesRamRouteToDurable) {
|
||||
ClientState client;
|
||||
const uint8_t path[] = {0x44};
|
||||
ASSERT_TRUE(mesh::applyReceivedClientPath(
|
||||
client, path, 1, false, FORCE_FLOOD_PATH).changed);
|
||||
ASSERT_FALSE(client.out_path_is_persistable);
|
||||
|
||||
const auto promoted = mesh::applyReceivedClientPath(
|
||||
client, path, 1, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(promoted.changed);
|
||||
EXPECT_TRUE(promoted.persistence_needed);
|
||||
EXPECT_TRUE(client.out_path_is_persistable);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence,
|
||||
TransientLearnedPathCannotEraseOperatorPathOnUnrelatedSaveReload) {
|
||||
ClientState client;
|
||||
const uint8_t operator_path[] = {0x11, 0x22};
|
||||
client.out_path_len = 2;
|
||||
memcpy(client.out_path, operator_path, sizeof(operator_path));
|
||||
client.out_path_is_persistable = true;
|
||||
|
||||
// This represents the already-published /s_contacts record.
|
||||
uint8_t published_path[64] = {};
|
||||
memcpy(published_path, client.out_path, sizeof(published_path));
|
||||
uint8_t published_path_len = client.out_path_len;
|
||||
|
||||
const uint8_t learned_path[] = {0x33, 0x44};
|
||||
const auto learned = mesh::applyReceivedClientPath(
|
||||
client, learned_path, 2, false, FORCE_FLOOD_PATH);
|
||||
ASSERT_TRUE(learned.changed);
|
||||
ASSERT_FALSE(learned.persistence_needed);
|
||||
ASSERT_FALSE(client.out_path_is_persistable);
|
||||
ASSERT_EQ(client.out_path[0], 0x33);
|
||||
|
||||
// An unrelated permissions/ACL save must select the prior durable route,
|
||||
// not UNKNOWN and not the replay-unproven runtime route.
|
||||
const uint8_t empty_path[64] = {};
|
||||
const auto selected = mesh::selectStoredClientPath(
|
||||
client.out_path_is_persistable,
|
||||
client.out_path_len,
|
||||
client.out_path,
|
||||
true,
|
||||
published_path_len,
|
||||
published_path,
|
||||
UNKNOWN_PATH,
|
||||
empty_path);
|
||||
EXPECT_EQ(selected.encoded_path_len, 2);
|
||||
EXPECT_EQ(memcmp(selected.path, operator_path, sizeof(operator_path)), 0);
|
||||
|
||||
// Emulate reload from that just-written record.
|
||||
ClientState reloaded;
|
||||
reloaded.out_path_len = selected.encoded_path_len;
|
||||
memcpy(reloaded.out_path, selected.path, sizeof(reloaded.out_path));
|
||||
reloaded.out_path_is_persistable = true;
|
||||
EXPECT_EQ(reloaded.out_path_len, 2);
|
||||
EXPECT_EQ(memcmp(reloaded.out_path, operator_path,
|
||||
sizeof(operator_path)), 0);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, TransientPathWithoutPriorRecordSavesUnknown) {
|
||||
ClientState client;
|
||||
const uint8_t learned_path[] = {0x55};
|
||||
ASSERT_TRUE(mesh::applyReceivedClientPath(
|
||||
client, learned_path, 1, false, FORCE_FLOOD_PATH).changed);
|
||||
|
||||
const uint8_t empty_path[64] = {};
|
||||
const auto selected = mesh::selectStoredClientPath(
|
||||
client.out_path_is_persistable,
|
||||
client.out_path_len,
|
||||
client.out_path,
|
||||
false,
|
||||
UNKNOWN_PATH,
|
||||
empty_path,
|
||||
UNKNOWN_PATH,
|
||||
empty_path);
|
||||
EXPECT_EQ(selected.encoded_path_len, UNKNOWN_PATH);
|
||||
EXPECT_EQ(selected.path, empty_path);
|
||||
}
|
||||
|
||||
TEST(ClientPathPersistence, PendingWriteKeepsFirstDeadlineAndLatestRamPath) {
|
||||
ClientState client;
|
||||
unsigned long pending = 0;
|
||||
const uint8_t first[] = {0x11};
|
||||
const uint8_t latest[] = {0x22};
|
||||
|
||||
auto result = mesh::applyReceivedClientPath(
|
||||
client, first, 1, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_TRUE(mesh::armFirstLazyPersistence(
|
||||
pending, 5000, result.persistence_needed));
|
||||
|
||||
result = mesh::applyReceivedClientPath(
|
||||
client, latest, 1, true, FORCE_FLOOD_PATH);
|
||||
EXPECT_FALSE(mesh::armFirstLazyPersistence(
|
||||
pending, 9000, result.persistence_needed));
|
||||
EXPECT_EQ(pending, 5000UL);
|
||||
EXPECT_EQ(client.out_path[0], 0x22);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -24,16 +24,46 @@ assert re.search(
|
||||
assert "offline_queue_len" in local
|
||||
assert "getOfflineQueueCapacity()" in local
|
||||
|
||||
# Mutable local settings must be dispatched before the terminal's generic
|
||||
# `set` fallback. `set pin` used to exist only in handleCommand(), so the
|
||||
# text terminal intercepted it as an unknown setting while framed/rescue CLI
|
||||
# happened to work.
|
||||
assert local.count('strncmp(command, "set pin", 7) == 0') == 1
|
||||
assert "mesh::cli::parseIntegerStrict(value, parsed)" in local
|
||||
assert "const uint32_t previous = _prefs.ble_pin;" in local
|
||||
assert "_prefs.ble_pin = previous;" in local
|
||||
|
||||
terminal_start = source.index("void MyMesh::handleTerminalCommand(")
|
||||
terminal_end = source.index("\nvoid MyMesh::enterCLIRescue()", terminal_start)
|
||||
terminal = source[terminal_start:terminal_end]
|
||||
assert terminal.index("handleLocalControlCommand(") < terminal.index(
|
||||
'strncmp(command, "set ", 4) == 0'
|
||||
)
|
||||
assert 'terminalOutput().print(" board\\r\\n")' in terminal
|
||||
assert 'terminalOutput().print(" set pin <0-999999>\\r\\n")' in terminal
|
||||
assert re.search(
|
||||
r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS\s+"
|
||||
r'terminalOutput\(\)\.print\(" memory\\r\\n"\);\s+#endif',
|
||||
terminal,
|
||||
)
|
||||
|
||||
# Terminal-specific handlers keep precedence, then both the generic `set`
|
||||
# fallthrough and the final unknown-command fallthrough delegate to the shared
|
||||
# framed/rescue command surface. This keeps get/set radio, get name, and
|
||||
# variant commands available without routing terminal-only commands twice.
|
||||
assert terminal.count("handleCommand(command, 0, local_reply)") == 2
|
||||
assert terminal.index('strncmp(config, "tx ", 3)') < terminal.index(
|
||||
"handleCommand(command, 0, local_reply)"
|
||||
)
|
||||
assert terminal.rindex('strcmp(command, "ver") == 0') < terminal.rindex(
|
||||
"handleCommand(command, 0, local_reply)"
|
||||
)
|
||||
|
||||
command_start = source.index("bool MyMesh::handleCommand(")
|
||||
command_end = source.index("\nvoid MyMesh::checkCLIRescueCmd()", command_start)
|
||||
command_handler = source[command_start:command_end]
|
||||
assert 'strncmp(command, "set pin' not in command_handler
|
||||
|
||||
# This compile-time contract prevents either the implementation or help entry
|
||||
# from leaking onto a non-ESP32 build merely because a recipe set the flag.
|
||||
assert re.search(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/DisplayTextLayout.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -32,7 +34,16 @@ bool isValidUTF8(const char* text) {
|
||||
|
||||
class TestDisplay : public DisplayDriver {
|
||||
public:
|
||||
struct DrawnText {
|
||||
int x;
|
||||
int y;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
std::string printed;
|
||||
std::vector<DrawnText> rows;
|
||||
int cursor_x = 0;
|
||||
int cursor_y = 0;
|
||||
|
||||
TestDisplay() : DisplayDriver(100, 100) {}
|
||||
|
||||
@@ -43,8 +54,14 @@ public:
|
||||
void startFrame(ColorVal) override {}
|
||||
void setTextSize(int) override {}
|
||||
void setColor(ColorVal) override {}
|
||||
void setCursor(int, int) override {}
|
||||
void print(const char* str) override { printed = str; }
|
||||
void setCursor(int x, int y) override {
|
||||
cursor_x = x;
|
||||
cursor_y = y;
|
||||
}
|
||||
void print(const char* str) override {
|
||||
printed = str;
|
||||
rows.push_back({cursor_x, cursor_y, str});
|
||||
}
|
||||
void fillRect(int, int, int, int) override {}
|
||||
void drawRect(int, int, int, int) override {}
|
||||
void drawXbm(int, int, const uint8_t*, int, int) override {}
|
||||
@@ -77,6 +94,43 @@ TEST(DisplayDriver, FixedBufferDoesNotSplitUTF8Codepoint) {
|
||||
EXPECT_TRUE(isValidUTF8(display.printed.c_str()));
|
||||
}
|
||||
|
||||
TEST(DisplayDriver, WrapsCompleteTextAcrossAvailableRows) {
|
||||
TestDisplay display;
|
||||
EXPECT_EQ(3, mesh::ui::drawTextWrapped(
|
||||
display, 4, 10, 3, 9, 3, "ABCDEFG"));
|
||||
ASSERT_EQ(3U, display.rows.size());
|
||||
EXPECT_EQ(4, display.rows[0].x);
|
||||
EXPECT_EQ(10, display.rows[0].y);
|
||||
EXPECT_EQ("ABC", display.rows[0].text);
|
||||
EXPECT_EQ(4, display.rows[1].x);
|
||||
EXPECT_EQ(19, display.rows[1].y);
|
||||
EXPECT_EQ("DEF", display.rows[1].text);
|
||||
EXPECT_EQ(28, display.rows[2].y);
|
||||
EXPECT_EQ("G", display.rows[2].text);
|
||||
}
|
||||
|
||||
TEST(DisplayDriver, WrappedTextKeepsUTF8CodepointsIntact) {
|
||||
TestDisplay display;
|
||||
EXPECT_EQ(2, mesh::ui::drawTextWrapped(
|
||||
display, 0, 0, 3, 10, 2, "AB\xF0\x9F\x98\x80" "CDE"));
|
||||
ASSERT_EQ(2U, display.rows.size());
|
||||
EXPECT_EQ("AB\xF0\x9F\x98\x80", display.rows[0].text);
|
||||
EXPECT_EQ("CDE", display.rows[1].text);
|
||||
EXPECT_TRUE(isValidUTF8(display.rows[0].text.c_str()));
|
||||
EXPECT_TRUE(isValidUTF8(display.rows[1].text.c_str()));
|
||||
}
|
||||
|
||||
TEST(DisplayDriver, KeepsMaximumLengthSSIDWithoutEllipsis) {
|
||||
TestDisplay display;
|
||||
const std::string ssid = "1234567890ABCDEF1234567890ABCDEF";
|
||||
EXPECT_EQ(3, mesh::ui::drawTextWrapped(
|
||||
display, 0, 0, 12, 10, 3, ssid.c_str()));
|
||||
ASSERT_EQ(3U, display.rows.size());
|
||||
EXPECT_EQ(ssid,
|
||||
display.rows[0].text + display.rows[1].text
|
||||
+ display.rows[2].text);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def source(path: str) -> str:
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
|
||||
class Esp32UsbSerialHygieneTest(unittest.TestCase):
|
||||
def test_operational_wifi_diagnostics_use_runtime_logging_port(self):
|
||||
for relative in (
|
||||
"src/helpers/ESP32Board.cpp",
|
||||
"src/helpers/CompanionMqttSetupPortal.cpp",
|
||||
"src/helpers/JWTHelper.cpp",
|
||||
"src/helpers/bridges/MQTTBridge.cpp",
|
||||
"src/helpers/esp32/WiFiOtaSeeder.cpp",
|
||||
"src/helpers/esp32/WebConfigServer.cpp",
|
||||
"src/helpers/WiFiSetupPortal.cpp",
|
||||
):
|
||||
text = source(relative)
|
||||
self.assertIn("UsbLogging.h", text, relative)
|
||||
self.assertNotRegex(
|
||||
text,
|
||||
r"\bSerial\.(?:print|println|printf|write)\s*\(",
|
||||
relative,
|
||||
)
|
||||
self.assertIn("mesh::usbLoggingPort()", text, relative)
|
||||
|
||||
def test_v4_companion_uses_usb_serial_jtag_mode(self):
|
||||
platformio = source("variants/heltec_v4/platformio.ini")
|
||||
base = platformio[
|
||||
platformio.index("[Heltec_lora32_v4]") :
|
||||
platformio.index("[heltec_v4_oled]")
|
||||
]
|
||||
self.assertIn("-D ARDUINO_USB_MODE=1", base)
|
||||
self.assertNotIn("ARDUINO_USB_MODE=0", base)
|
||||
|
||||
board = source("boards/heltec_v4.json")
|
||||
self.assertIn('"-DARDUINO_USB_CDC_ON_BOOT=1"', board)
|
||||
self.assertIn('"-DARDUINO_USB_MODE=1"', board)
|
||||
|
||||
def test_mqtt_ntp_detail_never_bypasses_logging_mode(self):
|
||||
text = source("src/helpers/bridges/MQTTBridge.cpp")
|
||||
self.assertIn(
|
||||
"if (verbose && mesh::isUsbLoggingEnabled())", text
|
||||
)
|
||||
self.assertIn("Stream& output = mesh::usbLoggingPort();", text)
|
||||
|
||||
def test_framework_diagnostics_follow_same_runtime_gate(self):
|
||||
text = source("src/helpers/UsbLogging.cpp")
|
||||
self.assertIn("Serial.setDebugOutput(enabled);", text)
|
||||
|
||||
setter = text[
|
||||
text.index("void setUsbLoggingEnabled(") :
|
||||
text.index("bool saveUsbLoggingBootPreference(")
|
||||
]
|
||||
self.assertIn("setPlatformDebugOutputEnabled(enabled);", setter)
|
||||
|
||||
begin = text[
|
||||
text.index("void beginUsbLoggingPort(") :
|
||||
text.index("void serviceUsbLoggingPort(")
|
||||
]
|
||||
self.assertIn(
|
||||
"setPlatformDebugOutputEnabled(isUsbLoggingEnabled());", begin
|
||||
)
|
||||
|
||||
def test_expected_fresh_nvs_state_is_silent(self):
|
||||
wifi_setup = source("src/helpers/WiFiSetupPortal.cpp")
|
||||
webconfig = source("src/helpers/esp32/WebConfigServer.cpp")
|
||||
radio_policy = source("src/helpers/esp32/WiFiRadioPolicy.h")
|
||||
mqtt_setup = source("src/helpers/CompanionMqttSetupPortal.cpp")
|
||||
|
||||
for text in (wifi_setup, webconfig, radio_policy, mqtt_setup):
|
||||
self.assertNotRegex(
|
||||
text,
|
||||
r'\.begin\("mesh-(?:wifi|webui|mqtt)",\s*true\)',
|
||||
)
|
||||
self.assertNotIn("nvs.begin(NVS_NAMESPACE, true)", mqtt_setup)
|
||||
|
||||
for text in (wifi_setup, webconfig):
|
||||
self.assertIn('isKey("ssid")', text)
|
||||
self.assertIn('isKey("password")', text)
|
||||
self.assertIn('isKey("enabled")', webconfig)
|
||||
self.assertIn('isKey("cli")', webconfig)
|
||||
self.assertIn('isKey("powersave")', webconfig)
|
||||
self.assertIn('isKey("espnow_ch")', radio_policy)
|
||||
self.assertIn("nvs.isKey(NVS_VERSION_KEY)", mqtt_setup)
|
||||
self.assertIn("nvs.isKey(NVS_PREFS_KEY)", mqtt_setup)
|
||||
|
||||
def test_indicator_reports_specific_hardware(self):
|
||||
header = source("variants/sensecap_indicator-espnow/target.h")
|
||||
implementation = source("variants/sensecap_indicator-espnow/target.cpp")
|
||||
self.assertIn(
|
||||
"class SenseCapIndicatorBoard : public ESP32Board", header
|
||||
)
|
||||
self.assertIn('return "Seeed SenseCAP Indicator";', header)
|
||||
self.assertIn("extern SenseCapIndicatorBoard board;", header)
|
||||
self.assertIn("SenseCapIndicatorBoard board;", implementation)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -572,12 +572,91 @@ const mergedRs232 = [
|
||||
installKinds: ["uf2"],
|
||||
});
|
||||
});
|
||||
const mergedRs232Visible = picker.applyMergedRak4631RepeaterCapabilities(
|
||||
picker.omitTransportsReplacedByFull(mergedRs232)
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
picker.omitTransportsReplacedByFull(mergedRs232).map(function (item) {
|
||||
mergedRs232Visible.map(function (item) {
|
||||
return item.target;
|
||||
}),
|
||||
["RAK_4631_repeater"]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
picker.profileFieldValues(mergedRs232Visible[0], "mode"),
|
||||
["standard", "rs232"]
|
||||
);
|
||||
assert(picker.profileMatches(
|
||||
mergedRs232Visible[0],
|
||||
{ mode: "standard" },
|
||||
["mode"]
|
||||
));
|
||||
assert(picker.profileMatches(
|
||||
mergedRs232Visible[0],
|
||||
{ mode: "rs232" },
|
||||
["mode"]
|
||||
));
|
||||
|
||||
// Exact legacy target rows must stay attached to their own compatibility
|
||||
// downloads. They disappear only from recommendations when the canonical
|
||||
// merged image is present; advertising RS232 on that image must not rewrite
|
||||
// or coalesce the legacy files into the canonical profile.
|
||||
const rakCompatibilityCatalog = picker.buildCatalog([
|
||||
release(family, "2026-08-23T13:00:02Z", []),
|
||||
release("repeater-room-" + family, "2026-08-23T13:00:01Z", [
|
||||
asset("RAK_4631_repeater-" + family + ".uf2"),
|
||||
asset(
|
||||
"RAK_4631_repeater_bridge_rs232_serial1-" + family + ".uf2"
|
||||
),
|
||||
asset(
|
||||
"RAK_4631_repeater_bridge_rs232_serial2-" + family + ".uf2"
|
||||
),
|
||||
]),
|
||||
release("lora-ota-" + family, "2026-08-23T13:00:00Z", [
|
||||
asset(
|
||||
"RAK_4631_repeater_lora_ota_no_external_sensors-ota-" +
|
||||
family + ".zip"
|
||||
),
|
||||
asset(
|
||||
"RAK_4631_repeater_bridge_rs232_serial1_" +
|
||||
"lora_ota_no_external_sensors-ota-" + family + ".zip"
|
||||
),
|
||||
asset(
|
||||
"RAK_4631_repeater_bridge_rs232_serial2_" +
|
||||
"lora_ota_no_external_sensors-ota-" + family + ".zip"
|
||||
),
|
||||
]),
|
||||
]);
|
||||
const expectedCompatibilityTargets = [
|
||||
"RAK_4631_repeater_bridge_rs232_serial1",
|
||||
"RAK_4631_repeater_bridge_rs232_serial2",
|
||||
"RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors",
|
||||
"RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors",
|
||||
];
|
||||
expectedCompatibilityTargets.forEach(function (target) {
|
||||
assert(rakCompatibilityCatalog.rows.some(function (row) {
|
||||
return row.target === target;
|
||||
}), "missing exact compatibility row " + target);
|
||||
assert(!rakCompatibilityCatalog.profiles.some(function (item) {
|
||||
return item.target === target;
|
||||
}), "legacy compatibility target was recommended " + target);
|
||||
});
|
||||
[
|
||||
"RAK_4631_repeater",
|
||||
"RAK_4631_repeater_lora_ota_no_external_sensors",
|
||||
].forEach(function (target) {
|
||||
const canonical = rakCompatibilityCatalog.profiles.find(function (item) {
|
||||
return item.target === target;
|
||||
});
|
||||
assert(canonical, "missing canonical RAK4631 profile " + target);
|
||||
assert.deepStrictEqual(
|
||||
picker.profileFieldValues(canonical, "mode"),
|
||||
["standard", "rs232"]
|
||||
);
|
||||
assert(canonical.files.every(function (file) {
|
||||
return file.target === target;
|
||||
}), "legacy compatibility file was mapped to " + target);
|
||||
});
|
||||
|
||||
const constrainedRs232 = [
|
||||
"wio-e5_repeater",
|
||||
"wio-e5-repeater_bridge_rs232",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <Arduino.h>
|
||||
|
||||
#define ENV_INCLUDE_GPS 1
|
||||
#include "../../src/helpers/SensorManager.cpp"
|
||||
|
||||
class TestGpsSensorManager : public SensorManager {
|
||||
public:
|
||||
bool detected = true;
|
||||
bool active = false;
|
||||
uint8_t starts = 0;
|
||||
uint8_t stops = 0;
|
||||
|
||||
void setUserEnabled(bool enabled) { setGpsTelemetryUserEnabled(enabled); }
|
||||
void setTransportAvailable(bool available) {
|
||||
setGpsTelemetryTransportAvailable(available);
|
||||
}
|
||||
bool receiverRequired(unsigned long now) const {
|
||||
return gpsTelemetryReceiverRequired(now);
|
||||
}
|
||||
bool queryLocation(CayenneLPP& telemetry) {
|
||||
return queryGpsTelemetry(TELEM_PERM_LOCATION, telemetry);
|
||||
}
|
||||
void acceptFix(float lat, float lon, float altitude, unsigned long now) {
|
||||
processGpsTelemetryFix(lat, lon, altitude, now);
|
||||
}
|
||||
void stopReceiver() { telemetryGpsStop(); }
|
||||
|
||||
protected:
|
||||
bool telemetryGpsDetected() const override { return detected; }
|
||||
bool telemetryGpsActive() const override { return active; }
|
||||
void telemetryGpsStart() override {
|
||||
active = true;
|
||||
starts++;
|
||||
}
|
||||
void telemetryGpsStop() override {
|
||||
active = false;
|
||||
stops++;
|
||||
}
|
||||
};
|
||||
|
||||
TEST(GpsTransportOwnership, BlockingCancelsAcquisitionAndRemoteHold) {
|
||||
resetArduinoMock();
|
||||
TestGpsSensorManager sensors;
|
||||
CayenneLPP telemetry(64);
|
||||
|
||||
EXPECT_FALSE(sensors.queryLocation(telemetry));
|
||||
EXPECT_TRUE(sensors.active);
|
||||
EXPECT_TRUE(sensors.receiverRequired(millis()));
|
||||
|
||||
// Hardware-specific ownership code stops the receiver before handing its
|
||||
// UART to the bridge; the base transition clears acquisition and hold state.
|
||||
sensors.stopReceiver();
|
||||
sensors.setTransportAvailable(false);
|
||||
EXPECT_FALSE(sensors.active);
|
||||
EXPECT_FALSE(sensors.receiverRequired(millis()));
|
||||
EXPECT_FALSE(sensors.queryLocation(telemetry));
|
||||
EXPECT_EQ(1, sensors.starts);
|
||||
}
|
||||
|
||||
TEST(GpsTransportOwnership, UserPreferenceSurvivesBlockAndRestartsOnRelease) {
|
||||
resetArduinoMock();
|
||||
TestGpsSensorManager sensors;
|
||||
|
||||
sensors.setUserEnabled(true);
|
||||
ASSERT_TRUE(sensors.active);
|
||||
ASSERT_EQ(1, sensors.starts);
|
||||
|
||||
sensors.stopReceiver();
|
||||
sensors.setTransportAvailable(false);
|
||||
EXPECT_FALSE(sensors.active);
|
||||
|
||||
sensors.setTransportAvailable(true);
|
||||
EXPECT_TRUE(sensors.active);
|
||||
EXPECT_EQ(2, sensors.starts);
|
||||
}
|
||||
|
||||
TEST(GpsTransportOwnership, LastGoodCacheSurvivesTemporaryUartOwnership) {
|
||||
resetArduinoMock();
|
||||
TestGpsSensorManager sensors;
|
||||
CayenneLPP telemetry(64);
|
||||
|
||||
EXPECT_FALSE(sensors.queryLocation(telemetry));
|
||||
sensors.acceptFix(47.61f, -122.33f, 125.0f, millis());
|
||||
g_mock_millis += 31000;
|
||||
sensors.acceptFix(47.61f, -122.33f, 125.0f, millis());
|
||||
ASSERT_TRUE(sensors.queryLocation(telemetry));
|
||||
ASSERT_EQ(1, telemetry.gps_count);
|
||||
|
||||
sensors.stopReceiver();
|
||||
sensors.setTransportAvailable(false);
|
||||
EXPECT_TRUE(sensors.queryLocation(telemetry));
|
||||
EXPECT_EQ(2, telemetry.gps_count);
|
||||
EXPECT_FALSE(sensors.receiverRequired(millis()));
|
||||
|
||||
sensors.setTransportAvailable(true);
|
||||
EXPECT_TRUE(sensors.queryLocation(telemetry));
|
||||
EXPECT_EQ(3, telemetry.gps_count);
|
||||
EXPECT_FLOAT_EQ(47.61f, telemetry.last_latitude);
|
||||
EXPECT_FLOAT_EQ(-122.33f, telemetry.last_longitude);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "helpers/sensors/I2CAddressClaimPolicy.h"
|
||||
#include "helpers/sensors/NmeaSentenceProbe.h"
|
||||
|
||||
static std::string nmeaSentence(const char* body) {
|
||||
uint8_t checksum = 0;
|
||||
for (const char* cursor = body; *cursor; ++cursor) {
|
||||
checksum ^= static_cast<uint8_t>(*cursor);
|
||||
}
|
||||
char suffix[8];
|
||||
std::snprintf(suffix, sizeof(suffix), "*%02X\r\n", checksum);
|
||||
return std::string("$") + body + suffix;
|
||||
}
|
||||
|
||||
static bool feed(mesh::NmeaSentenceProbe& probe, const std::string& bytes) {
|
||||
bool found = false;
|
||||
for (unsigned char byte : bytes) found = probe.ingest(byte) || found;
|
||||
return found;
|
||||
}
|
||||
|
||||
TEST(I2CAddressClaimPolicy, SkipsOnlyTheAddressClaimedByGps) {
|
||||
EXPECT_TRUE(mesh::shouldSkipSensorAtClaimedGpsAddress(
|
||||
true, true, 0x42, 0x42));
|
||||
EXPECT_FALSE(mesh::shouldSkipSensorAtClaimedGpsAddress(
|
||||
true, true, 0x43, 0x42));
|
||||
}
|
||||
|
||||
TEST(I2CAddressClaimPolicy, FailedOrAbsentGpsLeavesSensorsAvailable) {
|
||||
EXPECT_FALSE(mesh::shouldSkipSensorAtClaimedGpsAddress(
|
||||
false, true, 0x42, 0x42));
|
||||
}
|
||||
|
||||
TEST(I2CAddressClaimPolicy, SameNumericAddressOnAnotherBusIsIndependent) {
|
||||
EXPECT_FALSE(mesh::shouldSkipSensorAtClaimedGpsAddress(
|
||||
true, false, 0x42, 0x42));
|
||||
}
|
||||
|
||||
TEST(I2CAddressClaimPolicy, GpsProbeFailsClosedOnInaOrIncompleteIdentity) {
|
||||
EXPECT_FALSE(mesh::shouldProbeI2cGps(
|
||||
mesh::I2cIdentityProbeResult::Match));
|
||||
EXPECT_FALSE(mesh::shouldProbeI2cGps(
|
||||
mesh::I2cIdentityProbeResult::Inconclusive));
|
||||
EXPECT_FALSE(mesh::shouldProbeI2cGps(
|
||||
mesh::I2cIdentityProbeResult::NoResponse));
|
||||
EXPECT_TRUE(mesh::shouldProbeI2cGps(
|
||||
mesh::I2cIdentityProbeResult::NotMatch));
|
||||
}
|
||||
|
||||
TEST(I2CAddressClaimPolicy, RejectsReservedAndOutOfRangeAddresses) {
|
||||
EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x00));
|
||||
EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x07));
|
||||
EXPECT_TRUE(mesh::isValidI2cPeripheralAddress(0x08));
|
||||
EXPECT_TRUE(mesh::isValidI2cPeripheralAddress(0x42));
|
||||
EXPECT_TRUE(mesh::isValidI2cPeripheralAddress(0x77));
|
||||
EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x78));
|
||||
EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0xFF));
|
||||
EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x142));
|
||||
EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x10042));
|
||||
}
|
||||
|
||||
TEST(I2CAddressClaimPolicy, RejectsUnsafePinPairsBeforeGpioAccess) {
|
||||
EXPECT_TRUE(mesh::isValidI2cPinPair(8, 7, 23));
|
||||
EXPECT_FALSE(mesh::isValidI2cPinPair(-1, 7, 23));
|
||||
EXPECT_FALSE(mesh::isValidI2cPinPair(8, -1, 23));
|
||||
EXPECT_FALSE(mesh::isValidI2cPinPair(23, 7, 23));
|
||||
EXPECT_FALSE(mesh::isValidI2cPinPair(8, 23, 23));
|
||||
EXPECT_FALSE(mesh::isValidI2cPinPair(7, 7, 23));
|
||||
}
|
||||
|
||||
TEST(I2CAddressClaimPolicy, ClassifiesInaIdentityWithoutAssumingDynamicBytesMatch) {
|
||||
using Status = mesh::I2cRegisterProbeStatus;
|
||||
using Result = mesh::I2cIdentityProbeResult;
|
||||
constexpr uint16_t kManufacturer = 0x5449;
|
||||
constexpr uint16_t kDie = 0x3220;
|
||||
|
||||
EXPECT_EQ(mesh::classifyIna3221Identity(
|
||||
Status::Success, kManufacturer,
|
||||
Status::Success, kDie, kManufacturer, kDie),
|
||||
Result::Match);
|
||||
EXPECT_EQ(mesh::classifyIna3221Identity(
|
||||
Status::NoResponse, 0,
|
||||
Status::Inconclusive, 0, kManufacturer, kDie),
|
||||
Result::NoResponse);
|
||||
EXPECT_EQ(mesh::classifyIna3221Identity(
|
||||
Status::Success, kManufacturer,
|
||||
Status::Inconclusive, 0, kManufacturer, kDie),
|
||||
Result::Inconclusive);
|
||||
EXPECT_EQ(mesh::classifyIna3221Identity(
|
||||
Status::Success, kManufacturer,
|
||||
Status::Success, 0x1234, kManufacturer, kDie),
|
||||
Result::Inconclusive);
|
||||
EXPECT_EQ(mesh::classifyIna3221Identity(
|
||||
Status::Success, 0x0001,
|
||||
Status::Success, 0x0002, kManufacturer, kDie),
|
||||
Result::NotMatch);
|
||||
EXPECT_EQ(mesh::classifyIna3221Identity(
|
||||
Status::Success, 0x0001,
|
||||
Status::Success, kManufacturer, kManufacturer, kDie),
|
||||
Result::Inconclusive);
|
||||
}
|
||||
|
||||
TEST(NmeaSentenceProbe, AcceptsChecksumValidGpsTalkersIncrementally) {
|
||||
mesh::NmeaSentenceProbe gps;
|
||||
EXPECT_TRUE(feed(gps, nmeaSentence("GNGGA,123519,,,,,0,00,99.99,,,,,,")));
|
||||
EXPECT_TRUE(gps.found());
|
||||
|
||||
mesh::NmeaSentenceProbe beidou;
|
||||
EXPECT_TRUE(feed(beidou, nmeaSentence("BDRMC,123519,V,,,,,,,300826,,,N")));
|
||||
}
|
||||
|
||||
TEST(NmeaSentenceProbe, RejectsNoiseBadChecksumsAndNonGpsTalkers) {
|
||||
mesh::NmeaSentenceProbe probe;
|
||||
EXPECT_FALSE(feed(probe, "noise\x00\xff$GNGGA,123*00\r\n"));
|
||||
EXPECT_FALSE(feed(probe, nmeaSentence("AAGGA,123519,,,,,0,00,99.99,,,,,,")));
|
||||
EXPECT_FALSE(probe.found());
|
||||
}
|
||||
|
||||
TEST(NmeaSentenceProbe, NewDollarRestartsAnIncompleteSentence) {
|
||||
mesh::NmeaSentenceProbe probe;
|
||||
const std::string valid = nmeaSentence("GPGGA,123519,,,,,0,00,99.99,,,,,,");
|
||||
EXPECT_TRUE(feed(probe, "$GPGGA,broken" + valid));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
POLICY = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontRecoveryPolicy.h"
|
||||
CLIENT = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontClient.cpp"
|
||||
DISPLAY = ROOT / "variants/sensecap_indicator-espnow/SCIndicatorDisplay.h"
|
||||
PROFILE = ROOT / "variants/sensecap_indicator-espnow/platformio.ini"
|
||||
RP2040 = ROOT / "tools/sensecap_indicator_rp2040/src/main.cpp"
|
||||
STAGE_V2_PROTOCOL = ROOT / "src/helpers/IndicatorFontStageV2Protocol.h"
|
||||
ASSET = ROOT / "variants/sensecap_indicator-espnow/sd/ui-font.vlw"
|
||||
|
||||
|
||||
def source(path: Path) -> str:
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def function_body(text: str, signature: str, next_signature: str) -> str:
|
||||
start = text.index(signature)
|
||||
end = text.index(next_signature, start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def profile_section(name: str) -> str:
|
||||
text = source(PROFILE)
|
||||
match = re.search(
|
||||
rf"^\[{re.escape(name)}\]\n(?P<body>.*?)(?=^\[|\Z)",
|
||||
text,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing profile section {name}")
|
||||
return match.group("body")
|
||||
|
||||
|
||||
class IndicatorFontRecoveryTest(unittest.TestCase):
|
||||
def test_compiled_asset_identity_matches_checked_in_file(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
data = ASSET.read_bytes()
|
||||
|
||||
size = int(re.search(r"kAssetSize = (\d+)", policy).group(1))
|
||||
crc = int(re.search(r"kAssetCrc32 = 0x([0-9a-f]+)", policy).group(1), 16)
|
||||
sha = re.search(r'kAssetSha256\[\] =\s*"([0-9a-f]{64})"', policy).group(1)
|
||||
|
||||
self.assertEqual(len(data), size)
|
||||
self.assertEqual(zlib.crc32(data) & 0xFFFFFFFF, crc)
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), sha)
|
||||
digest_initializer = client[
|
||||
client.index("EXPECTED_SHA256[32]") : client.index(
|
||||
"enum class FontInfoResult"
|
||||
)
|
||||
]
|
||||
compiled_digest = bytes(
|
||||
int(value, 16)
|
||||
for value in re.findall(r"0x([0-9a-f]{2})", digest_initializer)
|
||||
)
|
||||
self.assertEqual(compiled_digest.hex(), sha)
|
||||
|
||||
def test_source_is_immutable_https_and_tls_is_never_disabled(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
url_literals = re.findall(
|
||||
r'"([^"]*)"',
|
||||
policy[policy.index("kAssetUrl") : policy.index("kAssetSize")],
|
||||
)
|
||||
url = "".join(url_literals)
|
||||
|
||||
self.assertRegex(
|
||||
url,
|
||||
r"^https://api\.github\.com/repos/mikecarper/MeshCore/git/blobs/"
|
||||
r"[0-9a-f]{40}$",
|
||||
)
|
||||
self.assertNotIn("setInsecure", client)
|
||||
self.assertNotIn("client.setCACertBundle(", client)
|
||||
self.assertIn("client.setCACert(FONT_TLS_GITHUB_ROOT_CA)", client)
|
||||
self.assertIn("Accept: application/vnd.github.raw+json", client)
|
||||
self.assertIn("X-GitHub-Api-Version: 2026-03-10", client)
|
||||
self.assertIn("parseHttpStatusCode(line, status_code)", client)
|
||||
|
||||
pem_match = re.search(
|
||||
r'FONT_TLS_GITHUB_ROOT_CA\[\].*?R"CERT\((.*?)\)CERT";',
|
||||
client,
|
||||
re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(pem_match)
|
||||
pem_lines = [
|
||||
line.strip() for line in pem_match.group(1).strip().splitlines()
|
||||
]
|
||||
self.assertEqual(pem_lines[0], "-----BEGIN CERTIFICATE-----")
|
||||
self.assertEqual(pem_lines[-1], "-----END CERTIFICATE-----")
|
||||
der = base64.b64decode("".join(pem_lines[1:-1]), validate=True)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(der).hexdigest(),
|
||||
"c90f26f0fb1b4018b22227519b5ca2b53e2ca5b3be5cf18efe1bef47380c5383",
|
||||
)
|
||||
|
||||
def test_wifi_only_recovery_bootstraps_a_bounded_tls_clock(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
proof = function_body(
|
||||
client, "bool fontTlsClockProofValid", "bool prepareTlsClock"
|
||||
)
|
||||
callback = function_body(
|
||||
client, "void clearFontNtpCallback", "bool fontTlsClockProofValid"
|
||||
)
|
||||
clock = function_body(
|
||||
client, "bool prepareTlsClock", "uint32_t remainingHttpHeaderTimeout"
|
||||
)
|
||||
response = function_body(
|
||||
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
||||
)
|
||||
transfer = function_body(
|
||||
client,
|
||||
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
||||
"void finishRecoveryFailure",
|
||||
)
|
||||
|
||||
self.assertIn("kAssetPublishedEpoch = 1787708237UL", policy)
|
||||
self.assertIn("kNtpSyncWaitMillis = 15000UL", policy)
|
||||
self.assertIn(
|
||||
"fontNtpTimeReceived.store(false, std::memory_order_release)",
|
||||
clock,
|
||||
)
|
||||
self.assertIn("sntp_set_time_sync_notification_cb(noteFontNtpTime)", clock)
|
||||
self.assertIn("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)", clock)
|
||||
self.assertIn('configTime(0, 0, "time.cloudflare.com"', clock)
|
||||
self.assertIn("OperationLease sntp_operation", clock)
|
||||
self.assertIn("processWideCoordinator()", clock)
|
||||
self.assertIn("if (!sntp_operation.tryAcquire())", clock)
|
||||
self.assertNotIn("sntp_set_time_sync_notification_cb(nullptr)", clock)
|
||||
self.assertIn("sntp_set_time_sync_notification_cb(nullptr)", callback)
|
||||
self.assertIn("processWideCoordinator().owns(generation)", callback)
|
||||
self.assertIn(
|
||||
"fontNtpTimeReceived.load(std::memory_order_acquire)", proof
|
||||
)
|
||||
self.assertIn("WiFi.status() == WL_CONNECTED", proof)
|
||||
self.assertIn("mesh::tls_clock::proofIsValid", proof)
|
||||
self.assertIn("mesh::tls_clock::proofAgeIsValid", proof)
|
||||
self.assertIn("mesh::tls_clock::proofGenerationIsValid", proof)
|
||||
self.assertIn("FONT_TLS_PROOF_MAX_AGE_MS = 300000UL", client)
|
||||
self.assertIn("const time_t now = time(nullptr)", clock)
|
||||
self.assertIn("fontTlsClockProofValid(now)", clock)
|
||||
self.assertIn(
|
||||
"now >= (time_t)mesh::indicator_font::kAssetPublishedEpoch", clock
|
||||
)
|
||||
self.assertNotIn("(uint32_t)time(nullptr)", clock)
|
||||
self.assertIn("WiFi.status() != WL_CONNECTED", clock)
|
||||
self.assertNotIn("alreadyPlausible", clock)
|
||||
self.assertTrue(clock.rstrip().endswith("}"))
|
||||
self.assertIn("return false;", clock)
|
||||
self.assertIn("prepareTlsClock()", transfer)
|
||||
self.assertLess(
|
||||
transfer.index("prepareTlsClock()"),
|
||||
transfer.index("openAssetResponse("),
|
||||
)
|
||||
self.assertIn("const time_t tls_now = time(nullptr)", response)
|
||||
proof_check = response.index("fontTlsClockProofValid(tls_now)")
|
||||
tls_connect = response.index("client.connect(host, 443)")
|
||||
self.assertLess(proof_check, tls_connect)
|
||||
self.assertIn(
|
||||
"std::atomic<bool> fontNtpTimeReceived{false}", client
|
||||
)
|
||||
self.assertIn(
|
||||
"fontNtpTimeReceived.store(true, std::memory_order_release)",
|
||||
client,
|
||||
)
|
||||
|
||||
def test_release_source_has_no_forced_download_interruption(self):
|
||||
client = source(CLIENT)
|
||||
self.assertNotIn("DOWNLOAD_TEST_INTERRUPT_AT", client)
|
||||
self.assertNotIn("test hook: closing initial HTTP response", client)
|
||||
|
||||
def test_http_headers_have_one_total_deadline(self):
|
||||
client = source(CLIENT)
|
||||
response = function_body(
|
||||
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
||||
)
|
||||
self.assertIn("HTTP_HEADER_TOTAL_TIMEOUT_MS = 30000UL", client)
|
||||
self.assertIn("remainingHttpHeaderTimeout(headerStarted)", response)
|
||||
self.assertNotIn(
|
||||
"readHttpLine(client, line, sizeof(line), HTTP_LINE_TIMEOUT_MS)",
|
||||
response,
|
||||
)
|
||||
|
||||
def test_http_wire_parser_is_bounded_and_rejects_ambiguous_framing(self):
|
||||
client = source(CLIENT)
|
||||
reader = function_body(
|
||||
client, "HttpLineResult readHttpLine", "bool isSecurityCriticalHttpHeader"
|
||||
)
|
||||
decimal = function_body(
|
||||
client, "bool parseUnsignedDecimalHeader", "bool parseHttpStatusCode"
|
||||
)
|
||||
response = function_body(
|
||||
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
||||
)
|
||||
|
||||
self.assertRegex(client, r"HTTP_HEADER_LIMIT = (?:6[4-9]|[7-9][0-9]|\d{3,})")
|
||||
self.assertIn("HTTP_WIRE_LINE_LIMIT = 2048", client)
|
||||
drain = reader.index("while (client.available())")
|
||||
self.assertIn(
|
||||
"millis() - started >= timeout_millis", reader[drain:]
|
||||
)
|
||||
self.assertIn("++wire_length > HTTP_WIRE_LINE_LIMIT", reader)
|
||||
self.assertIn("HttpLineResult::TooLong", reader)
|
||||
|
||||
# Decimal framing accepts digits only, with optional surrounding OWS;
|
||||
# strtoul-style signs and overflow are not accepted.
|
||||
self.assertIn("*value < '0' || *value > '9'", decimal)
|
||||
self.assertIn("UINT64_MAX - digit", decimal)
|
||||
self.assertNotIn("strtoul", response)
|
||||
self.assertIn("parsed > MAX_FONT_BYTES", response)
|
||||
|
||||
# Reject every obsolete folded field before handling either retained
|
||||
# or overflowed extension headers.
|
||||
obs_fold = response.index("if (line[0] == ' ' || line[0] == '\\t')")
|
||||
overflow = response.index("if (line_result == HttpLineResult::Overflow)")
|
||||
self.assertLess(obs_fold, overflow)
|
||||
|
||||
def test_non_200_headers_are_parsed_and_failures_are_classified(self):
|
||||
client = source(CLIENT)
|
||||
response = function_body(
|
||||
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
||||
)
|
||||
classify = function_body(
|
||||
client, "RecoveryAttemptOutcome classifyHttpResponse", "bool splitHttpsUrl"
|
||||
)
|
||||
finish = function_body(
|
||||
client,
|
||||
"void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome)",
|
||||
"void finishRecoveryFailure()",
|
||||
)
|
||||
|
||||
# The status is recorded first, but classification happens only after
|
||||
# Retry-After and X-RateLimit-Reset have been read from the full block.
|
||||
self.assertIn('"Retry-After:"', response)
|
||||
self.assertIn('"X-RateLimit-Reset:"', response)
|
||||
self.assertGreater(
|
||||
response.index("classifyHttpResponse("),
|
||||
response.index("while (header_count <= HTTP_HEADER_LIMIT)"),
|
||||
)
|
||||
self.assertNotIn("status_code != 200", response)
|
||||
|
||||
self.assertIn("status_code == 403 || status_code == 429", classify)
|
||||
self.assertIn("retry_after_seconds", classify)
|
||||
self.assertIn("rate_reset_epoch", classify)
|
||||
self.assertIn("HTTP_RATE_LIMIT_MIN_DELAY_MS", classify)
|
||||
self.assertIn("HTTP_RATE_LIMIT_FALLBACK_DELAY_MS", classify)
|
||||
self.assertIn("RecoveryAttemptDisposition::RateLimited", classify)
|
||||
self.assertIn("status_code >= 500 && status_code <= 599", classify)
|
||||
self.assertIn("RecoveryAttemptDisposition::RetryableFailure", classify)
|
||||
self.assertTrue(
|
||||
classify.rstrip().endswith(
|
||||
"return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);\n}"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIn("outcome.retryDelayMillis > delay_millis", finish)
|
||||
self.assertIn("outcome.retryDelayMillis", finish)
|
||||
self.assertIn(
|
||||
"outcome.disposition == RecoveryAttemptDisposition::PermanentFailure",
|
||||
finish,
|
||||
)
|
||||
self.assertIn("recoveryState = RecoveryState::Exhausted", finish)
|
||||
|
||||
def test_valid_current_font_does_not_arm_download(self):
|
||||
client = source(CLIENT)
|
||||
load = client[client.index("uint8_t* IndicatorFontClient::load") :]
|
||||
self.assertIn("if (!loaded.currentAsset)", load)
|
||||
self.assertNotIn(
|
||||
"armRecovery(mesh::indicator_font::RecoveryNeed::None)", load
|
||||
)
|
||||
self.assertIn("loaded.currentAsset = loaded.size ==", client)
|
||||
self.assertIn("memcmp(digest, EXPECTED_SHA256", client)
|
||||
|
||||
def test_length_and_digest_fail_before_commit(self):
|
||||
client = source(CLIENT)
|
||||
response = function_body(
|
||||
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
||||
)
|
||||
transfer = function_body(
|
||||
client,
|
||||
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
||||
"void finishRecoveryFailure",
|
||||
)
|
||||
|
||||
self.assertIn("mesh::indicator_font::kAssetSize - requested_offset", response)
|
||||
self.assertIn("content_length != expected_content_length", response)
|
||||
self.assertIn("Transfer-Encoding:", response)
|
||||
self.assertIn("Content-Encoding:", response)
|
||||
self.assertIn("HTTP_HEADER_LIMIT", response)
|
||||
self.assertIn("DOWNLOAD_TOTAL_TIMEOUT_MS", transfer)
|
||||
self.assertIn("DOWNLOAD_IDLE_TIMEOUT_MS", transfer)
|
||||
self.assertLess(transfer.index("digest_ok"), transfer.index("MCFONT COMMIT"))
|
||||
self.assertLess(transfer.index("if (!digest_ok)"), transfer.index("Serial2.begin"))
|
||||
|
||||
def test_range_resume_keeps_one_buffer_and_sha_stream_and_is_strict(self):
|
||||
client = source(CLIENT)
|
||||
response = function_body(
|
||||
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
||||
)
|
||||
transfer = function_body(
|
||||
client,
|
||||
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
||||
"void finishRecoveryFailure",
|
||||
)
|
||||
content_range = function_body(
|
||||
client, "bool parseContentRangeHeader", "bool parseStrongEtagHeader"
|
||||
)
|
||||
strong_etag = function_body(
|
||||
client, "bool parseStrongEtagHeader", "bool parseHttpStatusCode"
|
||||
)
|
||||
|
||||
self.assertIn("DOWNLOAD_MAX_RESUME_RECONNECTS = 2", client)
|
||||
self.assertIn("const bool is_resume = expected_etag != nullptr", response)
|
||||
self.assertIn('"Range: bytes=%lu-%lu\\r\\nIf-Range: %s\\r\\n"', response)
|
||||
self.assertIn("const int expected_status = is_resume ? 206 : 200", response)
|
||||
self.assertIn("status_code != expected_status", response)
|
||||
self.assertIn("content_length != expected_content_length", response)
|
||||
self.assertIn("parseContentRangeHeader", response)
|
||||
self.assertIn("parseStrongEtagHeader", response)
|
||||
self.assertIn("content_range_first != requested_offset", response)
|
||||
self.assertIn(
|
||||
"content_range_last != mesh::indicator_font::kAssetSize - 1",
|
||||
response,
|
||||
)
|
||||
self.assertIn(
|
||||
"content_range_total != mesh::indicator_font::kAssetSize", response
|
||||
)
|
||||
self.assertIn("strcmp(response_etag, expected_etag) != 0", response)
|
||||
self.assertIn("if (content_range_seen)", response)
|
||||
self.assertIn("!etag_seen || !etag_valid", response)
|
||||
self.assertIn("transfer_encoding_seen", response)
|
||||
self.assertIn("!content_encoding_identity", response)
|
||||
|
||||
# Content-Range and reflected If-Range inputs use strict decimal and
|
||||
# quoted-tag grammars; signs, overflow, weak tags, control bytes, and
|
||||
# embedded quotes cannot pass or inject another request field.
|
||||
self.assertIn('strncasecmp(value, "bytes ", 6)', content_range)
|
||||
self.assertIn("parsed_first > parsed_last", content_range)
|
||||
self.assertIn("parsed_last >= parsed_total", content_range)
|
||||
self.assertIn("parsed_total > MAX_FONT_BYTES", content_range)
|
||||
self.assertIn("value[0] != '\"'", strong_etag)
|
||||
self.assertIn("value[length - 1] != '\"'", strong_etag)
|
||||
self.assertIn("byte < 0x21 || byte > 0x7e || byte == '\"'", strong_etag)
|
||||
|
||||
# One fresh SNTP reply covers the bounded 180-second transfer. Every
|
||||
# initial/Range TLS handshake revalidates that proof and the signed
|
||||
# wall clock centrally in openAssetResponse().
|
||||
self.assertEqual(transfer.count("prepareTlsClock()"), 1)
|
||||
proof_check = response.index("fontTlsClockProofValid(tls_now)")
|
||||
tls_connect = response.index("client.connect(host, 443)")
|
||||
self.assertLess(proof_check, tls_connect)
|
||||
self.assertEqual(
|
||||
transfer.count("mbedtls_sha256_starts_ret(&sha, 0)"), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
transfer.count("ps_malloc(mesh::indicator_font::kAssetSize)"), 1
|
||||
)
|
||||
initial = transfer.index("client, 0, nullptr, asset_etag")
|
||||
resume = transfer.index("client, received, asset_etag, nullptr, 0")
|
||||
self.assertLess(initial, resume)
|
||||
self.assertIn(
|
||||
"resume_reconnects >= DOWNLOAD_MAX_RESUME_RECONNECTS", transfer
|
||||
)
|
||||
self.assertIn("discarding %lu unverified bytes", transfer)
|
||||
self.assertIn("last_progress = millis()", transfer[resume:])
|
||||
|
||||
# `received` moves only after the complete block has entered the one
|
||||
# SHA stream. A failed partial block is overwritten at that boundary.
|
||||
update = transfer.index("mbedtls_sha256_update_ret")
|
||||
advance = transfer.index("received += wanted", update)
|
||||
resume_call = transfer.index("openAssetResponse(", advance)
|
||||
self.assertLess(update, advance)
|
||||
self.assertLess(advance, resume_call)
|
||||
self.assertNotIn("received += filled", transfer)
|
||||
self.assertEqual(transfer.count("uint32_t started = millis();"), 1)
|
||||
self.assertNotIn("\n started = millis();", transfer[resume:])
|
||||
self.assertLess(transfer.index("digest_ok"), transfer.index("Serial2.begin"))
|
||||
|
||||
def test_download_is_buffered_before_receiver_paced_staging(self):
|
||||
client = source(CLIENT)
|
||||
protocol = source(STAGE_V2_PROTOCOL)
|
||||
transfer = function_body(
|
||||
client,
|
||||
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
||||
"void finishRecoveryFailure",
|
||||
)
|
||||
self.assertIn("kStageV2ChunkBytes = 512", protocol)
|
||||
self.assertIn("ps_malloc(mesh::indicator_font::kAssetSize)", transfer)
|
||||
self.assertIn("if (wanted > 16 * 1024) wanted = 16 * 1024", transfer)
|
||||
self.assertIn("client.read(asset + received + filled", transfer)
|
||||
self.assertLess(transfer.index("client.stop()"), transfer.index("Serial2.begin"))
|
||||
self.assertLess(transfer.index("if (!digest_ok)"), transfer.index("Serial2.begin"))
|
||||
self.assertIn("free(asset)", transfer)
|
||||
self.assertIn("writeAll(Serial2, asset + staged_bytes", transfer)
|
||||
self.assertIn("stagedChunkAcknowledged(Serial2, next_staged)", transfer)
|
||||
self.assertIn("stageV2ChunkSize(", transfer)
|
||||
self.assertIn("advanceStageV2Offset(", transfer)
|
||||
self.assertIn("delay(LEGACY_STAGE_PACE_MS)", transfer)
|
||||
|
||||
def test_stage_v2_is_receiver_paced_and_legacy_fallback_is_explicit(self):
|
||||
client = source(CLIENT)
|
||||
rp = source(RP2040)
|
||||
protocol = source(STAGE_V2_PROTOCOL)
|
||||
negotiate = function_body(
|
||||
client, "bool beginStagedUpload", "bool stagedChunkAcknowledged"
|
||||
)
|
||||
acknowledge = function_body(
|
||||
client,
|
||||
"bool stagedChunkAcknowledged",
|
||||
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
||||
)
|
||||
receiver = function_body(
|
||||
rp, "void receiveFontStage2", "struct CommandReader"
|
||||
)
|
||||
|
||||
self.assertIn("IndicatorFontStageV2Protocol.h", client)
|
||||
self.assertIn("IndicatorFontStageV2Protocol.h", rp)
|
||||
self.assertIn("kStageV2ChunkBytes = 512", protocol)
|
||||
self.assertIn('kStageV2ReadyReply[] = "READY 2 512"', protocol)
|
||||
self.assertIn(
|
||||
'kStageV2LegacyUnsupportedReply[] = "ERROR COMMAND"', protocol
|
||||
)
|
||||
self.assertIn('"MCFONT STAGEV2 %lu %08lx %lu"', negotiate)
|
||||
self.assertNotIn('"MCFONT STAGE2 ', negotiate)
|
||||
self.assertNotIn('"MCFONT STAGE2 ', rp)
|
||||
self.assertIn("classifyStageV2BeginReply(replied, reply)", negotiate)
|
||||
self.assertIn("StageV2BeginAction::UseAcknowledged", negotiate)
|
||||
self.assertIn("StageV2BeginAction::UseLegacy", negotiate)
|
||||
# A missing/delayed protocol-2 reply fails closed. Only the old
|
||||
# service's explicit unsupported-command response can reach STAGE.
|
||||
timeout_failure = negotiate.index("action !=")
|
||||
legacy_command = negotiate.index('"MCFONT STAGE %lu %08lx"')
|
||||
self.assertLess(timeout_failure, legacy_command)
|
||||
self.assertIn("return false;", negotiate[timeout_failure:legacy_command])
|
||||
self.assertIn("if (!replied)", negotiate[timeout_failure:legacy_command])
|
||||
|
||||
self.assertIn("parseStageV2Ack(reply, expected_offset)", acknowledge)
|
||||
self.assertNotIn("strtoul", acknowledge)
|
||||
|
||||
self.assertIn("RECEIVE_TOTAL_TIMEOUT_MS = 180000", rp)
|
||||
self.assertIn("output.print(mesh::indicator_font::kStageV2ReadyReply)", receiver)
|
||||
self.assertIn("stageV2ChunkSize(expectedSize, received)", receiver)
|
||||
fragment_loop = receiver.index("while (chunkReceived < chunkSize)")
|
||||
write = receiver.index("font.write(buffer, chunkSize)")
|
||||
ack = receiver.index('output.printf("ACK %lu\\n"')
|
||||
self.assertLess(fragment_loop, write)
|
||||
self.assertIn("chunkReceived += actual", receiver[fragment_loop:write])
|
||||
self.assertLess(write, ack)
|
||||
self.assertIn("advanceStageV2Offset(", receiver[write:ack])
|
||||
self.assertIn("received = nextReceived", receiver[write:ack])
|
||||
checksum = receiver.index("~crc != expectedCrc")
|
||||
metadata = receiver.index("writeMetadata(STAGED_META_PATH")
|
||||
staged = receiver.index('output.print("STAGED\\n")')
|
||||
close = receiver.index("font.close()")
|
||||
post_close_deadline = receiver.index(
|
||||
"espStage2LastElapsedMs >= RECEIVE_TOTAL_TIMEOUT_MS", close
|
||||
)
|
||||
failure_handling = receiver.index(
|
||||
"if (failure != Stage2Result::Idle)", post_close_deadline
|
||||
)
|
||||
self.assertLess(ack, checksum)
|
||||
self.assertLess(close, post_close_deadline)
|
||||
self.assertLess(post_close_deadline, failure_handling)
|
||||
self.assertLess(checksum, metadata)
|
||||
self.assertLess(metadata, staged)
|
||||
self.assertIn(
|
||||
'"MCFONT STAGEV2 %lu %lx %lu %n"',
|
||||
rp,
|
||||
)
|
||||
self.assertIn("reader.line[consumed] == 0", rp)
|
||||
|
||||
def test_rp2040_retries_a_transient_sd_mount_for_a_bounded_window(self):
|
||||
rp = source(RP2040)
|
||||
mount = function_body(rp, "bool mountSdCard", "void serviceSdMount")
|
||||
service = function_body(rp, "void serviceSdMount", "void sendInfo")
|
||||
setup = function_body(rp, "void setup()", "void loop()")
|
||||
loop = rp[rp.index("void loop()") :]
|
||||
|
||||
self.assertIn("SD_MOUNT_RETRY_INTERVAL_MS = 2000", rp)
|
||||
self.assertIn("SD_MOUNT_MAX_ATTEMPTS = 30", rp)
|
||||
self.assertIn("sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS", mount)
|
||||
self.assertIn("if (sdMountAttempts != 0) SD.end(false)", mount)
|
||||
self.assertLess(mount.index("++sdMountAttempts"), mount.index("SD.begin("))
|
||||
self.assertIn("sdMountNextAttempt = millis() +", mount)
|
||||
self.assertIn("sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS", service)
|
||||
self.assertIn("now - sdMountNextAttempt", service)
|
||||
self.assertIn("serviceSdMount(millis())", setup)
|
||||
self.assertIn("serviceSdMount(millis())", loop)
|
||||
self.assertNotIn("SD.begin(", setup)
|
||||
|
||||
def test_legacy_stage_and_usb_put_have_idle_and_total_deadlines(self):
|
||||
rp = source(RP2040)
|
||||
receive = function_body(rp, "void receiveFont(Stream&", "// STAGEV2")
|
||||
|
||||
self.assertIn("const uint32_t started = millis()", receive)
|
||||
self.assertIn("now - started >= RECEIVE_TOTAL_TIMEOUT_MS", receive)
|
||||
self.assertIn("now - lastProgress >= RECEIVE_IDLE_TIMEOUT_MS", receive)
|
||||
self.assertIn("timedOut = true", receive)
|
||||
self.assertGreater(
|
||||
receive.index("millis() - started >= RECEIVE_TOTAL_TIMEOUT_MS"),
|
||||
receive.index("font.close()"),
|
||||
)
|
||||
self.assertIn('timedOut ? "ERROR TIMEOUT\\n"', receive)
|
||||
|
||||
def test_interrupted_stage_cannot_replace_live_font(self):
|
||||
rp = source(RP2040)
|
||||
recovery = function_body(rp, "void recoverFontTransaction", "bool refreshFontInfo")
|
||||
mount = function_body(rp, "bool mountSdCard", "void serviceSdMount")
|
||||
stage_install = function_body(rp, "bool installStagedPair", "enum class ReceiveMode")
|
||||
receive = function_body(rp, "void receiveFont", "struct CommandReader")
|
||||
|
||||
self.assertNotIn("STAGED_FONT_PATH", recovery)
|
||||
self.assertNotIn("STAGED_META_PATH", recovery)
|
||||
self.assertIn("cleanStagedFiles();", mount)
|
||||
self.assertLess(stage_install.index("validatePairCrc"),
|
||||
stage_install.index("installTemporaryPair"))
|
||||
self.assertIn("removeIfPresent(destinationFont)", receive)
|
||||
self.assertIn("ReceiveMode::StageOnly", receive)
|
||||
self.assertIn('output.print("STAGED\\n")', receive)
|
||||
|
||||
def test_interrupted_pair_renames_are_crc_checked_and_recoverable(self):
|
||||
rp = source(RP2040)
|
||||
validator = function_body(
|
||||
rp, "bool validateStoredPairCrc", "void removeIfPresent"
|
||||
)
|
||||
promotion = function_body(rp, "bool promotePair", "void recoverFontTransaction")
|
||||
recovery = function_body(
|
||||
rp, "void recoverFontTransaction", "bool refreshFontInfo"
|
||||
)
|
||||
|
||||
self.assertIn("validatePairCrc(fontPath, metadataPath, size, crc)", validator)
|
||||
for font_path, metadata_path in (
|
||||
("FONT_PATH", "TEMP_META_PATH"),
|
||||
("FONT_PATH", "BACKUP_META_PATH"),
|
||||
("BACKUP_FONT_PATH", "FONT_META_PATH"),
|
||||
("TEMP_FONT_PATH", "TEMP_META_PATH"),
|
||||
("BACKUP_FONT_PATH", "BACKUP_META_PATH"),
|
||||
):
|
||||
self.assertIn(
|
||||
f"validateStoredPairCrc({font_path}, {metadata_path}", recovery
|
||||
)
|
||||
|
||||
# A reset while moving the old pair to backup leaves its metadata at
|
||||
# the live name. Complete that backup before trying the new temp pair,
|
||||
# so a failed temp promotion still has a rollback candidate.
|
||||
split_backup = recovery.index(
|
||||
"validateStoredPairCrc(BACKUP_FONT_PATH, FONT_META_PATH"
|
||||
)
|
||||
temp_promotion = recovery.index(
|
||||
"validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH"
|
||||
)
|
||||
self.assertLess(split_backup, temp_promotion)
|
||||
self.assertIn("SD.rename(FONT_META_PATH, BACKUP_META_PATH)", recovery)
|
||||
|
||||
# Once the source font has moved, a metadata-rename failure must leave
|
||||
# the split pair intact. Recovery also stops after trying any CRC-valid
|
||||
# candidate so later fallbacks/cleanup cannot erase that retry state.
|
||||
self.assertEqual(promotion.count("removeIfPresent(FONT_PATH)"), 1)
|
||||
self.assertNotIn(
|
||||
"if (SD.rename(sourceMetadata, FONT_META_PATH)) return true;\n"
|
||||
" removeIfPresent(FONT_PATH)",
|
||||
promotion,
|
||||
)
|
||||
self.assertIn(
|
||||
"if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH, size, crc))",
|
||||
recovery,
|
||||
)
|
||||
self.assertIn(
|
||||
"if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc))",
|
||||
recovery,
|
||||
)
|
||||
temp_start = recovery.index(
|
||||
"if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH"
|
||||
)
|
||||
backup_start = recovery.index(
|
||||
"if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH"
|
||||
)
|
||||
cleanup_start = recovery.index("removeIfPresent(FONT_PATH)", backup_start)
|
||||
for block in (
|
||||
recovery[temp_start:backup_start],
|
||||
recovery[backup_start:cleanup_start],
|
||||
):
|
||||
self.assertIn("promotePair", block)
|
||||
self.assertIn("return;", block)
|
||||
|
||||
# Keep the ordinary healthy-boot INFO path fast; full-file CRC reads
|
||||
# are reserved for abnormal transaction candidates.
|
||||
healthy = recovery.index("validatePair(FONT_PATH, FONT_META_PATH")
|
||||
first_transaction_crc = recovery.index("validateStoredPairCrc(")
|
||||
self.assertLess(healthy, first_transaction_crc)
|
||||
|
||||
def test_internal_uart_commit_is_separate_from_usb_put(self):
|
||||
rp = source(RP2040)
|
||||
self.assertIn(
|
||||
"CommandReader usbCommands = {Serial, Serial, true, false, false}", rp
|
||||
)
|
||||
self.assertIn(
|
||||
"CommandReader espCommands = {Serial1, Serial1, false, true, true}", rp
|
||||
)
|
||||
self.assertIn("if (!reader.allowStagedUpload)", rp)
|
||||
self.assertIn("if (!reader.allowUpload)", rp)
|
||||
|
||||
def test_retry_budget_and_backoff_are_bounded(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
self.assertIn("kMaximumAttemptsPerBoot = 4", policy)
|
||||
self.assertIn("30000UL", policy)
|
||||
self.assertIn("120000UL", policy)
|
||||
self.assertIn("600000UL", policy)
|
||||
self.assertIn(
|
||||
"recoveryAttempts >= mesh::indicator_font::kMaximumAttemptsPerBoot",
|
||||
client,
|
||||
)
|
||||
self.assertIn("RecoveryState::Exhausted", client)
|
||||
self.assertIn("WiFi.status() == WL_CONNECTED", client)
|
||||
|
||||
def test_initial_unavailable_service_gets_bounded_post_wifi_reprobe(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
load = function_body(
|
||||
client,
|
||||
"uint8_t* IndicatorFontClient::load",
|
||||
"uint8_t* IndicatorFontClient::serviceRecovery",
|
||||
)
|
||||
probe = function_body(client, "void serviceProbeTask", "void recoveryTask")
|
||||
service = function_body(
|
||||
client,
|
||||
"uint8_t* IndicatorFontClient::serviceRecovery",
|
||||
"void IndicatorFontClient::noteRuntimeFontInstalled",
|
||||
)
|
||||
|
||||
self.assertIn("armServiceProbe();", load)
|
||||
self.assertIn("RecoveryState::ProbeWaiting", service)
|
||||
self.assertIn("WiFi.status() == WL_CONNECTED", service)
|
||||
self.assertIn("++serviceProbeAttempts", service)
|
||||
self.assertIn("xTaskCreatePinnedToCore(serviceProbeTask", service)
|
||||
|
||||
self.assertIn("LoadedFont loaded = loadFromService();", probe)
|
||||
self.assertIn("if (loaded.currentAsset)", probe)
|
||||
self.assertIn("RecoveryState::Ready", probe)
|
||||
self.assertIn("RecoveryNeed::Missing", probe)
|
||||
self.assertIn("RecoveryNeed::Corrupt", probe)
|
||||
self.assertIn("RecoveryNeed::VersionMismatch, true", probe)
|
||||
self.assertIn("finishServiceProbeFailure();", probe)
|
||||
|
||||
self.assertIn("kMaximumServiceProbeAttemptsPerBoot = 4", policy)
|
||||
self.assertIn("serviceProbeRetryDelayAfter", policy)
|
||||
self.assertIn("2000UL", policy)
|
||||
self.assertIn("5000UL", policy)
|
||||
self.assertIn("15000UL", policy)
|
||||
self.assertIn(
|
||||
">= mesh::indicator_font::kMaximumServiceProbeAttemptsPerBoot",
|
||||
client,
|
||||
)
|
||||
|
||||
def test_acknowledged_commit_uses_bounded_local_reprobe_not_redownload(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
transfer = function_body(
|
||||
client,
|
||||
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
||||
"void finishRecoveryFailure",
|
||||
)
|
||||
arm = function_body(
|
||||
client, "void armPostCommitProbe", "enum class HttpLineResult"
|
||||
)
|
||||
finish = function_body(
|
||||
client,
|
||||
"void finishPostCommitProbeFailure",
|
||||
"void serviceProbeTask",
|
||||
)
|
||||
probe = function_body(
|
||||
client, "void postCommitProbeTask", "void recoveryTask"
|
||||
)
|
||||
recovery = function_body(
|
||||
client, "void recoveryTask", "#endif // INDICATOR_WIFI_FONT_RECOVERY"
|
||||
)
|
||||
service = function_body(
|
||||
client,
|
||||
"uint8_t* IndicatorFontClient::serviceRecovery",
|
||||
"void IndicatorFontClient::noteRuntimeFontInstalled",
|
||||
)
|
||||
|
||||
# An acknowledged COMMIT is distinct from a network/staging failure.
|
||||
# A transient post-commit INFO failure, or an ambiguous/missing COMMIT
|
||||
# reply, enters the local-only state. Only an explicit ERROR is allowed
|
||||
# to resume network attempts.
|
||||
self.assertIn("RecoveryAttemptDisposition::CommittedNeedsProbe", client)
|
||||
commit = transfer.index("const bool commitReplied = commandReply(")
|
||||
committed = transfer.index("const bool committed =", commit)
|
||||
rejected = transfer.index("const bool commitExplicitlyRejected", committed)
|
||||
verified = transfer.index("bool postCommitVerified = false", commit)
|
||||
probe_outcome = transfer.index(
|
||||
"RecoveryAttemptDisposition::CommittedNeedsProbe", verified
|
||||
)
|
||||
self.assertLess(commit, committed)
|
||||
self.assertLess(committed, rejected)
|
||||
self.assertLess(rejected, verified)
|
||||
self.assertLess(verified, probe_outcome)
|
||||
self.assertIn("if (!committed)", transfer[commit:probe_outcome])
|
||||
unresolved = transfer[
|
||||
transfer.index("if (!committed)", commit) :
|
||||
transfer.index("return recoveryOutcome(postCommitVerified", commit)
|
||||
]
|
||||
self.assertIn("commitExplicitlyRejected", unresolved)
|
||||
self.assertIn("RecoveryAttemptDisposition::RetryableFailure", unresolved)
|
||||
self.assertIn("RecoveryAttemptDisposition::CommittedNeedsProbe", unresolved)
|
||||
|
||||
self.assertIn("armPostCommitProbe(activateCommittedLive)", recovery)
|
||||
network_failure = function_body(
|
||||
client,
|
||||
"void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome)",
|
||||
"void finishRecoveryFailure()",
|
||||
)
|
||||
self.assertIn(
|
||||
"RecoveryAttemptDisposition::CommittedNeedsProbe",
|
||||
network_failure,
|
||||
)
|
||||
self.assertIn("RecoveryState::Exhausted", network_failure)
|
||||
local_load = recovery.index("LoadedFont loaded = loadFromService()")
|
||||
local_failure = recovery.index("loaded.data == nullptr", local_load)
|
||||
local_success = recovery.index("data = loaded.data", local_failure)
|
||||
self.assertIn("armPostCommitProbe(true)", recovery[local_failure:local_success])
|
||||
self.assertNotIn(
|
||||
"finishRecoveryFailure", recovery[local_failure:local_success]
|
||||
)
|
||||
|
||||
# The local path preserves the consumed download budget, needs no
|
||||
# Wi-Fi/NTP/TLS, and can only become Ready/Complete or retry/exhaust.
|
||||
self.assertNotIn("recoveryAttempts =", arm)
|
||||
self.assertIn("RecoveryNeed::None", arm)
|
||||
self.assertIn("RecoveryState::PostCommitProbeWaiting", arm)
|
||||
info_only = probe[
|
||||
probe.index("if (!activateLive)") :
|
||||
probe.index("LoadedFont loaded = loadFromService();")
|
||||
]
|
||||
self.assertIn("probeServiceInfo(size, crc)", info_only)
|
||||
self.assertIn("mesh::indicator_font::kAssetSize", info_only)
|
||||
self.assertIn("mesh::indicator_font::kAssetCrc32", info_only)
|
||||
self.assertIn("RecoveryState::Complete", info_only)
|
||||
self.assertNotIn("ps_malloc", info_only)
|
||||
self.assertNotIn("loadFromService", info_only)
|
||||
self.assertIn("LoadedFont loaded = loadFromService();", probe)
|
||||
self.assertIn("loaded.data != nullptr && loaded.currentAsset", probe)
|
||||
self.assertIn("RecoveryState::Ready", probe)
|
||||
self.assertIn("RecoveryState::Complete", probe)
|
||||
self.assertIn("finishPostCommitProbeFailure();", probe)
|
||||
for forbidden in (
|
||||
"downloadAndInstallAsset",
|
||||
"armRecovery",
|
||||
"finishRecoveryFailure",
|
||||
"prepareTlsClock",
|
||||
"WiFi.status",
|
||||
):
|
||||
self.assertNotIn(forbidden, probe)
|
||||
|
||||
self.assertIn(
|
||||
"kMaximumPostCommitProbeAttemptsPerBoot = 4", policy
|
||||
)
|
||||
post_commit_policy = policy[
|
||||
policy.index("postCommitProbeRetryDelayAfter") :
|
||||
policy.index("constexpr bool deadlineReached")
|
||||
]
|
||||
self.assertIn("2000UL", post_commit_policy)
|
||||
self.assertIn("5000UL", post_commit_policy)
|
||||
self.assertIn("15000UL", post_commit_policy)
|
||||
self.assertIn("RecoveryState::Exhausted", finish)
|
||||
self.assertIn("RecoveryState::PostCommitProbeWaiting", finish)
|
||||
self.assertNotIn("RecoveryState::Waiting", finish)
|
||||
|
||||
post_commit_branch = service[
|
||||
service.index("RecoveryState::PostCommitProbeWaiting") :
|
||||
service.index("RecoveryState::ProbeWaiting")
|
||||
]
|
||||
self.assertNotIn("wifiConnected", post_commit_branch)
|
||||
self.assertIn("++postCommitProbeAttempts", post_commit_branch)
|
||||
self.assertIn("launchPostCommitProbe = true", post_commit_branch)
|
||||
self.assertIn("xTaskCreatePinnedToCore(", service)
|
||||
self.assertIn("postCommitProbeTask", service)
|
||||
self.assertIn("finishPostCommitProbeFailure();", service)
|
||||
|
||||
maximum = int(
|
||||
re.search(
|
||||
r"kMaximumPostCommitProbeAttemptsPerBoot = (\d+)", policy
|
||||
).group(1)
|
||||
)
|
||||
downloads_started = 1
|
||||
state = "PostCommitProbeWaiting"
|
||||
for attempt in range(1, maximum + 1):
|
||||
state = (
|
||||
"Exhausted"
|
||||
if attempt >= maximum
|
||||
else "PostCommitProbeWaiting"
|
||||
)
|
||||
self.assertEqual(downloads_started, 1)
|
||||
self.assertEqual(state, "Exhausted")
|
||||
|
||||
def test_rejected_exact_recovered_font_cannot_redownload(self):
|
||||
client = source(CLIENT)
|
||||
display = source(DISPLAY)
|
||||
rejected = function_body(
|
||||
client,
|
||||
"void IndicatorFontClient::noteRecoveredFontInvalid",
|
||||
"#endif",
|
||||
)
|
||||
service = function_body(
|
||||
display,
|
||||
"void serviceFontRecovery",
|
||||
"void turnOn",
|
||||
)
|
||||
|
||||
self.assertIn("noteRecoveredFontInvalid()", service)
|
||||
self.assertIn("RecoveryNeed::None", rejected)
|
||||
self.assertIn("RecoveryState::Exhausted", rejected)
|
||||
self.assertNotIn("armRecovery", rejected)
|
||||
self.assertNotIn("finishRecoveryFailure", rejected)
|
||||
self.assertNotIn("finishPostCommitProbeFailure", rejected)
|
||||
self.assertNotIn("RecoveryState::Waiting", rejected)
|
||||
self.assertNotIn("RecoveryState::PostCommitProbeWaiting", rejected)
|
||||
self.assertNotIn("recoveryAttempts =", rejected)
|
||||
self.assertNotIn("downloadAndInstallAsset", rejected)
|
||||
|
||||
def test_missing_corrupt_and_version_mismatch_are_distinct(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
for state in ("Missing", "Corrupt", "VersionMismatch"):
|
||||
self.assertIn(state, policy)
|
||||
self.assertIn(f"RecoveryNeed::{state}", client)
|
||||
|
||||
def test_live_activation_only_replaces_fallback_font(self):
|
||||
policy = source(POLICY)
|
||||
client = source(CLIENT)
|
||||
display = source(DISPLAY)
|
||||
self.assertIn(
|
||||
"need == RecoveryNeed::Missing || need == RecoveryNeed::Corrupt",
|
||||
policy,
|
||||
)
|
||||
self.assertIn("!hadRuntimeFont", client)
|
||||
self.assertIn("IndicatorFontClient::serviceRecovery", display)
|
||||
self.assertIn("installRuntimeFont(fontData, fontSize)", display)
|
||||
self.assertIn("noteRuntimeFontInstalled", display)
|
||||
self.assertIn("noteRuntimeFontInvalid", display)
|
||||
|
||||
def test_tls_cost_and_global_state_are_limited_to_wifi_indicator_profile(self):
|
||||
wifi = profile_section("env:SenseCapIndicator-LoRa_comp_radio_usb_wifi")
|
||||
usb = profile_section("env:SenseCapIndicator-LoRa_comp_radio_usb")
|
||||
self.assertIn("INDICATOR_WIFI_FONT_RECOVERY=1", wifi)
|
||||
self.assertNotIn("board_build.embed_files", wifi)
|
||||
self.assertNotIn("INDICATOR_WIFI_FONT_RECOVERY", usb)
|
||||
self.assertNotIn("board_build.embed_files", usb)
|
||||
|
||||
def test_core2_timeout_and_worker_stack_fit_internal_tls_buffers(self):
|
||||
client = source(CLIENT)
|
||||
response = function_body(
|
||||
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
||||
)
|
||||
service = function_body(
|
||||
client,
|
||||
"uint8_t* IndicatorFontClient::serviceRecovery",
|
||||
"void IndicatorFontClient::noteRuntimeFontInstalled",
|
||||
)
|
||||
|
||||
self.assertIn("ESP_ARDUINO_VERSION_MAJOR >= 3", response)
|
||||
self.assertIn("HTTP_LINE_TIMEOUT_MS / 1000UL", response)
|
||||
self.assertIn("RECOVERY_TASK_STACK_BYTES = 8192UL", client)
|
||||
self.assertIn("RECOVERY_TASK_STACK_BYTES", service)
|
||||
self.assertNotIn('"indicator-font", 24576', service)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,132 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/IndicatorFontStageV2Protocol.h>
|
||||
|
||||
using mesh::indicator_font::StageV2BeginAction;
|
||||
|
||||
TEST(IndicatorFontStageV2Protocol, BeginReplyIsExactAndFallbackFailsClosed) {
|
||||
using mesh::indicator_font::classifyStageV2BeginReply;
|
||||
|
||||
EXPECT_EQ(classifyStageV2BeginReply(true, "READY 2 512"),
|
||||
StageV2BeginAction::UseAcknowledged);
|
||||
EXPECT_EQ(classifyStageV2BeginReply(true, "ERROR COMMAND"),
|
||||
StageV2BeginAction::UseLegacy);
|
||||
|
||||
// A missing response is ambiguous: the receiver may already be waiting for
|
||||
// binary data after its READY was lost. It must never trigger legacy bytes.
|
||||
EXPECT_EQ(classifyStageV2BeginReply(false, nullptr),
|
||||
StageV2BeginAction::Fail);
|
||||
EXPECT_EQ(classifyStageV2BeginReply(false, "ERROR COMMAND"),
|
||||
StageV2BeginAction::Fail);
|
||||
EXPECT_EQ(classifyStageV2BeginReply(true, nullptr),
|
||||
StageV2BeginAction::Fail);
|
||||
|
||||
for (const char* reply : {
|
||||
"", "READY", "READY 2", "READY 2 0512", "READY 2 512 ",
|
||||
"ERROR COMMAND ", "ERROR SD", "ERROR OPEN", "ERROR CHUNK",
|
||||
"STAGED",
|
||||
}) {
|
||||
EXPECT_EQ(classifyStageV2BeginReply(true, reply),
|
||||
StageV2BeginAction::Fail)
|
||||
<< reply;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(IndicatorFontStageV2Protocol, ChunkBoundariesIncludeShortFinalBlock) {
|
||||
using mesh::indicator_font::stageV2ChunkSize;
|
||||
|
||||
EXPECT_EQ(stageV2ChunkSize(0, 0), 0u);
|
||||
EXPECT_EQ(stageV2ChunkSize(64, 0), 64u);
|
||||
EXPECT_EQ(stageV2ChunkSize(64, 64), 0u);
|
||||
EXPECT_EQ(stageV2ChunkSize(511, 0), 511u);
|
||||
EXPECT_EQ(stageV2ChunkSize(512, 0), 512u);
|
||||
EXPECT_EQ(stageV2ChunkSize(512, 512), 0u);
|
||||
EXPECT_EQ(stageV2ChunkSize(513, 0), 512u);
|
||||
EXPECT_EQ(stageV2ChunkSize(513, 512), 1u);
|
||||
EXPECT_EQ(stageV2ChunkSize(513, 513), 0u);
|
||||
EXPECT_EQ(stageV2ChunkSize(513, 514), 0u);
|
||||
}
|
||||
|
||||
TEST(IndicatorFontStageV2Protocol, RealAssetHasCanonicalAckSchedule) {
|
||||
using mesh::indicator_font::advanceStageV2Offset;
|
||||
using mesh::indicator_font::kStageV2ChunkBytes;
|
||||
using mesh::indicator_font::stageV2ChunkSize;
|
||||
|
||||
constexpr size_t assetSize = 1302608;
|
||||
size_t offset = 0;
|
||||
size_t fullBlocks = 0;
|
||||
size_t shortBlocks = 0;
|
||||
size_t blocks = 0;
|
||||
while (offset < assetSize) {
|
||||
const size_t chunk = stageV2ChunkSize(assetSize, offset);
|
||||
ASSERT_GT(chunk, 0u);
|
||||
size_t next = 0;
|
||||
ASSERT_TRUE(advanceStageV2Offset(assetSize, offset, chunk, next));
|
||||
ASSERT_GT(next, offset);
|
||||
ASSERT_LE(next, assetSize);
|
||||
if (chunk == kStageV2ChunkBytes) {
|
||||
++fullBlocks;
|
||||
} else {
|
||||
++shortBlocks;
|
||||
EXPECT_EQ(chunk, 80u);
|
||||
EXPECT_EQ(next, assetSize);
|
||||
}
|
||||
offset = next;
|
||||
ASSERT_LT(++blocks, 3000u);
|
||||
}
|
||||
|
||||
EXPECT_EQ(offset, assetSize);
|
||||
EXPECT_EQ(fullBlocks, 2544u);
|
||||
EXPECT_EQ(shortBlocks, 1u);
|
||||
EXPECT_EQ(blocks, 2545u);
|
||||
}
|
||||
|
||||
TEST(IndicatorFontStageV2Protocol, OffsetAdvancesOnlyForACompleteBlock) {
|
||||
using mesh::indicator_font::advanceStageV2Offset;
|
||||
|
||||
size_t next = 999;
|
||||
EXPECT_FALSE(advanceStageV2Offset(1025, 0, 0, next));
|
||||
EXPECT_FALSE(advanceStageV2Offset(1025, 0, 511, next));
|
||||
EXPECT_FALSE(advanceStageV2Offset(1025, 0, 513, next));
|
||||
EXPECT_EQ(next, 999u);
|
||||
|
||||
ASSERT_TRUE(advanceStageV2Offset(1025, 0, 512, next));
|
||||
EXPECT_EQ(next, 512u);
|
||||
ASSERT_TRUE(advanceStageV2Offset(1025, next, 512, next));
|
||||
EXPECT_EQ(next, 1024u);
|
||||
ASSERT_TRUE(advanceStageV2Offset(1025, next, 1, next));
|
||||
EXPECT_EQ(next, 1025u);
|
||||
EXPECT_FALSE(advanceStageV2Offset(1025, next, 1, next));
|
||||
}
|
||||
|
||||
TEST(IndicatorFontStageV2Protocol, AckMustBeCanonicalAndCumulative) {
|
||||
using mesh::indicator_font::parseStageV2Ack;
|
||||
|
||||
EXPECT_TRUE(parseStageV2Ack("ACK 512", 512));
|
||||
EXPECT_TRUE(parseStageV2Ack("ACK 1024", 1024));
|
||||
EXPECT_TRUE(parseStageV2Ack("ACK 1302608", 1302608));
|
||||
|
||||
EXPECT_FALSE(parseStageV2Ack(nullptr, 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK ", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 0", 0));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 0512", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK +512", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK -512", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 512", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 512 ", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 512x", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 184467440737095516160", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("READY 2 512", 512));
|
||||
EXPECT_FALSE(parseStageV2Ack("STAGED", 512));
|
||||
|
||||
// A prior or future block's otherwise valid ACK cannot release this block.
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 512", 1024));
|
||||
EXPECT_FALSE(parseStageV2Ack("ACK 1024", 512));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/LazyPersistence.h>
|
||||
|
||||
TEST(LazyPersistence, ArmsOnlyTheFirstPendingDeadline) {
|
||||
unsigned long pending = 0;
|
||||
EXPECT_TRUE(mesh::armFirstLazyPersistence(pending, 5000, true));
|
||||
EXPECT_EQ(pending, 5000UL);
|
||||
EXPECT_FALSE(mesh::armFirstLazyPersistence(pending, 9000, true));
|
||||
EXPECT_EQ(pending, 5000UL);
|
||||
EXPECT_FALSE(mesh::armFirstLazyPersistence(pending, 9000, false));
|
||||
}
|
||||
|
||||
TEST(LazyPersistence, SuccessfulSaveClearsPendingWrite) {
|
||||
unsigned long pending = 5000;
|
||||
mesh::completeLazyPersistenceSave(pending, true, 9000);
|
||||
EXPECT_EQ(pending, 0UL);
|
||||
}
|
||||
|
||||
TEST(LazyPersistence, FailedSaveRearmsInsteadOfTightLooping) {
|
||||
unsigned long pending = 5000;
|
||||
mesh::completeLazyPersistenceSave(pending, false, 10000);
|
||||
EXPECT_EQ(pending, 10000UL);
|
||||
}
|
||||
|
||||
TEST(LazyPersistence, WrappedDeadlineNeverLosesPendingWrite) {
|
||||
unsigned long pending = 0;
|
||||
EXPECT_TRUE(mesh::armFirstLazyPersistence(pending, 0, true));
|
||||
EXPECT_EQ(pending, 1UL);
|
||||
|
||||
mesh::completeLazyPersistenceSave(pending, false, 0);
|
||||
EXPECT_EQ(pending, 1UL);
|
||||
}
|
||||
|
||||
TEST(LazyPersistence, MutationDoesNotPostponeFirstPendingWrite) {
|
||||
unsigned long pending = 0;
|
||||
uint8_t failures = 0;
|
||||
EXPECT_TRUE(mesh::scheduleLazyPersistenceMutation(
|
||||
pending, failures, 5000));
|
||||
EXPECT_FALSE(mesh::scheduleLazyPersistenceMutation(
|
||||
pending, failures, 9000));
|
||||
EXPECT_EQ(pending, 5000UL);
|
||||
EXPECT_EQ(failures, 0);
|
||||
}
|
||||
|
||||
TEST(LazyPersistence, FailedSavesBackOffAndCap) {
|
||||
uint8_t failures = 0;
|
||||
EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure(
|
||||
failures, 5000, 300000), 5000u);
|
||||
EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure(
|
||||
failures, 5000, 300000), 10000u);
|
||||
EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure(
|
||||
failures, 5000, 300000), 20000u);
|
||||
for (int i = 0; i < 20; i++) {
|
||||
EXPECT_LE(mesh::recordLazyPersistenceSaveFailure(
|
||||
failures, 5000, 300000), 300000u);
|
||||
}
|
||||
EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure(
|
||||
failures, 5000, 300000), 300000u);
|
||||
}
|
||||
|
||||
TEST(LazyPersistence, MutationCannotDefeatFailedSaveBackoff) {
|
||||
unsigned long pending = 300000;
|
||||
uint8_t failures = 6;
|
||||
EXPECT_FALSE(mesh::scheduleLazyPersistenceMutation(
|
||||
pending, failures, 5000));
|
||||
EXPECT_EQ(pending, 300000UL);
|
||||
EXPECT_EQ(failures, 6);
|
||||
}
|
||||
|
||||
TEST(LazyPersistence, SuccessfulSaveResetsTimerAndBackoff) {
|
||||
unsigned long pending = 300000;
|
||||
uint8_t failures = 6;
|
||||
mesh::resetLazyPersistenceAfterSuccess(pending, failures);
|
||||
EXPECT_EQ(pending, 0UL);
|
||||
EXPECT_EQ(failures, 0);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -13,6 +13,74 @@ TEST(MQTTConnectionPolicy, ElapsedTimeHandlesNormalAndWrappedClocks) {
|
||||
EXPECT_EQ(150U, Policy::elapsedMs(50U, before_wrap));
|
||||
}
|
||||
|
||||
TEST(MQTTConnectionPolicy, NtpRefreshHasExactDailyBoundaryAndHandlesRollover) {
|
||||
EXPECT_EQ(86400000U, Policy::kNtpRefreshIntervalMs);
|
||||
EXPECT_FALSE(Policy::ntpRefreshDue(
|
||||
Policy::kNtpRefreshIntervalMs - 1U, 0U, 0U));
|
||||
EXPECT_TRUE(Policy::ntpRefreshDue(
|
||||
Policy::kNtpRefreshIntervalMs, 0U, 0U));
|
||||
|
||||
const uint32_t last = std::numeric_limits<uint32_t>::max() - 999U;
|
||||
EXPECT_FALSE(Policy::ntpRefreshDue(
|
||||
last + Policy::kNtpRefreshIntervalMs - 1U, last, 0U));
|
||||
EXPECT_TRUE(Policy::ntpRefreshDue(
|
||||
last + Policy::kNtpRefreshIntervalMs, last, 0U));
|
||||
}
|
||||
|
||||
TEST(MQTTConnectionPolicy, NtpShortRetryOverridesDailySchedule) {
|
||||
const uint32_t retry_at = Policy::kNtpRefreshIntervalMs + 100U;
|
||||
EXPECT_FALSE(Policy::ntpRefreshDue(
|
||||
Policy::kNtpRefreshIntervalMs + 99U, 0U, retry_at));
|
||||
EXPECT_TRUE(Policy::ntpRefreshDue(retry_at, 0U, retry_at));
|
||||
}
|
||||
|
||||
TEST(MQTTConnectionPolicy, NtpRetryDeadlineHandlesZeroSentinelAtRollover) {
|
||||
EXPECT_EQ(5000U, Policy::kNtpRetryMs);
|
||||
EXPECT_EQ(5000U, Policy::ntpRetryAt(0U));
|
||||
|
||||
const uint32_t now = std::numeric_limits<uint32_t>::max() - 4999U;
|
||||
const uint32_t retry_at = Policy::ntpRetryAt(now);
|
||||
EXPECT_EQ(1U, retry_at);
|
||||
EXPECT_FALSE(Policy::ntpRefreshDue(
|
||||
std::numeric_limits<uint32_t>::max(), 0U, retry_at));
|
||||
EXPECT_FALSE(Policy::ntpRefreshDue(0U, 0U, retry_at));
|
||||
EXPECT_TRUE(Policy::ntpRefreshDue(1U, 0U, retry_at));
|
||||
}
|
||||
|
||||
TEST(MQTTConnectionPolicy, NtpReconnectRefreshDiscardsOldDeadlineSafely) {
|
||||
EXPECT_EQ(1U, Policy::ntpReconnectRefreshAt(0U));
|
||||
EXPECT_FALSE(Policy::ntpRefreshDue(
|
||||
0U, Policy::kNtpRefreshIntervalMs - 1U,
|
||||
Policy::ntpReconnectRefreshAt(0U)));
|
||||
EXPECT_TRUE(Policy::ntpRefreshDue(
|
||||
1U, Policy::kNtpRefreshIntervalMs - 1U,
|
||||
Policy::ntpReconnectRefreshAt(0U)));
|
||||
|
||||
const uint32_t reconnected_at = 123456U;
|
||||
EXPECT_EQ(reconnected_at,
|
||||
Policy::ntpReconnectRefreshAt(reconnected_at));
|
||||
EXPECT_TRUE(Policy::ntpRefreshDue(
|
||||
reconnected_at, reconnected_at - 1000U,
|
||||
Policy::ntpReconnectRefreshAt(reconnected_at)));
|
||||
}
|
||||
|
||||
TEST(MQTTConnectionPolicy, NtpReconnectLatchPreservesEdgesUntilConnected) {
|
||||
Policy::NtpReconnectLatch latch;
|
||||
|
||||
EXPECT_FALSE(latch.consumeIfConnected(true));
|
||||
latch.noteGotIp();
|
||||
EXPECT_FALSE(latch.consumeIfConnected(false));
|
||||
EXPECT_TRUE(latch.consumeIfConnected(true));
|
||||
EXPECT_FALSE(latch.consumeIfConnected(true));
|
||||
|
||||
// An edge arriving after one exchange remains available to the next task
|
||||
// tick, which is the short-flap/store-after-exchange case.
|
||||
latch.noteGotIp();
|
||||
EXPECT_TRUE(latch.consumeIfConnected(true));
|
||||
latch.noteGotIp();
|
||||
EXPECT_TRUE(latch.consumeIfConnected(true));
|
||||
}
|
||||
|
||||
TEST(MQTTConnectionPolicy, CrossSlotReconnectGuardHasExactBoundary) {
|
||||
EXPECT_TRUE(Policy::reconnectGuardActive(14999U, 0U));
|
||||
EXPECT_FALSE(Policy::reconnectGuardActive(15000U, 0U));
|
||||
@@ -295,6 +363,30 @@ TEST(StaleToken, FailedMintAlwaysDefers) {
|
||||
|
||||
using Policy::ClockSource;
|
||||
|
||||
TEST(FallbackClock, RejectsSignedFailureAndRtcRangeOverflow) {
|
||||
const uint32_t floor = 1767225600UL;
|
||||
uint32_t accepted = 1234U;
|
||||
|
||||
EXPECT_FALSE(Policy::checkedRtcEpoch(-1, floor, accepted));
|
||||
EXPECT_EQ(0U, accepted);
|
||||
EXPECT_FALSE(Policy::checkedRtcEpoch(
|
||||
static_cast<int64_t>(UINT32_MAX) + 1, floor, accepted));
|
||||
EXPECT_EQ(0U, accepted);
|
||||
}
|
||||
|
||||
TEST(FallbackClock, AcceptsOnlyPlausibleValuesWithinRtcRange) {
|
||||
const uint32_t floor = 1767225600UL;
|
||||
uint32_t accepted = 0;
|
||||
|
||||
EXPECT_FALSE(Policy::checkedRtcEpoch(
|
||||
static_cast<int64_t>(floor) - 1, floor, accepted));
|
||||
EXPECT_EQ(0U, accepted);
|
||||
EXPECT_TRUE(Policy::checkedRtcEpoch(floor, floor, accepted));
|
||||
EXPECT_EQ(floor, accepted);
|
||||
EXPECT_TRUE(Policy::checkedRtcEpoch(UINT32_MAX, floor, accepted));
|
||||
EXPECT_EQ(UINT32_MAX, accepted);
|
||||
}
|
||||
|
||||
TEST(FallbackClock, PrefersSystemThenRtcAndRejectsInvalidSources) {
|
||||
const uint32_t floor = 1767225600U;
|
||||
const uint32_t old_time = 1715770351U;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#define ARDUINO 1
|
||||
#define NRF52_PLATFORM 1
|
||||
#define MESH_DEBUG 1
|
||||
#define BRIDGE_DEBUG 1
|
||||
#define POWERSAVING_DEBUG 1
|
||||
#include <MeshCore.h>
|
||||
|
||||
namespace {
|
||||
|
||||
class CapacityStream : public Stream {
|
||||
public:
|
||||
void reset(int capacity) {
|
||||
capacity_ = capacity;
|
||||
available_calls_ = 0;
|
||||
write_calls_ = 0;
|
||||
oversized_write_ = false;
|
||||
reenter_on_write_ = false;
|
||||
reentrant_stream_ = nullptr;
|
||||
reentrant_result_ = 0;
|
||||
output_.clear();
|
||||
}
|
||||
|
||||
int availableForWrite() override {
|
||||
available_calls_++;
|
||||
return capacity_;
|
||||
}
|
||||
|
||||
size_t write(const uint8_t* buffer, size_t size) override {
|
||||
write_calls_++;
|
||||
if (size > static_cast<size_t>(capacity_)) {
|
||||
oversized_write_ = true;
|
||||
return 0;
|
||||
}
|
||||
if (reenter_on_write_) {
|
||||
reenter_on_write_ = false;
|
||||
if (reentrant_stream_ != nullptr) {
|
||||
static const uint8_t nested[] = {'n', 'e', 's', 't', 'e', 'd'};
|
||||
reentrant_result_ = reentrant_stream_->write(nested, sizeof(nested));
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("nested record must be dropped");
|
||||
}
|
||||
}
|
||||
output_.append(reinterpret_cast<const char*>(buffer), size);
|
||||
capacity_ -= static_cast<int>(size);
|
||||
return size;
|
||||
}
|
||||
|
||||
int capacity_ = 0;
|
||||
int available_calls_ = 0;
|
||||
int write_calls_ = 0;
|
||||
bool oversized_write_ = false;
|
||||
bool reenter_on_write_ = false;
|
||||
Stream* reentrant_stream_ = nullptr;
|
||||
size_t reentrant_result_ = 0;
|
||||
std::string output_;
|
||||
};
|
||||
|
||||
CapacityStream logging_port;
|
||||
mesh::WholeRecordNonBlockingStream<> bounded_logging_port(logging_port);
|
||||
bool logging_enabled = true;
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* getLogDateTime() {
|
||||
return "12:34";
|
||||
}
|
||||
|
||||
namespace mesh {
|
||||
|
||||
bool isUsbLoggingEnabled() {
|
||||
return logging_enabled;
|
||||
}
|
||||
|
||||
Stream& usbLoggingPort() {
|
||||
return bounded_logging_port;
|
||||
}
|
||||
|
||||
} // namespace mesh
|
||||
|
||||
class Nrf52DebugOutputTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
logging_enabled = true;
|
||||
logging_port.reset(256);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, WritesACompleteFormattedRecordWhenItFits) {
|
||||
MESH_DEBUG_PRINTLN("value=%d", 42);
|
||||
|
||||
EXPECT_EQ(logging_port.output_, "DEBUG: value=42\n");
|
||||
EXPECT_EQ(logging_port.available_calls_, 1);
|
||||
EXPECT_EQ(logging_port.write_calls_, 1);
|
||||
EXPECT_FALSE(logging_port.oversized_write_);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, DropsTheWholeRecordWhenFifoCannotFitIt) {
|
||||
logging_port.reset(10);
|
||||
|
||||
MESH_DEBUG_PRINTLN("this record is longer than ten bytes");
|
||||
|
||||
EXPECT_TRUE(logging_port.output_.empty());
|
||||
EXPECT_EQ(logging_port.available_calls_, 1);
|
||||
EXPECT_EQ(logging_port.write_calls_, 0);
|
||||
EXPECT_FALSE(logging_port.oversized_write_);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, AnUndrainedFullFifoNeverCallsTheWriter) {
|
||||
logging_port.reset(0);
|
||||
|
||||
MESH_DEBUG_PRINTLN("must not wait");
|
||||
|
||||
EXPECT_EQ(logging_port.available_calls_, 1);
|
||||
EXPECT_EQ(logging_port.write_calls_, 0);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, LongLinesAreBoundedAndVisiblyTruncated) {
|
||||
const std::string payload(600, 'x');
|
||||
|
||||
MESH_DEBUG_PRINTLN("%s", payload.c_str());
|
||||
|
||||
ASSERT_EQ(logging_port.output_.size(), 255U);
|
||||
EXPECT_EQ(logging_port.output_.substr(0, 7), "DEBUG: ");
|
||||
EXPECT_EQ(logging_port.output_.substr(logging_port.output_.size() - 4),
|
||||
"...\n");
|
||||
EXPECT_EQ(logging_port.write_calls_, 1);
|
||||
EXPECT_FALSE(logging_port.oversized_write_);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, DisabledLoggingDoesNotTouchThePort) {
|
||||
logging_enabled = false;
|
||||
|
||||
MESH_DEBUG_PRINTLN("disabled");
|
||||
|
||||
EXPECT_EQ(logging_port.available_calls_, 0);
|
||||
EXPECT_EQ(logging_port.write_calls_, 0);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, ReentrantDebugOutputIsDroppedWithoutWaiting) {
|
||||
logging_port.reenter_on_write_ = true;
|
||||
|
||||
MESH_DEBUG_PRINTLN("outer record");
|
||||
|
||||
EXPECT_EQ(logging_port.output_, "DEBUG: outer record\n");
|
||||
EXPECT_EQ(logging_port.available_calls_, 1);
|
||||
EXPECT_EQ(logging_port.write_calls_, 1);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, BridgeAndPowerDebugUseTheSameBoundedPath) {
|
||||
BRIDGE_DEBUG_PRINTLN("started\n");
|
||||
POWERSAVING_DEBUG_PRINTLN("sleeping");
|
||||
|
||||
EXPECT_EQ(logging_port.output_,
|
||||
"12:34 BRIDGE: started\nPOWERSAVING: sleeping\n");
|
||||
EXPECT_EQ(logging_port.write_calls_, 2);
|
||||
EXPECT_FALSE(logging_port.oversized_write_);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, DedicatedFacadeRejectsAStalePrintfLength) {
|
||||
mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port);
|
||||
const std::string oversized(600, 'x');
|
||||
|
||||
const size_t written = nonblocking_port.write(
|
||||
reinterpret_cast<const uint8_t*>(oversized.data()), oversized.size());
|
||||
|
||||
EXPECT_EQ(written, 0U);
|
||||
EXPECT_EQ(logging_port.available_calls_, 0);
|
||||
EXPECT_EQ(logging_port.write_calls_, 0);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, DedicatedFacadeChecksThenWritesOnce) {
|
||||
mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port);
|
||||
const uint8_t record[] = {'o', 'k', '\n'};
|
||||
logging_port.reset(sizeof(record));
|
||||
|
||||
EXPECT_EQ(nonblocking_port.write(record, sizeof(record)), sizeof(record));
|
||||
EXPECT_EQ(logging_port.output_, "ok\n");
|
||||
EXPECT_EQ(logging_port.available_calls_, 1);
|
||||
EXPECT_EQ(logging_port.write_calls_, 1);
|
||||
}
|
||||
|
||||
TEST_F(Nrf52DebugOutputTest, DedicatedFacadeDropsAReentrantWriter) {
|
||||
mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port);
|
||||
const uint8_t outer[] = {'o', 'u', 't', 'e', 'r'};
|
||||
logging_port.reenter_on_write_ = true;
|
||||
logging_port.reentrant_stream_ = &nonblocking_port;
|
||||
|
||||
EXPECT_EQ(nonblocking_port.write(outer, sizeof(outer)), sizeof(outer));
|
||||
EXPECT_EQ(logging_port.output_, "outer");
|
||||
EXPECT_EQ(logging_port.reentrant_result_, 0U);
|
||||
EXPECT_EQ(logging_port.available_calls_, 1);
|
||||
EXPECT_EQ(logging_port.write_calls_, 1);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static integration guards for nRF52 nonblocking USB diagnostics."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class Nrf52UsbLoggingContractTest(unittest.TestCase):
|
||||
def test_all_nrf52_logging_ports_return_the_nonblocking_facade(self):
|
||||
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
||||
|
||||
self.assertIn(
|
||||
"nonblocking_dedicated_usb_logging_port(dedicated_usb_logging_port)",
|
||||
source,
|
||||
)
|
||||
self.assertIn("nonblocking_primary_usb_logging_port(Serial)", source)
|
||||
self.assertIn("return nonblocking_dedicated_usb_logging_port;", source)
|
||||
self.assertIn("return nonblocking_primary_usb_logging_port;", source)
|
||||
# ESP32 and other platforms retain their original Serial path.
|
||||
self.assertIn("#else\n return Serial;\n #endif", source)
|
||||
|
||||
def test_facade_uses_a_zero_wait_gate_around_capacity_and_write(self):
|
||||
source = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text()
|
||||
|
||||
enter = source.index("_writer_busy.test_and_set")
|
||||
capacity = source.index("_delegate.availableForWrite()", enter)
|
||||
write = source.index("_delegate.write(data, size)")
|
||||
release = source.index("_writer_busy.clear")
|
||||
self.assertLess(enter, capacity)
|
||||
self.assertLess(capacity, write)
|
||||
self.assertLess(write, release)
|
||||
self.assertIn("size > MAX_WRITE_SIZE", source)
|
||||
|
||||
def test_meshcore_debug_macros_use_the_bounded_formatter_only_on_nrf52(self):
|
||||
source = (ROOT / "src/MeshCore.h").read_text()
|
||||
|
||||
self.assertIn("#if defined(NRF52_PLATFORM)", source)
|
||||
self.assertIn('mesh::nrf52DebugPrintf("DEBUG: " F', source)
|
||||
self.assertIn(
|
||||
'mesh::usbLoggingPort().printf("DEBUG: " F',
|
||||
source,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static safety contracts for nRF52840 variants with incompatible layouts."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def ini_section(source: str, name: str) -> str:
|
||||
"""Return one PlatformIO section without resolving its inheritance."""
|
||||
match = re.search(
|
||||
rf"^\[{re.escape(name)}\]\s*$\n(?P<body>.*?)(?=^\[|\Z)",
|
||||
source,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing PlatformIO section [{name}]")
|
||||
return match.group("body")
|
||||
|
||||
|
||||
def hex_define(source: str, name: str) -> int:
|
||||
"""Read a simple hexadecimal preprocessor address definition."""
|
||||
match = re.search(
|
||||
rf"^\s*#define\s+{re.escape(name)}\s+(0x[0-9A-Fa-f]+)\b",
|
||||
source,
|
||||
re.MULTILINE,
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing hexadecimal definition for {name}")
|
||||
return int(match.group(1), 16)
|
||||
|
||||
|
||||
class Nrf52VariantContractsTest(unittest.TestCase):
|
||||
def test_rak3401_wisblock_i2c_aliases_are_complete(self) -> None:
|
||||
variant = (ROOT / "variants/rak3401/variant.h").read_text()
|
||||
|
||||
self.assertRegex(variant, r"#define\s+WB_I2C1_SDA\s+\(13\)")
|
||||
self.assertRegex(variant, r"#define\s+WB_I2C1_SCL\s+\(14\)")
|
||||
self.assertRegex(
|
||||
variant, r"#define\s+PIN_WIRE_SDA\s+\(WB_I2C1_SDA\)"
|
||||
)
|
||||
self.assertRegex(
|
||||
variant, r"#define\s+PIN_WIRE_SCL\s+\(WB_I2C1_SCL\)"
|
||||
)
|
||||
self.assertRegex(variant, r"#define\s+PIN_BOARD_SDA\s+PIN_WIRE_SDA")
|
||||
self.assertRegex(variant, r"#define\s+PIN_BOARD_SCL\s+PIN_WIRE_SCL")
|
||||
|
||||
def test_rak3401_gps_uart_is_cross_connected(self) -> None:
|
||||
variant = (ROOT / "variants/rak3401/variant.h").read_text()
|
||||
|
||||
self.assertRegex(variant, r"#define\s+PIN_SERIAL1_RX\s+\(15\)")
|
||||
self.assertRegex(variant, r"#define\s+PIN_SERIAL1_TX\s+\(16\)")
|
||||
self.assertRegex(variant, r"#define\s+PIN_GPS_RX\s+PIN_SERIAL1_TX")
|
||||
self.assertRegex(variant, r"#define\s+PIN_GPS_TX\s+PIN_SERIAL1_RX")
|
||||
|
||||
def test_rak3401_bsec_opt_in_requires_hard_float_recipe(self) -> None:
|
||||
recipe = (ROOT / "variants/rak3401/platformio.ini").read_text()
|
||||
shared = (ROOT / "platformio.ini").read_text()
|
||||
sensor_base = re.search(
|
||||
r"^\[sensor_base\]\s*$\n(?P<body>.*?)(?=^\[|\Z)",
|
||||
shared,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(sensor_base)
|
||||
effective_recipe = sensor_base.group("body") + recipe
|
||||
|
||||
if re.search(
|
||||
r"^\s*-D\s*ENV_INCLUDE_BME680_BSEC(?:=1)?\s*$",
|
||||
effective_recipe,
|
||||
re.MULTILINE,
|
||||
):
|
||||
self.assertIn("fix_bsec_lib.py", recipe)
|
||||
self.assertIn("BSEC Software Library", recipe)
|
||||
|
||||
def test_rak3401_reduced_profile_names_its_narrow_sensor_trim(self) -> None:
|
||||
recipe = (ROOT / "variants/rak3401/platformio.ini").read_text()
|
||||
shared = (ROOT / "platformio.ini").read_text()
|
||||
build_script = (ROOT / "build.sh").read_text()
|
||||
|
||||
expected_ina = {
|
||||
"ENV_INCLUDE_INA219",
|
||||
"ENV_INCLUDE_INA226",
|
||||
"ENV_INCLUDE_INA260",
|
||||
"ENV_INCLUDE_INA3221",
|
||||
}
|
||||
voltage_monitor_section = ini_section(shared, "i2c_voltage_monitor_base")
|
||||
enabled_monitors = set(
|
||||
re.findall(
|
||||
r"-D\s+(ENV_INCLUDE_[A-Z0-9_]+)=1\b",
|
||||
voltage_monitor_section,
|
||||
)
|
||||
)
|
||||
self.assertEqual(enabled_monitors, expected_ina)
|
||||
|
||||
reduced_sensor_section = ini_section(
|
||||
shared, "nrf52_reduced_sensors_keep_ina_gps"
|
||||
)
|
||||
self.assertIn(
|
||||
"${nrf52_external_environmental_sensor_trim.build_flags}",
|
||||
reduced_sensor_section,
|
||||
)
|
||||
self.assertIn(
|
||||
"${i2c_voltage_monitor_base.build_flags}", reduced_sensor_section
|
||||
)
|
||||
self.assertNotRegex(reduced_sensor_section, r"-U\s*ENV_INCLUDE_GPS\b")
|
||||
|
||||
reduced_target = ini_section(
|
||||
recipe, "env:RAK_3401_repeater_lora_ota_no_external_sensors"
|
||||
)
|
||||
self.assertIn(
|
||||
"${nrf52_reduced_sensors_keep_ina_gps.build_flags}", reduced_target
|
||||
)
|
||||
|
||||
monitor_flags = re.search(
|
||||
r'^\s*local voltage_monitor_flags="(?P<flags>[^"]+)"',
|
||||
build_script,
|
||||
re.MULTILINE,
|
||||
)
|
||||
self.assertIsNotNone(monitor_flags)
|
||||
self.assertEqual(set(monitor_flags.group("flags").split()), expected_ina)
|
||||
|
||||
self.assertIn(
|
||||
"selected optional environmental/ranging drivers omitted",
|
||||
build_script,
|
||||
)
|
||||
self.assertIn("board display/RTC/GPS retained", build_script)
|
||||
self.assertIn("GPS conflicts with RS-232 on Serial1", build_script)
|
||||
self.assertNotIn("sensors.external omitted except", build_script)
|
||||
self.assertIn("environmental telemetry sensors", recipe)
|
||||
self.assertIn("both RAK12500 I2C and RAK12501 UART GPS paths", recipe)
|
||||
|
||||
def test_rak3401_reduced_profile_retains_non_ina_i2c_peripherals(self) -> None:
|
||||
recipe = (ROOT / "variants/rak3401/platformio.ini").read_text()
|
||||
target = (ROOT / "variants/rak3401/target.cpp").read_text()
|
||||
sensors = (
|
||||
ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp"
|
||||
).read_text()
|
||||
rtc = (ROOT / "src/helpers/AutoDiscoverRTCClock.cpp").read_text()
|
||||
display = (ROOT / "src/helpers/ui/SSD1306Display.h").read_text()
|
||||
|
||||
reduced_target = ini_section(
|
||||
recipe, "env:RAK_3401_repeater_lora_ota_no_external_sensors"
|
||||
)
|
||||
self.assertRegex(reduced_target, r"-D\s+DISPLAY_CLASS=SSD1306Display\b")
|
||||
self.assertIn("SparkFun u-blox GNSS Arduino Library", reduced_target)
|
||||
self.assertIn("AutoDiscoverRTCClock rtc_clock", target)
|
||||
self.assertIn("rtc_clock.begin(Wire)", target)
|
||||
self.assertIn("MicroNMEALocationProvider(Serial1, &rtc_clock)", target)
|
||||
self.assertIn(
|
||||
"ublox_GNSS.begin(Wire, gps_i2c_address)", sensors
|
||||
)
|
||||
self.assertIn(
|
||||
"shouldSkipSensorAtClaimedGpsAddress(", sensors
|
||||
)
|
||||
self.assertIn("i2cGPSFlag, TELEM_WIRE == &Wire", sensors)
|
||||
self.assertIn(
|
||||
"probeIna3221Identity(&Wire, gps_i2c_address)", sensors
|
||||
)
|
||||
self.assertIn("static_cast<uint32_t>(TELEM_RAK12500_ADDRESS)", sensors)
|
||||
self.assertIn("I2cRegisterProbeStatus::Inconclusive", sensors)
|
||||
self.assertIn("serialHasValidGpsSentence(Serial1", sensors)
|
||||
self.assertNotIn("else if (Serial1.available())", sensors)
|
||||
|
||||
self.assertEqual(hex_define(display, "DISPLAY_ADDRESS"), 0x3C)
|
||||
self.assertEqual(
|
||||
{
|
||||
"DS3231": hex_define(rtc, "DS3231_ADDRESS"),
|
||||
"RV3028": hex_define(rtc, "RV3028_ADDRESS"),
|
||||
"PCF8563": hex_define(rtc, "PCF8563_ADDRESS"),
|
||||
"RX8130CE": hex_define(rtc, "RX8130CE_ADDRESS"),
|
||||
},
|
||||
{
|
||||
"DS3231": 0x68,
|
||||
"RV3028": 0x52,
|
||||
"PCF8563": 0x51,
|
||||
"RX8130CE": 0x32,
|
||||
},
|
||||
)
|
||||
|
||||
def test_i2c_recovery_uses_board_remapped_primary_pins(self) -> None:
|
||||
recipe = (ROOT / "variants/promicro/platformio.ini").read_text()
|
||||
board = (ROOT / "variants/promicro/PromicroBoard.cpp").read_text()
|
||||
sensors = (
|
||||
ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp"
|
||||
).read_text()
|
||||
|
||||
self.assertRegex(recipe, r"-D\s+PIN_BOARD_SDA=8\b")
|
||||
self.assertRegex(recipe, r"-D\s+PIN_BOARD_SCL=7\b")
|
||||
self.assertIn("Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL)", board)
|
||||
board_pin_branch = sensors.index(
|
||||
"defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL)"
|
||||
)
|
||||
variant_pin_branch = sensors.index(
|
||||
"defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL)"
|
||||
)
|
||||
self.assertLess(board_pin_branch, variant_pin_branch)
|
||||
self.assertIn("Wire.setPins(static_cast<uint8_t>(sda)", sensors)
|
||||
|
||||
def test_rak3401_rak12500_ina3221_address_collision_is_explicit(self) -> None:
|
||||
recipe = (ROOT / "variants/rak3401/platformio.ini").read_text()
|
||||
sensors = (
|
||||
ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp"
|
||||
).read_text()
|
||||
|
||||
gps_address = hex_define(sensors, "TELEM_RAK12500_ADDRESS")
|
||||
ina3221_address = hex_define(sensors, "TELEM_INA3221_ADDRESS")
|
||||
self.assertEqual(gps_address, 0x42)
|
||||
self.assertEqual(ina3221_address, 0x42)
|
||||
self.assertEqual(gps_address, ina3221_address)
|
||||
self.assertIn(
|
||||
"RAK12500 and the configured INA3221 both use I2C address 0x42",
|
||||
recipe,
|
||||
)
|
||||
self.assertIn("cannot be installed", recipe)
|
||||
self.assertIn("strap INA3221 A0 to SCL for 0x43", recipe)
|
||||
self.assertIn("-DTELEM_INA3221_ADDRESS=0x43", recipe)
|
||||
|
||||
def test_rak4631_shared_sensor_rail_stays_enabled(self) -> None:
|
||||
recipe = (ROOT / "variants/rak4631/platformio.ini").read_text()
|
||||
board = (ROOT / "variants/rak4631/RAK4631Board.cpp").read_text()
|
||||
sensors = (
|
||||
ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp"
|
||||
).read_text()
|
||||
|
||||
self.assertRegex(recipe, r"-D\s+FORCE_GPS_ALIVE\b")
|
||||
self.assertIn("pinMode(WB_IO2, OUTPUT)", board)
|
||||
self.assertIn("digitalWrite(WB_IO2, HIGH)", board)
|
||||
self.assertIn("setRakGpsControl(ioPin, shared_power_rail, true)", sensors)
|
||||
self.assertIn("setRakGpsControl(ioPin, shared_power_rail, false)", sensors)
|
||||
|
||||
def test_rak4631_ethernet_does_not_suppress_shared_rail_gps(self) -> None:
|
||||
recipe = (ROOT / "variants/rak4631/platformio.ini").read_text()
|
||||
sensors = (
|
||||
ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp"
|
||||
).read_text()
|
||||
|
||||
ethernet = ini_section(recipe, "env:RAK_4631_repeater_ethernet")
|
||||
self.assertRegex(ethernet, r"-D\s+ETHERNET_ENABLED=1\b")
|
||||
self.assertNotIn(
|
||||
"#if defined(ETHERNET_ENABLED) && defined(RAK_BOARD)", sensors
|
||||
)
|
||||
self.assertNotIn("if (ioPin == WB_IO2)", sensors)
|
||||
self.assertIn(
|
||||
"setRakGpsControl(ioPin, shared_power_rail, true)", sensors
|
||||
)
|
||||
|
||||
def test_rak4631_runtime_bridge_arbitrates_only_uart_gps(self) -> None:
|
||||
recipe = (ROOT / "variants/rak4631/platformio.ini").read_text()
|
||||
mesh_header = (
|
||||
ROOT / "examples/simple_repeater/MyMesh.h"
|
||||
).read_text()
|
||||
sensor_header = (
|
||||
ROOT / "src/helpers/sensors/EnvironmentSensorManager.h"
|
||||
).read_text()
|
||||
sensors = (
|
||||
ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp"
|
||||
).read_text()
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
|
||||
begin = mesh_header[
|
||||
mesh_header.index("bool beginRS232Bridge()"):
|
||||
mesh_header.index("bool endRS232Bridge()")
|
||||
]
|
||||
end = mesh_header[
|
||||
mesh_header.index("bool endRS232Bridge()"):
|
||||
mesh_header.index("bool setBridgeState(bool enable)")
|
||||
]
|
||||
can_yield = sensors[
|
||||
sensors.index("bool EnvironmentSensorManager::gpsSerialTransportCanYield"):
|
||||
sensors.index("bool EnvironmentSensorManager::setGpsSerialTransportBlocked")
|
||||
]
|
||||
block = sensors[
|
||||
sensors.index("bool EnvironmentSensorManager::setGpsSerialTransportBlocked"):
|
||||
sensors.index("#if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC)")
|
||||
]
|
||||
self.assertLess(
|
||||
begin.index("setGpsSerialTransportBlocked(selected_uart, true)"),
|
||||
begin.index("bridge->begin()"),
|
||||
)
|
||||
self.assertLess(
|
||||
begin.index("active_rs232_bridge_uart = selected_uart"),
|
||||
begin.index("bridge->begin()"),
|
||||
)
|
||||
failed_start = begin[
|
||||
begin.index("if (!bridge->isRunning())"):
|
||||
begin.index(
|
||||
"active_rs232_bridge_uart = selected_uart",
|
||||
begin.index("bridge->begin()"),
|
||||
)
|
||||
]
|
||||
self.assertIn(
|
||||
"if (sensors.setGpsSerialTransportBlocked(selected_uart, false))",
|
||||
failed_start,
|
||||
)
|
||||
self.assertIn("active_rs232_bridge_uart = 0", failed_start)
|
||||
self.assertLess(
|
||||
begin.index("gpsSerialTransportMayConflict(selected_uart)"),
|
||||
begin.index("bridge->begin()"),
|
||||
)
|
||||
self.assertLess(
|
||||
end.index("if (bridge && bridge->isRunning())"),
|
||||
end.index("bridge->end()"),
|
||||
)
|
||||
self.assertLess(
|
||||
end.index("bridge->end()"),
|
||||
end.index("setGpsSerialTransportBlocked(released_uart, false)"),
|
||||
)
|
||||
self.assertIn("gpsUsesSerialUart(uint8_t uart) const override", sensor_header)
|
||||
self.assertIn(
|
||||
"gpsSerialTransportMayConflict(uint8_t uart) const override",
|
||||
sensor_header,
|
||||
)
|
||||
self.assertIn("-D WITH_RS232_BRIDGE_GPS_CONFLICT_UART=1", recipe)
|
||||
self.assertIn(
|
||||
"if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) return true;",
|
||||
sensors,
|
||||
)
|
||||
self.assertIn(
|
||||
"return uart == 1 && gps_detected && gps_serial_transport", sensors
|
||||
)
|
||||
self.assertIn(
|
||||
"#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)",
|
||||
can_yield,
|
||||
)
|
||||
forced_rak = can_yield[
|
||||
can_yield.index(
|
||||
"#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)"
|
||||
):
|
||||
can_yield.index("#elif defined(RAK_WISBLOCK_GPS)")
|
||||
]
|
||||
self.assertIn("return false;", forced_rak)
|
||||
refusal = block.index(
|
||||
"if (blocked && !gpsSerialTransportCanYield(uart)) return false;"
|
||||
)
|
||||
for mutation in (
|
||||
"setGpsTelemetryTransportAvailable(false)",
|
||||
"gps_serial_transport_blocked = true",
|
||||
"stop_gps()",
|
||||
"Serial1.end()",
|
||||
):
|
||||
self.assertLess(refusal, block.index(mutation))
|
||||
self.assertIn("setGpsTelemetryTransportAvailable(false)", sensors)
|
||||
self.assertIn("setGpsTelemetryTransportAvailable(true)", sensors)
|
||||
self.assertNotIn(
|
||||
"turn the RS232 bridge off or select another UART first", cli
|
||||
)
|
||||
self.assertNotIn("Error: turn GPS off or select another UART first", cli)
|
||||
|
||||
def test_bridge_reports_runtime_state_and_rolls_back_failed_changes(self) -> None:
|
||||
implementation = (
|
||||
ROOT / "examples/simple_repeater/MyMesh.cpp"
|
||||
).read_text()
|
||||
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text()
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
|
||||
self.assertIn("bool isBridgeRunning() const override", header)
|
||||
self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x01", implementation)
|
||||
self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x03", implementation)
|
||||
boot_failure = implementation[
|
||||
implementation.index("if (!bridge || !beginRS232Bridge())"):
|
||||
implementation.index("#else", implementation.index(
|
||||
"if (!bridge || !beginRS232Bridge())"
|
||||
))
|
||||
]
|
||||
self.assertIn("setBridgeState(false)", boot_failure)
|
||||
self.assertNotIn("_prefs.bridge_enabled", boot_failure)
|
||||
self.assertIn("parseOnOffStrict(&config[15], enable)", cli)
|
||||
self.assertIn("parseUnsignedIntegerStrict(&config[12], baud)", cli)
|
||||
self.assertIn("_prefs->bridge_baud = previous_baud", cli)
|
||||
self.assertIn("_prefs->bridge_uart = previous_uart", cli)
|
||||
self.assertIn("bridge state change failed; setting unchanged", cli)
|
||||
self.assertIn('configKeyEquals(config, "bridge.running")', cli)
|
||||
self.assertIn('_prefs->bridge_pkt_src ? "rx" : "tx"', cli)
|
||||
self.assertIn("!defined(RS232_BRIDGE_DEFAULT_ON)", cli)
|
||||
|
||||
def test_default_on_rs232_profile_skips_default_off_tail_migration(self) -> None:
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text()
|
||||
dedicated = ini_section(
|
||||
xiao, "env:solarxiao_30S_repeater_bridge_rs232"
|
||||
)
|
||||
|
||||
self.assertIn("extends = env:solarxiao_30S_repeater", dedicated)
|
||||
self.assertRegex(dedicated, r"-D\s+RS232_BRIDGE_DEFAULT_ON=1\b")
|
||||
guard = (
|
||||
"#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \\\n"
|
||||
" && !defined(RS232_BRIDGE_DEFAULT_ON)"
|
||||
)
|
||||
# Declaration, persisted-tail detection, and migration must share the
|
||||
# same guard. A broader use breaks dedicated default-on bridge builds.
|
||||
self.assertEqual(cli.count(guard), 3)
|
||||
self.assertEqual(cli.count("has_runtime_bridge_uart"), 3)
|
||||
|
||||
def test_gat562_30s_gps_enable_and_buzzer_pins_are_distinct(self) -> None:
|
||||
recipe = (
|
||||
ROOT / "variants/gat562_30s_mesh_kit/platformio.ini"
|
||||
).read_text()
|
||||
variant = (ROOT / "variants/gat562_30s_mesh_kit/variant.h").read_text()
|
||||
|
||||
# GAT562 30S Mesh KIT V1.1 schematic: IO2 is nRF P1.01 (Arduino
|
||||
# GPIO 33) and drives the GPS supply; BEE_EN is P1.02 (GPIO 34).
|
||||
gps_pin = re.search(
|
||||
r"#define\s+PIN_GPS_EN\s+\((\d+)\)", variant
|
||||
)
|
||||
buzzer_pin = re.search(r"-D\s+PIN_BUZZER=(\d+)\b", recipe)
|
||||
self.assertIsNotNone(gps_pin)
|
||||
self.assertIsNotNone(buzzer_pin)
|
||||
self.assertEqual(int(gps_pin.group(1)), 33)
|
||||
self.assertEqual(int(buzzer_pin.group(1)), 34)
|
||||
self.assertNotEqual(gps_pin.group(1), buzzer_pin.group(1))
|
||||
|
||||
def test_xiao_and_mesh_pocket_softdevice_layouts_are_not_interchangeable(
|
||||
self,
|
||||
) -> None:
|
||||
xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text()
|
||||
mesh_pocket = (ROOT / "variants/mesh_pocket/platformio.ini").read_text()
|
||||
|
||||
self.assertRegex(
|
||||
xiao,
|
||||
re.compile(
|
||||
r"^\[Xiao_nrf52\].*?^board_build\.ldscript\s*=\s*"
|
||||
r"boards/nrf52840_s140_v7\.ld$",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
),
|
||||
)
|
||||
self.assertRegex(
|
||||
mesh_pocket,
|
||||
re.compile(
|
||||
r"^\[Mesh_pocket\].*?^board_build\.ldscript\s*=\s*"
|
||||
r"boards/nrf52840_s140_v6\.ld$",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,6 +37,7 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB0), 0xB0);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB8), 0xB8);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBC), 0xBC);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0xBD); // SD authorization failure
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC0), 0xC0);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF);
|
||||
@@ -50,7 +51,7 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xD4), 0x00); // legacy handoff
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xED), 0x00); // expanded handoff
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xE0), 0x00); // retired handoff remains non-result
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBE), 0x00);
|
||||
}
|
||||
|
||||
TEST(OtaPolicy, TrustedAutoInstallIsStrictlyForwardOnly) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
source build.sh
|
||||
|
||||
fail() {
|
||||
echo "test_rak4631_ota_compatibility: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_array_equals() {
|
||||
local label=$1
|
||||
local actual_name=$2
|
||||
shift 2
|
||||
local -n actual=$actual_name
|
||||
local -a expected=("$@")
|
||||
local index
|
||||
|
||||
[ "${#actual[@]}" -eq "${#expected[@]}" ] \
|
||||
|| fail "$label count was ${#actual[@]}, expected ${#expected[@]}: ${actual[*]}"
|
||||
for index in "${!expected[@]}"; do
|
||||
[ "${actual[$index]}" = "${expected[$index]}" ] \
|
||||
|| fail "$label[$index] was ${actual[$index]}, expected ${expected[$index]}"
|
||||
done
|
||||
}
|
||||
|
||||
compatibility_targets=(
|
||||
RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors
|
||||
RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors
|
||||
)
|
||||
mapfile -t declared_compatibility_targets < <(
|
||||
get_deployed_lora_ota_compatibility_targets
|
||||
)
|
||||
assert_array_equals \
|
||||
"declared compatibility targets" declared_compatibility_targets \
|
||||
"${compatibility_targets[@]}"
|
||||
|
||||
for target in "${compatibility_targets[@]}"; do
|
||||
is_deployed_lora_ota_compatibility_target "$target" \
|
||||
|| fail "$target lost its deployed-node compatibility classification"
|
||||
if is_redundant_bulk_build_target "$target"; then
|
||||
fail "$target was omitted from canonical bulk builds"
|
||||
fi
|
||||
done
|
||||
if is_deployed_lora_ota_compatibility_target RAK_4631_repeater; then
|
||||
fail "canonical RAK4631 repeater was classified as a legacy OTA identity"
|
||||
fi
|
||||
|
||||
[ "$(get_merged_rs232_repeater_replacement "${compatibility_targets[0]}")" \
|
||||
= RAK_4631_repeater_lora_ota_no_external_sensors ] \
|
||||
|| fail "Serial1 compatibility target lost its canonical recommendation"
|
||||
[ "$(get_merged_rs232_repeater_replacement "${compatibility_targets[1]}")" \
|
||||
= RAK_4631_repeater_lora_ota_no_external_sensors ] \
|
||||
|| fail "Serial2 compatibility target lost its canonical recommendation"
|
||||
|
||||
canonical_target=RAK_4631_repeater
|
||||
canonical_ota_target=RAK_4631_repeater_lora_ota_no_external_sensors
|
||||
legacy_install_targets=(
|
||||
RAK_4631_repeater_bridge_rs232_serial1
|
||||
RAK_4631_repeater_bridge_rs232_serial2
|
||||
)
|
||||
SUPPORTED_PIO_ENVS=(
|
||||
"$canonical_target"
|
||||
"$canonical_ota_target"
|
||||
"${legacy_install_targets[@]}"
|
||||
"${compatibility_targets[@]}"
|
||||
)
|
||||
for target in "${SUPPORTED_PIO_ENVS[@]}"; do
|
||||
PIO_ENV_PLATFORM_BY_NAME[$target]=NRF52_PLATFORM
|
||||
done
|
||||
|
||||
mapfile -t all_targets < <(resolve_all_firmwares)
|
||||
assert_array_equals \
|
||||
"canonical bulk target list" all_targets \
|
||||
"$canonical_target" \
|
||||
"$canonical_ota_target" \
|
||||
"${compatibility_targets[@]}"
|
||||
|
||||
mapfile -t release_targets < <(
|
||||
print_release_firmware_targets get-repeater-firmwares-to-build
|
||||
)
|
||||
assert_array_equals \
|
||||
"repeater release target list" release_targets \
|
||||
"$canonical_target" \
|
||||
"${compatibility_targets[@]}"
|
||||
|
||||
echo "test_rak4631_ota_compatibility: OK"
|
||||
@@ -555,6 +555,27 @@ TEST(SerialFlowControl, FinishesAnUnexpectedShortWriteBeforeNextFrame) {
|
||||
EXPECT_FALSE(interface.hasPendingIO());
|
||||
}
|
||||
|
||||
TEST(SerialFlowControl, RetriesAZeroLengthWriteWithoutLosingTheFrame) {
|
||||
BufferStream stream;
|
||||
stream.write_capacity = MAX_FRAME_SIZE + 3;
|
||||
stream.max_write = 0;
|
||||
ArduinoSerialInterface interface;
|
||||
interface.begin(stream);
|
||||
interface.enableFlowControl(true);
|
||||
interface.enable();
|
||||
|
||||
const uint8_t payload[] = {0x88, 0x11, 0x22};
|
||||
EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload));
|
||||
EXPECT_TRUE(stream.output.empty());
|
||||
EXPECT_TRUE(interface.hasPendingIO());
|
||||
|
||||
stream.max_write = std::numeric_limits<size_t>::max();
|
||||
interface.loop();
|
||||
const std::vector<uint8_t> expected = {'>', 3, 0, 0x88, 0x11, 0x22};
|
||||
EXPECT_EQ(stream.output, expected);
|
||||
EXPECT_FALSE(interface.hasPendingIO());
|
||||
}
|
||||
|
||||
TEST(SerialFlowControl, DrainsFramesThroughAFifoSmallerThanTheFrame) {
|
||||
BufferStream stream;
|
||||
stream.write_capacity = 4;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/TempRadioLeaseDeadline.h>
|
||||
|
||||
TEST(TempRadioLeaseDeadline, EpochWindowGetsAnIndependentUptimeEnd) {
|
||||
const uint64_t hard_end = mesh::TempRadioLeaseDeadline::fromEpochEnd(
|
||||
50'000ULL, 1'800'000'000UL, 1'800'000'180UL);
|
||||
|
||||
EXPECT_EQ(hard_end, 230'000ULL);
|
||||
EXPECT_FALSE(mesh::TempRadioLeaseDeadline::expired(229'999ULL, hard_end));
|
||||
EXPECT_TRUE(mesh::TempRadioLeaseDeadline::expired(230'000ULL, hard_end));
|
||||
}
|
||||
|
||||
TEST(TempRadioLeaseDeadline, BackwardRtcCorrectionCannotExtendLease) {
|
||||
const uint64_t hard_end = mesh::TempRadioLeaseDeadline::fromEpochEnd(
|
||||
10'000ULL, 1'800'000'000UL, 1'800'000'180UL);
|
||||
|
||||
// The wall clock has moved backward by an hour and no longer considers the
|
||||
// epoch end due. Uptime remains authoritative for the safety bound.
|
||||
const uint32_t corrected_wall_clock = 1'799'996'580UL;
|
||||
EXPECT_LT(corrected_wall_clock, 1'800'000'180UL);
|
||||
EXPECT_TRUE(mesh::TempRadioLeaseDeadline::expired(190'000ULL, hard_end));
|
||||
}
|
||||
|
||||
TEST(TempRadioLeaseDeadline, PendingWindowAlsoExpiresAndBoundsSleep) {
|
||||
const uint64_t hard_end = mesh::TempRadioLeaseDeadline::fromEpochEnd(
|
||||
4'000ULL, 1'800'000'000UL, 1'800'000'181UL);
|
||||
|
||||
EXPECT_EQ(
|
||||
mesh::TempRadioLeaseDeadline::secondsUntil(180'001ULL, hard_end),
|
||||
5U);
|
||||
EXPECT_EQ(
|
||||
mesh::TempRadioLeaseDeadline::secondsUntil(185'000ULL, hard_end),
|
||||
0U);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/TempRadioReplyBarrier.h>
|
||||
|
||||
TEST(TempRadioReplyBarrier, WaitsForTheExactReplyPacket) {
|
||||
mesh::TempRadioReplyBarrier barrier;
|
||||
int reply_packet = 1;
|
||||
int unrelated_packet = 2;
|
||||
|
||||
EXPECT_FALSE(barrier.waiting());
|
||||
barrier.arm(&reply_packet);
|
||||
EXPECT_TRUE(barrier.waiting());
|
||||
|
||||
EXPECT_FALSE(barrier.complete(&unrelated_packet));
|
||||
EXPECT_TRUE(barrier.waiting());
|
||||
EXPECT_TRUE(barrier.complete(&reply_packet));
|
||||
EXPECT_FALSE(barrier.waiting());
|
||||
}
|
||||
|
||||
TEST(TempRadioReplyBarrier, ExactTransmitFailureReleasesForFailClosedCleanup) {
|
||||
mesh::TempRadioReplyBarrier barrier;
|
||||
int reply_packet = 1;
|
||||
|
||||
barrier.arm(&reply_packet);
|
||||
EXPECT_TRUE(barrier.fail(&reply_packet));
|
||||
EXPECT_FALSE(barrier.waiting());
|
||||
EXPECT_FALSE(barrier.fail(&reply_packet));
|
||||
}
|
||||
|
||||
TEST(TempRadioReplyBarrier, UntrackedAlternateCannotResolveAuthoritativeReply) {
|
||||
mesh::TempRadioReplyBarrier barrier;
|
||||
int primary_reply = 1;
|
||||
int alternate_reply = 2;
|
||||
|
||||
barrier.arm(&primary_reply);
|
||||
|
||||
// TempRadio mutation acknowledgements deliberately queue only the primary
|
||||
// copy, without an alternate-path copy or a transport retry. If an
|
||||
// obsolete/foreign packet callback arrives anyway, it must not release or
|
||||
// fail the authoritative handoff.
|
||||
EXPECT_FALSE(barrier.complete(&alternate_reply));
|
||||
EXPECT_FALSE(barrier.fail(&alternate_reply));
|
||||
EXPECT_TRUE(barrier.waiting());
|
||||
|
||||
EXPECT_TRUE(barrier.complete(&primary_reply));
|
||||
EXPECT_FALSE(barrier.waiting());
|
||||
}
|
||||
|
||||
TEST(TempRadioReplyBarrier, ClearCancelsAnObsoleteHandoff) {
|
||||
mesh::TempRadioReplyBarrier barrier;
|
||||
int old_reply = 1;
|
||||
int new_reply = 2;
|
||||
|
||||
barrier.arm(&old_reply);
|
||||
barrier.clear();
|
||||
EXPECT_FALSE(barrier.complete(&old_reply));
|
||||
|
||||
barrier.arm(&new_reply);
|
||||
EXPECT_FALSE(barrier.complete(&old_reply));
|
||||
EXPECT_TRUE(barrier.complete(&new_reply));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static integration contract for TempRadio's single-copy reply barrier."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "examples/simple_repeater/MyMesh.cpp"
|
||||
|
||||
|
||||
def function_body(text: str, signature: str, next_signature: str) -> str:
|
||||
start = text.index(signature)
|
||||
end = text.index(next_signature, start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
class TempRadioReplyDeliveryContractTest(unittest.TestCase):
|
||||
def test_exact_packet_tracking_suppresses_all_untracked_copies(self):
|
||||
source = SOURCE.read_text(encoding="utf-8")
|
||||
reply = function_body(
|
||||
source,
|
||||
"bool MyMesh::sendRemoteCliReply(",
|
||||
"void MyMesh::onUserGpioTimerCompleted(",
|
||||
)
|
||||
delivery = function_body(
|
||||
source,
|
||||
"bool MyMesh::sendClientReplyWithFallbackScope(",
|
||||
"bool MyMesh::floodChannelDataHopApplies(",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"const bool allow_redundant_copies = queued_packet == NULL;", reply
|
||||
)
|
||||
self.assertIn("fallback_scope,\n allow_redundant_copies", reply)
|
||||
self.assertIn(
|
||||
"if (allow_redundant_copies\n"
|
||||
" && mesh::Packet::isValidPathLen(client->alt_path_len)",
|
||||
delivery,
|
||||
)
|
||||
self.assertIn(
|
||||
"if (!allow_redundant_copies) _prefs.direct_retry_enabled = 0;",
|
||||
delivery,
|
||||
)
|
||||
self.assertIn(
|
||||
"if (!allow_redundant_copies) _prefs.flood_retry_attempts = 0;",
|
||||
delivery,
|
||||
)
|
||||
|
||||
def test_only_successful_parameterized_temp_radio_requests_track_reply(self):
|
||||
source = SOURCE.read_text(encoding="utf-8")
|
||||
receive = function_body(
|
||||
source,
|
||||
"void MyMesh::onPeerDataRecv(",
|
||||
"bool MyMesh::sendRemoteCliReply(",
|
||||
)
|
||||
command = function_body(
|
||||
source,
|
||||
"void __attribute__((noinline)) MyMesh::processDeferredCliCommand()",
|
||||
"bool MyMesh::onPeerPathRecv(",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'strncmp(deferred_cli_command.command, "tempradio ", 10)', command
|
||||
)
|
||||
self.assertIn('strncmp(reply, "OK - temp params for ", 21)', command)
|
||||
self.assertIn("arms_temp_radio ? &queued_reply : NULL", command)
|
||||
|
||||
# A packet-level retry can arrive after the success text was cached but
|
||||
# before the authoritative reply drains. It must not create a second,
|
||||
# untracked success packet either.
|
||||
self.assertIn("const bool cached_temp_radio_success", receive)
|
||||
suppress_start = receive.index("if (cached_temp_radio_success)")
|
||||
replay_start = receive.index("} else {", suppress_start)
|
||||
replay_end = receive.index(
|
||||
"} else if (deferred_cli_command.matches", replay_start
|
||||
)
|
||||
self.assertNotIn(
|
||||
"sendRemoteCliReply(", receive[suppress_start:replay_start]
|
||||
)
|
||||
self.assertIn("sendRemoteCliReply(", receive[replay_start:replay_end])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,20 @@
|
||||
#include <helpers/esp32/SntpOperationCoordinator.h>
|
||||
|
||||
namespace {
|
||||
|
||||
mesh::sntp_coord::OperationLease peer_lease(
|
||||
mesh::sntp_coord::processWideCoordinator());
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" void* sntpCoordinatorPeerAddress() {
|
||||
return &mesh::sntp_coord::processWideCoordinator();
|
||||
}
|
||||
|
||||
extern "C" bool sntpCoordinatorPeerAcquire() {
|
||||
return peer_lease.tryAcquire();
|
||||
}
|
||||
|
||||
extern "C" bool sntpCoordinatorPeerRelease() {
|
||||
return peer_lease.release();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/esp32/SntpOperationCoordinator.h>
|
||||
#include <helpers/esp32/TlsClockValidity.h>
|
||||
|
||||
namespace Clock = mesh::tls_clock;
|
||||
|
||||
extern "C" void* sntpCoordinatorPeerAddress();
|
||||
extern "C" bool sntpCoordinatorPeerAcquire();
|
||||
extern "C" bool sntpCoordinatorPeerRelease();
|
||||
|
||||
namespace {
|
||||
|
||||
int cleanup_calls = 0;
|
||||
|
||||
void recordCleanup() {
|
||||
++cleanup_calls;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(TlsClockValidity, RejectsNegativeAndPreMinimumEpochs) {
|
||||
EXPECT_FALSE(Clock::timeIsValid((time_t)-1));
|
||||
EXPECT_FALSE(Clock::timeIsValid(Clock::kMinimumValidEpoch - 1));
|
||||
}
|
||||
|
||||
TEST(TlsClockValidity, AcceptsTheExactMinimumAndLaterEpochs) {
|
||||
EXPECT_TRUE(Clock::timeIsValid(Clock::kMinimumValidEpoch));
|
||||
EXPECT_TRUE(Clock::timeIsValid(Clock::kMinimumValidEpoch + 1));
|
||||
}
|
||||
|
||||
TEST(TlsClockValidity, RequiresFreshProofAndConnectedWiFi) {
|
||||
const time_t now = Clock::kMinimumValidEpoch;
|
||||
EXPECT_FALSE(Clock::proofIsValid(false, true, now));
|
||||
EXPECT_FALSE(Clock::proofIsValid(true, false, now));
|
||||
EXPECT_FALSE(Clock::proofIsValid(true, true, now - 1));
|
||||
EXPECT_TRUE(Clock::proofIsValid(true, true, now));
|
||||
}
|
||||
|
||||
TEST(TlsClockValidity, ProofAgeIsBoundedAndMillisRolloverSafe) {
|
||||
EXPECT_FALSE(Clock::proofAgeIsValid(false, 100, 90, 10));
|
||||
EXPECT_TRUE(Clock::proofAgeIsValid(true, 100, 90, 10));
|
||||
EXPECT_FALSE(Clock::proofAgeIsValid(true, 101, 90, 10));
|
||||
|
||||
const uint32_t before_wrap = UINT32_MAX - 5U;
|
||||
EXPECT_TRUE(Clock::proofAgeIsValid(true, 4, before_wrap, 10));
|
||||
EXPECT_FALSE(Clock::proofAgeIsValid(true, 5, before_wrap, 10));
|
||||
}
|
||||
|
||||
TEST(TlsClockValidity, LateCallbackGenerationFailsClosed) {
|
||||
EXPECT_FALSE(Clock::proofGenerationIsValid(false, 7, 7));
|
||||
EXPECT_FALSE(Clock::proofGenerationIsValid(true, 7, 0));
|
||||
EXPECT_FALSE(Clock::proofGenerationIsValid(true, 6, 7));
|
||||
EXPECT_TRUE(Clock::proofGenerationIsValid(true, 7, 7));
|
||||
}
|
||||
|
||||
TEST(SntpOperationCoordinator, IsNonBlockingAndOnlyOwnerRunsCleanup) {
|
||||
mesh::sntp_coord::OperationCoordinator coordinator;
|
||||
cleanup_calls = 0;
|
||||
|
||||
mesh::sntp_coord::OperationLease first(coordinator, recordCleanup);
|
||||
mesh::sntp_coord::OperationLease second(coordinator, recordCleanup);
|
||||
ASSERT_TRUE(first.tryAcquire());
|
||||
const uint32_t first_generation = first.generation();
|
||||
EXPECT_NE(first_generation, 0U);
|
||||
EXPECT_TRUE(first.owns());
|
||||
EXPECT_FALSE(second.tryAcquire());
|
||||
EXPECT_EQ(cleanup_calls, 0);
|
||||
|
||||
EXPECT_TRUE(first.release());
|
||||
EXPECT_EQ(cleanup_calls, 1);
|
||||
EXPECT_FALSE(first.release());
|
||||
EXPECT_EQ(cleanup_calls, 1);
|
||||
|
||||
ASSERT_TRUE(second.tryAcquire());
|
||||
EXPECT_NE(second.generation(), first_generation);
|
||||
EXPECT_TRUE(second.owns());
|
||||
// A stale/repeated release from the first lease cannot clear the new owner.
|
||||
EXPECT_FALSE(first.release());
|
||||
EXPECT_TRUE(second.owns());
|
||||
EXPECT_EQ(cleanup_calls, 1);
|
||||
EXPECT_TRUE(second.release());
|
||||
EXPECT_EQ(cleanup_calls, 2);
|
||||
}
|
||||
|
||||
TEST(SntpOperationCoordinator, ProcessWideAccessorReturnsOneInstance) {
|
||||
EXPECT_EQ(&mesh::sntp_coord::processWideCoordinator(),
|
||||
sntpCoordinatorPeerAddress());
|
||||
|
||||
mesh::sntp_coord::OperationLease local(
|
||||
mesh::sntp_coord::processWideCoordinator());
|
||||
ASSERT_TRUE(local.tryAcquire());
|
||||
EXPECT_FALSE(sntpCoordinatorPeerAcquire());
|
||||
ASSERT_TRUE(local.release());
|
||||
EXPECT_TRUE(sntpCoordinatorPeerAcquire());
|
||||
EXPECT_TRUE(sntpCoordinatorPeerRelease());
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ESP32_BOARD = ROOT / "src/helpers/ESP32Board.cpp"
|
||||
INDICATOR_FONT = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontClient.cpp"
|
||||
OBSERVER_CLI = ROOT / "src/helpers/CommonCLI_Observer.cpp"
|
||||
TLS_POLICY = ROOT / "src/helpers/esp32/TlsClockValidity.h"
|
||||
SNTP_COORDINATOR = ROOT / "src/helpers/esp32/SntpOperationCoordinator.h"
|
||||
MQTT_BRIDGE = ROOT / "src/helpers/bridges/MQTTBridge.h"
|
||||
MQTT_BRIDGE_IMPL = ROOT / "src/helpers/bridges/MQTTBridge.cpp"
|
||||
MQTT_POLICY = ROOT / "src/helpers/MQTTConnectionPolicy.h"
|
||||
|
||||
|
||||
def source(path: Path) -> str:
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def section(text: str, start: str, end: str) -> str:
|
||||
first = text.index(start)
|
||||
return text[first:text.index(end, first)]
|
||||
|
||||
|
||||
class TlsDownloadClockGateTest(unittest.TestCase):
|
||||
def test_outbound_secure_socket_inventory_has_no_unreviewed_path(self):
|
||||
# Keep this inventory deliberately small and explicit. A new direct
|
||||
# WiFiClientSecure connect is a new certificate-validating network
|
||||
# boundary; adding one must also add its clock/proof contract here.
|
||||
runtime_roots = (ROOT / "src", ROOT / "examples", ROOT / "variants")
|
||||
secure_connect_files = set()
|
||||
for runtime_root in runtime_roots:
|
||||
for path in runtime_root.rglob("*.cpp"):
|
||||
implementation = source(path)
|
||||
if "WiFiClientSecure" in implementation and ".connect(" in implementation:
|
||||
secure_connect_files.add(path.relative_to(ROOT).as_posix())
|
||||
|
||||
self.assertEqual(
|
||||
secure_connect_files,
|
||||
{
|
||||
"src/helpers/CommonCLI_Observer.cpp", # connect-only CA diagnostic
|
||||
"src/helpers/ESP32Board.cpp", # manifest + firmware pull OTA
|
||||
"variants/sensecap_indicator-espnow/IndicatorFontClient.cpp",
|
||||
},
|
||||
)
|
||||
|
||||
# These higher-level outbound HTTP clients can hide their TLS connect
|
||||
# from the inventory above. None is currently used by device runtime
|
||||
# code; fail visibly if one is introduced without a matching audit.
|
||||
combined_runtime = "\n".join(
|
||||
source(path)
|
||||
for runtime_root in runtime_roots
|
||||
for path in runtime_root.rglob("*.cpp")
|
||||
)
|
||||
for helper in ("HTTPClient", "esp_https_ota", "esp_http_client"):
|
||||
self.assertNotIn(helper, combined_runtime)
|
||||
|
||||
def test_shared_policy_uses_signed_time_and_requires_every_input(self):
|
||||
policy = source(TLS_POLICY)
|
||||
self.assertIn("constexpr bool timeIsValid(time_t now)", policy)
|
||||
self.assertIn("now >= kMinimumValidEpoch", policy)
|
||||
self.assertIn("fresh_proof && wifi_connected && timeIsValid(now)", policy)
|
||||
self.assertNotIn("timeIsValid(uint32_t now", policy)
|
||||
self.assertNotIn("proofIsValid(bool fresh_proof, bool wifi_connected,\n uint32_t now", policy)
|
||||
|
||||
def test_pull_ota_awaits_fresh_ntp_before_the_https_sequence(self):
|
||||
board = source(ESP32_BOARD)
|
||||
prepare = section(
|
||||
board, "static bool ota_prepareTlsClock", "struct OtaHttpResponse"
|
||||
)
|
||||
implementation = section(
|
||||
board,
|
||||
"bool ESP32Board::otaFromManifestImpl",
|
||||
"#else\nbool ESP32Board::otaFromManifest",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"ota_tls_fresh_ntp_received.store(false, std::memory_order_release)",
|
||||
prepare,
|
||||
)
|
||||
self.assertIn("sntp_set_time_sync_notification_cb(ota_noteNtpTime)", prepare)
|
||||
self.assertIn("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)", prepare)
|
||||
self.assertIn('configTime(0, 0, "time.cloudflare.com"', prepare)
|
||||
self.assertIn("const time_t now = time(nullptr)", prepare)
|
||||
self.assertIn("ota_tlsClockProofValid()", prepare)
|
||||
self.assertIn("OTA_NTP_SYNC_WAIT_MS", prepare)
|
||||
self.assertNotIn("(uint32_t)time(nullptr)", prepare)
|
||||
self.assertIn("OperationLease sntp_operation", prepare)
|
||||
self.assertIn("processWideCoordinator()", prepare)
|
||||
self.assertIn("if (!sntp_operation.tryAcquire())", prepare)
|
||||
self.assertNotIn("sntp_set_time_sync_notification_cb(nullptr)", prepare)
|
||||
|
||||
gate = implementation.index(
|
||||
"if (!dry_run && !ota_prepareTlsClock(reply))"
|
||||
)
|
||||
manifest_client = implementation.index("WiFiClientSecure mclient")
|
||||
manifest_log = implementation.index("OTA: downloading manifest")
|
||||
manifest_fetch = implementation.index(
|
||||
"ota_fetchManifest(mclient, murl, true, doc, reply)"
|
||||
)
|
||||
self.assertLess(gate, manifest_client)
|
||||
self.assertLess(gate, manifest_log)
|
||||
self.assertLess(manifest_log, manifest_fetch)
|
||||
self.assertIn(
|
||||
"ota_openHttp(uclient, file_url, true, response, reply)",
|
||||
implementation,
|
||||
)
|
||||
|
||||
def test_every_pull_ota_tls_connect_revalidates_the_bounded_proof(self):
|
||||
board = source(ESP32_BOARD)
|
||||
proof = section(
|
||||
board, "static bool ota_tlsClockProofValid", "static bool ota_prepareTlsClock"
|
||||
)
|
||||
request = section(board, "static bool ota_openHttp", "static bool ota_fetchManifest")
|
||||
|
||||
self.assertIn("ota_tls_fresh_ntp_received.load", proof)
|
||||
self.assertIn("WiFi.status() == WL_CONNECTED", proof)
|
||||
self.assertIn("time(nullptr)", proof)
|
||||
self.assertIn("OTA_TLS_PROOF_MAX_AGE_MS", proof)
|
||||
self.assertIn("mesh::tls_clock::proofAgeIsValid", proof)
|
||||
self.assertIn("mesh::tls_clock::proofGenerationIsValid", proof)
|
||||
guard = request.index("require_tls && !ota_tlsClockProofValid()")
|
||||
connect = request.index("client.connect(host, port)")
|
||||
self.assertLess(guard, connect)
|
||||
self.assertEqual(request.count("client.connect("), 1)
|
||||
|
||||
def test_all_sntp_callback_owners_share_one_nonblocking_generation_lease(self):
|
||||
board = source(ESP32_BOARD)
|
||||
indicator = source(INDICATOR_FONT)
|
||||
coordinator = source(SNTP_COORDINATOR)
|
||||
|
||||
self.assertIn("compare_exchange_strong", coordinator)
|
||||
self.assertIn("if (!owns(generation)) return false", coordinator)
|
||||
cleanup = coordinator.index("if (cleanup != nullptr) cleanup()")
|
||||
publish_free = coordinator.index("expected, 0", cleanup)
|
||||
self.assertLess(cleanup, publish_free)
|
||||
self.assertIn("~OperationLease() { release(); }", coordinator)
|
||||
|
||||
for implementation, callback, cleanup_name in (
|
||||
(board, "ota_noteNtpTime", "ota_clearNtpCallback"),
|
||||
(indicator, "noteFontNtpTime", "clearFontNtpCallback"),
|
||||
):
|
||||
self.assertIn("processWideCoordinator()", implementation)
|
||||
self.assertIn("OperationLease sntp_operation", implementation)
|
||||
self.assertIn("processWideCoordinator().owns(generation)", implementation)
|
||||
cleanup_body = section(
|
||||
implementation,
|
||||
f"void {cleanup_name}",
|
||||
f"void {callback}",
|
||||
)
|
||||
self.assertIn("sntp_set_time_sync_notification_cb(nullptr)", cleanup_body)
|
||||
self.assertIn(".store(0, std::memory_order_release)", cleanup_body)
|
||||
|
||||
def test_tls_bundle_diagnostic_rejects_an_invalid_clock_before_connect(self):
|
||||
cli = source(OBSERVER_CLI)
|
||||
diagnostic = section(
|
||||
cli,
|
||||
'if (memcmp(command, "tls.bundletest ", 15) == 0)',
|
||||
'} else if (memcmp(command, "ota check", 9)',
|
||||
)
|
||||
signed_clock = diagnostic.index("const time_t tls_now = time(nullptr)")
|
||||
gate = diagnostic.index("mesh::tls_clock::timeIsValid(tls_now)")
|
||||
connect = diagnostic.index("client.connect(host, port)")
|
||||
self.assertLess(signed_clock, gate)
|
||||
self.assertLess(gate, connect)
|
||||
|
||||
def test_mqtt_clock_flag_is_not_documented_as_fresh_ntp_proof(self):
|
||||
header = source(MQTT_BRIDGE)
|
||||
self.assertIn("plausible retained RTC/system clock", header)
|
||||
self.assertNotIn(
|
||||
"True after this bridge has successfully set the RTC from NTP",
|
||||
header,
|
||||
)
|
||||
|
||||
def test_every_mqtt_sntp_mutation_is_coordinator_leased(self):
|
||||
implementation = source(MQTT_BRIDGE_IMPL)
|
||||
refresh = section(
|
||||
implementation,
|
||||
"void MQTTBridge::refreshNTP()",
|
||||
"bool MQTTBridge::syncTimeWithNTP",
|
||||
)
|
||||
sync = section(
|
||||
implementation,
|
||||
"bool MQTTBridge::syncTimeWithNTP",
|
||||
"bool MQTTBridge::requestForcedNtpSync",
|
||||
)
|
||||
|
||||
self.assertIn("SntpOperationCoordinator.h", implementation)
|
||||
self.assertEqual(len(re.findall(r"(?m)^\s*configTime\(", implementation)), 3)
|
||||
self.assertEqual(
|
||||
len(re.findall(r"(?m)^\s*sntp_set_sync_status\(", implementation)),
|
||||
2,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(re.findall(r"sntp_get_sync_status\(", implementation)),
|
||||
2,
|
||||
)
|
||||
|
||||
refresh_lease = refresh.index("OperationLease sntp_operation")
|
||||
refresh_acquire = refresh.index("if (!sntp_operation.tryAcquire())")
|
||||
refresh_configure = refresh.index("configTime(")
|
||||
self.assertLess(refresh_lease, refresh_acquire)
|
||||
self.assertLess(refresh_acquire, refresh_configure)
|
||||
|
||||
fallback_start = sync.index(
|
||||
"// Fallback: use ESP32 built-in SNTP (configTime)"
|
||||
)
|
||||
fallback_end = sync.index("\n #endif\n\n if (!ntp_ok) {", fallback_start)
|
||||
fallback = sync[fallback_start:fallback_end]
|
||||
fallback_lease = fallback.index("OperationLease sntp_operation")
|
||||
fallback_acquire = fallback.index("if (!sntp_operation.tryAcquire())")
|
||||
reset = fallback.index("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)")
|
||||
configure = fallback.index("configTime(0, 0, server)")
|
||||
completed = fallback.index(
|
||||
"sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED"
|
||||
)
|
||||
self.assertLess(fallback_lease, fallback_acquire)
|
||||
self.assertLess(fallback_acquire, reset)
|
||||
self.assertLess(reset, configure)
|
||||
self.assertLess(configure, completed)
|
||||
|
||||
final_rearm = section(
|
||||
sync,
|
||||
"if (ntp_server_used && !sntp_fallback_configured)",
|
||||
"if (_rtc)",
|
||||
)
|
||||
final_lease = final_rearm.index("OperationLease sntp_operation")
|
||||
final_acquire = final_rearm.index("if (sntp_operation.tryAcquire())")
|
||||
final_configure = final_rearm.index("configTime(0, 0, ntp_server_used)")
|
||||
self.assertLess(final_lease, final_acquire)
|
||||
self.assertLess(final_acquire, final_configure)
|
||||
self.assertIn("else if (sntp_fallback_configured)", final_rearm)
|
||||
|
||||
def test_mqtt_async_sntp_contention_uses_a_wrap_safe_bounded_retry(self):
|
||||
header = source(MQTT_BRIDGE)
|
||||
implementation = source(MQTT_BRIDGE_IMPL)
|
||||
policy = source(MQTT_POLICY)
|
||||
refresh = section(
|
||||
implementation,
|
||||
"void MQTTBridge::refreshNTP()",
|
||||
"bool MQTTBridge::syncTimeWithNTP",
|
||||
)
|
||||
|
||||
self.assertIn("unsigned long _ntp_refresh_retry_at", header)
|
||||
self.assertIn("kNtpRetryMs = 5000UL", policy)
|
||||
self.assertIn(
|
||||
"static_cast<int32_t>(now - retry_at) >= 0", policy
|
||||
)
|
||||
self.assertIn("return retry_at == 0 ? 1 : retry_at", policy)
|
||||
self.assertIn("kNtpRefreshIntervalMs = 86400000UL", policy)
|
||||
self.assertIn(
|
||||
"elapsedMs(now, last_sync) >= kNtpRefreshIntervalMs", policy
|
||||
)
|
||||
self.assertIn("ntpReconnectRefreshAt", policy)
|
||||
self.assertEqual(
|
||||
implementation.count(
|
||||
"MQTTConnectionPolicy::ntpReconnectRefreshAt("
|
||||
),
|
||||
2,
|
||||
)
|
||||
self.assertGreaterEqual(implementation.count("mqttNtpRefreshDue("), 4)
|
||||
busy = refresh.index("if (!sntp_operation.tryAcquire())")
|
||||
retry = refresh.index("mqttNtpRetryAt(now)", busy)
|
||||
leave = refresh.index("return;", retry)
|
||||
configure = refresh.index(
|
||||
"configTime(0, 0, effectiveNtpPrimary(_obs))"
|
||||
)
|
||||
self.assertLess(busy, retry)
|
||||
self.assertLess(retry, leave)
|
||||
self.assertLess(leave, configure)
|
||||
self.assertIn("_ntp_refresh_retry_at = 0", refresh[configure:])
|
||||
|
||||
sync = section(
|
||||
implementation,
|
||||
"bool MQTTBridge::syncTimeWithNTP",
|
||||
"bool MQTTBridge::requestForcedNtpSync",
|
||||
)
|
||||
final_rearm = section(
|
||||
sync,
|
||||
"if (ntp_server_used && !sntp_fallback_configured)",
|
||||
"if (_rtc)",
|
||||
)
|
||||
self.assertIn(
|
||||
"_ntp_refresh_retry_at = mqttNtpRetryAt(millis())",
|
||||
final_rearm,
|
||||
)
|
||||
|
||||
def test_mqtt_periodic_refresh_updates_rtc_only_after_sntp_completion(self):
|
||||
header = source(MQTT_BRIDGE)
|
||||
implementation = source(MQTT_BRIDGE_IMPL)
|
||||
refresh = section(
|
||||
implementation,
|
||||
"void MQTTBridge::refreshNTP()",
|
||||
"void MQTTBridge::pollNtpRefresh",
|
||||
)
|
||||
poll = section(
|
||||
implementation,
|
||||
"void MQTTBridge::pollNtpRefresh",
|
||||
"bool MQTTBridge::syncTimeWithNTP",
|
||||
)
|
||||
|
||||
self.assertIn("unsigned long _ntp_refresh_started_at", header)
|
||||
self.assertIn("bool _ntp_refresh_pending", header)
|
||||
self.assertIn("std::atomic<bool> _ntp_synced", header)
|
||||
self.assertIn(
|
||||
"MQTTConnectionPolicy::NtpReconnectLatch "
|
||||
"_ntp_reconnect_latch",
|
||||
header,
|
||||
)
|
||||
self.assertIn("MQTT_NTP_REFRESH_TIMEOUT_MS = 15000UL", implementation)
|
||||
self.assertEqual(implementation.count("pollNtpRefresh("), 3)
|
||||
self.assertGreaterEqual(
|
||||
implementation.count("&& !_ntp_refresh_pending"), 2
|
||||
)
|
||||
|
||||
reset = refresh.index("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)")
|
||||
configure = refresh.index(
|
||||
"configTime(0, 0, effectiveNtpPrimary(_obs))"
|
||||
)
|
||||
pending = refresh.index("_ntp_refresh_pending = true", configure)
|
||||
self.assertLess(reset, configure)
|
||||
self.assertLess(configure, pending)
|
||||
self.assertNotIn("_last_ntp_sync =", refresh)
|
||||
|
||||
completed = poll.index("sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED")
|
||||
system_clock = poll.index("time(nullptr)", completed)
|
||||
valid = poll.index(
|
||||
"MQTTConnectionPolicy::checkedRtcEpoch(", system_clock
|
||||
)
|
||||
self.assertIn("static_cast<int64_t>(raw_system_time)", poll[valid:])
|
||||
rtc = poll.index("_rtc->setCurrentTime(system_time)", valid)
|
||||
success = poll.index("_last_ntp_sync = now", rtc)
|
||||
self.assertLess(completed, system_clock)
|
||||
self.assertLess(system_clock, valid)
|
||||
self.assertLess(valid, rtc)
|
||||
self.assertLess(rtc, success)
|
||||
self.assertEqual(poll.count("_last_ntp_sync ="), 1)
|
||||
|
||||
timeout = poll.index("elapsed >= MQTT_NTP_REFRESH_TIMEOUT_MS")
|
||||
retry = poll.index("mqttNtpRetryAt(now)", timeout)
|
||||
self.assertLess(timeout, retry)
|
||||
self.assertNotIn("_last_ntp_sync =", poll[timeout:])
|
||||
|
||||
got_ip = section(
|
||||
implementation,
|
||||
"case ARDUINO_EVENT_WIFI_STA_GOT_IP:",
|
||||
"case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:",
|
||||
)
|
||||
self.assertIn("_ntp_reconnect_latch.noteGotIp()", got_ip)
|
||||
self.assertNotIn("_ntp_synced", got_ip)
|
||||
self.assertNotIn("_ntp_sync_pending", got_ip)
|
||||
task_loop = section(
|
||||
implementation,
|
||||
"void MQTTBridge::mqttTaskLoop()",
|
||||
"bool MQTTBridge::isSlotReady",
|
||||
)
|
||||
connected = task_loop.index(
|
||||
"const bool wifi_connected = WiFi.status() == WL_CONNECTED"
|
||||
)
|
||||
consume = task_loop.index(
|
||||
"_ntp_reconnect_latch.consumeIfConnected(wifi_connected)"
|
||||
)
|
||||
self.assertLess(connected, consume)
|
||||
begin = section(
|
||||
implementation,
|
||||
"void MQTTBridge::begin()",
|
||||
"void MQTTBridge::end()",
|
||||
)
|
||||
self.assertNotIn("_ntp_reconnect_latch.", begin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2835
-114
File diff suppressed because it is too large.
Load diff
+3057
-42
File diff suppressed because it is too large.
Load diff
@@ -30,4 +30,84 @@ resolution. See
|
||||
|
||||
For diagnostics, send `MCFONT STATUS` over RP2040 USB serial. The response
|
||||
reports internal-UART INFO requests, GET attempts, completed streams, and
|
||||
bytes sent since the RP2040 last booted.
|
||||
bytes sent since the RP2040 last booted. `MCFONT STAGESTATUS` reports the
|
||||
version-2 staging attempts, completed stages, cumulative bytes and elapsed
|
||||
milliseconds from the last attempt, and its final result. For example:
|
||||
|
||||
```text
|
||||
MCFONT STAGESTATUS 2 1 1 1302608 18437 STAGED
|
||||
```
|
||||
|
||||
The result is one of `IDLE`, `RECEIVING`, `STAGED`, `SD`, `OPEN`, `TIMEOUT`,
|
||||
`WRITE`, `CHECKSUM`, `METADATA`, or `ABORTED`.
|
||||
|
||||
The internal ESP32 UART also supports the recovery-only two-phase commands
|
||||
`MCFONT STAGE`, `MCFONT STAGEV2`, `MCFONT COMMIT`, and `MCFONT ABORT`. A staged
|
||||
file never replaces the live font and is discarded after a reset. Only a
|
||||
subsequent commit moves the CRC-verified staged pair through the same
|
||||
temporary/backup transaction used by USB upload. The public USB command parser
|
||||
does not accept these commands; USB retains its explicit `MCFONT PUT` workflow.
|
||||
The ESP32 buffers the complete HTTPS response in PSRAM and verifies the
|
||||
compiled SHA-256 before it starts this staged UART transfer. The RP2040 writes
|
||||
that verified buffer to the separate staging file and still withholds commit
|
||||
until the size and CRC32 also match.
|
||||
|
||||
After the ESP32 sends `MCFONT COMMIT`, either an `OK` response or a missing or
|
||||
malformed response moves recovery to local-only verification. A lost UART reply
|
||||
is ambiguous because the RP2040 may already have completed its durable rename;
|
||||
it must not authorize another download of the same GitHub blob. Only an
|
||||
explicit `ERROR ...` response is treated as a rejected transaction eligible
|
||||
for a later network attempt. Local verification runs immediately and then with
|
||||
bounded 2-, 5-, and 15-second delays, for at most four post-commit probes per
|
||||
boot, and does not require Wi-Fi. When the built-in fallback is active, a probe
|
||||
must stream the exact compiled size, CRC32, and SHA-256 before activating the
|
||||
font. When a valid older runtime font is deliberately retained until reboot,
|
||||
the probe uses exact INFO size and CRC32 instead; RP2040 COMMIT has already read
|
||||
and CRC-verified the complete stored file, and this avoids allocating a second
|
||||
1.3 MiB runtime buffer. Failure exhausts locally, keeps the existing/fallback
|
||||
runtime font, and waits for reboot to classify the service again.
|
||||
|
||||
`STAGEV2` adds receiver pacing for SD-card latency. The distinct command name
|
||||
also avoids being prefix-parsed as `STAGE 2 ...` by older services. Its
|
||||
exchange is:
|
||||
|
||||
```text
|
||||
ESP32 -> MCFONT STAGEV2 <total-size> <crc32-hex> 512
|
||||
RP2040 -> READY 2 512
|
||||
ESP32 -> exactly 512 binary bytes (or the exact shorter final chunk)
|
||||
RP2040 -> ACK <cumulative-byte-count>
|
||||
... repeat one chunk at a time ...
|
||||
RP2040 -> STAGED
|
||||
```
|
||||
|
||||
The RP2040 does not acknowledge a partial chunk. It writes each complete chunk
|
||||
to the SD file before acknowledging it, and accepts no more than 512 bytes per
|
||||
chunk. Both the receiver-paced protocol and the legacy `STAGE`/USB `PUT` path
|
||||
have a 10-second idle timeout and a 180-second whole-transfer timeout, so even a
|
||||
slow byte trickle cannot hold the command service forever. Transfer errors are
|
||||
single bounded lines (`ERROR SD`,
|
||||
`ERROR OPEN`, `ERROR TIMEOUT`, `ERROR WRITE`, `ERROR CHECKSUM`, or
|
||||
`ERROR INSTALL`); invalid requests are rejected before receiving binary data
|
||||
with `ERROR SIZE`, `ERROR CHUNK`, or `ERROR READONLY`. The original unpaced
|
||||
`STAGE` command remains available for compatibility with already-deployed
|
||||
ESP32 firmware, while new firmware should prefer `STAGEV2`.
|
||||
|
||||
The whole-transfer deadline also covers the final SD write and flush: a last
|
||||
chunk received at the deadline edge is cleaned up and reported as
|
||||
`ERROR TIMEOUT`, never published as `STAGED` after the budget has expired.
|
||||
|
||||
If the SD card is not ready on the RP2040's first mount attempt, the service
|
||||
clears the failed filesystem state and retries every two seconds for up to 30
|
||||
attempts (about one minute). INFO and upload commands remain responsive with a
|
||||
missing/SD error during that window and begin using the card automatically as
|
||||
soon as a retry succeeds. The finite retry budget prevents a permanently
|
||||
missing card from creating an endless mount loop; reboot to start a new window.
|
||||
|
||||
Boot recovery also covers resets between either pair of file renames: a live
|
||||
font whose metadata still has the temporary or backup name, and an old backup
|
||||
font whose metadata still has the live name. These abnormal cross-path pairs
|
||||
must pass a full-file CRC check before metadata is renamed or a pair is
|
||||
promoted. If any install transaction artifact exists, the live pair must also
|
||||
pass its full CRC before recovery deletes the candidates. The normal healthy
|
||||
boot still uses the metadata/size check and lets the ESP32 verify the streamed
|
||||
CRC, avoiding an extra full SD read every boot.
|
||||
@@ -2,11 +2,16 @@
|
||||
#include <SD.h>
|
||||
#include <SPI.h>
|
||||
|
||||
#include "../../../src/helpers/IndicatorFontStageV2Protocol.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t FONT_UART_BAUD = 1000000;
|
||||
constexpr uint32_t SD_CLOCK_HZ = 1000000;
|
||||
constexpr uint32_t SD_MOUNT_RETRY_INTERVAL_MS = 2000;
|
||||
constexpr uint8_t SD_MOUNT_MAX_ATTEMPTS = 30;
|
||||
constexpr uint32_t RECEIVE_IDLE_TIMEOUT_MS = 10000;
|
||||
constexpr uint32_t RECEIVE_TOTAL_TIMEOUT_MS = 180000;
|
||||
constexpr size_t MAX_FONT_BYTES = 1536 * 1024;
|
||||
|
||||
constexpr int FONT_UART_TX = 16;
|
||||
@@ -23,14 +28,56 @@ constexpr const char* TEMP_FONT_PATH = "/meshcore/ui-font.tmp";
|
||||
constexpr const char* TEMP_META_PATH = "/meshcore/ui-font-meta.tmp";
|
||||
constexpr const char* BACKUP_FONT_PATH = "/meshcore/ui-font.bak";
|
||||
constexpr const char* BACKUP_META_PATH = "/meshcore/ui-font-meta.bak";
|
||||
// Wi-Fi recovery stages into separate paths. Unlike TEMP_*, these are never
|
||||
// promoted during boot recovery: the ESP32 must first verify the downloaded
|
||||
// SHA-256 and explicitly issue MCFONT COMMIT.
|
||||
constexpr const char* STAGED_FONT_PATH = "/meshcore/ui-font.stage";
|
||||
constexpr const char* STAGED_META_PATH = "/meshcore/ui-font-meta.stage";
|
||||
|
||||
bool sdReady = false;
|
||||
uint8_t sdMountAttempts = 0;
|
||||
uint32_t sdMountNextAttempt = 0;
|
||||
size_t fontSize = 0;
|
||||
uint32_t fontCrc = 0;
|
||||
uint32_t espInfoRequests = 0;
|
||||
uint32_t espGetAttempts = 0;
|
||||
uint32_t espGetCompleted = 0;
|
||||
size_t espLastBytes = 0;
|
||||
uint32_t espStage2Attempts = 0;
|
||||
uint32_t espStage2Completed = 0;
|
||||
size_t espStage2LastBytes = 0;
|
||||
uint32_t espStage2LastElapsedMs = 0;
|
||||
|
||||
enum class Stage2Result : uint8_t {
|
||||
Idle,
|
||||
Receiving,
|
||||
Staged,
|
||||
SdUnavailable,
|
||||
OpenFailed,
|
||||
Timeout,
|
||||
WriteFailed,
|
||||
ChecksumFailed,
|
||||
MetadataFailed,
|
||||
Aborted,
|
||||
};
|
||||
|
||||
Stage2Result espStage2LastResult = Stage2Result::Idle;
|
||||
|
||||
const char* stage2ResultName(Stage2Result result) {
|
||||
switch (result) {
|
||||
case Stage2Result::Idle: return "IDLE";
|
||||
case Stage2Result::Receiving: return "RECEIVING";
|
||||
case Stage2Result::Staged: return "STAGED";
|
||||
case Stage2Result::SdUnavailable: return "SD";
|
||||
case Stage2Result::OpenFailed: return "OPEN";
|
||||
case Stage2Result::Timeout: return "TIMEOUT";
|
||||
case Stage2Result::WriteFailed: return "WRITE";
|
||||
case Stage2Result::ChecksumFailed: return "CHECKSUM";
|
||||
case Stage2Result::MetadataFailed: return "METADATA";
|
||||
case Stage2Result::Aborted: return "ABORTED";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) {
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
@@ -90,6 +137,46 @@ bool validatePair(const char* fontPath, const char* metadataPath,
|
||||
return valid;
|
||||
}
|
||||
|
||||
bool validatePairCrc(const char* fontPath, const char* metadataPath,
|
||||
size_t expectedSize, uint32_t expectedCrc) {
|
||||
size_t size = 0;
|
||||
uint32_t crc = 0;
|
||||
if (!validatePair(fontPath, metadataPath, size, crc)
|
||||
|| size != expectedSize || crc != expectedCrc) {
|
||||
return false;
|
||||
}
|
||||
|
||||
File font = SD.open(fontPath, FILE_READ);
|
||||
if (!font) return false;
|
||||
uint8_t buffer[512];
|
||||
size_t readTotal = 0;
|
||||
uint32_t calculated = 0xFFFFFFFFUL;
|
||||
while (readTotal < expectedSize) {
|
||||
size_t wanted = expectedSize - readTotal;
|
||||
if (wanted > sizeof(buffer)) wanted = sizeof(buffer);
|
||||
size_t count = font.read(buffer, wanted);
|
||||
if (count == 0) break;
|
||||
calculated = updateCrc32(calculated, buffer, count);
|
||||
readTotal += count;
|
||||
}
|
||||
font.close();
|
||||
return readTotal == expectedSize && ~calculated == expectedCrc;
|
||||
}
|
||||
|
||||
// Transaction recovery may need to match a font with metadata left under a
|
||||
// different path by an interrupted rename. Font releases commonly have the
|
||||
// same byte length, so size alone is not enough to prove that cross-path pair.
|
||||
bool validateStoredPairCrc(const char* fontPath, const char* metadataPath,
|
||||
size_t& size, uint32_t& crc) {
|
||||
return validatePair(fontPath, metadataPath, size, crc)
|
||||
&& validatePairCrc(fontPath, metadataPath, size, crc);
|
||||
}
|
||||
|
||||
bool hasInstallTransactionFiles() {
|
||||
return SD.exists(TEMP_FONT_PATH) || SD.exists(TEMP_META_PATH)
|
||||
|| SD.exists(BACKUP_FONT_PATH) || SD.exists(BACKUP_META_PATH);
|
||||
}
|
||||
|
||||
void removeIfPresent(const char* path) {
|
||||
if (SD.exists(path)) SD.remove(path);
|
||||
}
|
||||
@@ -101,12 +188,19 @@ void cleanTransactionFiles() {
|
||||
removeIfPresent(BACKUP_META_PATH);
|
||||
}
|
||||
|
||||
void cleanStagedFiles() {
|
||||
removeIfPresent(STAGED_FONT_PATH);
|
||||
removeIfPresent(STAGED_META_PATH);
|
||||
}
|
||||
|
||||
bool promotePair(const char* sourceFont, const char* sourceMetadata) {
|
||||
removeIfPresent(FONT_PATH);
|
||||
removeIfPresent(FONT_META_PATH);
|
||||
if (!SD.rename(sourceFont, FONT_PATH)) return false;
|
||||
if (SD.rename(sourceMetadata, FONT_META_PATH)) return true;
|
||||
removeIfPresent(FONT_PATH);
|
||||
// Keep the already-moved font next to its source metadata. A retry or reset
|
||||
// can recognize that CRC-valid split pair and finish the metadata rename;
|
||||
// deleting FONT_PATH here would destroy the only copy of the candidate.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -114,29 +208,61 @@ void recoverFontTransaction() {
|
||||
size_t size;
|
||||
uint32_t crc;
|
||||
if (validatePair(FONT_PATH, FONT_META_PATH, size, crc)) {
|
||||
cleanTransactionFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
// A reset between the two final renames can leave the new font next to its
|
||||
// temporary metadata. Complete that transaction when the sizes still agree.
|
||||
if (validatePair(FONT_PATH, TEMP_META_PATH, size, crc)) {
|
||||
removeIfPresent(FONT_META_PATH);
|
||||
if (SD.rename(TEMP_META_PATH, FONT_META_PATH)) {
|
||||
// Metadata and length are enough on an ordinary boot because the ESP32
|
||||
// verifies the streamed CRC. If an interrupted install left candidates,
|
||||
// however, prove the live bytes before deleting the only possible backup.
|
||||
if (!hasInstallTransactionFiles()
|
||||
|| validatePairCrc(FONT_PATH, FONT_META_PATH, size, crc)) {
|
||||
cleanTransactionFiles();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (validatePair(TEMP_FONT_PATH, TEMP_META_PATH, size, crc)
|
||||
&& promotePair(TEMP_FONT_PATH, TEMP_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
// A reset between the two final renames can leave the new font next to its
|
||||
// temporary metadata. Complete only a byte-for-byte CRC-valid transaction;
|
||||
// different font releases can have identical lengths.
|
||||
if (validateStoredPairCrc(FONT_PATH, TEMP_META_PATH, size, crc)) {
|
||||
removeIfPresent(FONT_META_PATH);
|
||||
if (SD.rename(TEMP_META_PATH, FONT_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
}
|
||||
// A failed metadata rename still leaves a CRC-valid split pair for the
|
||||
// next boot. Do not fall through to destructive cleanup.
|
||||
return;
|
||||
}
|
||||
|
||||
if (validatePair(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc)
|
||||
&& promotePair(BACKUP_FONT_PATH, BACKUP_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
// Restoring a backup has the same two-rename reset window. In that case the
|
||||
// backup font is already live while its metadata retains the backup name.
|
||||
if (validateStoredPairCrc(FONT_PATH, BACKUP_META_PATH, size, crc)) {
|
||||
removeIfPresent(FONT_META_PATH);
|
||||
if (SD.rename(BACKUP_META_PATH, FONT_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Moving the old live pair to backup can reset after the font rename but
|
||||
// before its metadata rename. Finish forming the CRC-valid backup before
|
||||
// trying the pending replacement, so a failed replacement can roll back.
|
||||
if (validateStoredPairCrc(BACKUP_FONT_PATH, FONT_META_PATH, size, crc)) {
|
||||
removeIfPresent(BACKUP_META_PATH);
|
||||
if (!SD.rename(FONT_META_PATH, BACKUP_META_PATH)) return;
|
||||
}
|
||||
|
||||
if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH, size, crc)) {
|
||||
if (promotePair(TEMP_FONT_PATH, TEMP_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
}
|
||||
// A failed rename leaves either the intact temp pair or a live-font/temp-
|
||||
// metadata split pair. Preserve it for a later retry instead of replacing
|
||||
// or deleting a candidate whose full CRC has already passed.
|
||||
return;
|
||||
}
|
||||
|
||||
if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc)) {
|
||||
if (promotePair(BACKUP_FONT_PATH, BACKUP_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,6 +277,39 @@ bool refreshFontInfo() {
|
||||
return sdReady && validatePair(FONT_PATH, FONT_META_PATH, fontSize, fontCrc);
|
||||
}
|
||||
|
||||
bool mountSdCard() {
|
||||
if (sdReady || sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS) return sdReady;
|
||||
|
||||
// A failed SDFS begin can leave partial mount state behind. Clear only the
|
||||
// filesystem wrapper before another attempt; SPI1 remains configured below.
|
||||
if (sdMountAttempts != 0) SD.end(false);
|
||||
++sdMountAttempts;
|
||||
sdReady = SD.begin(SD_CS, SD_CLOCK_HZ, SPI1);
|
||||
if (!sdReady) {
|
||||
sdMountNextAttempt = millis() + SD_MOUNT_RETRY_INTERVAL_MS;
|
||||
if (sdMountNextAttempt == 0) sdMountNextAttempt = 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
sdMountNextAttempt = 0;
|
||||
if (!SD.exists(FONT_DIRECTORY)) SD.mkdir(FONT_DIRECTORY);
|
||||
recoverFontTransaction();
|
||||
// A staged Wi-Fi download has not passed the ESP32's SHA-256 gate after a
|
||||
// reset, so it is discarded rather than boot-promoted.
|
||||
cleanStagedFiles();
|
||||
refreshFontInfo();
|
||||
return true;
|
||||
}
|
||||
|
||||
void serviceSdMount(uint32_t now) {
|
||||
if (sdReady || sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS) return;
|
||||
if (sdMountNextAttempt != 0
|
||||
&& static_cast<int32_t>(now - sdMountNextAttempt) < 0) {
|
||||
return;
|
||||
}
|
||||
mountSdCard();
|
||||
}
|
||||
|
||||
void sendInfo(Print& output, bool trackEspRequest) {
|
||||
if (trackEspRequest) ++espInfoRequests;
|
||||
if (!refreshFontInfo()) {
|
||||
@@ -224,7 +383,13 @@ bool moveCurrentToBackup() {
|
||||
}
|
||||
|
||||
bool installTemporaryPair(size_t expectedSize, uint32_t expectedCrc) {
|
||||
if (!moveCurrentToBackup()) return false;
|
||||
if (!moveCurrentToBackup()) {
|
||||
// The first rename pair can itself be interrupted. Repair or promote the
|
||||
// CRC-valid candidate immediately rather than leaving split paths until a
|
||||
// reboot, then report success only if the requested asset became live.
|
||||
recoverFontTransaction();
|
||||
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
|
||||
}
|
||||
if (!SD.rename(TEMP_FONT_PATH, FONT_PATH)) {
|
||||
recoverFontTransaction();
|
||||
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
|
||||
@@ -237,16 +402,48 @@ bool installTemporaryPair(size_t expectedSize, uint32_t expectedCrc) {
|
||||
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
|
||||
}
|
||||
|
||||
bool installStagedPair(size_t expectedSize, uint32_t expectedCrc) {
|
||||
// Re-read the staged file before touching the live pair. The ESP32 has
|
||||
// already checked SHA-256; this verifies the bytes still on the SD card.
|
||||
if (!validatePairCrc(STAGED_FONT_PATH, STAGED_META_PATH,
|
||||
expectedSize, expectedCrc)) {
|
||||
cleanStagedFiles();
|
||||
return false;
|
||||
}
|
||||
|
||||
removeIfPresent(TEMP_FONT_PATH);
|
||||
removeIfPresent(TEMP_META_PATH);
|
||||
if (!SD.rename(STAGED_FONT_PATH, TEMP_FONT_PATH)) {
|
||||
cleanStagedFiles();
|
||||
return false;
|
||||
}
|
||||
if (!SD.rename(STAGED_META_PATH, TEMP_META_PATH)) {
|
||||
removeIfPresent(TEMP_FONT_PATH);
|
||||
cleanStagedFiles();
|
||||
return false;
|
||||
}
|
||||
return installTemporaryPair(expectedSize, expectedCrc);
|
||||
}
|
||||
|
||||
enum class ReceiveMode : uint8_t {
|
||||
InstallNow,
|
||||
StageOnly,
|
||||
};
|
||||
|
||||
void receiveFont(Stream& input, Print& output, size_t expectedSize,
|
||||
uint32_t expectedCrc) {
|
||||
uint32_t expectedCrc, ReceiveMode mode) {
|
||||
if (!sdReady) {
|
||||
output.print("ERROR SD\n");
|
||||
return;
|
||||
}
|
||||
|
||||
removeIfPresent(TEMP_FONT_PATH);
|
||||
removeIfPresent(TEMP_META_PATH);
|
||||
File font = SD.open(TEMP_FONT_PATH, "w");
|
||||
const char* destinationFont = mode == ReceiveMode::StageOnly
|
||||
? STAGED_FONT_PATH : TEMP_FONT_PATH;
|
||||
const char* destinationMetadata = mode == ReceiveMode::StageOnly
|
||||
? STAGED_META_PATH : TEMP_META_PATH;
|
||||
removeIfPresent(destinationFont);
|
||||
removeIfPresent(destinationMetadata);
|
||||
File font = SD.open(destinationFont, "w");
|
||||
if (!font) {
|
||||
output.print("ERROR OPEN\n");
|
||||
return;
|
||||
@@ -256,10 +453,17 @@ void receiveFont(Stream& input, Print& output, size_t expectedSize,
|
||||
uint8_t buffer[512];
|
||||
size_t received = 0;
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
uint32_t lastProgress = millis();
|
||||
const uint32_t started = millis();
|
||||
uint32_t lastProgress = started;
|
||||
bool writeFailed = false;
|
||||
while (received < expectedSize
|
||||
&& millis() - lastProgress < RECEIVE_IDLE_TIMEOUT_MS) {
|
||||
bool timedOut = false;
|
||||
while (received < expectedSize) {
|
||||
const uint32_t now = millis();
|
||||
if (now - started >= RECEIVE_TOTAL_TIMEOUT_MS
|
||||
|| now - lastProgress >= RECEIVE_IDLE_TIMEOUT_MS) {
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
int available = input.available();
|
||||
if (available <= 0) {
|
||||
delay(1);
|
||||
@@ -280,14 +484,30 @@ void receiveFont(Stream& input, Print& output, size_t expectedSize,
|
||||
}
|
||||
font.flush();
|
||||
font.close();
|
||||
// Also reject a final read/write/flush which crossed the whole-transfer
|
||||
// deadline after receiving the last byte; success must fit in the same
|
||||
// absolute budget as every intermediate iteration.
|
||||
if (millis() - started >= RECEIVE_TOTAL_TIMEOUT_MS) timedOut = true;
|
||||
|
||||
if (writeFailed || received != expectedSize || ~crc != expectedCrc) {
|
||||
removeIfPresent(TEMP_FONT_PATH);
|
||||
output.print(writeFailed ? "ERROR WRITE\n" : "ERROR CHECKSUM\n");
|
||||
if (writeFailed || timedOut || received != expectedSize
|
||||
|| ~crc != expectedCrc) {
|
||||
removeIfPresent(destinationFont);
|
||||
removeIfPresent(destinationMetadata);
|
||||
output.print(writeFailed ? "ERROR WRITE\n"
|
||||
: timedOut ? "ERROR TIMEOUT\n" : "ERROR CHECKSUM\n");
|
||||
return;
|
||||
}
|
||||
if (!writeMetadata(TEMP_META_PATH, expectedSize, expectedCrc)
|
||||
|| !installTemporaryPair(expectedSize, expectedCrc)) {
|
||||
if (!writeMetadata(destinationMetadata, expectedSize, expectedCrc)) {
|
||||
removeIfPresent(destinationFont);
|
||||
removeIfPresent(destinationMetadata);
|
||||
output.print("ERROR INSTALL\n");
|
||||
return;
|
||||
}
|
||||
if (mode == ReceiveMode::StageOnly) {
|
||||
output.print("STAGED\n");
|
||||
return;
|
||||
}
|
||||
if (!installTemporaryPair(expectedSize, expectedCrc)) {
|
||||
recoverFontTransaction();
|
||||
output.print("ERROR INSTALL\n");
|
||||
return;
|
||||
@@ -295,17 +515,135 @@ void receiveFont(Stream& input, Print& output, size_t expectedSize,
|
||||
output.print("OK\n");
|
||||
}
|
||||
|
||||
// STAGEV2 is receiver paced: the sender transmits exactly the negotiated chunk
|
||||
// (or the shorter final chunk) and waits for the cumulative ACK before sending
|
||||
// more. This prevents SD latency from overflowing the RP2040 UART FIFO.
|
||||
void receiveFontStage2(Stream& input, Print& output, size_t expectedSize,
|
||||
uint32_t expectedCrc) {
|
||||
++espStage2Attempts;
|
||||
espStage2LastBytes = 0;
|
||||
espStage2LastElapsedMs = 0;
|
||||
espStage2LastResult = Stage2Result::Receiving;
|
||||
const uint32_t started = millis();
|
||||
|
||||
if (!sdReady) {
|
||||
espStage2LastResult = Stage2Result::SdUnavailable;
|
||||
espStage2LastElapsedMs = millis() - started;
|
||||
output.print("ERROR SD\n");
|
||||
return;
|
||||
}
|
||||
|
||||
cleanStagedFiles();
|
||||
File font = SD.open(STAGED_FONT_PATH, "w");
|
||||
if (!font) {
|
||||
espStage2LastResult = Stage2Result::OpenFailed;
|
||||
espStage2LastElapsedMs = millis() - started;
|
||||
output.print("ERROR OPEN\n");
|
||||
return;
|
||||
}
|
||||
|
||||
output.print(mesh::indicator_font::kStageV2ReadyReply);
|
||||
output.print('\n');
|
||||
uint8_t buffer[mesh::indicator_font::kStageV2ChunkBytes];
|
||||
size_t received = 0;
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
uint32_t lastProgress = started;
|
||||
Stage2Result failure = Stage2Result::Idle;
|
||||
|
||||
while (received < expectedSize) {
|
||||
const size_t chunkSize =
|
||||
mesh::indicator_font::stageV2ChunkSize(expectedSize, received);
|
||||
size_t chunkReceived = 0;
|
||||
while (chunkReceived < chunkSize) {
|
||||
const uint32_t now = millis();
|
||||
if (now - started >= RECEIVE_TOTAL_TIMEOUT_MS
|
||||
|| now - lastProgress >= RECEIVE_IDLE_TIMEOUT_MS) {
|
||||
failure = Stage2Result::Timeout;
|
||||
break;
|
||||
}
|
||||
|
||||
int available = input.available();
|
||||
if (available <= 0) {
|
||||
delay(1);
|
||||
continue;
|
||||
}
|
||||
size_t count = (size_t)available;
|
||||
if (count > chunkSize - chunkReceived) {
|
||||
count = chunkSize - chunkReceived;
|
||||
}
|
||||
size_t actual = input.readBytes(buffer + chunkReceived, count);
|
||||
if (actual == 0) continue;
|
||||
chunkReceived += actual;
|
||||
lastProgress = millis();
|
||||
}
|
||||
if (failure != Stage2Result::Idle) break;
|
||||
|
||||
// ACK only after the entire negotiated chunk has reached the SD layer.
|
||||
if (font.write(buffer, chunkSize) != chunkSize) {
|
||||
failure = Stage2Result::WriteFailed;
|
||||
break;
|
||||
}
|
||||
crc = updateCrc32(crc, buffer, chunkSize);
|
||||
size_t nextReceived = 0;
|
||||
if (!mesh::indicator_font::advanceStageV2Offset(
|
||||
expectedSize, received, chunkSize, nextReceived)) {
|
||||
failure = Stage2Result::WriteFailed;
|
||||
break;
|
||||
}
|
||||
received = nextReceived;
|
||||
espStage2LastBytes = received;
|
||||
output.printf("ACK %lu\n", (unsigned long)received);
|
||||
}
|
||||
|
||||
font.flush();
|
||||
font.close();
|
||||
espStage2LastElapsedMs = millis() - started;
|
||||
// A final SD write/flush can cross the same absolute transfer deadline even
|
||||
// though the last byte arrived just before it. Do not publish STAGED after
|
||||
// the receiver's whole-transfer budget has expired.
|
||||
if (failure == Stage2Result::Idle
|
||||
&& espStage2LastElapsedMs >= RECEIVE_TOTAL_TIMEOUT_MS) {
|
||||
failure = Stage2Result::Timeout;
|
||||
}
|
||||
|
||||
if (failure != Stage2Result::Idle) {
|
||||
cleanStagedFiles();
|
||||
espStage2LastResult = failure;
|
||||
output.print(failure == Stage2Result::Timeout
|
||||
? "ERROR TIMEOUT\n" : "ERROR WRITE\n");
|
||||
return;
|
||||
}
|
||||
if (received != expectedSize || ~crc != expectedCrc) {
|
||||
cleanStagedFiles();
|
||||
espStage2LastResult = Stage2Result::ChecksumFailed;
|
||||
output.print("ERROR CHECKSUM\n");
|
||||
return;
|
||||
}
|
||||
if (!writeMetadata(STAGED_META_PATH, expectedSize, expectedCrc)) {
|
||||
cleanStagedFiles();
|
||||
espStage2LastResult = Stage2Result::MetadataFailed;
|
||||
output.print("ERROR INSTALL\n");
|
||||
return;
|
||||
}
|
||||
|
||||
++espStage2Completed;
|
||||
espStage2LastResult = Stage2Result::Staged;
|
||||
output.print("STAGED\n");
|
||||
}
|
||||
|
||||
struct CommandReader {
|
||||
Stream& input;
|
||||
Print& output;
|
||||
bool allowUpload;
|
||||
bool allowStagedUpload;
|
||||
bool trackEspRequest;
|
||||
char line[96] = {};
|
||||
size_t length = 0;
|
||||
};
|
||||
|
||||
void processCommand(CommandReader& reader) {
|
||||
if (strcmp(reader.line, "MCFONT INFO") == 0) {
|
||||
sendInfo(reader.output, !reader.allowUpload);
|
||||
sendInfo(reader.output, reader.trackEspRequest);
|
||||
return;
|
||||
}
|
||||
if (strcmp(reader.line, "MCFONT STATUS") == 0) {
|
||||
@@ -316,20 +654,79 @@ void processCommand(CommandReader& reader) {
|
||||
(unsigned long)espLastBytes);
|
||||
return;
|
||||
}
|
||||
if (strcmp(reader.line, "MCFONT STAGESTATUS") == 0) {
|
||||
reader.output.printf("MCFONT STAGESTATUS 2 %lu %lu %lu %lu %s\n",
|
||||
(unsigned long)espStage2Attempts,
|
||||
(unsigned long)espStage2Completed,
|
||||
(unsigned long)espStage2LastBytes,
|
||||
(unsigned long)espStage2LastElapsedMs,
|
||||
stage2ResultName(espStage2LastResult));
|
||||
return;
|
||||
}
|
||||
if (strcmp(reader.line, "MCFONT GET") == 0) {
|
||||
sendFont(reader.output, !reader.allowUpload);
|
||||
sendFont(reader.output, reader.trackEspRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned long size = 0;
|
||||
unsigned long crc = 0;
|
||||
unsigned long chunkSize = 0;
|
||||
int consumed = 0;
|
||||
if (sscanf(reader.line, "MCFONT STAGEV2 %lu %lx %lu %n",
|
||||
&size, &crc, &chunkSize, &consumed) == 3
|
||||
&& reader.line[consumed] == 0) {
|
||||
if (!reader.allowStagedUpload) {
|
||||
reader.output.print("ERROR READONLY\n");
|
||||
} else if (size < 64 || size > MAX_FONT_BYTES) {
|
||||
reader.output.print("ERROR SIZE\n");
|
||||
} else if (chunkSize != mesh::indicator_font::kStageV2ChunkBytes) {
|
||||
reader.output.print("ERROR CHUNK\n");
|
||||
} else {
|
||||
receiveFontStage2(reader.input, reader.output,
|
||||
(size_t)size, (uint32_t)crc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (sscanf(reader.line, "MCFONT STAGE %lu %lx", &size, &crc) == 2) {
|
||||
if (!reader.allowStagedUpload) {
|
||||
reader.output.print("ERROR READONLY\n");
|
||||
} else if (size < 64 || size > MAX_FONT_BYTES) {
|
||||
reader.output.print("ERROR SIZE\n");
|
||||
} else {
|
||||
receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc,
|
||||
ReceiveMode::StageOnly);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (sscanf(reader.line, "MCFONT COMMIT %lu %lx", &size, &crc) == 2) {
|
||||
if (!reader.allowStagedUpload) {
|
||||
reader.output.print("ERROR READONLY\n");
|
||||
} else if (installStagedPair((size_t)size, (uint32_t)crc)) {
|
||||
reader.output.print("OK\n");
|
||||
} else {
|
||||
reader.output.print("ERROR INSTALL\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (strcmp(reader.line, "MCFONT ABORT") == 0) {
|
||||
if (!reader.allowStagedUpload) {
|
||||
reader.output.print("ERROR READONLY\n");
|
||||
} else {
|
||||
cleanStagedFiles();
|
||||
espStage2LastResult = Stage2Result::Aborted;
|
||||
reader.output.print("OK\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sscanf(reader.line, "MCFONT PUT %lu %lx", &size, &crc) == 2) {
|
||||
if (!reader.allowUpload) {
|
||||
reader.output.print("ERROR READONLY\n");
|
||||
} else if (size < 64 || size > MAX_FONT_BYTES) {
|
||||
reader.output.print("ERROR SIZE\n");
|
||||
} else {
|
||||
receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc);
|
||||
receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc,
|
||||
ReceiveMode::InstallNow);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -354,8 +751,11 @@ void pollCommands(CommandReader& reader) {
|
||||
}
|
||||
}
|
||||
|
||||
CommandReader usbCommands = {Serial, Serial, true};
|
||||
CommandReader espCommands = {Serial1, Serial1, false};
|
||||
// USB keeps the explicit host-side PUT command. The internal UART exposes a
|
||||
// two-phase STAGE/COMMIT path so ESP32 Wi-Fi recovery cannot replace a valid
|
||||
// font until its immutable-source SHA-256 has passed.
|
||||
CommandReader usbCommands = {Serial, Serial, true, false, false};
|
||||
CommandReader espCommands = {Serial1, Serial1, false, true, true};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -370,15 +770,11 @@ void setup() {
|
||||
SPI1.setSCK(SD_SCK);
|
||||
SPI1.setTX(SD_MOSI);
|
||||
SPI1.setRX(SD_MISO);
|
||||
sdReady = SD.begin(SD_CS, SD_CLOCK_HZ, SPI1);
|
||||
if (sdReady) {
|
||||
if (!SD.exists(FONT_DIRECTORY)) SD.mkdir(FONT_DIRECTORY);
|
||||
recoverFontTransaction();
|
||||
refreshFontInfo();
|
||||
}
|
||||
serviceSdMount(millis());
|
||||
}
|
||||
|
||||
void loop() {
|
||||
serviceSdMount(millis());
|
||||
pollCommands(espCommands);
|
||||
pollCommands(usbCommands);
|
||||
delay(1);
|
||||
|
||||
@@ -17,7 +17,7 @@ build_flags = ${rak4631_hw.build_flags}
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D PIN_BUZZER=33
|
||||
-D PIN_BUZZER=34 ; schematic net BEE_EN is nRF P1.02; GPIO 33 is GPS IO2
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
build_src_filter = ${rak4631_hw.build_src_filter}
|
||||
|
||||
@@ -54,8 +54,12 @@ build_src_filter = ${rak3401.build_src_filter}
|
||||
+<../examples/simple_repeater>
|
||||
|
||||
; Explicit lean alternative to the full-sensor, non-OTA repeater above. It removes optional
|
||||
; environmental sensors while retaining the common INA I2C voltage/current monitors and RAK12501
|
||||
; GPS support. Use sensor slot A: slot D conflicts with the RAK13302 radio's BUSY/DIO1 wiring.
|
||||
; environmental telemetry sensors while retaining the common INA I2C voltage/current monitors,
|
||||
; board display/RTC support, and both RAK12500 I2C and RAK12501 UART GPS paths. Use sensor slot A:
|
||||
; slot D conflicts with the RAK13302 radio's BUSY/DIO1 wiring.
|
||||
; RAK12500 and the configured INA3221 both use I2C address 0x42, so they cannot be installed
|
||||
; together at those addresses. Keep GPS at 0x42, strap INA3221 A0 to SCL for 0x43, and build
|
||||
; with -DTELEM_INA3221_ADDRESS=0x43 when both modules are required.
|
||||
; The legacy target name remains stable for OTA compatibility.
|
||||
[env:RAK_3401_repeater_lora_ota_no_external_sensors]
|
||||
extends = rak3401
|
||||
|
||||
@@ -135,6 +135,13 @@ static const uint8_t AREF = PIN_AREF;
|
||||
#define PIN_WIRE_SDA (WB_I2C1_SDA)
|
||||
#define PIN_WIRE_SCL (WB_I2C1_SCL)
|
||||
|
||||
// Keep the board-level aliases in the variant rather than duplicating the
|
||||
// physical pin numbers in each PlatformIO environment. MeshCore board and
|
||||
// sensor helpers use PIN_BOARD_* when they need to select the WisBlock I2C
|
||||
// bus explicitly; Wire itself uses the equivalent PIN_WIRE_* definitions.
|
||||
#define PIN_BOARD_SDA PIN_WIRE_SDA
|
||||
#define PIN_BOARD_SCL PIN_WIRE_SCL
|
||||
|
||||
// The RAK3401 has no on-board QSPI flash. These pads are the WisBlock SPI bus
|
||||
// and are used by the external RAK13300/RAK13302 radio module.
|
||||
#define PIN_QSPI_SCK 3
|
||||
@@ -183,6 +190,8 @@ static const uint8_t AREF = PIN_AREF;
|
||||
// Power is on the controllable 3V3_S rail
|
||||
#define PIN_GPS_PPS (17) // Pulse per second input from the GPS
|
||||
|
||||
// PIN_GPS_RX/TX name the pins from the GPS module's perspective. GPS RX is
|
||||
// connected to the MCU UART TX pin, and GPS TX to the MCU UART RX pin.
|
||||
#define PIN_GPS_RX PIN_SERIAL1_TX
|
||||
#define PIN_GPS_TX PIN_SERIAL1_RX
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ void RAK4631Board::begin() {
|
||||
|
||||
Wire.begin();
|
||||
|
||||
// WB_IO2 is the shared 3V3_S supply for WisBlock sensor/interface slots.
|
||||
// Keep it enabled before GPS and environmental discovery; treating it as a
|
||||
// GPS-only switch makes the subsequent I2C scan see an unpowered bus.
|
||||
pinMode(WB_IO2, OUTPUT);
|
||||
digitalWrite(WB_IO2, HIGH);
|
||||
|
||||
pinMode(SX126X_POWER_EN, OUTPUT);
|
||||
#ifdef NRF52_POWER_MANAGEMENT
|
||||
// Boot voltage protection check (may not return if voltage too low)
|
||||
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user