#!/usr/bin/env python3 """Static safety contracts for nRF52840 variants with incompatible layouts.""" from pathlib import Path import re import unittest ROOT = Path(__file__).resolve().parents[1] def ini_section(source: str, name: str) -> str: """Return one PlatformIO section without resolving its inheritance.""" match = re.search( rf"^\[{re.escape(name)}\]\s*$\n(?P.*?)(?=^\[|\Z)", source, re.MULTILINE | re.DOTALL, ) if match is None: raise AssertionError(f"missing PlatformIO section [{name}]") return match.group("body") def hex_define(source: str, name: str) -> int: """Read a simple hexadecimal preprocessor address definition.""" match = re.search( rf"^\s*#define\s+{re.escape(name)}\s+(0x[0-9A-Fa-f]+)\b", source, re.MULTILINE, ) if match is None: raise AssertionError(f"missing hexadecimal definition for {name}") return int(match.group(1), 16) class Nrf52VariantContractsTest(unittest.TestCase): def test_rak3401_w25q16_handoff_keeps_rak13302_and_gps_isolated(self) -> None: shared = (ROOT / "platformio.ini").read_text() recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() variant = (ROOT / "variants/rak3401/variant.h").read_text() store_h = (ROOT / "src/helpers/ota/OtaStoreQspiNrf52.h").read_text() store_cpp = (ROOT / "src/helpers/ota/OtaStoreQspiNrf52.cpp").read_text() flash = ini_section(shared, "nrf52_wisblock_w25q16_ota") for define in ( "OTA_QSPI_SCK_ARDUINO_PIN=3", "OTA_QSPI_CS_ARDUINO_PIN=31", "OTA_QSPI_IO0_ARDUINO_PIN=30", "OTA_QSPI_IO1_ARDUINO_PIN=29", ): self.assertIn(define, flash) profile = ini_section( recipe, "env:RAK_3401_repeater_rak13302_w25q16_lora_ota" ) self.assertIn("OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF=1", profile) self.assertNotRegex(profile, r"-D\s+ENV_INCLUDE_GPS=0\b") self.assertRegex(variant, r"#define\s+SX126X_CS\s+\(26\)") self.assertRegex(variant, r"#define\s+PIN_SERIAL1_RX\s+\(15\)") self.assertRegex(variant, r"#define\s+PIN_SERIAL1_TX\s+\(16\)") self.assertRegex(variant, r"#define\s+PIN_GPS_PPS\s+\(17\)") self.assertIn( "RAK3401 W25 flash and RAK13302 radio must use different chip-selects", store_h, ) acquire = store_cpp.split("static void acquire_rak3401_radio_bus()", 1)[1] acquire = acquire.split("static void release_rak3401_radio_bus()", 1)[0] self.assertLess( acquire.index("digitalWrite(P_LORA_NSS, HIGH)"), acquire.index("SPI1.end()"), ) release = store_cpp.split("static void release_rak3401_radio_bus()", 1)[1] release = release.split("#endif", 1)[0] self.assertLess(release.index("disconnect_qspi_pins()"), release.index("SPI1.begin()")) def test_rak3401_wisblock_i2c_aliases_are_complete(self) -> None: variant = (ROOT / "variants/rak3401/variant.h").read_text() self.assertRegex(variant, r"#define\s+WB_I2C1_SDA\s+\(13\)") self.assertRegex(variant, r"#define\s+WB_I2C1_SCL\s+\(14\)") self.assertRegex( variant, r"#define\s+PIN_WIRE_SDA\s+\(WB_I2C1_SDA\)" ) self.assertRegex( variant, r"#define\s+PIN_WIRE_SCL\s+\(WB_I2C1_SCL\)" ) self.assertRegex(variant, r"#define\s+PIN_BOARD_SDA\s+PIN_WIRE_SDA") self.assertRegex(variant, r"#define\s+PIN_BOARD_SCL\s+PIN_WIRE_SCL") def test_rak3401_gps_uart_is_cross_connected(self) -> None: variant = (ROOT / "variants/rak3401/variant.h").read_text() self.assertRegex(variant, r"#define\s+PIN_SERIAL1_RX\s+\(15\)") self.assertRegex(variant, r"#define\s+PIN_SERIAL1_TX\s+\(16\)") self.assertRegex(variant, r"#define\s+PIN_GPS_RX\s+PIN_SERIAL1_TX") self.assertRegex(variant, r"#define\s+PIN_GPS_TX\s+PIN_SERIAL1_RX") def test_rak3401_bsec_opt_in_requires_hard_float_recipe(self) -> None: recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() shared = (ROOT / "platformio.ini").read_text() sensor_base = re.search( r"^\[sensor_base\]\s*$\n(?P.*?)(?=^\[|\Z)", shared, re.MULTILINE | re.DOTALL, ) self.assertIsNotNone(sensor_base) effective_recipe = sensor_base.group("body") + recipe if re.search( r"^\s*-D\s*ENV_INCLUDE_BME680_BSEC(?:=1)?\s*$", effective_recipe, re.MULTILINE, ): self.assertIn("fix_bsec_lib.py", recipe) self.assertIn("BSEC Software Library", recipe) def test_rak3401_reduced_profile_names_its_narrow_sensor_trim(self) -> None: recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() shared = (ROOT / "platformio.ini").read_text() build_script = (ROOT / "build_legacy.sh").read_text() expected_ina = { "ENV_INCLUDE_INA219", "ENV_INCLUDE_INA226", "ENV_INCLUDE_INA260", "ENV_INCLUDE_INA3221", } voltage_monitor_section = ini_section(shared, "i2c_voltage_monitor_base") enabled_monitors = set( re.findall( r"-D\s+(ENV_INCLUDE_[A-Z0-9_]+)=1\b", voltage_monitor_section, ) ) self.assertEqual(enabled_monitors, expected_ina) reduced_sensor_section = ini_section( shared, "nrf52_reduced_sensors_keep_ina_gps" ) self.assertIn( "${nrf52_external_environmental_sensor_trim.build_flags}", reduced_sensor_section, ) self.assertIn( "${i2c_voltage_monitor_base.build_flags}", reduced_sensor_section ) self.assertNotRegex(reduced_sensor_section, r"-U\s*ENV_INCLUDE_GPS\b") reduced_target = ini_section( recipe, "env:RAK_3401_repeater_lora_ota_no_external_sensors" ) self.assertIn( "${nrf52_reduced_sensors_keep_ina_gps.build_flags}", reduced_target ) monitor_flags = re.search( r'^\s*local voltage_monitor_flags="(?P[^"]+)"', build_script, re.MULTILINE, ) self.assertIsNotNone(monitor_flags) self.assertEqual(set(monitor_flags.group("flags").split()), expected_ina) self.assertIn( "selected optional environmental/ranging drivers omitted", build_script, ) self.assertIn("board display/RTC/GPS retained", build_script) self.assertIn("GPS conflicts with RS-232 on Serial1", build_script) self.assertNotIn("sensors.external omitted except", build_script) self.assertIn("environmental telemetry sensors", recipe) self.assertIn("both RAK12500 I2C and RAK12501 UART GPS paths", recipe) def test_rak3401_reduced_profile_retains_non_ina_i2c_peripherals(self) -> None: recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() target = (ROOT / "variants/rak3401/target.cpp").read_text() sensors = ( ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" ).read_text() rtc = (ROOT / "src/helpers/AutoDiscoverRTCClock.cpp").read_text() display = (ROOT / "src/helpers/ui/SSD1306Display.h").read_text() reduced_target = ini_section( recipe, "env:RAK_3401_repeater_lora_ota_no_external_sensors" ) self.assertRegex(reduced_target, r"-D\s+DISPLAY_CLASS=SSD1306Display\b") self.assertIn("SparkFun u-blox GNSS Arduino Library", reduced_target) self.assertIn("AutoDiscoverRTCClock rtc_clock", target) self.assertIn("rtc_clock.begin(Wire)", target) self.assertIn("MicroNMEALocationProvider(Serial1, &rtc_clock)", target) self.assertIn( "ublox_GNSS.begin(Wire, gps_i2c_address)", sensors ) self.assertIn( "shouldSkipSensorAtClaimedGpsAddress(", sensors ) self.assertIn("i2cGPSFlag, TELEM_WIRE == &Wire", sensors) self.assertIn( "probeIna3221Identity(&Wire, gps_i2c_address)", sensors ) self.assertIn("static_cast(TELEM_RAK12500_ADDRESS)", sensors) self.assertIn("I2cRegisterProbeStatus::Inconclusive", sensors) self.assertIn("serialHasValidGpsSentence(Serial1", sensors) self.assertNotIn("else if (Serial1.available())", sensors) self.assertEqual(hex_define(display, "DISPLAY_ADDRESS"), 0x3C) self.assertEqual( { "DS3231": hex_define(rtc, "DS3231_ADDRESS"), "RV3028": hex_define(rtc, "RV3028_ADDRESS"), "PCF8563": hex_define(rtc, "PCF8563_ADDRESS"), "RX8130CE": hex_define(rtc, "RX8130CE_ADDRESS"), }, { "DS3231": 0x68, "RV3028": 0x52, "PCF8563": 0x51, "RX8130CE": 0x32, }, ) def test_i2c_recovery_uses_board_remapped_primary_pins(self) -> None: recipe = (ROOT / "variants/promicro/platformio.ini").read_text() board = (ROOT / "variants/promicro/PromicroBoard.cpp").read_text() sensors = ( ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" ).read_text() self.assertRegex(recipe, r"-D\s+PIN_BOARD_SDA=8\b") self.assertRegex(recipe, r"-D\s+PIN_BOARD_SCL=7\b") self.assertIn("Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL)", board) board_pin_branch = sensors.index( "defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL)" ) variant_pin_branch = sensors.index( "defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL)" ) self.assertLess(board_pin_branch, variant_pin_branch) self.assertIn("Wire.setPins(static_cast(sda)", sensors) def test_rak3401_rak12500_ina3221_address_collision_is_explicit(self) -> None: recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() sensors = ( ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" ).read_text() gps_address = hex_define(sensors, "TELEM_RAK12500_ADDRESS") ina3221_address = hex_define(sensors, "TELEM_INA3221_ADDRESS") self.assertEqual(gps_address, 0x42) self.assertEqual(ina3221_address, 0x42) self.assertEqual(gps_address, ina3221_address) self.assertIn( "RAK12500 and the configured INA3221 both use I2C address 0x42", recipe, ) self.assertIn("cannot be installed", recipe) self.assertIn("strap INA3221 A0 to SCL for 0x43", recipe) self.assertIn("-DTELEM_INA3221_ADDRESS=0x43", recipe) def test_rak4631_shared_sensor_rail_is_owned_by_boot_policy(self) -> None: recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() rak3401_recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() board = (ROOT / "variants/rak4631/RAK4631Board.cpp").read_text() sensor_header = ( ROOT / "src/helpers/sensors/EnvironmentSensorManager.h" ).read_text() policy = ( ROOT / "src/helpers/sensors/WisBlockSharedPowerPolicy.h" ).read_text() prefs = (ROOT / "src/helpers/PeripheralModePrefs.h").read_text() for current_recipe in (recipe, rak3401_recipe): self.assertNotRegex(current_recipe, r"-D\s+FORCE_GPS_ALIVE\b") self.assertIn("peripherals.mode defaults to gps-only", recipe) self.assertIn("pinMode(WB_IO2, OUTPUT)", board) self.assertIn("digitalWrite(WB_IO2, HIGH)", board) self.assertIn("WisBlockSharedPowerPolicy _wisblock_power", sensor_header) self.assertIn("_state(DiscoveryActive)", policy) self.assertIn("GpsOnly = 0", policy) self.assertIn("Shared = 1", policy) self.assertIn("#if defined(RAK_3401)", policy) self.assertIn("| RadioPower", policy) self.assertIn("defined(ETHERNET_ENABLED)", policy) self.assertIn("| EthernetPower", policy) self.assertIn("ExternalStorage =", policy) self.assertIn("get peripherals.mode", prefs) self.assertIn("set peripherals.mode", prefs) self.assertIn("saved; reboot to apply", prefs) def test_rak4631_ethernet_does_not_suppress_shared_rail_gps(self) -> None: recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() sensors = ( ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" ).read_text() ethernet = ini_section(recipe, "env:RAK_4631_repeater_ethernet") self.assertRegex(ethernet, r"-D\s+ETHERNET_ENABLED=1\b") self.assertNotIn( "#if defined(ETHERNET_ENABLED) && defined(RAK_BOARD)", sensors ) self.assertNotIn("if (ioPin == WB_IO2)", sensors) self.assertIn( "setRakGpsControl(ioPin, shared_power_rail, true)", sensors ) def test_rak4631_runtime_bridge_arbitrates_only_uart_gps(self) -> None: recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() mesh_header = ( ROOT / "examples/simple_repeater/MyMesh.h" ).read_text() sensor_header = ( ROOT / "src/helpers/sensors/EnvironmentSensorManager.h" ).read_text() sensors = ( ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" ).read_text() cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() begin = mesh_header[ mesh_header.index("bool beginRS232Bridge()"): mesh_header.index("bool endRS232Bridge()") ] end = mesh_header[ mesh_header.index("bool endRS232Bridge()"): mesh_header.index("bool setBridgeState(bool enable)") ] can_yield = sensors[ sensors.index("bool EnvironmentSensorManager::gpsSerialTransportCanYield"): sensors.index("bool EnvironmentSensorManager::setGpsSerialTransportBlocked") ] block = sensors[ sensors.index("bool EnvironmentSensorManager::setGpsSerialTransportBlocked"): sensors.index("#if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC)") ] self.assertLess( begin.index("setGpsSerialTransportBlocked(selected_uart, true)"), begin.index("bridge->begin()"), ) self.assertLess( begin.index("active_rs232_bridge_uart = selected_uart"), begin.index("bridge->begin()"), ) failed_start = begin[ begin.index("if (!bridge->isRunning())"): begin.index( "active_rs232_bridge_uart = selected_uart", begin.index("bridge->begin()"), ) ] self.assertIn( "if (sensors.setGpsSerialTransportBlocked(selected_uart, false))", failed_start, ) self.assertIn("active_rs232_bridge_uart = 0", failed_start) self.assertLess( begin.index("gpsSerialTransportMayConflict(selected_uart)"), begin.index("bridge->begin()"), ) self.assertLess( end.index("if (bridge && bridge->isRunning())"), end.index("bridge->end()"), ) self.assertLess( end.index("bridge->end()"), end.index("setGpsSerialTransportBlocked(released_uart, false)"), ) self.assertIn("gpsUsesSerialUart(uint8_t uart) const override", sensor_header) self.assertIn( "gpsSerialTransportMayConflict(uint8_t uart) const override", sensor_header, ) self.assertIn("-D WITH_RS232_BRIDGE_GPS_CONFLICT_UART=1", recipe) self.assertIn( "if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) return true;", sensors, ) self.assertIn( "return uart == 1 && gps_detected && gps_serial_transport", sensors ) shared_rail_guard = ( "#if defined(RAK_WISBLOCK_GPS) " "&& !(defined(PIN_GPS_EN) && PIN_GPS_EN >= 0)" ) self.assertIn(shared_rail_guard, can_yield) shared_rak = can_yield[ can_yield.index(shared_rail_guard): can_yield.index("#endif", can_yield.index(shared_rail_guard)) ] self.assertIn("return false;", shared_rak) self.assertNotIn("FORCE_GPS_ALIVE", can_yield) refusal = block.index( "if (blocked && !gpsSerialTransportCanYield(uart)) return false;" ) for mutation in ( "setGpsTelemetryTransportAvailable(false)", "gps_serial_transport_blocked = true", "stop_gps()", "Serial1.end()", ): self.assertLess(refusal, block.index(mutation)) self.assertIn("setGpsTelemetryTransportAvailable(false)", sensors) self.assertIn("setGpsTelemetryTransportAvailable(true)", sensors) self.assertNotIn( "turn the RS232 bridge off or select another UART first", cli ) self.assertNotIn("Error: turn GPS off or select another UART first", cli) def test_bridge_reports_runtime_state_and_rolls_back_failed_changes(self) -> None: implementation = ( ROOT / "examples/simple_repeater/MyMesh.cpp" ).read_text() header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() self.assertIn("bool isBridgeRunning() const override", header) self.assertIn("if (isRs232BridgeRunning()) reply_data[8] |= 0x01", implementation) self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x03", implementation) boot_failure = implementation[ implementation.index("if (!bridge || !beginRS232Bridge())"): implementation.index("#else", implementation.index( "if (!bridge || !beginRS232Bridge())" )) ] self.assertIn("setRs232BridgeState(false)", boot_failure) self.assertNotIn("_prefs.bridge_enabled", boot_failure) self.assertIn("parseOnOffStrict(&config[15], enable)", cli) self.assertIn("parseUnsignedIntegerStrict(&config[12], baud)", cli) self.assertIn("_prefs->bridge_baud = previous_baud", cli) self.assertIn("_prefs->bridge_uart = previous_uart", cli) self.assertIn("bridge state change failed; setting unchanged", cli) self.assertIn('configKeyEquals(config, "bridge.running")', cli) self.assertIn('_prefs->bridge_pkt_src ? "rx" : "tx"', cli) self.assertIn("!defined(RS232_BRIDGE_DEFAULT_ON)", cli) def test_default_on_rs232_profile_retains_legacy_alias_and_checks_new_intent(self) -> None: cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text() dedicated = ini_section( xiao, "env:solarxiao_30S_repeater_bridge_rs232" ) self.assertIn("extends = env:solarxiao_30S_repeater", dedicated) self.assertRegex(dedicated, r"-D\s+RS232_BRIDGE_DEFAULT_ON=1\b") # Dedicated compatibility profiles retain their old primary UART alias; # every current UART profile checks the independent marker before a # newer MQTT image can donate intent. Executable nRF52/default-on and # cross-profile round trips are covered by test_rs232_runtime.py. loader_start = cli.index("void CommonCLI::loadPrefsInt(") loader_end = cli.index("static bool writeCommonPrefsImage(", loader_start) binary_loader = cli[loader_start:loader_end] self.assertTrue("has_runtime_rs232_intent = rs232_profile == 0xB1" in binary_loader) self.assertTrue("&& rs232_intent <= 1" in binary_loader) self.assertTrue("if (!has_runtime_rs232_intent)" in binary_loader) self.assertTrue("legacy_uart_intent" in binary_loader) self.assertTrue("_prefs->bridge_enabled = _prefs->rs232_bridge_enabled;" in binary_loader) def test_gat562_30s_gps_enable_and_buzzer_pins_are_distinct(self) -> None: recipe = ( ROOT / "variants/gat562_30s_mesh_kit/platformio.ini" ).read_text() variant = (ROOT / "variants/gat562_30s_mesh_kit/variant.h").read_text() # GAT562 30S Mesh KIT V1.1 schematic: IO2 is nRF P1.01 (Arduino # GPIO 33) and drives the GPS supply; BEE_EN is P1.02 (GPIO 34). gps_pin = re.search( r"#define\s+PIN_GPS_EN\s+\((\d+)\)", variant ) buzzer_pin = re.search(r"-D\s+PIN_BUZZER=(\d+)\b", recipe) self.assertIsNotNone(gps_pin) self.assertIsNotNone(buzzer_pin) self.assertEqual(int(gps_pin.group(1)), 33) self.assertEqual(int(buzzer_pin.group(1)), 34) self.assertNotEqual(gps_pin.group(1), buzzer_pin.group(1)) def test_xiao_and_mesh_pocket_softdevice_layouts_are_not_interchangeable( self, ) -> None: xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text() mesh_pocket = (ROOT / "variants/mesh_pocket/platformio.ini").read_text() self.assertRegex( xiao, re.compile( r"^\[Xiao_nrf52\].*?^board_build\.ldscript\s*=\s*" r"boards/nrf52840_s140_v7\.ld$", re.MULTILINE | re.DOTALL, ), ) self.assertRegex( mesh_pocket, re.compile( r"^\[Mesh_pocket\].*?^board_build\.ldscript\s*=\s*" r"boards/nrf52840_s140_v6\.ld$", re.MULTILINE | re.DOTALL, ), ) if __name__ == "__main__": unittest.main()