From 4f8f36354ee52ca72411f60876f1c5645ceddb0e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 11 Jul 2026 12:45:02 -0700 Subject: [PATCH] Restore Keymind features after LoRa OTA merge --- build.sh | 256 ++++++++++++++--- docs/cli_commands.md | 16 ++ docs/gps_tracking.md | 143 ++++++++++ docs/index.md | 1 + docs/ota_user_guide.md | 3 +- examples/companion_radio/MyMesh.cpp | 104 ++++++- examples/companion_radio/MyMesh.h | 2 + examples/simple_repeater/MyMesh.cpp | 54 ++-- examples/simple_repeater/MyMesh.h | 105 ++++--- examples/simple_sensor/SensorMesh.cpp | 50 +++- examples/simple_sensor/SensorMesh.h | 2 + .../src/PsychicMqttClient.cpp | 4 + src/helpers/CommonCLI.cpp | 55 +++- src/helpers/CommonCLI.h | 6 +- src/helpers/ESP32Board.cpp | 261 +++++++++++++++++- src/helpers/ESP32Board.h | 6 + src/helpers/JWTHelper.cpp | 4 + src/helpers/JWTHelper.h | 4 + src/helpers/MQTTMessageBuilder.cpp | 7 +- src/helpers/MQTTMessageBuilder.h | 4 + src/helpers/SensorManager.cpp | 175 ++++++++++++ src/helpers/SensorManager.h | 42 +++ .../sensors/EnvironmentSensorManager.cpp | 45 +-- .../sensors/EnvironmentSensorManager.h | 4 + variants/heltec_mesh_solar/platformio.ini | 1 + variants/heltec_mesh_solar/target.cpp | 25 +- variants/heltec_mesh_solar/target.h | 4 + variants/heltec_tracker/target.cpp | 23 +- variants/heltec_tracker/target.h | 4 + variants/lilygo_tlora_v2_1/platformio.ini | 8 +- variants/meshadventurer/platformio.ini | 1 + variants/meshadventurer/target.cpp | 23 +- variants/meshadventurer/target.h | 5 +- variants/nano_g2_ultra/platformio.ini | 1 + variants/nano_g2_ultra/target.cpp | 23 +- variants/nano_g2_ultra/target.h | 5 +- variants/sensecap_solar/variant.cpp | 4 +- variants/sensecap_solar/variant.h | 4 +- variants/station_g2/platformio.ini | 1 + variants/station_g3_esp32/platformio.ini | 1 + variants/t1000-e/target.cpp | 24 +- variants/t1000-e/target.h | 4 + variants/thinknode_m1/platformio.ini | 2 +- variants/thinknode_m1/target.cpp | 34 +-- variants/thinknode_m1/target.h | 5 +- 45 files changed, 1320 insertions(+), 235 deletions(-) create mode 100644 docs/gps_tracking.md create mode 100644 src/helpers/SensorManager.cpp diff --git a/build.sh b/build.sh index 6a8c4869..41d90f32 100755 --- a/build.sh +++ b/build.sh @@ -3,12 +3,15 @@ ALL_PIO_ENVS=() SUPPORTED_PIO_ENVS=() declare -A PIO_ENV_PLATFORM_BY_NAME=() +declare -A PIO_ENV_MQTT_BY_NAME=() PIO_CONFIG_JSON="" MENU_CHOICE="" SELECTED_TARGET="" SELECTED_COMMAND_ARGS=() MESHDEBUG_OVERRIDE="" PACKET_LOGGING_OVERRIDE="" +MQTT_BRIDGE_OVERRIDE="" +MQTT_DEBUG_OVERRIDE="" FIRMWARE_FILENAME_INFIX="" RADIO_SETTINGS_API_URL="https://api.meshcore.nz/api/v1/config" RADIO_SETTING_TITLE="" @@ -35,6 +38,7 @@ DEFAULT_VARIANT_LABEL="default" TAG_PREFIX_ROOM_SERVER="room-server" TAG_PREFIX_COMPANION="companion" TAG_PREFIX_REPEATER="repeater" +TAG_PREFIX_SENSOR="sensor" SUPPORTED_PLATFORM_PATTERN='ESP32_PLATFORM|NRF52_PLATFORM|STM32_PLATFORM|RP2040_PLATFORM' OUTPUT_DIR="out" FALLBACK_VERSION_PREFIX="dev" @@ -54,11 +58,12 @@ Commands: list|-l: List firmwares available to build. build-firmware : Build the firmware for the given build target. build-firmwares: Build all firmwares for all targets. - build-firmwares-logging-matrix: Build all firmwares twice, first with MESH_DEBUG/MESH_PACKET_LOGGING off and then with both on for non-Bluetooth targets. + build-firmwares-logging-matrix: Build all firmwares in three profiles: logging off, logging on for non-Bluetooth targets, and MQTT bridge on with logging off. build-matching-firmwares : Build all firmwares for build targets containing the string given for . build-companion-firmwares: Build all companion firmwares for all build targets. build-repeater-firmwares: Build all repeater firmwares for all build targets. build-room-server-firmwares: Build all chat room server firmwares for all build targets. + build-sensor-firmwares: Build all sensor firmwares for all build targets. Examples: Build firmware for the "RAK_4631_repeater" device target @@ -70,7 +75,7 @@ $ bash build.sh Build all firmwares for device targets containing the string "RAK_4631" $ bash build.sh build-matching-firmwares -Build all firmwares twice, with logging-off artifacts named "name-version" and logging-on artifacts named "name-logging-version": +Build all firmwares in three profiles, adding MQTT observer firmware with logging off after the logging-off and logging-on passes: $ bash build.sh build-firmwares-logging-matrix Build all companion firmwares @@ -82,6 +87,9 @@ $ bash build.sh build-repeater-firmwares Build all chat room server firmwares $ bash build.sh build-room-server-firmwares +Build all sensor firmwares +$ bash build.sh build-sensor-firmwares + Environment Variables: FIRMWARE_VERSION=vX.Y.Z: Firmware version to embed in the build output. If not set, build.sh derives a default from the latest matching git tag and appends "-dev". @@ -117,12 +125,13 @@ init_project_context() { fi if [ ${#SUPPORTED_PIO_ENVS[@]} -eq 0 ]; then - while IFS=$'\t' read -r env_name env_platform; do + while IFS=$'\t' read -r env_name env_platform env_mqtt; do if [ -z "$env_name" ] || [ -z "$env_platform" ]; then continue fi SUPPORTED_PIO_ENVS+=("$env_name") PIO_ENV_PLATFORM_BY_NAME["$env_name"]=$env_platform + PIO_ENV_MQTT_BY_NAME["$env_name"]=$env_mqtt done < <( python3 -c ' import json @@ -135,18 +144,20 @@ for section, options in data: if not section.startswith("env:"): continue env_name = section[4:] + mqtt_enabled = False + platform = None for key, value in options: if key != "build_flags": continue values = value if isinstance(value, list) else str(value).split() for flag in values: + if "WITH_MQTT_BRIDGE" in str(flag): + mqtt_enabled = True match = pattern.search(str(flag)) - if match: - print(f"{env_name}\t{match.group(0)}") - break - else: - continue - break + if match and platform is None: + platform = match.group(0) + if platform: + print(f"{env_name}\t{platform}\t{1 if mqtt_enabled else 0}") ' "$SUPPORTED_PLATFORM_PATTERN" <<<"$PIO_CONFIG_JSON" ) fi @@ -265,10 +276,11 @@ prompt_for_build_mode() { local options=( "Build one firmware target" "Build all firmwares" - "Build all firmwares twice (logging off, then logging on for non-Bluetooth targets)" + "Build all firmwares in 3 profiles (logging off, logging on, MQTT bridge with logging off)" "Build all repeater firmwares" "Build all companion firmwares" "Build all chat room server firmwares" + "Build all sensor firmwares" ) echo "No command provided. Select a build action:" @@ -306,6 +318,10 @@ prompt_for_build_mode() { SELECTED_COMMAND_ARGS=(build-room-server-firmwares) return 0 ;; + 7) + SELECTED_COMMAND_ARGS=(build-sensor-firmwares) + return 0 + ;; esac done } @@ -321,6 +337,13 @@ prompt_for_debug_build_settings() { echo "Using debug options: meshdebug=${MESHDEBUG_OVERRIDE}, packet_logging=${PACKET_LOGGING_OVERRIDE}" } +prompt_for_mqtt_bridge_build_setting() { + echo "MQTT bridge sends mesh radio traffic directly to MQTT over WiFi." + prompt_on_off_choice "MQTT bridge (radio WiFi to MQTT direct)" "off" + MQTT_BRIDGE_OVERRIDE="$MENU_CHOICE" + echo "Using MQTT bridge: ${MQTT_BRIDGE_OVERRIDE}" +} + is_logging_matrix_command() { [ "$1" == "build-firmwares-logging-matrix" ] } @@ -630,6 +653,9 @@ get_env_metadata() { repeater*) tag_prefix="$TAG_PREFIX_REPEATER" ;; + sensor) + tag_prefix="$TAG_PREFIX_SENSOR" + ;; *) tag_prefix="" ;; @@ -943,6 +969,9 @@ get_pio_envs_for_variant_role() { room_server:room_server) echo "$env" ;; + sensor:sensor) + echo "$env" + ;; esac done } @@ -1054,7 +1083,7 @@ normalize_resume_build_output() { } prompt_for_logging_matrix_output_policy() { - local choice + local choice file_count file_label normalize_resume_build_output @@ -1063,15 +1092,23 @@ prompt_for_logging_matrix_output_policy() { return 0 fi + file_count=$(find "$OUTPUT_DIR" -type f -printf '.' | wc -c) + file_count=$((file_count)) + if [ "$file_count" -eq 1 ]; then + file_label="file" + else + file_label="files" + fi + if ! [ -t 0 ]; then if [ "$RESUME_BUILD_OUTPUT" == "1" ]; then - echo "Resuming previous logging matrix output in ${OUTPUT_DIR}." + echo "Resuming previous logging matrix output in ${OUTPUT_DIR} (${file_count} ${file_label})." fi return 0 fi while true; do - read -r -p "Output directory '${OUTPUT_DIR}' exists. Resume previous option 3 progress or clean it? [resume/clean] (default: clean): " choice + read -r -p "Output directory '${OUTPUT_DIR}' exists with ${file_count} ${file_label}. Resume previous option 3 progress or clean it? [resume/clean] (default: clean): " choice choice=${choice,,} if [ -z "$choice" ]; then choice="clean" @@ -1144,12 +1181,97 @@ is_supported_build_env() { [ -n "${PIO_ENV_PLATFORM_BY_NAME[$env_name]+x}" ] } +is_mqtt_bridge_target() { + [ "${PIO_ENV_MQTT_BY_NAME[$1]:-0}" == "1" ] +} + +normalize_resolved_targets_for_mqtt() { + local command=$1 + local target + local candidate + local skipped_count=0 + local -a candidates=("${RESOLVED_BUILD_TARGETS[@]}") + local -a normalized_targets=() + local -A seen_targets=() + + if [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ]; then + case "$command" in + build-repeater-firmwares) + for target in "${SUPPORTED_PIO_ENVS[@]}"; do + if is_mqtt_bridge_target "$target" && [[ "$target" == *_repeater_observer_mqtt ]]; then + candidates+=("$target") + fi + done + ;; + build-room-server-firmwares) + for target in "${SUPPORTED_PIO_ENVS[@]}"; do + if is_mqtt_bridge_target "$target" && [[ "$target" == *_room_server_observer_mqtt ]]; then + candidates+=("$target") + fi + done + ;; + esac + fi + + for target in "${candidates[@]}"; do + candidate="" + if [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ]; then + if is_mqtt_bridge_target "$target"; then + candidate=$target + elif is_mqtt_bridge_target "${target}_observer_mqtt"; then + candidate="${target}_observer_mqtt" + fi + else + if is_mqtt_bridge_target "$target"; then + candidate=${target%_observer_mqtt} + if ! is_supported_build_env "$candidate" || is_mqtt_bridge_target "$candidate"; then + candidate="" + fi + else + candidate=$target + fi + fi + + if [ -z "$candidate" ]; then + skipped_count=$((skipped_count + 1)) + continue + fi + if [ -z "${seen_targets[$candidate]+x}" ]; then + normalized_targets+=("$candidate") + seen_targets["$candidate"]=1 + fi + done + + RESOLVED_BUILD_TARGETS=("${normalized_targets[@]}") + if [ ${#RESOLVED_BUILD_TARGETS[@]} -eq 0 ]; then + echo "No targets support the selected MQTT bridge setting. MQTT bridge is for direct radio-to-MQTT forwarding over WiFi." + return 1 + fi + + if [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ]; then + echo "MQTT bridge enabled for ${#RESOLVED_BUILD_TARGETS[@]} target(s); ${skipped_count} target(s) without a WiFi MQTT environment were skipped." + else + echo "MQTT bridge disabled; using ${#RESOLVED_BUILD_TARGETS[@]} standard target(s)." + fi +} + disable_debug_flags() { if [ "$DISABLE_DEBUG" == "1" ]; then export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMESH_DEBUG -UMESH_PACKET_LOGGING -UBLE_DEBUG_LOGGING -UWIFI_DEBUG_LOGGING -UBRIDGE_DEBUG -UGPS_NMEA_DEBUG -UCORE_DEBUG_LEVEL -UESPNOW_DEBUG_LOGGING -UDEBUG_RP2040_WIRE -UDEBUG_RP2040_SPI -UDEBUG_RP2040_CORE -UDEBUG_RP2040_PORT -URADIOLIB_DEBUG_SPI -DCFG_DEBUG=0 -URADIOLIB_DEBUG_BASIC -URADIOLIB_DEBUG_PROTOCOL" fi } +apply_mqtt_bridge_override() { + case "${MQTT_BRIDGE_OVERRIDE,,}" in + on) + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWITH_MQTT_BRIDGE=1" + ;; + off) + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWITH_MQTT_BRIDGE" + ;; + esac +} + apply_debug_overrides() { case "${MESHDEBUG_OVERRIDE,,}" in on) @@ -1168,21 +1290,40 @@ apply_debug_overrides() { export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMESH_PACKET_LOGGING" ;; esac + + case "${MQTT_DEBUG_OVERRIDE,,}" in + off) + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMQTT_DEBUG -UMQTT_MEMORY_DEBUG" + ;; + esac +} + +is_lora_ota_build() { + local env_name=$1 + + if [[ "$env_name" == *mqtt* ]] \ + || is_mqtt_bridge_target "$env_name" \ + || [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ] \ + || [ "${MESHDEBUG_OVERRIDE,,}" == "on" ] \ + || [ "${PACKET_LOGGING_OVERRIDE,,}" == "on" ] \ + || [ "$FIRMWARE_FILENAME_INFIX" == "logging" ]; then + return 1 + fi + + case "$env_name" in + *repeater*|*repeatr*|*room_server*|*room_svr*|*sensor*) return 0 ;; + *) return 1 ;; + esac } apply_lora_ota_override() { local env_name=$1 - if [[ "$env_name" == *mqtt* ]] \ - || [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ] \ - || [ "${MESHDEBUG_OVERRIDE,,}" == "on" ] \ - || [ "${PACKET_LOGGING_OVERRIDE,,}" == "on" ] \ - || [ "$FIRMWARE_FILENAME_INFIX" == "logging" ]; then + if is_lora_ota_build "$env_name"; then + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DENABLE_OTA=1" + else export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UENABLE_OTA" - return fi - - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DENABLE_OTA=1" } apply_radio_overrides() { @@ -1417,10 +1558,12 @@ build_firmware() { firmware_filename=$(get_firmware_filename "$env_name" "$firmware_version_string") # OTA target id = sha2-256:4(env_name) as a little-endian uint32 (matches tools/mota target_id_for_env - # and the device's MainBoard::getOtaTargetId()). Harmless when OTA is disabled. - mota_target_id=$(python3 -c "import hashlib,sys;print('0x%08x'%int.from_bytes(hashlib.sha256(sys.argv[1].encode()).digest()[:4],'little'))" "$env_name" 2>/dev/null || echo "") - if [ -n "$mota_target_id" ]; then - mota_target_flag=" -DMOTA_TARGET_ID=${mota_target_id}" + # and the device's MainBoard::getOtaTargetId()). Only OTA-enabled profiles receive this identifier. + if is_lora_ota_build "$env_name"; then + mota_target_id=$(python3 -c "import hashlib,sys;print('0x%08x'%int.from_bytes(hashlib.sha256(sys.argv[1].encode()).digest()[:4],'little'))" "$env_name" 2>/dev/null || echo "") + if [ -n "$mota_target_id" ]; then + mota_target_flag=" -DMOTA_TARGET_ID=${mota_target_id}" + fi fi # Fork CI hooks (consumed by .github/workflows/build-observer*-firmwares.yml). @@ -1456,6 +1599,7 @@ build_firmware() { export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}" disable_debug_flags apply_debug_overrides + apply_mqtt_bridge_override apply_lora_ota_override "$env_name" apply_radio_overrides apply_firmware_profile_overrides @@ -1497,6 +1641,10 @@ resolve_room_server_firmwares() { get_pio_envs_for_variant_role room_server } +resolve_sensor_firmwares() { + get_pio_envs_for_variant_role sensor +} + # Keep bulk build command names mapped to their target resolvers in one place. get_bulk_build_resolver_name() { case "$1" in @@ -1515,6 +1663,9 @@ get_bulk_build_resolver_name() { build-room-server-firmwares) echo "resolve_room_server_firmwares" ;; + build-sensor-firmwares) + echo "resolve_sensor_firmwares" + ;; *) return 1 ;; @@ -1640,9 +1791,14 @@ run_resolved_build_targets() { run_logging_matrix_build_targets() { local targets=("$@") + local target + local standard_targets=() local logging_targets=() + local mqtt_targets=() local original_meshdebug_override=$MESHDEBUG_OVERRIDE local original_packet_logging_override=$PACKET_LOGGING_OVERRIDE + local original_mqtt_bridge_override=$MQTT_BRIDGE_OVERRIDE + local original_mqtt_debug_override=$MQTT_DEBUG_OVERRIDE local original_firmware_filename_infix=$FIRMWARE_FILENAME_INFIX local bluetooth_skip_count=0 local build_status=0 @@ -1652,26 +1808,38 @@ run_logging_matrix_build_targets() { return 1 fi - echo "Building ${#targets[@]} target(s) with MESH_DEBUG=off and MESH_PACKET_LOGGING=off." + for target in "${targets[@]}"; do + if is_mqtt_bridge_target "$target"; then + mqtt_targets+=("$target") + else + standard_targets+=("$target") + fi + done + + echo "Profile 1/3: building ${#standard_targets[@]} standard target(s) with logging off and MQTT bridge off." MESHDEBUG_OVERRIDE="off" PACKET_LOGGING_OVERRIDE="off" + MQTT_BRIDGE_OVERRIDE="off" FIRMWARE_FILENAME_INFIX="" - run_resolved_build_targets "${targets[@]}" - build_status=$? + if [ ${#standard_targets[@]} -gt 0 ]; then + run_resolved_build_targets "${standard_targets[@]}" + build_status=$? + fi if [ "$build_status" -eq 0 ]; then - mapfile -t logging_targets < <(filter_out_bluetooth_targets "${targets[@]}") - bluetooth_skip_count=$((${#targets[@]} - ${#logging_targets[@]})) + mapfile -t logging_targets < <(filter_out_bluetooth_targets "${standard_targets[@]}") + bluetooth_skip_count=$((${#standard_targets[@]} - ${#logging_targets[@]})) if [ "$bluetooth_skip_count" -gt 0 ]; then echo "Skipping ${bluetooth_skip_count} Bluetooth target(s) for logging-on pass." fi if [ ${#logging_targets[@]} -gt 0 ]; then - echo "Building ${#logging_targets[@]} target(s) with MESH_DEBUG=on and MESH_PACKET_LOGGING=on." + echo "Profile 2/3: building ${#logging_targets[@]} standard target(s) with logging on and MQTT bridge off." echo "Logging-on artifacts use filename form: name-logging-version" MESHDEBUG_OVERRIDE="on" PACKET_LOGGING_OVERRIDE="on" + MQTT_BRIDGE_OVERRIDE="off" FIRMWARE_FILENAME_INFIX="logging" run_resolved_build_targets "${logging_targets[@]}" build_status=$? @@ -1680,8 +1848,25 @@ run_logging_matrix_build_targets() { fi fi + if [ "$build_status" -eq 0 ]; then + if [ ${#mqtt_targets[@]} -gt 0 ]; then + echo "Profile 3/3: building ${#mqtt_targets[@]} MQTT bridge target(s) for direct radio-to-MQTT forwarding over WiFi, with logging off." + MESHDEBUG_OVERRIDE="off" + PACKET_LOGGING_OVERRIDE="off" + MQTT_BRIDGE_OVERRIDE="on" + MQTT_DEBUG_OVERRIDE="off" + FIRMWARE_FILENAME_INFIX="" + run_resolved_build_targets "${mqtt_targets[@]}" + build_status=$? + else + echo "No MQTT bridge targets are configured; skipping profile 3/3." + fi + fi + MESHDEBUG_OVERRIDE=$original_meshdebug_override PACKET_LOGGING_OVERRIDE=$original_packet_logging_override + MQTT_BRIDGE_OVERRIDE=$original_mqtt_bridge_override + MQTT_DEBUG_OVERRIDE=$original_mqtt_debug_override FIRMWARE_FILENAME_INFIX=$original_firmware_filename_infix return "$build_status" @@ -1756,9 +1941,10 @@ main() { prompt_for_build_mode if is_logging_matrix_command "${SELECTED_COMMAND_ARGS[0]}"; then - echo "Skipping debug option prompts; this action builds logging off and logging on passes." + echo "Skipping debug and MQTT prompts; this action builds all three profiles automatically." else prompt_for_debug_build_settings + prompt_for_mqtt_bridge_build_setting fi prompt_for_radio_build_settings prompt_for_firmware_profile_settings @@ -1770,6 +1956,12 @@ main() { exit 1 fi + if ! is_logging_matrix_command "$1" && [ -n "$MQTT_BRIDGE_OVERRIDE" ]; then + if ! normalize_resolved_targets_for_mqtt "$1"; then + exit 1 + fi + fi + prompt_for_resolved_firmware_version if is_logging_matrix_command "$1"; then prompt_for_logging_matrix_output_policy diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 6f2b66a1..42f76ef0 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1663,6 +1663,22 @@ clear recent.repeater ### Sensors (When sensor support is compiled in) +#### View or change telemetry access mode +**Usage:** +- `get telemetry.access` +- `set telemetry.access ` + +**Parameters:** +- `mode`: `all`|`acl` + - `all`: allow telemetry requests using the requester-provided telemetry mask + - `acl`: require ACL read-only or higher for telemetry, including GPS + +**Default:** `all` + +**Note:** `all` matches the previous sensor telemetry behavior. + +--- + #### View the list of sensors on this node **Usage:** `sensor list [start]` diff --git a/docs/gps_tracking.md b/docs/gps_tracking.md new file mode 100644 index 00000000..649d0340 --- /dev/null +++ b/docs/gps_tracking.md @@ -0,0 +1,143 @@ +# GPS Tracking + +This document describes how GPS telemetry works for companion/client nodes and +sensor nodes. + +## Scope + +GPS tracking uses the existing CayenneLPP GPS telemetry field. It does not add a +new phone app protocol field. + +The GPS telemetry value contains latitude, longitude, and altitude. Speed, +heading, and freshness are not sent as separate telemetry fields. + +If the firmware does not have a valid fresh GPS cache, it omits the GPS field +from telemetry. This is intentional: stale or missing fixes are not reported as +zero coordinates. + +## Freshness + +GPS telemetry is cached separately from advert location. + +The cache behavior is: + +- Refresh the cached GPS value every 2 hours only when at least one contact or + ACL client can receive location telemetry. +- Try for up to 15 minutes during a refresh. +- If the position stays within about 100 meters for 30 seconds, cache a weighted + average of the stable fixes, with newer fixes weighted more heavily. +- If the position moves outside that 100 meter circle during acquisition, cache + the latest valid fix. +- Omit GPS from telemetry if there is no fix or if the cached fix is more than + 12 hours old. + +When a telemetry request asks for location, GPS is kept on for 2 hours after the +latest location request. During that hold window, later location telemetry +requests can use fresh GPS data as soon as valid fixes are available. + +If GPS is manually enabled, it stays on and valid fixes continue to update the +cache. + +If no contact or ACL client can receive location telemetry, the scheduled +2-hour refresh does not run. A real location telemetry request still turns GPS +on for the 2-hour hold window, and manual GPS-on still keeps the cache updated. + +## Companion/Client Nodes + +Companion/client telemetry uses the existing companion telemetry permission +system: + +- base telemetry +- location telemetry +- environment telemetry + +Location telemetry is sent only when the requester's effective telemetry +permissions include location. Those permissions are derived from the companion +telemetry mode settings and contact flags. + +The scheduled GPS cache refresh runs only when at least one stored contact has +effective location telemetry access: + +- `location: allow all` with at least one stored contact +- `location: allow flags` with at least one stored contact whose flags include + location + +No new phone app behavior is required. Existing clients see the existing GPS +telemetry field when it is present. + +## Sensor Nodes + +Sensor telemetry access is controlled by: + +```text +get telemetry.access +set telemetry.access all +set telemetry.access acl +``` + +`all` is the default and matches the previous sensor telemetry behavior. A +request that reaches the normal sensor telemetry request path can receive the +requested telemetry fields, including GPS, subject to the request mask and GPS +freshness rules. + +`acl` gates telemetry through the sensor ACL. A requester with read-only or +higher permissions receives the existing telemetry set, including GPS. Guest or +unknown requesters receive no telemetry fields. + +The scheduled GPS cache refresh follows the same access setting: + +- `all`: runs only when at least one ACL client exists +- `acl`: runs only when at least one ACL client is read-only or higher + +Use the existing ACL command to grant access: + +```text +setperm 1 +``` + +Permission values: + +- `1`: read-only, suitable for telemetry access +- `2`: read-write +- `3`: admin + +## Advert Location + +Advert location is separate from telemetry GPS. The advert policy is controlled +with: + +```text +gps advert +gps advert none +gps advert share +gps advert prefs +``` + +Policies: + +- `none`: do not include location in adverts +- `share`: use the live/shared sensor manager location +- `prefs`: use the stored node latitude and longitude preferences + +Telemetry GPS can be fresh while advert location is fixed or disabled, depending +on this policy. + +## Recommended Setup + +For private sensor tracking: + +```text +set telemetry.access acl +setperm 3 +setperm 1 +``` + +For compatibility with the older sensor behavior: + +```text +set telemetry.access all +``` + +For GPS telemetry behavior, leave the phone app unchanged. The firmware omits +GPS when it is stale or missing and sends the existing GPS telemetry field when +fresh data is available. diff --git a/docs/index.md b/docs/index.md index 9460a00c..ddad15e2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,7 @@ Below are a few quick start guides. - [Frequently Asked Questions](./faq.md) - [CLI Commands](./cli_commands.md) +- [GPS Tracking](./gps_tracking.md) - [Companion Protocol](./companion_protocol.md) - [Packet Format](./packet_format.md) - [QR Codes](./qr_codes.md) diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 9c45f700..c4e5814e 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -4,7 +4,8 @@ This guide is for **node operators**: how to update your MeshCore device's firmw plain language. No cables, no programmer — your node can download a new firmware from a neighbour and install it. (For the technical wire format, see [the OTA protocol spec](ota_protocol.md).) -LoRa OTA is present only in standard, non-logging Keymind firmware. MQTT bridge and logging builds exclude it. +LoRa OTA is present only in standard, non-logging Keymind repeater, room-server, and sensor firmware. MQTT +bridge, logging, and roles without `tempradio` support exclude it. OTA radio traffic is accepted, generated, and relayed only while `tempradio` is actually running on that node. Every source, receiver, and intermediate repeater must therefore have an overlapping temporary-radio window. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 664dda9a..bc962932 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -121,6 +121,13 @@ #define DEFAULT_AUTOADD_CONFIG 0 #endif +#ifndef EMERGENCY_CLIENT_REPEAT_HOLD_MS +#define EMERGENCY_CLIENT_REPEAT_HOLD_MS 120000UL +#endif +#ifndef EMERGENCY_CLIENT_REPEAT_JITTER_MS +#define EMERGENCY_CLIENT_REPEAT_JITTER_MS 15000UL +#endif + #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" // these are _pushed_ to client app at any time @@ -138,6 +145,19 @@ #define PUSH_CODE_TELEMETRY_RESPONSE 0x8B #define PUSH_CODE_BINARY_RESPONSE 0x8C #define PUSH_CODE_PATH_DISCOVERY_RESPONSE 0x8D + +static const uint8_t EMERGENCY_CHANNEL_SECRET[CIPHER_KEY_SIZE] = { + 0xe1, 0xad, 0x57, 0x8d, 0x25, 0x10, 0x8e, 0x34, + 0x48, 0x08, 0xf3, 0x0d, 0xfd, 0xaa, 0xf9, 0x26 +}; + +#define EMERGENCY_CLIENT_REPEAT_TABLE_SIZE 63 +static uint16_t emergency_client_repeats[EMERGENCY_CLIENT_REPEAT_TABLE_SIZE]; +static uint8_t emergency_client_repeat_next; +static unsigned long emergency_client_repeat_send_at; +static uint16_t emergency_client_repeat_key; +static mesh::Packet* emergency_client_repeat_packet; + #define PUSH_CODE_CONTROL_DATA 0x8E // v8+ #define PUSH_CODE_CONTACT_DELETED 0x8F // used to notify client app of deleted contact when overwriting oldest #define PUSH_CODE_CONTACTS_FULL 0x90 // used to notify client app that contacts storage is full @@ -280,9 +300,6 @@ bool MyMesh::getCADEnabled() const { int MyMesh::getInterferenceThreshold() const { return 0; // disabled for now, until currentRSSI() problem is resolved } -bool MyMesh::getCADEnabled() const { - return true; // hardware CAD before TX (no CLI toggle on companion; enabled by default) -} int MyMesh::calcRxDelay(float score, uint32_t air_time) const { if (_prefs.rx_delay_base <= 0.0f) return 0; @@ -302,6 +319,26 @@ uint8_t MyMesh::getExtraAckTransmitCount() const { return _prefs.multi_acks; } +bool MyMesh::hasLocationTelemetryRecipient() { + if (_prefs.telemetry_mode_loc == TELEM_MODE_DENY) return false; + + ContactsIterator iter = startContactsIterator(); + ContactInfo contact; + while (iter.hasNext(this, contact)) { + if (contact.type == ADV_TYPE_NONE) continue; + if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_ALL) return true; + if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS && + ((contact.flags >> 1) & TELEM_PERM_LOCATION)) { + return true; + } + } + return false; +} + +void MyMesh::updateGpsTelemetryPolicy() { + sensors.setTelemetryLocationAccessAvailable(hasLocationTelemetryRecipient()); +} + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) { int i = 0; @@ -354,7 +391,7 @@ uint8_t MyMesh::getAutoAddMaxHops() const { } void MyMesh::onContactOverwrite(const uint8_t* pub_key) { - _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); // delete from storage + _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); // delete from storage if (_serial->isConnected()) { out_frame[0] = PUSH_CODE_CONTACT_DELETED; memcpy(&out_frame[1], pub_key, PUB_KEY_SIZE); @@ -406,6 +443,7 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path } if (!is_new) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // only schedule lazy write for contacts that are in contacts[] + updateGpsTelemetryPolicy(); } static int sort_by_recent(const void *a, const void *b) { @@ -495,7 +533,27 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe #endif } +static uint16_t emergencyClientRepeatKey(const mesh::Packet* packet) { + const uint8_t* p = packet->payload; + return ((uint16_t)p[1]) | ((uint16_t)p[2] << 8); +} + +static bool __attribute__((noinline)) hasEmergencyClientRepeat(uint16_t key) { + for (uint8_t i = 0; i < EMERGENCY_CLIENT_REPEAT_TABLE_SIZE; i++) { + if (emergency_client_repeats[i] == key) { + return true; + } + } + return false; +} + bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { + if (emergency_client_repeat_packet != NULL && packet->getPathHashCount() > 0) { + if (emergencyClientRepeatKey(packet) == emergency_client_repeat_key) { + releasePacket(emergency_client_repeat_packet); + emergency_client_repeat_packet = NULL; + } + } // REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses // if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender return false; @@ -606,6 +664,29 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe } if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); #endif + + if (pkt->isRouteFlood() && memcmp(channel.secret, EMERGENCY_CHANNEL_SECRET, sizeof(EMERGENCY_CHANNEL_SECRET)) == 0) { + bool zero_path = pkt->getPathHashCount() == 0; + uint16_t key = emergencyClientRepeatKey(pkt); + if (hasEmergencyClientRepeat(key)) { + pkt->markDoNotRetransmit(); + } else { + emergency_client_repeats[emergency_client_repeat_next] = key; + emergency_client_repeat_next++; + if (emergency_client_repeat_next >= EMERGENCY_CLIENT_REPEAT_TABLE_SIZE) emergency_client_repeat_next = 0; + + if (zero_path && emergency_client_repeat_packet == NULL) { + emergency_client_repeat_packet = obtainNewPacket(); + if (emergency_client_repeat_packet != NULL) { + *emergency_client_repeat_packet = *pkt; + emergency_client_repeat_key = key; + emergency_client_repeat_send_at = futureMillis( + (int)(EMERGENCY_CLIENT_REPEAT_HOLD_MS + getRNG()->nextInt(0, EMERGENCY_CLIENT_REPEAT_JITTER_MS + 1))); + pkt->markDoNotRetransmit(); + } + } + } + } } void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, @@ -989,6 +1070,7 @@ void MyMesh::begin(bool has_display) { resetContacts(); _store->loadContacts(this); + updateGpsTelemetryPolicy(); bootstrapRTCfromContacts(); addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); @@ -1307,6 +1389,7 @@ void MyMesh::handleCmdFrame(size_t len) { updateContactFromFrame(*recipient, last_mod, cmd_frame, len); recipient->lastmod = last_mod; dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + updateGpsTelemetryPolicy(); writeOKFrame(); } else { ContactInfo contact; @@ -1315,6 +1398,7 @@ void MyMesh::handleCmdFrame(size_t len) { contact.sync_since = 0; if (addContact(contact)) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + updateGpsTelemetryPolicy(); writeOKFrame(); } else { writeErrFrame(ERR_CODE_TABLE_FULL); @@ -1326,6 +1410,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (recipient && removeContact(*recipient)) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + updateGpsTelemetryPolicy(); writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove @@ -1382,6 +1467,7 @@ void MyMesh::handleCmdFrame(size_t len) { } } else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) { if (importContact(&cmd_frame[1], len - 1)) { + updateGpsTelemetryPolicy(); writeOKFrame(); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -1475,6 +1561,7 @@ void MyMesh::handleCmdFrame(size_t len) { } } } + updateGpsTelemetryPolicy(); savePrefs(); writeOKFrame(); } else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && cmd_frame[1] == 0 && len >= 3) { @@ -1523,6 +1610,7 @@ void MyMesh::handleCmdFrame(size_t len) { // re-load contacts, to invalidate ecdh shared_secrets resetContacts(); _store->loadContacts(this); + updateGpsTelemetryPolicy(); } else { writeErrFrame(ERR_CODE_FILE_IO_ERROR); } @@ -2268,6 +2356,11 @@ void MyMesh::checkSerialInterface() { void MyMesh::loop() { BaseChatMesh::loop(); + if (emergency_client_repeat_packet != NULL && millisHasNowPassed(emergency_client_repeat_send_at)) { + mesh::Packet* pkt = emergency_client_repeat_packet; + emergency_client_repeat_packet = NULL; + sendPacket(pkt, 1, 0); + } if (_cli_rescue) { checkCLIRescueCmd(); @@ -2303,5 +2396,6 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; + return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0 + || emergency_client_repeat_packet != NULL; } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7acbb402..5f16ac4a 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -200,6 +200,8 @@ private: void checkCLIRescueCmd(); void checkSerialInterface(); bool isValidClientRepeatFreq(uint32_t f) const; + bool hasLocationTelemetryRecipient(); + void updateGpsTelemetryPolicy(); // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7cf77b9b..5366cde5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -814,10 +814,10 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { } #endif -#ifdef WITH_BRIDGE +#ifdef WITH_MQTT_BRIDGE if (_prefs.bridge_enabled) { // Store raw radio data for MQTT messages - if (bridge) bridge->storeRawRadioData(raw, len, snr, rssi); + if (mqtt_bridge) mqtt_bridge->storeRawRadioData(raw, len, snr, rssi); } #endif } @@ -825,11 +825,11 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets — bridge decides based on mqtt.rx setting - if (bridge) bridge->onPacketReceived(pkt); + if (mqtt_bridge) mqtt_bridge->onPacketReceived(pkt); #elif defined(WITH_BRIDGE) - // Non-MQTT bridge (ESP-NOW): use bridge.source setting + // Non-MQTT bridge: use bridge.source setting if (_prefs.bridge_pkt_src == 1) { - if (bridge) bridge->onPacketReceived(pkt); + activeBridge()->sendPacket(pkt); } #endif @@ -855,11 +855,11 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { void MyMesh::logTx(mesh::Packet *pkt, int len) { #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed TX packets — bridge decides based on mqtt.tx setting - if (bridge) bridge->sendPacket(pkt); + if (mqtt_bridge) mqtt_bridge->sendPacket(pkt); #elif defined(WITH_BRIDGE) - // Non-MQTT bridge (ESP-NOW): use bridge.source setting + // Non-MQTT bridge: use bridge.source setting if (_prefs.bridge_pkt_src == 0) { - if (bridge) bridge->sendPacket(pkt); + activeBridge()->sendPacket(pkt); } #endif @@ -2210,12 +2210,12 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc telemetry(MAX_PACKET_PAYLOAD - 4), discover_limiter(4, 120), // max 4 every 2 minutes anon_limiter(4, 180) // max 4 every 3 minutes -#if defined(WITH_RS232_BRIDGE) +#if defined(WITH_MQTT_BRIDGE) + , mqtt_bridge(nullptr) +#elif defined(WITH_RS232_BRIDGE) , bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc) #elif defined(WITH_ESPNOW_BRIDGE) , bridge(&_prefs, _mgr, &rtc) -#elif defined(WITH_MQTT_BRIDGE) - , bridge(nullptr) #endif { last_millis = 0; @@ -2365,38 +2365,39 @@ void MyMesh::begin(FILESYSTEM *fs) { if (_prefs.bridge_enabled) { #ifdef WITH_MQTT_BRIDGE // Defer construction to avoid static init crashes on ESP32 classic - bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); + mqtt_bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); #endif - if (bridge) { + BridgeBase* active_bridge = activeBridge(); + if (active_bridge) { +#ifdef WITH_MQTT_BRIDGE // Set device public key for MQTT topics char device_id[65]; mesh::LocalIdentity self_id = getSelfId(); mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); MESH_DEBUG_PRINTLN("Setting device ID: %s", device_id); - bridge->setDeviceID(device_id); + mqtt_bridge->setDeviceID(device_id); // Set firmware version - bridge->setFirmwareVersion(getFirmwareVer()); + mqtt_bridge->setFirmwareVersion(getFirmwareVer()); // Set board model - bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + mqtt_bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); // Set build date - bridge->setBuildDate(getBuildDate()); + mqtt_bridge->setBuildDate(getBuildDate()); -#ifdef WITH_MQTT_BRIDGE // Set stats sources for automatic stats collection - bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); + mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); #ifdef WITH_SNMP if (_cli.getObserverPrefs()->snmp_enabled) { _snmp_agent.setNodeName(_prefs.node_name); _snmp_agent.setFirmwareVersion(getFirmwareVer()); - bridge->setSNMPAgent(&_snmp_agent); + mqtt_bridge->setSNMPAgent(&_snmp_agent); } #endif #endif - bridge->begin(); + active_bridge->begin(); } } #endif @@ -2407,7 +2408,7 @@ void MyMesh::begin(FILESYSTEM *fs) { // floods ride the same scope as adverts/channel messages. #ifdef WITH_MQTT_BRIDGE _alerter.begin(&_prefs, _cli.getObserverPrefs(), this, this); - _alerter.setBridge(bridge); + _alerter.setBridge(mqtt_bridge); #endif applySavedRadioParams(); @@ -3878,8 +3879,10 @@ void MyMesh::loop() { mesh::Mesh::loop(); checkBatteryAlert(); -#ifdef WITH_BRIDGE - // bridge.loop() is now handled by FreeRTOS task on Core 0 - no need to call it here +#if defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) + // MQTT runs its own task; serial and ESP-NOW bridges remain cooperative. + BridgeBase* active_bridge = activeBridge(); + if (active_bridge && active_bridge->isRunning()) active_bridge->loop(); #endif if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { @@ -3953,7 +3956,8 @@ void MyMesh::loop() { // To check if there is pending work bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) - if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep + const BridgeBase* active_bridge = activeBridge(); + if (active_bridge && active_bridge->isRunning()) return true; #endif if (_mgr->getOutboundTotal() > 0) return true; if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f21a489e..52ede1e1 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -167,12 +167,28 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t active_cr; // live CR, including temporary radio overrides ScheduledRadioSetting scheduled_radio_settings[MAX_SCHEDULED_RADIO_SETTINGS]; int matching_peer_indexes[MAX_CLIENTS]; -#if defined(WITH_RS232_BRIDGE) +#if defined(WITH_MQTT_BRIDGE) + MQTTBridge* mqtt_bridge; +#elif defined(WITH_RS232_BRIDGE) RS232Bridge bridge; #elif defined(WITH_ESPNOW_BRIDGE) ESPNowBridge bridge; -#elif defined(WITH_MQTT_BRIDGE) - MQTTBridge* bridge; +#endif +#ifdef WITH_BRIDGE + BridgeBase* activeBridge() { +#ifdef WITH_MQTT_BRIDGE + return mqtt_bridge; +#else + return &bridge; +#endif + } + const BridgeBase* activeBridge() const { +#ifdef WITH_MQTT_BRIDGE + return mqtt_bridge; +#else + return &bridge; +#endif + } #endif #ifdef WITH_SNMP MeshSNMPAgent _snmp_agent; @@ -402,34 +418,35 @@ public: #if defined(WITH_BRIDGE) void setBridgeState(bool enable) override { - if (!bridge) { #ifdef WITH_MQTT_BRIDGE - bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); -#endif - if (!bridge) return; + if (!mqtt_bridge) { + mqtt_bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); + if (!mqtt_bridge) return; } - if (enable == bridge->isRunning()) return; +#endif + BridgeBase* active_bridge = activeBridge(); + if (!active_bridge || enable == active_bridge->isRunning()) return; if (enable) { +#ifdef WITH_MQTT_BRIDGE // Set device metadata before starting bridge (same as in begin()) char device_id[65]; mesh::LocalIdentity self_id = getSelfId(); mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); - bridge->setDeviceID(device_id); - bridge->setFirmwareVersion(getFirmwareVer()); - bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); - bridge->setBuildDate(getBuildDate()); -#ifdef WITH_MQTT_BRIDGE - bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); + mqtt_bridge->setDeviceID(device_id); + mqtt_bridge->setFirmwareVersion(getFirmwareVer()); + mqtt_bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + mqtt_bridge->setBuildDate(getBuildDate()); + mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); #endif - bridge->begin(); + active_bridge->begin(); #ifdef WITH_MQTT_BRIDGE - _alerter.setBridge(bridge); + _alerter.setBridge(mqtt_bridge); #endif } else { - bridge->end(); + active_bridge->end(); #ifdef WITH_MQTT_BRIDGE _alerter.setBridge(nullptr); #endif @@ -437,26 +454,27 @@ public: } void restartBridge() override { - if (!bridge || !bridge->isRunning()) return; - bridge->end(); + BridgeBase* active_bridge = activeBridge(); + if (!active_bridge || !active_bridge->isRunning()) return; + active_bridge->end(); +#ifdef WITH_MQTT_BRIDGE // Set device metadata before restarting bridge (same as in begin()) char device_id[65]; mesh::LocalIdentity self_id = getSelfId(); mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); - bridge->setDeviceID(device_id); - bridge->setFirmwareVersion(getFirmwareVer()); - bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); - bridge->setBuildDate(getBuildDate()); -#ifdef WITH_MQTT_BRIDGE - bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); + mqtt_bridge->setDeviceID(device_id); + mqtt_bridge->setFirmwareVersion(getFirmwareVer()); + mqtt_bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + mqtt_bridge->setBuildDate(getBuildDate()); + mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); #endif - bridge->begin(); + active_bridge->begin(); } void restartBridgeSlot(int slot) override { #ifdef WITH_MQTT_BRIDGE - if (!bridge || !bridge->isRunning()) return; - bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]); + if (!mqtt_bridge || !mqtt_bridge->isRunning()) return; + mqtt_bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]); #else (void)slot; #endif @@ -472,22 +490,41 @@ public: } int getQueueSize() override { - return bridge ? bridge->getQueueSize() : 0; +#ifdef WITH_MQTT_BRIDGE + return mqtt_bridge ? mqtt_bridge->getQueueSize() : 0; +#else + return 0; +#endif } bool isMqttBridgeRunning() override { - return bridge && bridge->isRunning(); +#ifdef WITH_MQTT_BRIDGE + return mqtt_bridge && mqtt_bridge->isRunning(); +#else + return false; +#endif } bool syncMqttNtp() override { - if (!bridge || !bridge->isRunning()) return false; +#ifdef WITH_MQTT_BRIDGE + if (!mqtt_bridge || !mqtt_bridge->isRunning()) return false; // Marshal onto the MQTT task (Core 0); this runs on the CLI thread (Core 1). - return bridge->requestForcedNtpSync(); + return mqtt_bridge->requestForcedNtpSync(); +#else + return false; +#endif } bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) override { - if (!bridge || !bridge->isRunning()) return false; - return bridge->ntpDiag(reply, reply_size, verbose); +#ifdef WITH_MQTT_BRIDGE + if (!mqtt_bridge || !mqtt_bridge->isRunning()) return false; + return mqtt_bridge->ntpDiag(reply, reply_size, verbose); +#else + (void)reply; + (void)reply_size; + (void)verbose; + return false; +#endif } #endif diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b8455c86..cfebd9ca 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -147,6 +147,36 @@ static float getFloat(const uint8_t * buffer, uint8_t size, uint32_t multiplier, return sign * ((float) value / multiplier); } +static uint8_t getTelemetryPermissions(uint8_t acl_perms, uint8_t telemetry_access) { + if (telemetry_access == TELEMETRY_ACCESS_ALL) { + return TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT; + } + + uint8_t role = acl_perms & PERM_ACL_ROLE_MASK; + if (role >= PERM_ACL_READ_ONLY) { + return TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT; + } + return 0; +} + +bool SensorMesh::hasLocationTelemetryClient() { + if (_prefs.telemetry_access == TELEMETRY_ACCESS_ALL) { + return acl.getNumClients() > 0; + } + + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* c = acl.getClientByIdx(i); + if ((c->permissions & PERM_ACL_ROLE_MASK) >= PERM_ACL_READ_ONLY) { + return true; + } + } + return false; +} + +void SensorMesh::updateGpsTelemetryPolicy() { + sensors.setTelemetryLocationAccessAvailable(hasLocationTelemetryClient()); +} + static uint8_t putFloat(uint8_t * dest, float value, uint8_t size, uint32_t multiplier, bool is_signed) { // check sign bool sign = value < 0; @@ -173,13 +203,16 @@ static uint8_t putFloat(uint8_t * dest, float value, uint8_t size, uint32_t mult uint8_t SensorMesh::handleRequest(uint8_t perms, uint32_t sender_timestamp, uint8_t req_type, uint8_t* payload, size_t payload_len) { memcpy(reply_data, &sender_timestamp, 4); // reflect sender_timestamp back in response packet (kind of like a 'tag') - if (req_type == REQ_TYPE_GET_TELEMETRY_DATA) { // allow all + if (req_type == REQ_TYPE_GET_TELEMETRY_DATA) { uint8_t perm_mask = ~(payload[0]); // NEW: first reserved byte (of 4), is now inverse mask to apply to permissions + uint8_t telemetry_permissions = getTelemetryPermissions(perms, _prefs.telemetry_access) & perm_mask; telemetry.reset(); - telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + if (telemetry_permissions & TELEM_PERM_BASE) { + telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + } // query other sensors -- target specific - sensors.querySensors(0xFF & perm_mask, telemetry); // allow all telemetry permissions for admin or guest + sensors.querySensors(telemetry_permissions, telemetry); // TODO: let requester know permissions they have: telemetry.addPresence(TELEM_CHANNEL_SELF, perms); uint8_t tlen = telemetry.getSize(); @@ -327,9 +360,6 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { int SensorMesh::getInterferenceThreshold() const { return _prefs.interference_threshold; } -bool SensorMesh::getCADEnabled() const { - return _prefs.cad_enabled; -} int SensorMesh::getAGCResetInterval() const { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } @@ -365,6 +395,7 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* memcpy(client->shared_secret, secret, PUB_KEY_SIZE); dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + updateGpsTelemetryPolicy(); } if (is_flood) { @@ -412,6 +443,7 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r uint8_t perms = atoi(sp); if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() + updateGpsTelemetryPolicy(); strcpy(reply, "OK"); } else { strcpy(reply, "Err - invalid params"); @@ -452,6 +484,7 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r sprintf(reply, "%x", board.getGpio()); } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands + updateGpsTelemetryPolicy(); } } @@ -535,7 +568,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i memcpy(×tamp, data, 4); if (timestamp > from->last_timestamp) { // prevent replay attacks - uint8_t reply_len = handleRequest(from->isAdmin() ? 0xFF : from->permissions, timestamp, data[4], &data[5], len - 5); + uint8_t reply_len = handleRequest(from->permissions, timestamp, data[4], &data[5], len - 5); if (reply_len == 0) return; // invalid command from->last_timestamp = timestamp; @@ -754,6 +787,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { acl.load(_fs, self_id); region_map.load(_fs); + updateGpsTelemetryPolicy(); // establish default-scope { @@ -935,7 +969,7 @@ void SensorMesh::loop() { telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); // query other sensors -- target specific - sensors.querySensors(0xFF, telemetry); // allow all telemetry permissions + sensors.querySensors(TELEM_PERM_BASE | TELEM_PERM_ENVIRONMENT, telemetry); onSensorDataRead(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 2fc83433..1538a837 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -161,6 +161,8 @@ private: uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleRequest(uint8_t perms, uint32_t sender_timestamp, uint8_t req_type, uint8_t* payload, size_t payload_len); + bool hasLocationTelemetryClient(); + void updateGpsTelemetryPolicy(); mesh::Packet* createSelfAdvert(); void sendAlert(const ClientInfo* c, Trigger* t); diff --git a/lib/PsychicMqttClient/src/PsychicMqttClient.cpp b/lib/PsychicMqttClient/src/PsychicMqttClient.cpp index c91f3a50..da276e3c 100644 --- a/lib/PsychicMqttClient/src/PsychicMqttClient.cpp +++ b/lib/PsychicMqttClient/src/PsychicMqttClient.cpp @@ -1,3 +1,5 @@ +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) + #include "PsychicMqttClient.h" #include @@ -813,3 +815,5 @@ bool PsychicMqttClient::_isTopicMatch(const char *topic, const char *subscriptio s = s_end + 1; } } + +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index fe7be65f..33b94e02 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -774,6 +774,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->ota_advert_interval = 0; _prefs->ota_max_hops = 3; #endif + _prefs->telemetry_access = TELEMETRY_ACCESS_ALL; // A remainder larger than the smallest legacy MQTT gap (864) means an old fork // file with the zero-filled gap; detect and recover it below. Anything smaller // (upstream/flex 5-byte tails, or the ~384-byte keymind retry tail) takes the @@ -957,9 +958,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->flood_channel_data_max_hops)) { file.read((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops)); } + if (file.available() >= (int)sizeof(_prefs->telemetry_access)) { + file.read((uint8_t *)&_prefs->telemetry_access, sizeof(_prefs->telemetry_access)); + } } #if defined(ENABLE_OTA) - // OTA config starts at 674. Guard every appended field so older files keep defaults. + // OTA config starts at 675, after telemetry_access. Guard every appended field so older files keep defaults. if (file.available() >= (int)sizeof(_prefs->ota_autofetch)) { file.read((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); } @@ -981,7 +985,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->ota_max_hops)) { file.read((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); } - // next: 810 + // next: 811 #endif } @@ -1044,6 +1048,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->battery_alert_enabled = constrain(_prefs->battery_alert_enabled, 0, 1); _prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1); _prefs->flood_channel_data_enabled = constrain(_prefs->flood_channel_data_enabled, 0, 1); + _prefs->telemetry_access = constrain(_prefs->telemetry_access, 0, 1); if (_prefs->flood_channel_block_max_hops != FLOOD_CHANNEL_BLOCK_HOPS_ALL && (_prefs->flood_channel_block_max_hops < 1 || _prefs->flood_channel_block_max_hops > 7)) { _prefs->flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL; @@ -1163,15 +1168,16 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->flood_channel_data_enabled, sizeof(_prefs->flood_channel_data_enabled)); file.write((uint8_t *)&_prefs->flood_channel_block_max_hops, sizeof(_prefs->flood_channel_block_max_hops)); file.write((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops)); + file.write((uint8_t *)&_prefs->telemetry_access, sizeof(_prefs->telemetry_access)); // 674 #if defined(ENABLE_OTA) - file.write((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 674 - file.write((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 675 - file.write((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 676 - file.write((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 677 - file.write((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 805 - file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 807 - file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 809 - // next: 810 + file.write((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 675 + file.write((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 676 + file.write((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 677 + file.write((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 678 + file.write((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 806 + file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 808 + file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 810 + // next: 811 #endif file.close(); @@ -1528,6 +1534,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "ERR: clock cannot go backwards"); } +#ifdef ESP_PLATFORM } else if (memcmp(command, "memory", 6) == 0) { sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d", ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(), @@ -1536,15 +1543,27 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re (int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), (int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), (int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM)); +#endif } else if (memcmp(command, "start ota", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { - // Manual OTA: bring up the ElegantOTA server for a hand-uploaded binary. + // Manual OTA: bring up the board's browser server for a hand-uploaded binary. if (!_board->startOTAUpdate(_prefs->node_name, reply)) { strcpy(reply, "Error"); } +#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) + else { + // Keep WiFi up, but release MQTT/TLS heap while the browser uploader runs. + _callbacks->setBridgeState(false); + } +#endif } else if (memcmp(command, "stop ota", 8) == 0 && (command[8] == 0 || command[8] == ' ')) { if (!_board->stopOTAUpdate(reply)) { strcpy(reply, "Error"); } +#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) + else if (_prefs->bridge_enabled) { + _callbacks->setBridgeState(true); + } +#endif } else if (memcmp(command, "clock", 5) == 0) { uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); @@ -1881,6 +1900,18 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0; savePrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "telemetry.access ", 17) == 0) { + if (strcmp(&config[17], "all") == 0) { + _prefs->telemetry_access = TELEMETRY_ACCESS_ALL; + savePrefs(); + strcpy(reply, "OK"); + } else if (strcmp(&config[17], "acl") == 0) { + _prefs->telemetry_access = TELEMETRY_ACCESS_ACL; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "ERROR: telemetry.access must be all or acl"); + } } else if (memcmp(config, "flood.advert.interval ", 22) == 0) { int hours = _atoi(&config[22]); if ((hours > 0 && hours < 3) || (hours > 168)) { @@ -2509,6 +2540,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks); } else if (memcmp(config, "allow.read.only", 15) == 0) { sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off"); + } else if (memcmp(config, "telemetry.access", 16) == 0) { + sprintf(reply, "> %s", _prefs->telemetry_access == TELEMETRY_ACCESS_ACL ? "acl" : "all"); } else if (memcmp(config, "flood.advert.interval", 21) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->flood_advert_interval)); } else if (memcmp(config, "advert.interval", 15) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c92b5e68..d0d2788e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -99,6 +99,9 @@ #define DIRECT_RETRY_PREFS_MAGIC_0 0xD1 #define DIRECT_RETRY_PREFS_MAGIC_1 0x52 +#define TELEMETRY_ACCESS_ALL 0 +#define TELEMETRY_ACCESS_ACL 1 + struct NodePrefs { // persisted to file float airtime_factor; char node_name[32]; @@ -176,10 +179,11 @@ struct NodePrefs { // persisted to file uint8_t flood_channel_data_enabled; uint8_t flood_channel_block_max_hops; uint8_t flood_channel_data_max_hops; + uint8_t telemetry_access; // NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) were moved out of // NodePrefs into MQTTPrefs (persisted to /mqtt_prefs) so this struct stays - // aligned with upstream (plus the keymind retry/flood tail above). See + // aligned with upstream (plus the keymind retry/flood/telemetry tail above). See // struct MQTTPrefs below. #if defined(ENABLE_OTA) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 5910209f..b20683b4 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -3,7 +3,266 @@ #include "ESP32Board.h" #include -#if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only +#if defined(ADMIN_PASSWORD) && defined(LIGHTWEIGHT_WIFI_OTA) +#include +#include +#include +#include +#include +#include + +static bool lightweight_ota_started_ap; + +static const char LIGHTWEIGHT_OTA_PAGE[] = R"HTML( +MeshCore OTA + +

MeshCore firmware update


+
)HTML";
+
+class LightweightOTAServer {
+  WiFiServer server{80};
+  TaskHandle_t task = nullptr;
+  volatile bool running = false;
+  ESP32Board* board = nullptr;
+
+  static void sendResponse(WiFiClient& client, int code, const char* reason,
+                           const char* content_type, const char* body, size_t length) {
+    client.printf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %u\r\n"
+                  "Connection: close\r\nCache-Control: no-store\r\n\r\n",
+                  code, reason, content_type, static_cast(length));
+    if (length) client.write(reinterpret_cast(body), length);
+  }
+
+  bool readLine(WiFiClient& client, char* line, size_t capacity) {
+    size_t length = 0;
+    uint32_t deadline = millis() + 5000;
+    while (running && client.connected()) {
+      while (client.available()) {
+        int c = client.read();
+        if (c < 0) break;
+        if (c == '\n') {
+          if (length && line[length - 1] == '\r') length--;
+          line[length] = 0;
+          return true;
+        }
+        if (length + 1 < capacity) line[length++] = static_cast(c);
+      }
+      if (static_cast(millis() - deadline) >= 0) break;
+      delay(1);
+    }
+    return false;
+  }
+
+  void sendPage(WiFiClient& client) {
+    sendResponse(client, 200, "OK", "text/html", LIGHTWEIGHT_OTA_PAGE,
+                 strlen(LIGHTWEIGHT_OTA_PAGE));
+  }
+
+  void sendLog(WiFiClient& client) {
+    File log = SPIFFS.open("/packet_log", FILE_READ);
+    if (!log) {
+      static const char missing[] = "packet log not found";
+      sendResponse(client, 404, "Not Found", "text/plain", missing, sizeof(missing) - 1);
+      return;
+    }
+
+    client.printf("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %u\r\n"
+                  "Connection: close\r\nCache-Control: no-store\r\n\r\n",
+                  static_cast(log.size()));
+    uint8_t buf[1024];
+    while (running && log.available() && client.connected()) {
+      size_t count = log.read(buf, sizeof(buf));
+      if (!count || client.write(buf, count) != count) break;
+    }
+    log.close();
+  }
+
+  void sendUpdateError(WiFiClient& client, int code, const char* reason) {
+    char error[96];
+    snprintf(error, sizeof(error), "OTA error: %s", Update.errorString());
+    sendResponse(client, code, reason, "text/plain", error, strlen(error));
+  }
+
+  void receiveUpdate(WiFiClient& client, size_t total) {
+    if (total == 0) {
+      static const char empty[] = "empty firmware image";
+      sendResponse(client, 400, "Bad Request", "text/plain", empty, sizeof(empty) - 1);
+      return;
+    }
+
+    board->setInhibitSleep(true);
+    if (!Update.begin(total, U_FLASH)) {
+      sendUpdateError(client, 400, "Bad Request");
+      return;
+    }
+
+    uint8_t buf[1024];
+    size_t received_total = 0;
+    uint32_t deadline = millis() + 15000;
+    while (running && received_total < total && client.connected()) {
+      int available = client.available();
+      if (available <= 0) {
+        if (static_cast(millis() - deadline) >= 0) break;
+        delay(1);
+        continue;
+      }
+      size_t wanted = total - received_total;
+      if (wanted > sizeof(buf)) wanted = sizeof(buf);
+      if (wanted > static_cast(available)) wanted = static_cast(available);
+      int received = client.read(buf, wanted);
+      if (received <= 0) break;
+      deadline = millis() + 15000;
+      if (Update.write(buf, static_cast(received)) != static_cast(received)) {
+        sendUpdateError(client, 500, "Internal Server Error");
+        Update.abort();
+        return;
+      }
+      received_total += static_cast(received);
+    }
+
+    if (received_total != total) {
+      Update.abort();
+      static const char incomplete[] = "firmware upload incomplete";
+      sendResponse(client, 408, "Request Timeout", "text/plain", incomplete, sizeof(incomplete) - 1);
+      return;
+    }
+    if (!Update.end()) {
+      sendUpdateError(client, 500, "Internal Server Error");
+      return;
+    }
+
+    static const char success[] = "OK - firmware installed; rebooting";
+    sendResponse(client, 200, "OK", "text/plain", success, sizeof(success) - 1);
+    delay(500);
+    client.stop();
+    esp_restart();
+  }
+
+  void handleClient(WiFiClient& client) {
+    char line[256];
+    if (!readLine(client, line, sizeof(line))) return;
+    bool get_page = strncmp(line, "GET / ", 6) == 0 || strncmp(line, "GET /update ", 12) == 0;
+    bool get_log = strncmp(line, "GET /log ", 9) == 0;
+    bool post_update = strncmp(line, "POST /update ", 13) == 0;
+
+    size_t content_length = 0;
+    do {
+      if (!readLine(client, line, sizeof(line))) return;
+      if (strncasecmp(line, "Content-Length:", 15) == 0) {
+        content_length = static_cast(strtoul(line + 15, nullptr, 10));
+      }
+    } while (line[0]);
+
+    if (get_page) sendPage(client);
+    else if (get_log) sendLog(client);
+    else if (post_update) receiveUpdate(client, content_length);
+    else {
+      static const char missing[] = "not found";
+      sendResponse(client, 404, "Not Found", "text/plain", missing, sizeof(missing) - 1);
+    }
+  }
+
+  static void taskEntry(void* arg) {
+    LightweightOTAServer* self = static_cast(arg);
+    while (self->running) {
+      WiFiClient client = self->server.available();
+      if (client) {
+        client.setTimeout(15000);
+        self->handleClient(client);
+        client.stop();
+      } else {
+        delay(10);
+      }
+    }
+    self->task = nullptr;
+    vTaskDelete(nullptr);
+  }
+
+public:
+  bool begin(ESP32Board* owner) {
+    if (running) return true;
+    if (task != nullptr) return false;
+    board = owner;
+    server.begin();
+    server.setNoDelay(true);
+    running = true;
+    if (xTaskCreate(taskEntry, "ota-http", 6144, this, 4, &task) != pdPASS) {
+      running = false;
+      server.stop();
+      task = nullptr;
+      return false;
+    }
+    return true;
+  }
+
+  void end() {
+    running = false;
+    server.stop();
+    for (unsigned i = 0; task != nullptr && i < 100; i++) delay(10);
+    board = nullptr;
+  }
+};
+
+static LightweightOTAServer lightweight_ota_server;
+
+bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
+  (void)id;
+  inhibit_sleep = true;
+
+  IPAddress ip;
+  if (WiFi.status() == WL_CONNECTED) {
+    ip = WiFi.localIP();
+  } else {
+    if (!lightweight_ota_started_ap) {
+      lightweight_ota_started_ap = WiFi.softAP("MeshCore-OTA", nullptr);
+    }
+    if (!lightweight_ota_started_ap) {
+      inhibit_sleep = false;
+      strcpy(reply, "ERR: OTA WiFi failed");
+      return false;
+    }
+    ip = WiFi.softAPIP();
+  }
+
+  if (ota_server == nullptr) {
+    if (!lightweight_ota_server.begin(this)) {
+      if (lightweight_ota_started_ap) WiFi.softAPdisconnect(true);
+      lightweight_ota_started_ap = false;
+      inhibit_sleep = false;
+      strcpy(reply, "ERR: OTA server failed");
+      return false;
+    }
+    ota_server = &lightweight_ota_server;
+  }
+
+  snprintf(reply, 160, "Started: http://%s/update", ip.toString().c_str());
+  MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
+  return true;
+}
+
+bool ESP32Board::stopOTAUpdate(char reply[]) {
+  if (ota_server == nullptr) {
+    strcpy(reply, "OK - OTA not running");
+    return true;
+  }
+
+  lightweight_ota_server.end();
+  ota_server = nullptr;
+  if (lightweight_ota_started_ap) WiFi.softAPdisconnect(true);
+  lightweight_ota_started_ap = false;
+  inhibit_sleep = false;
+  strcpy(reply, "OK - OTA stopped");
+  MESH_DEBUG_PRINTLN("stopOTAUpdate: %s", reply);
+  return true;
+}
+
+#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)   // Repeater or Room Server only
 #include 
 #include 
 #include 
diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h
index ba3fa57a..c620c96a 100644
--- a/src/helpers/ESP32Board.h
+++ b/src/helpers/ESP32Board.h
@@ -16,13 +16,19 @@
 #include "esp_system.h"
 #include 
 
+#if !defined(LIGHTWEIGHT_WIFI_OTA)
 class AsyncWebServer;
+#endif
 
 class ESP32Board : public mesh::MainBoard {
 protected:
   uint8_t startup_reason;
   bool inhibit_sleep = false;
+#if defined(LIGHTWEIGHT_WIFI_OTA)
+  void* ota_server = nullptr;
+#else
   AsyncWebServer* ota_server = nullptr;
+#endif
   static inline portMUX_TYPE sleepMux = portMUX_INITIALIZER_UNLOCKED;
 
 public:
diff --git a/src/helpers/JWTHelper.cpp b/src/helpers/JWTHelper.cpp
index 0b73d87e..37d601ef 100644
--- a/src/helpers/JWTHelper.cpp
+++ b/src/helpers/JWTHelper.cpp
@@ -1,4 +1,7 @@
 #include "JWTHelper.h"
+
+#ifdef WITH_MQTT_BRIDGE
+
 #include 
 #include 
 #include 
@@ -196,3 +199,4 @@ size_t JWTHelper::createPayload(
   return base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize);
 }
 
