diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 4ec2aef61..fbe3ffc72 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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: | diff --git a/build_legacy.sh b/build_legacy.sh index 80c95fd0b..4bab183fd 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -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 diff --git a/docs/udp_bridge_setup.md b/docs/udp_bridge_setup.md index 1101a8c9a..cdf71d8f3 100644 --- a/docs/udp_bridge_setup.md +++ b/docs/udp_bridge_setup.md @@ -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 diff --git a/test/test_indicator_repeater_profile.py b/test/test_indicator_repeater_profile.py new file mode 100644 index 000000000..3419e35d1 --- /dev/null +++ b/test/test_indicator_repeater_profile.py @@ -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() diff --git a/variants/sensecap_indicator-espnow/platformio.ini b/variants/sensecap_indicator-espnow/platformio.ini index fa8a06640..60d74df50 100644 --- a/variants/sensecap_indicator-espnow/platformio.ini +++ b/variants/sensecap_indicator-espnow/platformio.ini @@ -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> + + + - +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"'