Add SenseCAP Indicator LoRa repeater profiles

This commit is contained in:
mikecarper committed 2026-10-22 12:42:15 -07:00
1 parent 624efe152d
commit 3236164d21
5 files changed
+218 -3

No files matched your search

+1
View File
@@ -416,6 +416,7 @@ jobs:
python3 -B test/test_udp_packet_origin.py -v
python3 -B test/test_udp_bridge.py -v
python3 -B test/test_udp_bridge_integration.py -v
python3 -B test/test_indicator_repeater_profile.py -v
- name: Verify saved repeat-off trace exception and relay guards
run: |
+26 -3
View File
@@ -2564,8 +2564,11 @@ supports_esp32_full_shared_espnow() {
&& { is_mqtt_bridge_target "$env_name" || is_repeater_role_target "$env_name"; } || return 1
# Primary ESP-NOW radios already own the SDK callbacks and packet transport.
# A second bridge instance would conflict with that radio, not add a mode.
! pio_env_option_contains "$env_name" build_flags "MESH_PRIMARY_ESPNOW" \
&& ! pio_env_option_contains "$env_name" build_flags "MESH_ESPNOW_RADIO"
# The Indicator LoRa board inherits the ESP-NOW board's original declaration
# followed by -UMESH_PRIMARY_ESPNOW. Inspect effective definitions rather
# than mistaking that overridden declaration for a live radio owner.
! pio_env_has_effective_define "$env_name" MESH_PRIMARY_ESPNOW \
&& ! pio_env_has_effective_define "$env_name" MESH_ESPNOW_RADIO
}
apply_esp32_full_shared_bridge_profile() {
@@ -3402,7 +3405,7 @@ is_esp32_full_only_bulk_target() {
station_g3_esp32_repeater|station_g3_esp32_logging_repeater|station_g3_esp32_room_server|station_g3_esp32_sensor|station_g3_esp32_r2_sensor|station_g3_esp32_repeater_observer_mqtt|station_g3_esp32_room_server_observer_mqtt|\
rak_3112_repeater|rak_3112_repeater_bridge_rs232|rak_3112_repeater_bridge_espnow|rak_3112_repeater_observer_mqtt|rak_3112_room_server|rak_3112_room_server_observer_mqtt|rak_3112_sensor|\
xiao_s3_wio_repeater|xiao_s3_wio_repeater_bridge_espnow|xiao_s3_wio_repeater_observer_mqtt|xiao_s3_wio_room_server|xiao_s3_wio_room_server_observer_mqtt|xiao_s3_wio_sensor|\
lilygo_tdeck_repeater|\
lilygo_tdeck_repeater|sensecapindicator-lora_repeater|sensecapindicator-lora-n16r2_repeater|\
thinknode_m7_repeater|thinknode_m7_room_server|thinknode_m7_repeater_observer_mqtt|thinknode_m7_room_server_observer_mqtt|\
heltec_v4_r8_repeater|heltec_v4_r8_repeater_observer_mqtt|heltec_v4_r8_room_server|heltec_v4_r8_room_server_observer_mqtt|heltec_v4_r8_sensor|\
heltec_v4_r8_tft_repeater|heltec_v4_r8_tft_repeater_observer_mqtt|heltec_v4_r8_tft_portrait_repeater_observer_mqtt|heltec_v4_r8_tft_room_server|heltec_v4_r8_tft_room_server_observer_mqtt|heltec_v4_r8_tft_portrait_room_server_observer_mqtt|heltec_v4_r8_tft_sensor)
@@ -4143,6 +4146,26 @@ sys.exit(1)
' "$env_name" "$option_name" "$needle" <<<"$PIO_CONFIG_JSON"
}
pio_env_has_effective_define() {
local env_name=$1
local define_name=$2
python3 -c '
import json, re, sys
rows = {name: dict(options) for name, options in json.load(sys.stdin)}
options = rows.get("env:" + sys.argv[1], {})
flags = options.get("build_flags", [])
if not isinstance(flags, list): flags = str(flags).splitlines()
definitions = {}
for flag in flags:
match = re.fullmatch(r"-([DU])\s*(\w+)(?:=(.*))?", str(flag).strip())
if not match: continue
kind, name, value = match.groups()
if kind == "U": definitions.pop(name, None)
else: definitions[name] = "1" if value is None else value
sys.exit(0 if sys.argv[2] in definitions else 1)
' "$env_name" "$define_name" <<<"$PIO_CONFIG_JSON"
}
append_platformio_build_src_filter() {
local rule=$1
+7
View File
@@ -46,6 +46,13 @@ WiFi network using the normal [WiFi settings](WiFi.md). Read `get wifi.status`
on each board to find its local address once connected. The commands below use
example addresses; substitute the actual reserved addresses.
For a LoRa-capable SenseCAP Indicator, use the Full
`SenseCapIndicator-LoRa_repeater` target on the original 8 MiB/OPI-PSRAM board.
The distinct `SenseCapIndicator-LoRa-N16R2_repeater` target is for 16 MiB/QSPI
PSRAM hardware. Both retain the LoRa expander and display; their PSRAM modes
are selected before app startup and the images are not interchangeable.
The Indicator Companion and primary-ESP-NOW images do not provide this bridge.
Both boards use the existing `bridge.secret` as their shared authentication
secret. Set the same strong secret on both before enabling the transport. Do
not paste it into shared diagnostic captures. The transport authenticates
+138
View File
@@ -0,0 +1,138 @@
"""Resolve the real Indicator repeater recipes without PlatformIO or hardware."""
import configparser
import fnmatch
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[1]
N8 = "SenseCapIndicator-LoRa_repeater"
N16 = "SenseCapIndicator-LoRa-N16R2_repeater"
class IndicatorRepeaterProfileTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.config = configparser.ConfigParser(interpolation=None, strict=False)
cls.config.read([ROOT / "platformio.ini",
ROOT / "variants/sensecap_indicator-espnow/platformio.ini"], encoding="ascii")
def option(self, section, option, depth=0):
self.assertLess(depth, 30)
if self.config.has_option(section, option):
value = self.config.get(section, option)
return re.sub(r"\$\{([^}]+)\}", lambda match:
self.option(*match[1].rsplit(".", 1), depth=depth + 1), value)
for parent in self.config.get(section, "extends", fallback="").split(","):
if parent.strip():
try:
return self.option(parent.strip(), option, depth + 1)
except KeyError:
pass
raise KeyError((section, option))
def flags(self, target):
return [line.strip() for line in self.option("env:" + target, "build_flags").splitlines()
if line.strip() and not line.lstrip().startswith((";", "#"))]
def definitions(self, target):
definitions = {}
for flag in self.flags(target):
match = re.fullmatch(r"-([DU])\s*(\w+)(?:=(.*))?", flag)
if not match:
continue
kind, name, value = match.groups()
if kind == "U":
definitions.pop(name, None)
else:
definitions[name] = (value or "1").strip("'")
return definitions
def selected(self, target, source):
result = False
rules = self.option("env:" + target, "build_src_filter")
for action, pattern in re.findall(r"([+-])<([^>]+)>", rules):
if "/" not in pattern and "/" in source:
continue
match = fnmatch.fnmatchcase(source, pattern)
if not any(character in pattern for character in "*?["):
match = match or source.startswith(pattern.rstrip("/") + "/")
if match:
result = action == "+"
return result
def test_real_radio_and_display_are_retained_in_both_memory_variants(self):
for target in (N8, N16):
with self.subTest(target=target):
definitions = self.definitions(target)
self.assertIn("SENSECAP_INDICATOR_LORA", definitions)
self.assertNotIn("MESH_PRIMARY_ESPNOW", definitions)
self.assertNotIn("DISABLE_WIFI_OTA", definitions)
self.assertEqual(definitions["WRAPPER_CLASS"], "IndicatorSX1262Wrapper")
self.assertEqual(definitions["DISPLAY_CLASS"], "SCIndicatorDisplay")
self.assertEqual(definitions["INDICATOR_WIFI_FONT_RECOVERY"], "1")
self.assertEqual(definitions["MOTA_HW_ID"], '"' + target[:-9] + '"')
self.assertEqual([definitions[key] for key in ("P_LORA_NSS", "P_LORA_RESET",
"P_LORA_BUSY", "P_LORA_DIO_1")], ["64", "65", "66", "67"])
for source in ("../examples/simple_repeater/main.cpp", "../examples/simple_repeater/MyMesh.cpp",
"../variants/sensecap_indicator-espnow/target.cpp",
"../variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp",
"../variants/sensecap_indicator-espnow/IndicatorFontClient.cpp",
"helpers/ui/LGFXDisplay.cpp", "helpers/ota/OtaContext.cpp",
"helpers/esp32/WebConfigServer.cpp"):
self.assertTrue(self.selected(target, source), source)
for source in ("helpers/esp32/ESPNOWRadio.cpp", "../examples/companion_radio/main.cpp"):
self.assertFalse(self.selected(target, source), source)
self.assertIn("AsyncElegantOTA", self.option("env:" + target, "lib_deps"))
def test_flash_partition_and_psram_modes_are_explicit(self):
for target, flash, mode, psram, table, size in (
(N8, "8MB", "qio_opi", "opi", "dual_ota_2560k_preserve_spiffs.csv", "2621440"),
(N16, "16MB", "qio_qspi", "qspi", "dual_ota_6400k_preserve_spiffs.csv", "6553600")):
with self.subTest(target=target):
for key, expected in (("board_upload.flash_size", flash),
("board_build.arduino.memory_type", mode), ("board_build.psram_type", psram),
("board_upload.maximum_size", size)):
self.assertEqual(self.option("env:" + target, key), expected)
self.assertEqual(self.option("env:" + target, "board_build.partitions"),
"variants/sensecap_indicator-espnow/" + table)
def test_actual_full_overlay_uses_lora_not_overridden_primary_espnow_flag(self):
rows = [["env:" + target, [["build_flags", self.flags(target)]]] for target in (N8, N16)]
environment = dict(os.environ, INDICATOR_TEST_CONFIG=json.dumps(rows))
script = r'''
set -euo pipefail
source build.sh
PIO_CONFIG_JSON=$INDICATOR_TEST_CONFIG
for target in SenseCapIndicator-LoRa_repeater SenseCapIndicator-LoRa-N16R2_repeater; do
PIO_ENV_PLATFORM_BY_NAME[$target]=ESP32_PLATFORM
PIO_ENV_BOARD_BY_NAME[$target]=esp32-s3-devkitc-1
PIO_ENV_FULL_BUILD_BY_NAME[$target]=1
PIO_ENV_MQTT_BY_NAME[$target]=0
supports_esp32_full_shared_espnow "$target"
is_esp32_full_only_bulk_target "$target"
[[ $(get_exact_identity_full_pio_env "$target") == "$target" ]]
ESP32_FULL_BUILD=1
PLATFORMIO_BUILD_FLAGS=''
PLATFORMIO_BUILD_SRC_FILTER=''
apply_esp32_full_shared_bridge_profile "$target"
[[ $PLATFORMIO_BUILD_FLAGS == *-DWITH_UDP_BRIDGE=1* ]]
[[ $PLATFORMIO_BUILD_FLAGS == *-DWITH_ESPNOW_BRIDGE=1* ]]
[[ $PLATFORMIO_BUILD_SRC_FILTER == *UDPBridgeSettings.cpp* ]]
done
PIO_CONFIG_JSON='[["env:bad", [["build_flags", ["-DMESH_PRIMARY_ESPNOW=1", "-UMESH_PRIMARY_ESPNOW", "-DMESH_PRIMARY_ESPNOW=1"]]]]]'
pio_env_has_effective_define bad MESH_PRIMARY_ESPNOW
PIO_CONFIG_JSON='[["env:bad", [["build_flags", ["-DMESH_ESPNOW_RADIO=1"]]]]]'
pio_env_has_effective_define bad MESH_ESPNOW_RADIO
'''
result = subprocess.run(["bash", "-c", script], cwd=ROOT, env=environment,
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
@@ -201,3 +201,49 @@ board_build.psram_type = qspi
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_6400k_preserve_spiffs.csv
; Production LoRa repeater on the original N8R8 Indicator. Retain the actual
; SX1262 expander, RGB display/touch and RP2040 font link from the LoRa board.
; Full builds add runtime-selectable ESP-NOW and eight-peer WiFi UDP through
; the common repeater overlay. These are not primary-ESP-NOW radio images.
[env:SenseCapIndicator-LoRa_repeater]
extends = SenseCapIndicator-LoRa
board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv
board_upload.maximum_size = 2621440
build_unflags =
-D MESH_PRIMARY_ESPNOW=1
-D DISABLE_WIFI_OTA=1
build_flags =
${SenseCapIndicator-LoRa.build_flags}
-UMESH_PRIMARY_ESPNOW
-UDISABLE_WIFI_OTA
-D ADVERT_NAME='"Indicator Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D MOTA_HW_ID='"SenseCapIndicator-LoRa"'
-D INDICATOR_WIFI_FONT_RECOVERY=1
build_src_filter =
${SenseCapIndicator-LoRa.build_src_filter}
+<../examples/simple_repeater>
+<helpers/esp32/*.cpp>
-<helpers/esp32/ESPNOWRadio.cpp>
lib_deps =
${SenseCapIndicator-LoRa.lib_deps}
${esp32_ota.lib_deps}
; PSRAM line mode is fixed before app startup: never flash this QSPI image on
; an original OPI N8R8 Indicator merely because both use the same SX1262 pins.
[env:SenseCapIndicator-LoRa-N16R2_repeater]
extends = env:SenseCapIndicator-LoRa_repeater
board_build.arduino.memory_type = qio_qspi
board_build.flash_mode = qio
board_build.psram_type = qspi
board_upload.flash_size = 16MB
board_upload.maximum_size = 6553600
board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_6400k_preserve_spiffs.csv
build_flags =
${env:SenseCapIndicator-LoRa_repeater.build_flags}
-UMOTA_HW_ID
-D MOTA_HW_ID='"SenseCapIndicator-LoRa-N16R2"'