+#endif
diff --git a/src/helpers/JWTHelper.h b/src/helpers/JWTHelper.h
index a84889d8..1bacfa81 100644
--- a/src/helpers/JWTHelper.h
+++ b/src/helpers/JWTHelper.h
@@ -1,5 +1,7 @@
 #pragma once
 
+#ifdef WITH_MQTT_BRIDGE
+
 #include "MeshCore.h"
 #include "Identity.h"
 
@@ -85,3 +87,5 @@ private:
   );
   
 };
+
+#endif
diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp
index a29b3d96..1025cce2 100644
--- a/src/helpers/MQTTMessageBuilder.cpp
+++ b/src/helpers/MQTTMessageBuilder.cpp
@@ -1,4 +1,7 @@
 #include "MQTTMessageBuilder.h"
+
+#ifdef WITH_MQTT_BRIDGE
+
 #include 
 #include 
 #include 
@@ -435,4 +438,6 @@ void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex
   
   // Convert serialized packet to hex
   bytesToHex(raw_buf, raw_len, hex, hex_size);
-}
\ No newline at end of file
+}
+
+#endif
diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h
index 8e3a6d45..c3f10b69 100644
--- a/src/helpers/MQTTMessageBuilder.h
+++ b/src/helpers/MQTTMessageBuilder.h
@@ -1,5 +1,7 @@
 #pragma once
 
+#ifdef WITH_MQTT_BRIDGE
+
 #include "MeshCore.h"
 #include 
 #include 
@@ -226,3 +228,5 @@ private:
    */
   static void packetToHex(mesh::Packet* packet, char* hex, size_t hex_size);
 };
+
+#endif
diff --git a/src/helpers/SensorManager.cpp b/src/helpers/SensorManager.cpp
new file mode 100644
index 00000000..2ab391ac
--- /dev/null
+++ b/src/helpers/SensorManager.cpp
@@ -0,0 +1,175 @@
+#include "SensorManager.h"
+
+#if ENV_INCLUDE_GPS
+#ifndef GPS_TELEMETRY_CACHE_INTERVAL_SEC
+#define GPS_TELEMETRY_CACHE_INTERVAL_SEC (2UL * 60UL * 60UL)
+#endif
+#ifndef GPS_TELEMETRY_MAX_ACQUIRE_SEC
+#define GPS_TELEMETRY_MAX_ACQUIRE_SEC (15UL * 60UL)
+#endif
+#ifndef GPS_TELEMETRY_STABLE_SEC
+#define GPS_TELEMETRY_STABLE_SEC 30UL
+#endif
+#ifndef GPS_TELEMETRY_HOLD_SEC
+#define GPS_TELEMETRY_HOLD_SEC (2UL * 60UL * 60UL)
+#endif
+#ifndef GPS_TELEMETRY_MAX_STALE_SEC
+#define GPS_TELEMETRY_MAX_STALE_SEC (12UL * 60UL * 60UL)
+#endif
+#ifndef GPS_TELEMETRY_STABLE_RADIUS_M
+#define GPS_TELEMETRY_STABLE_RADIUS_M 100.0f
+#endif
+
+static bool millisDue(unsigned long now, unsigned long deadline) {
+  return (long)(now - deadline) >= 0;
+}
+
+static float gpsDistanceSquaredMeters(float lat1, float lon1, float lat2, float lon2) {
+  static const float METERS_PER_DEGREE = 111320.0f;
+  float x = (lon2 - lon1) * METERS_PER_DEGREE;
+  float y = (lat2 - lat1) * METERS_PER_DEGREE;
+  return x * x + y * y;
+}
+
+bool SensorManager::gpsTelemetryHoldActive(unsigned long now) const {
+  return gps_hold_until != 0 && !millisDue(now, gps_hold_until);
+}
+
+bool SensorManager::gpsTelemetryCacheFresh(unsigned long now) const {
+  return gps_cache_valid &&
+      (unsigned long)(now - gps_cache_updated_at) <= GPS_TELEMETRY_MAX_STALE_SEC * 1000UL;
+}
+
+void SensorManager::updateGpsTelemetryCache(float lat, float lon, float altitude, unsigned long now) {
+  gps_cache_lat = lat;
+  gps_cache_lon = lon;
+  gps_cache_altitude = altitude;
+  gps_cache_updated_at = now;
+  gps_cache_valid = true;
+}
+
+void SensorManager::maybeStopGpsForTelemetry(unsigned long now) {
+#if defined(PERSISTANT_GPS) || defined(FORCE_GPS_ALIVE)
+  (void)now;
+#else
+  if (telemetryGpsActive() && !gps_user_enabled && !gps_acquiring && !gpsTelemetryHoldActive(now)) {
+    telemetryGpsStop();
+    gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL;
+  }
+#endif
+}
+
+void SensorManager::beginGpsTelemetryAcquisition(unsigned long now) {
+  if (!telemetryGpsDetected() || gps_acquiring) return;
+
+  gps_acquiring = true;
+  gps_acquire_has_fix = false;
+  gps_acquire_started_at = now;
+  gps_stable_started_at = now;
+  gps_weighted_lat = 0;
+  gps_weighted_lon = 0;
+  gps_weighted_altitude = 0;
+  gps_weight_sum = 0;
+  gps_weight_count = 0;
+  if (!telemetryGpsActive()) telemetryGpsStart();
+}
+
+void SensorManager::finishGpsTelemetryAcquisition(unsigned long now, bool use_weighted_average) {
+  if (use_weighted_average && gps_weight_sum > 0) {
+    updateGpsTelemetryCache(gps_weighted_lat / gps_weight_sum,
+                            gps_weighted_lon / gps_weight_sum,
+                            gps_weighted_altitude / gps_weight_sum,
+                            now);
+  }
+  gps_acquiring = false;
+  gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL;
+  maybeStopGpsForTelemetry(now);
+}
+
+bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP& telemetry) {
+  if (!(requester_permissions & TELEM_PERM_LOCATION) || !telemetryGpsDetected()) return false;
+
+  unsigned long now = millis();
+  gps_hold_until = now + GPS_TELEMETRY_HOLD_SEC * 1000UL;
+  if (!telemetryGpsActive()) telemetryGpsStart();
+  if (!gpsTelemetryCacheFresh(now) && !gps_acquiring) beginGpsTelemetryAcquisition(now);
+
+  if (!gpsTelemetryCacheFresh(now)) return false;
+  telemetry.addGPS(TELEM_CHANNEL_SELF, gps_cache_lat, gps_cache_lon, gps_cache_altitude);
+  return true;
+}
+
+void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude, unsigned long now) {
+  if (!gps_acquiring) {
+    if (gps_user_enabled || gpsTelemetryHoldActive(now)) {
+      updateGpsTelemetryCache(lat, lon, altitude, now);
+    }
+    return;
+  }
+
+  if (!gps_acquire_has_fix) {
+    gps_acquire_has_fix = true;
+    gps_stable_started_at = now;
+    gps_stable_origin_lat = lat;
+    gps_stable_origin_lon = lon;
+  }
+
+  static const float STABLE_RADIUS_M2 =
+      GPS_TELEMETRY_STABLE_RADIUS_M * GPS_TELEMETRY_STABLE_RADIUS_M;
+  if (gpsDistanceSquaredMeters(gps_stable_origin_lat, gps_stable_origin_lon, lat, lon) > STABLE_RADIUS_M2) {
+    updateGpsTelemetryCache(lat, lon, altitude, now);
+    finishGpsTelemetryAcquisition(now, false);
+    return;
+  }
+
+  float weight = (float)++gps_weight_count;
+  gps_weighted_lat += lat * weight;
+  gps_weighted_lon += lon * weight;
+  gps_weighted_altitude += altitude * weight;
+  gps_weight_sum += weight;
+  if (millisDue(now, gps_stable_started_at + GPS_TELEMETRY_STABLE_SEC * 1000UL)) {
+    finishGpsTelemetryAcquisition(now, true);
+  }
+}
+
+void SensorManager::loopGpsTelemetry(unsigned long now) {
+  if (!gps_user_enabled && !gpsTelemetryHoldActive(now) && !gps_acquiring) {
+    maybeStopGpsForTelemetry(now);
+  }
+  if (gps_location_access_available && telemetryGpsDetected() && !gps_user_enabled &&
+      !gpsTelemetryHoldActive(now) && !gps_acquiring &&
+      (gps_next_cache_update_at == 0 || millisDue(now, gps_next_cache_update_at))) {
+    beginGpsTelemetryAcquisition(now);
+  }
+  if (gps_acquiring && millisDue(now, gps_acquire_started_at + GPS_TELEMETRY_MAX_ACQUIRE_SEC * 1000UL)) {
+    finishGpsTelemetryAcquisition(now, gps_acquire_has_fix && gps_weight_sum > 0);
+  }
+}
+
+void SensorManager::setGpsTelemetryUserEnabled(bool enabled) {
+  gps_user_enabled = enabled;
+  unsigned long now = millis();
+  if (enabled) {
+    if (telemetryGpsDetected() && !telemetryGpsActive()) telemetryGpsStart();
+  } else {
+    maybeStopGpsForTelemetry(now);
+  }
+}
+#endif
+
+void SensorManager::setTelemetryLocationAccessAvailable(bool available) {
+#if ENV_INCLUDE_GPS
+  unsigned long now = millis();
+  if (gps_location_access_available == available) return;
+
+  gps_location_access_available = available;
+  if (available) {
+    gps_next_cache_update_at = 0;
+  } else if (gps_acquiring && !gps_user_enabled && !gpsTelemetryHoldActive(now)) {
+    gps_acquiring = false;
+    maybeStopGpsForTelemetry(now);
+  }
+#else
+  (void)available;
+#endif
+}
diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h
index d4aa63b7..15cdebdf 100644
--- a/src/helpers/SensorManager.h
+++ b/src/helpers/SensorManager.h
@@ -11,6 +11,47 @@
 #define TELEM_CHANNEL_SELF   1   // LPP data channel for 'self' device
 
 class SensorManager {
+#if ENV_INCLUDE_GPS
+  bool gps_cache_valid = false;
+  bool gps_location_access_available = false;
+  bool gps_user_enabled = false;
+  bool gps_acquiring = false;
+  bool gps_acquire_has_fix = false;
+  float gps_cache_lat = 0;
+  float gps_cache_lon = 0;
+  float gps_cache_altitude = 0;
+  float gps_stable_origin_lat = 0;
+  float gps_stable_origin_lon = 0;
+  float gps_weighted_lat = 0;
+  float gps_weighted_lon = 0;
+  float gps_weighted_altitude = 0;
+  float gps_weight_sum = 0;
+  uint16_t gps_weight_count = 0;
+  unsigned long gps_cache_updated_at = 0;
+  unsigned long gps_next_cache_update_at = 0;
+  unsigned long gps_hold_until = 0;
+  unsigned long gps_acquire_started_at = 0;
+  unsigned long gps_stable_started_at = 0;
+
+  bool gpsTelemetryHoldActive(unsigned long now) const;
+  bool gpsTelemetryCacheFresh(unsigned long now) const;
+  void beginGpsTelemetryAcquisition(unsigned long now);
+  void finishGpsTelemetryAcquisition(unsigned long now, bool use_weighted_average);
+  void updateGpsTelemetryCache(float lat, float lon, float altitude, unsigned long now);
+  void maybeStopGpsForTelemetry(unsigned long now);
+
+protected:
+  virtual bool telemetryGpsDetected() const { return false; }
+  virtual bool telemetryGpsActive() const { return false; }
+  virtual void telemetryGpsStart() { }
+  virtual void telemetryGpsStop() { }
+  bool queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP& telemetry);
+  void processGpsTelemetryFix(float lat, float lon, float altitude, unsigned long now);
+  void loopGpsTelemetry(unsigned long now);
+  void setGpsTelemetryUserEnabled(bool enabled);
+  bool isGpsTelemetryUserEnabled() const { return gps_user_enabled; }
+#endif
+
 public:
   double node_lat, node_lon;  // modify these, if you want to affect Advert location
   double node_altitude;       // altitude in meters
@@ -20,6 +61,7 @@ public:
   virtual bool begin() { return false; }
   virtual bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { return false; }
   virtual void loop() { }
+  virtual void setTelemetryLocationAccessAvailable(bool available);
   virtual int getNumSettings() const { return 0; }
   virtual const char* getSettingName(int i) const { return NULL; }
   virtual const char* getSettingValue(int i) const { return NULL; }
diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp
index cfa215a4..5c449ffa 100644
--- a/src/helpers/sensors/EnvironmentSensorManager.cpp
+++ b/src/helpers/sensors/EnvironmentSensorManager.cpp
@@ -689,9 +689,9 @@ bool EnvironmentSensorManager::begin() {
 bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) {
   next_available_channel = TELEM_CHANNEL_SELF + 1;
 
-  if (requester_permissions & TELEM_PERM_LOCATION && gps_active) {
-    telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
-  }
+  #if ENV_INCLUDE_GPS
+  queryGpsTelemetry(requester_permissions, telemetry);
+  #endif
 
   if (requester_permissions & TELEM_PERM_ENVIRONMENT) {
     for (int i = 0; i < _active_sensor_count; i++) {
@@ -726,7 +726,7 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const {
   int settings = 0;
   #if ENV_INCLUDE_GPS
     if (gps_detected && i == settings++) {
-      return gps_active ? "1" : "0";
+      return isGpsTelemetryUserEnabled() ? "1" : "0";
     }
   #endif
   return NULL;
@@ -735,11 +735,7 @@ const char* EnvironmentSensorManager::getSettingValue(int i) const {
 bool EnvironmentSensorManager::setSettingValue(const char* name, const char* value) {
   #if ENV_INCLUDE_GPS
   if (gps_detected && strcmp(name, "gps") == 0) {
-    if (strcmp(value, "0") == 0) {
-      stop_gps();
-    } else {
-      start_gps();
-    }
+    setGpsTelemetryUserEnabled(strcmp(value, "0") != 0);
     return true;
   }
   if (strcmp(name, "gps_interval") == 0) {
@@ -784,6 +780,7 @@ void EnvironmentSensorManager::initBasicGPS() {
     MESH_DEBUG_PRINTLN("GPS detected");
     #ifdef PERSISTANT_GPS
       gps_active = true;
+      setGpsTelemetryUserEnabled(true);
       return;
     #endif
   } else {
@@ -876,6 +873,7 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){
 #endif
 
 void EnvironmentSensorManager::start_gps() {
+  if (gps_active) return;
   gps_active = true;
   #ifdef RAK_WISBLOCK_GPS
     pinMode(gpsResetPin, OUTPUT);
@@ -911,32 +909,43 @@ void EnvironmentSensorManager::stop_gps() {
 void EnvironmentSensorManager::loop() {
 
   #if ENV_INCLUDE_GPS
-  static long next_gps_update = 0;
+  static unsigned long next_gps_update = 0;
+  unsigned long now = millis();
+  loopGpsTelemetry(now);
+
   if (gps_active) {
     _location->loop();
   }
-  if (millis() > next_gps_update) {
+  if ((long)(now - next_gps_update) >= 0) {
 
     if(gps_active){
     #ifdef RAK_WISBLOCK_GPS
     if ((i2cGPSFlag || serialGPSFlag) && _location->isValid()) {
-      node_lat = ((double)_location->getLatitude())/1000000.;
-      node_lon = ((double)_location->getLongitude())/1000000.;
+      float gps_lat = ((float)_location->getLatitude()) / 1000000.0f;
+      float gps_lon = ((float)_location->getLongitude()) / 1000000.0f;
+      float gps_altitude = ((float)_location->getAltitude()) / 1000.0f;
+      node_lat = gps_lat;
+      node_lon = gps_lon;
       MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon);
-      node_altitude = ((double)_location->getAltitude()) / 1000.0;
+      node_altitude = gps_altitude;
       MESH_DEBUG_PRINTLN("lat %f lon %f alt %f", node_lat, node_lon, node_altitude);
+      processGpsTelemetryFix(gps_lat, gps_lon, gps_altitude, now);
     }
     #else
     if (_location->isValid()) {
-      node_lat = ((double)_location->getLatitude())/1000000.;
-      node_lon = ((double)_location->getLongitude())/1000000.;
+      float gps_lat = ((float)_location->getLatitude()) / 1000000.0f;
+      float gps_lon = ((float)_location->getLongitude()) / 1000000.0f;
+      float gps_altitude = ((float)_location->getAltitude()) / 1000.0f;
+      node_lat = gps_lat;
+      node_lon = gps_lon;
       MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon);
-      node_altitude = ((double)_location->getAltitude()) / 1000.0;
+      node_altitude = gps_altitude;
       MESH_DEBUG_PRINTLN("lat %f lon %f alt %f", node_lat, node_lon, node_altitude);
+      processGpsTelemetryFix(gps_lat, gps_lon, gps_altitude, now);
     }
     #endif
     }
-    next_gps_update = millis() + (gps_update_interval_sec * 1000);
+    next_gps_update = now + (gps_update_interval_sec * 1000);
   }
   #endif
   #if ENV_INCLUDE_BME680_BSEC
diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h
index b91d2dc3..9b7eedad 100644
--- a/src/helpers/sensors/EnvironmentSensorManager.h
+++ b/src/helpers/sensors/EnvironmentSensorManager.h
@@ -27,6 +27,10 @@ protected:
   LocationProvider* _location;
   void start_gps();
   void stop_gps();
+  bool telemetryGpsDetected() const override { return gps_detected; }
+  bool telemetryGpsActive() const override { return gps_active; }
+  void telemetryGpsStart() override { start_gps(); }
+  void telemetryGpsStop() override { stop_gps(); }
   void initBasicGPS();
   #ifdef RAK_BOARD
   void rakGPSInit();
diff --git a/variants/heltec_mesh_solar/platformio.ini b/variants/heltec_mesh_solar/platformio.ini
index bd042322..8365ef68 100644
--- a/variants/heltec_mesh_solar/platformio.ini
+++ b/variants/heltec_mesh_solar/platformio.ini
@@ -8,6 +8,7 @@ build_flags =  ${nrf52_base.build_flags}
   -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
   -I variants/heltec_mesh_solar
   -D HELTEC_MESH_SOLAR
+  -D ENV_INCLUDE_GPS=1
   -D RADIO_CLASS=CustomSX1262
   -D WRAPPER_CLASS=CustomSX1262Wrapper
   -D LORA_TX_POWER=22
diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp
index 4c49146b..c6224000 100644
--- a/variants/heltec_mesh_solar/target.cpp
+++ b/variants/heltec_mesh_solar/target.cpp
@@ -58,25 +58,26 @@ bool SolarSensorManager::begin() {
 }
 
 bool SolarSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) {
-  if (requester_permissions & TELEM_PERM_LOCATION) {   // does requester have permission?
-    telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
-  }
+  queryGpsTelemetry(requester_permissions, telemetry);
   return true;
 }
 
 void SolarSensorManager::loop() {
-  static long next_gps_update = 0;
+  static unsigned long next_gps_update = 0;
+  unsigned long now = millis();
+  loopGpsTelemetry(now);
 
-  _location->loop();
+  if (gps_active) _location->loop();
 
-  if (millis() > next_gps_update) {
-    if (_location->isValid()) {
+  if ((long)(now - next_gps_update) >= 0) {
+    if (gps_active && _location->isValid()) {
       node_lat = ((double)_location->getLatitude())/1000000.;
       node_lon = ((double)_location->getLongitude())/1000000.;
       node_altitude = ((double)_location->getAltitude()) / 1000.0;
+      processGpsTelemetryFix(node_lat, node_lon, node_altitude, now);
       MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon);
     }
-    next_gps_update = millis() + 1000;
+    next_gps_update = now + 1000;
   }
 }
 
@@ -90,18 +91,14 @@ const char* SolarSensorManager::getSettingName(int i) const {
 
 const char* SolarSensorManager::getSettingValue(int i) const {
   if (gps_detected && i == 0) {
-    return gps_active ? "1" : "0";
+    return isGpsTelemetryUserEnabled() ? "1" : "0";
   }
   return NULL;
 }
 
 bool SolarSensorManager::setSettingValue(const char* name, const char* value) {
   if (gps_detected && strcmp(name, "gps") == 0) {
-    if (strcmp(value, "0") == 0) {
-      stop_gps();
-    } else {
-      start_gps();
-    }
+    setGpsTelemetryUserEnabled(strcmp(value, "0") != 0);
     return true;
   }
   return false;  // not supported
diff --git a/variants/heltec_mesh_solar/target.h b/variants/heltec_mesh_solar/target.h
index 1956f50e..23a1db90 100644
--- a/variants/heltec_mesh_solar/target.h
+++ b/variants/heltec_mesh_solar/target.h
@@ -19,6 +19,10 @@ class SolarSensorManager : public SensorManager {
 
   void start_gps();
   void stop_gps();
+  bool telemetryGpsDetected() const override { return gps_detected; }
+  bool telemetryGpsActive() const override { return gps_active; }
+  void telemetryGpsStart() override { start_gps(); }
+  void telemetryGpsStop() override { stop_gps(); }
 public:
   SolarSensorManager(LocationProvider &location): _location(&location) { }
   bool begin() override;
diff --git a/variants/heltec_tracker/target.cpp b/variants/heltec_tracker/target.cpp
index f32c41ff..6140298c 100644
--- a/variants/heltec_tracker/target.cpp
+++ b/variants/heltec_tracker/target.cpp
@@ -64,25 +64,26 @@ bool HWTSensorManager::begin() {
 }
 
 bool HWTSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) {
-  if (requester_permissions & TELEM_PERM_LOCATION) {   // does requester have permission?
-    telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
-  }
+  queryGpsTelemetry(requester_permissions, telemetry);
   return true;
 }
 
 void HWTSensorManager::loop() {
-  static long next_gps_update = 0;
+  static unsigned long next_gps_update = 0;
+  unsigned long now = millis();
+  loopGpsTelemetry(now);
 
-  _location->loop();
+  if (gps_active) _location->loop();
 
-  if (millis() > next_gps_update) {
+  if ((long)(now - next_gps_update) >= 0) {
     if (gps_active && _location->isValid()) {
       node_lat = ((double)_location->getLatitude())/1000000.;
       node_lon = ((double)_location->getLongitude())/1000000.;
       node_altitude = ((double)_location->getAltitude()) / 1000.0;
+      processGpsTelemetryFix(node_lat, node_lon, node_altitude, now);
       MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon);
     }
-    next_gps_update = millis() + 1000;
+    next_gps_update = now + 1000;
   }
 }
 
@@ -93,17 +94,13 @@ const char* HWTSensorManager::getSettingName(int i) const {
 }
 const char* HWTSensorManager::getSettingValue(int i) const {
   if (i == 0) {
-    return gps_active ? "1" : "0";
+    return isGpsTelemetryUserEnabled() ? "1" : "0";
   }
   return NULL;
 }
 bool HWTSensorManager::setSettingValue(const char* name, const char* value) {
   if (strcmp(name, "gps") == 0) {
-    if (strcmp(value, "0") == 0) {
-      stop_gps();
-    } else {
-      start_gps();
-    }
+    setGpsTelemetryUserEnabled(strcmp(value, "0") != 0);
     return true;
   }
   return false;  // not supported
diff --git a/variants/heltec_tracker/target.h b/variants/heltec_tracker/target.h
index 2931eda8..823186c1 100644
--- a/variants/heltec_tracker/target.h
+++ b/variants/heltec_tracker/target.h
@@ -19,6 +19,10 @@ class HWTSensorManager : public SensorManager {
 
   void start_gps();
   void stop_gps();
+  bool telemetryGpsDetected() const override { return true; }
+  bool telemetryGpsActive() const override { return gps_active; }
+  void telemetryGpsStart() override { start_gps(); }
+  void telemetryGpsStop() override { stop_gps(); }
 public:
   HWTSensorManager(LocationProvider &location): _location(&location) { }
   bool begin() override;
diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini
index 77d81754..32039286 100644
--- a/variants/lilygo_tlora_v2_1/platformio.ini
+++ b/variants/lilygo_tlora_v2_1/platformio.ini
@@ -165,6 +165,7 @@ build_flags =
   -D MQTT_MAX_PACKET_SIZE=1024
   -D MQTT_TASK_STACK_SIZE=16384
   -D ESP32_CPU_FREQ=240
+  -D LIGHTWEIGHT_WIFI_OTA=1  ; manual browser upload without the ElegantOTA dependency cost
 ;  -D MQTT_DEBUG=1
 ;  -D MESH_PACKET_LOGGING=1
 ;  -D MESH_DEBUG=1
@@ -183,7 +184,6 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6_core.build_src_filter}
   +<../examples/simple_repeater>
 lib_deps =
   ${LilyGo_TLora_V2_1_1_6_core.lib_deps}
-  ${esp32_ota.lib_deps}
   elims/PsychicMqttClient@^0.2.4
   bblanchon/ArduinoJson
   arduino-libraries/NTPClient
@@ -211,9 +211,8 @@ build_flags =
   -D MQTT_MAX_PACKET_SIZE=1024
   -D MQTT_TASK_STACK_SIZE=16384
   -D ESP32_CPU_FREQ=240
-; MQTT_DEBUG must stay off here: with it on, this min_spiffs build overflows the
-; app partition by ~357 bytes and produces no binary (the repeater observer env
-; keeps it commented for the same reason).
+  -D LIGHTWEIGHT_WIFI_OTA=1  ; manual browser upload without the ElegantOTA dependency cost
+; Keep MQTT debug logging off to preserve flash and runtime heap headroom.
 ;  -D MQTT_DEBUG=1
 ;  -D MESH_PACKET_LOGGING=1
 ;  -D MESH_DEBUG=1
@@ -226,7 +225,6 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6_core.build_src_filter}
   +<../examples/simple_room_server>
 lib_deps =
   ${LilyGo_TLora_V2_1_1_6_core.lib_deps}
-  ${esp32_ota.lib_deps}
   elims/PsychicMqttClient@^0.2.4
   bblanchon/ArduinoJson
   arduino-libraries/NTPClient
diff --git a/variants/meshadventurer/platformio.ini b/variants/meshadventurer/platformio.ini
index f85be238..eb9b201d 100644
--- a/variants/meshadventurer/platformio.ini
+++ b/variants/meshadventurer/platformio.ini
@@ -6,6 +6,7 @@ build_flags =
   ${esp32_base.build_flags}
   -I variants/meshadventurer
   -D MESHADVENTURER
+  -D ENV_INCLUDE_GPS=1
   -D P_LORA_TX_LED=2
   -D PIN_VBAT_READ=35
   -D PIN_USER_BTN=39
diff --git a/variants/meshadventurer/target.cpp b/variants/meshadventurer/target.cpp
index a384b941..fd7a9763 100644
--- a/variants/meshadventurer/target.cpp
+++ b/variants/meshadventurer/target.cpp
@@ -58,23 +58,24 @@ bool MASensorManager::begin() {
 }
 
 bool MASensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) {
-  if(requester_permissions & TELEM_PERM_LOCATION) {   // does requester have permission?
-    telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
-  }
+  queryGpsTelemetry(requester_permissions, telemetry);
   return true;
 }
 
 void MASensorManager::loop() {
-  static long next_gps_update = 0;
-  _location->loop();
-  if(millis() > next_gps_update && gps_active) {
+  static unsigned long next_gps_update = 0;
+  unsigned long now = millis();
+  loopGpsTelemetry(now);
+  if (gps_active) _location->loop();
+  if ((long)(now - next_gps_update) >= 0 && gps_active) {
     if(_location->isValid()) {
       node_lat = ((double)_location->getLatitude()) / 1000000.;
       node_lon = ((double)_location->getLongitude()) / 1000000.;
       node_altitude = ((double)_location->getAltitude()) / 1000.0;
+      processGpsTelemetryFix(node_lat, node_lon, node_altitude, now);
       MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon);
     }
-    next_gps_update = millis() + 1000;
+    next_gps_update = now + 1000;
   }
 }
 
@@ -85,17 +86,13 @@ const char* MASensorManager::getSettingName(int i) const {
 }
 const char* MASensorManager::getSettingValue(int i) const {
   if(i == 0) {
-    return gps_active ? "1" : "0";
+    return isGpsTelemetryUserEnabled() ? "1" : "0";
   }
   return NULL;
 }
 bool MASensorManager::setSettingValue(const char* name, const char* value) {
   if(strcmp(name, "gps") == 0) {
-    if(strcmp(value, "0") == 0) {
-      stop_gps();
-    } else {
-      start_gps();
-    }
+    setGpsTelemetryUserEnabled(strcmp(value, "0") != 0);
     return true;
   }
   return false;  // not supported
diff --git a/variants/meshadventurer/target.h b/variants/meshadventurer/target.h
index d59b3aee..f846d3f9 100644
--- a/variants/meshadventurer/target.h
+++ b/variants/meshadventurer/target.h
@@ -20,6 +20,10 @@ class MASensorManager : public SensorManager {
 
   void start_gps();
   void stop_gps();
+  bool telemetryGpsDetected() const override { return true; }
+  bool telemetryGpsActive() const override { return gps_active; }
+  void telemetryGpsStart() override { start_gps(); }
+  void telemetryGpsStop() override { stop_gps(); }
 public:
   MASensorManager(LocationProvider &location): _location(&location) { }
   bool begin() override;
@@ -44,4 +48,3 @@ extern MASensorManager sensors;
 
 bool radio_init();
 mesh::LocalIdentity radio_new_identity();
-
diff --git a/variants/nano_g2_ultra/platformio.ini b/variants/nano_g2_ultra/platformio.ini
index 3cdc29ff..e92acccf 100644
--- a/variants/nano_g2_ultra/platformio.ini
+++ b/variants/nano_g2_ultra/platformio.ini
@@ -8,6 +8,7 @@ build_flags = ${nrf52_base.build_flags}
   -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
   -I variants/nano_g2_ultra
   -D NANO_G2_ULTRA
+  -D ENV_INCLUDE_GPS=1
   -D RADIO_CLASS=CustomSX1262
   -D WRAPPER_CLASS=CustomSX1262Wrapper
   -D LORA_TX_POWER=22
diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp
index 60c6dfb5..af376892 100644
--- a/variants/nano_g2_ultra/target.cpp
+++ b/variants/nano_g2_ultra/target.cpp
@@ -66,14 +66,14 @@ bool NanoG2UltraSensorManager::begin() {
 }
 
 bool NanoG2UltraSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) {
-  if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission?
-    telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
-  }
+  queryGpsTelemetry(requester_permissions, telemetry);
   return true;
 }
 
 void NanoG2UltraSensorManager::loop() {
-  static long next_gps_update = 0;
+  static unsigned long next_gps_update = 0;
+  unsigned long now = millis();
+  loopGpsTelemetry(now);
 
   if (!gps_active) {
     return; // GPS is not active, skip further processing
@@ -81,17 +81,18 @@ void NanoG2UltraSensorManager::loop() {
 
   _location->loop();
 
-  if (millis() > next_gps_update) {
+  if ((long)(now - next_gps_update) >= 0) {
     if (_location->isValid()) {
       node_lat = ((double)_location->getLatitude()) / 1000000.;
       node_lon = ((double)_location->getLongitude()) / 1000000.;
       node_altitude = ((double)_location->getAltitude()) / 1000.0;
+      processGpsTelemetryFix(node_lat, node_lon, node_altitude, now);
       MESH_DEBUG_PRINTLN("VALID location: lat %f lon %f", node_lat, node_lon);
     } else {
       MESH_DEBUG_PRINTLN("INVALID location, waiting for fix");
     }
     MESH_DEBUG_PRINTLN("GPS satellites: %d", _location->satellitesCount());
-    next_gps_update = millis() + 1000;
+    next_gps_update = now + 1000;
   }
 }
 
@@ -105,18 +106,14 @@ const char *NanoG2UltraSensorManager::getSettingName(int i) const {
 
 const char *NanoG2UltraSensorManager::getSettingValue(int i) const {
   if (i == 0) {
-    return gps_active ? "1" : "0";
+    return isGpsTelemetryUserEnabled() ? "1" : "0";
   }
   return NULL;
 }
 
 bool NanoG2UltraSensorManager::setSettingValue(const char *name, const char *value) {
   if (strcmp(name, "gps") == 0) {
-    if (strcmp(value, "0") == 0) {
-      stop_gps();
-    } else {
-      start_gps();
-    }
+    setGpsTelemetryUserEnabled(strcmp(value, "0") != 0);
     return true;
   }
   return false; // not supported
@@ -125,4 +122,4 @@ bool NanoG2UltraSensorManager::setSettingValue(const char *name, const char *val
 mesh::LocalIdentity radio_new_identity() {
   RadioNoiseListener rng(radio);
   return mesh::LocalIdentity(&rng); // create new random identity
-}
\ No newline at end of file
+}
diff --git a/variants/nano_g2_ultra/target.h b/variants/nano_g2_ultra/target.h
index 5c6ebee1..e11b28f5 100644
--- a/variants/nano_g2_ultra/target.h
+++ b/variants/nano_g2_ultra/target.h
@@ -20,6 +20,10 @@ class NanoG2UltraSensorManager : public SensorManager {
 
   void start_gps();
   void stop_gps();
+  bool telemetryGpsDetected() const override { return true; }
+  bool telemetryGpsActive() const override { return gps_active; }
+  void telemetryGpsStart() override { start_gps(); }
+  void telemetryGpsStop() override { stop_gps(); }
 
 public:
   NanoG2UltraSensorManager(LocationProvider &location) : _location(&location) {}
@@ -44,4 +48,3 @@ extern MomentaryButton user_btn;
 
 bool radio_init();
 mesh::LocalIdentity radio_new_identity();
-
diff --git a/variants/sensecap_solar/variant.cpp b/variants/sensecap_solar/variant.cpp
index 05774c10..ac45b1b5 100644
--- a/variants/sensecap_solar/variant.cpp
+++ b/variants/sensecap_solar/variant.cpp
@@ -58,8 +58,8 @@ void initVariant() {
     pinMode(PIN_QSPI_CS, OUTPUT);
     digitalWrite(PIN_QSPI_CS, HIGH);
 
-    pinMode(LED_GREEN, OUTPUT);
-    digitalWrite(LED_GREEN, LOW);
+    pinMode(LED_WHITE, OUTPUT);
+    digitalWrite(LED_WHITE, LOW);
 
     pinMode(LED_BLUE, OUTPUT);
     digitalWrite(LED_BLUE, LOW);
diff --git a/variants/sensecap_solar/variant.h b/variants/sensecap_solar/variant.h
index 537c9024..ef87c7eb 100644
--- a/variants/sensecap_solar/variant.h
+++ b/variants/sensecap_solar/variant.h
@@ -24,8 +24,8 @@
 #define LED_BUILTIN             (PIN_LED)
 
 #define LED_RED                 (PINS_COUNT)
-#define LED_GREEN               (12)
-#define LED_BLUE                (11)
+#define LED_WHITE               (11)
+#define LED_BLUE                (12)    // LoRa TX indicator
 
 #define LED_STATE_ON            (1)     // State when LED is litted
 
diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini
index 338f4c31..2dfe8ec0 100644
--- a/variants/station_g2/platformio.ini
+++ b/variants/station_g2/platformio.ini
@@ -1,6 +1,7 @@
 [Station_G2]
 extends = esp32_base
 board = station-g2
+board_build.partitions = default_16MB.csv
 build_flags =
   ${esp32_base.build_flags}
   ${sensor_base.build_flags}
diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini
index b29388ef..0f5b4b70 100644
--- a/variants/station_g3_esp32/platformio.ini
+++ b/variants/station_g3_esp32/platformio.ini
@@ -1,6 +1,7 @@
 [Station_G3_ESP32]
 extends = esp32_base
 board = station-g3-esp32
+board_build.partitions = default_16MB.csv
 build_flags =
   ${esp32_base.build_flags}
   ${sensor_base.build_flags}
diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp
index 42532827..8b1d67de 100644
--- a/variants/t1000-e/target.cpp
+++ b/variants/t1000-e/target.cpp
@@ -80,6 +80,7 @@ mesh::LocalIdentity radio_new_identity() {
 }
 
 void T1000SensorManager::start_gps() {
+  if (gps_active) return;
   gps_active = true;
   //_nmea->begin();
   // this init sequence should be better 
@@ -135,9 +136,7 @@ bool T1000SensorManager::begin() {
 }
 
 bool T1000SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) {
-  if (requester_permissions & TELEM_PERM_LOCATION) {   // does requester have permission?
-    telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
-  }
+  queryGpsTelemetry(requester_permissions, telemetry);
   if (requester_permissions & TELEM_PERM_ENVIRONMENT) {
     // Firmware reports light as a 0-100 % scale, but expose it via Luminosity so app labels it "Luminosity".
     telemetry.addLuminosity(TELEM_CHANNEL_SELF, t1000e_get_light());
@@ -147,18 +146,21 @@ bool T1000SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP&
 }
 
 void T1000SensorManager::loop() {
-  static long next_gps_update = 0;
+  static unsigned long next_gps_update = 0;
+  unsigned long now = millis();
+  loopGpsTelemetry(now);
 
-  _nmea->loop();
+  if (gps_active) _nmea->loop();
 
-  if (millis() > next_gps_update) {
+  if ((long)(now - next_gps_update) >= 0) {
     if (gps_active && _nmea->isValid()) {
       node_lat = ((double)_nmea->getLatitude())/1000000.;
       node_lon = ((double)_nmea->getLongitude())/1000000.;
       node_altitude = ((double)_nmea->getAltitude()) / 1000.0;
+      processGpsTelemetryFix(node_lat, node_lon, node_altitude, now);
       //Serial.printf("lat %f lon %f\r\n", _lat, _lon);
     }
-    next_gps_update = millis() + 1000;
+    next_gps_update = now + 1000;
   }
 }
 
@@ -169,17 +171,13 @@ const char* T1000SensorManager::getSettingName(int i) const {
 }
 const char* T1000SensorManager::getSettingValue(int i) const {
   if (i == 0) {
-    return gps_active ? "1" : "0";
+    return isGpsTelemetryUserEnabled() ? "1" : "0";
   }
   return NULL;
 }
 bool T1000SensorManager::setSettingValue(const char* name, const char* value) {
   if (strcmp(name, "gps") == 0) {
-    if (strcmp(value, "0") == 0) {
-      sleep_gps(); // sleep for faster fix !
-    } else {
-      start_gps();
-    }
+    setGpsTelemetryUserEnabled(strcmp(value, "0") != 0);
     return true;
   }
   return false;  // not supported
diff --git a/variants/t1000-e/target.h b/variants/t1000-e/target.h
index db003cc5..27e79d42 100644
--- a/variants/t1000-e/target.h
+++ b/variants/t1000-e/target.h
@@ -19,6 +19,10 @@ class T1000SensorManager: public SensorManager {
   void start_gps();
   void sleep_gps();
   void stop_gps();
+  bool telemetryGpsDetected() const override { return true; }
+  bool telemetryGpsActive() const override { return gps_active; }
+  void telemetryGpsStart() override { start_gps(); }
+  void telemetryGpsStop() override { sleep_gps(); }
 public:
   T1000SensorManager(LocationProvider &nmea): _nmea(&nmea) { }
   bool begin() override;
diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini
index e10ddaa7..9977d805 100644
--- a/variants/thinknode_m1/platformio.ini
+++ b/variants/thinknode_m1/platformio.ini
@@ -8,6 +8,7 @@ build_flags = ${rak4631_hw.build_flags}
   -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
   -I variants/thinknode_m1
   -D THINKNODE_M1=1
+  -D ENV_INCLUDE_GPS=1
   -D RADIO_CLASS=CustomSX1262
   -D WRAPPER_CLASS=CustomSX1262Wrapper
   -D P_LORA_DIO_1=20
@@ -83,7 +84,6 @@ build_flags =
   -D PIN_BUZZER=6
   -D AUTO_SHUTDOWN_MILLIVOLTS=3300
   -D QSPIFLASH=1
-  -D ENV_INCLUDE_GPS=1
 ;  -D MESH_PACKET_LOGGING=1
 ;  -D MESH_DEBUG=1
 build_src_filter = ${ThinkNode_M1.build_src_filter}
diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp
index 69306fc0..9cb43e34 100644
--- a/variants/thinknode_m1/target.cpp
+++ b/variants/thinknode_m1/target.cpp
@@ -55,25 +55,25 @@ bool ThinkNodeM1SensorManager::begin() {
 
   // Check initial switch state to determine if GPS should be active
   if (last_gps_switch_state == HIGH) {  // Switch is HIGH when ON
-    start_gps();
+    setGpsTelemetryUserEnabled(true);
   }
 
   return true;
 }
 
 bool ThinkNodeM1SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) {
-  if (requester_permissions & TELEM_PERM_LOCATION) {   // does requester have permission?
-    telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
-  }
+  queryGpsTelemetry(requester_permissions, telemetry);
   return true;
 }
 
 void ThinkNodeM1SensorManager::loop() {
-  static long next_gps_update = 0;
-  static long last_switch_check = 0;
+  static unsigned long next_gps_update = 0;
+  static unsigned long last_switch_check = 0;
+  unsigned long now = millis();
+  loopGpsTelemetry(now);
 
   // Check GPS switch state every second
-  if (millis() - last_switch_check > 1000) {
+  if (now - last_switch_check > 1000) {
     bool current_switch_state = digitalRead(PIN_GPS_SWITCH);
     
     // Detect switch state change
@@ -82,14 +82,14 @@ void ThinkNodeM1SensorManager::loop() {
       
       if (current_switch_state == HIGH) {  // Switch is ON
         MESH_DEBUG_PRINTLN("GPS switch ON");
-        start_gps();
+        setGpsTelemetryUserEnabled(true);
       } else {  // Switch is OFF
         MESH_DEBUG_PRINTLN("GPS switch OFF");
-        stop_gps();
+        setGpsTelemetryUserEnabled(false);
       }
     }
     
-    last_switch_check = millis();
+    last_switch_check = now;
   }
 
   if (!gps_active) {
@@ -98,14 +98,15 @@ void ThinkNodeM1SensorManager::loop() {
 
   _location->loop();
 
-  if (millis() > next_gps_update) {
+  if ((long)(now - next_gps_update) >= 0) {
     if (_location->isValid()) {
       node_lat = ((double)_location->getLatitude())/1000000.;
       node_lon = ((double)_location->getLongitude())/1000000.;
       node_altitude = ((double)_location->getAltitude()) / 1000.0;
+      processGpsTelemetryFix(node_lat, node_lon, node_altitude, now);
       MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon);
     }
-    next_gps_update = millis() + 1000;
+    next_gps_update = now + 1000;
   }
 }
 
@@ -119,20 +120,15 @@ const char* ThinkNodeM1SensorManager::getSettingName(int i) const {
 
 const char* ThinkNodeM1SensorManager::getSettingValue(int i) const {
   if (i == 0) {
-    return gps_active ? "1" : "0";
+    return isGpsTelemetryUserEnabled() ? "1" : "0";
   }
   return NULL;
 }
 
 bool ThinkNodeM1SensorManager::setSettingValue(const char* name, const char* value) {
   if (strcmp(name, "gps") == 0) {
-    if (strcmp(value, "0") == 0) {
-      stop_gps();
-    } else {
-      start_gps();
-    }
+    setGpsTelemetryUserEnabled(strcmp(value, "0") != 0);
     return true;
   }
   return false;  // not supported
 }
-
diff --git a/variants/thinknode_m1/target.h b/variants/thinknode_m1/target.h
index ec1297be..7a95d1b9 100644
--- a/variants/thinknode_m1/target.h
+++ b/variants/thinknode_m1/target.h
@@ -20,6 +20,10 @@ class ThinkNodeM1SensorManager : public SensorManager {
 
   void start_gps();
   void stop_gps();
+  bool telemetryGpsDetected() const override { return true; }
+  bool telemetryGpsActive() const override { return gps_active; }
+  void telemetryGpsStart() override { start_gps(); }
+  void telemetryGpsStop() override { stop_gps(); }
 public:
   ThinkNodeM1SensorManager(LocationProvider &location): _location(&location) { }
   LocationProvider* getLocationProvider() override { return _location; }
@@ -44,4 +48,3 @@ extern ThinkNodeM1SensorManager sensors;
 
 bool radio_init();
 mesh::LocalIdentity radio_new_identity();
-