Files
MeshCore/test/test_host_kiss_infrastructure.py
T

273 lines
14 KiB
Python

#!/usr/bin/env python3
"""Exercise Full infrastructure console ownership without stopping RF/network service."""
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = r'''
#include <cassert>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <string>
static std::deque<unsigned char> input;
static bool kiss_active = false, reset_pending = false, network_pending = false;
static bool inject_late_fend=false;
static unsigned reset_reads = 0, kiss_resets = 0, ascii_commands = 0;
static unsigned board_loops = 0, ethernet_loops = 0, network_commands = 0;
static unsigned serial_cancels = 0, terminal_drains = 0;
struct Stream {
int available() const {
if(inject_late_fend){inject_late_fend=false;input={0xc0,0x00,0x41,0xc0};}
return int(input.size());
}
int peek()const{return input.empty()?-1:input.front();}
int read() {
assert(!input.empty() && input.front() != 0xc0);
int value = input.front(); input.pop_front(); return value;
}
void print(char) { assert(!kiss_active); }
void println(const char*) { assert(!kiss_active); }
void printf(const char*, ...) { assert(!kiss_active); }
} console;
namespace mesh {
void serviceUsbLoggingPort() {}
void serviceUsbTerminalPort() { assert(!kiss_active); ++terminal_drains; }
bool takeUsbTerminalSessionReset() { ++reset_reads; bool value=reset_pending; reset_pending=false; return value; }
bool tryCompleteUsbTerminalSessionReset() { return true; }
bool canAcceptUsbConsoleCommand() { return true; }
Stream& usbConsolePort() { return console; }
void noteUsbLoggingStatsCommand(const char*) {}
void noteUsbConsoleCommand(const char*) {}
namespace power { void serviceRegulatorSettings() {} }
#if MESH_ENABLE_HOST_KISS
namespace hostkiss {
void resetUsbSession() { ++kiss_resets; kiss_active=false; }
bool usbInputReserved(int next){return kiss_active || next==0xc0;}
bool serviceUsb() {
if (!input.empty() && input.front()==0xc0) {
input.pop_front(); kiss_active=true;
}
bool owned=kiss_active;
if (kiss_active) {
while (!input.empty()) {
unsigned char value=input.front(); input.pop_front();
if (value==0xff) { kiss_active=false; break; }
}
}
return owned;
}
}
#endif
}
struct Mesh {
bool hasPendingSerialOutput() { return false; }
void cancelPendingSerialOutput() { ++serial_cancels; }
void handleUsbCommand(const char* text, char* reply) {
assert(!kiss_active && !strcmp(text,"ver")); ++ascii_commands; strcpy(reply,"version");
}
void cancelLocalOutput(Stream&) {}
bool hasPendingLocalOutput() { return false; }
void handleLocalCommand(const char* text, char* reply, Stream&) {
assert(!strcmp(text,"get kiss")); ++network_commands; strcpy(reply,"status");
}
} the_mesh;
struct Board { void loop() { ++board_loops; } } board;
struct Ethernet : Stream { bool connected() { return true; } } ethernet_client;
static char ethernet_command[160];
void ethernet_loop_maintain() { ++ethernet_loops; }
bool ethernet_take_session_reset() { return false; }
bool ethernet_read_line(char* dest, size_t) {
if (!network_pending) return false;
network_pending=false; strcpy(dest,"get kiss"); return true;
}
bool ethernet_handle_command(const char*, char*) { return false; }
void ethernet_send_reply(const char* text) { assert(!strcmp(text,"status")); }
static constexpr size_t LOCAL_SERIAL_COMMAND_MAX=159;
static char command[LOCAL_SERIAL_COMMAND_MAX+2] = {};
static bool command_overflow=false;
@PUMP@
static void ascii() { for (unsigned char c : std::string("ver\r")) input.push_back(c); }
int main() {
ascii(); serviceCommandInterfaces();
assert(ascii_commands==1 && board_loops==1 && ethernet_loops==1);
#if MESH_ENABLE_HOST_KISS
strcpy(command,"old prefix"); command_overflow=true;
input={0xc0,0x00,0x41,0x42}; network_pending=true;
unsigned old_drains=terminal_drains;
serviceCommandInterfaces();
assert(kiss_active && input.empty());
assert(command[0]==0 && !command_overflow && ascii_commands==1);
assert(board_loops==2 && ethernet_loops==2 && network_commands==1);
assert(terminal_drains==old_drains);
#if MESH_USB_CONSOLE_COOPERATIVE
assert(serial_cancels>0);
#endif
// A binary session with no new USB bytes must keep ownership and service
// unrelated network commands. The owner must not return out of the pump.
network_pending=true; serviceCommandInterfaces();
assert(kiss_active && board_loops==3 && ethernet_loops==3 && network_commands==2);
assert(terminal_drains==old_drains);
// EXIT returns ownership but its poll still bypasses the ASCII parser.
input={0xff}; serviceCommandInterfaces();
assert(!kiss_active && ascii_commands==1);
ascii(); serviceCommandInterfaces(); assert(ascii_commands==2);
#if MESH_USB_CONSOLE_COOPERATIVE
input={0xc0,0x00,0x43}; serviceCommandInterfaces(); assert(kiss_active);
reset_pending=true;
unsigned previous_resets=reset_reads;
strcpy(command,"stale"); command_overflow=true;
serviceCommandInterfaces();
assert(!kiss_active && kiss_resets==1 && reset_reads==previous_resets+1);
assert(command[0]==0 && !command_overflow);
ascii(); serviceCommandInterfaces(); assert(ascii_commands==3);
#endif
// The first FEND arrives after serviceUsb() returned false this poll.
inject_late_fend=true;const unsigned prior_commands=ascii_commands;
serviceCommandInterfaces();
assert(!kiss_active && !input.empty() && input.front()==0xc0 && ascii_commands==prior_commands);
serviceCommandInterfaces();assert(kiss_active && input.empty());
#endif
}
'''
class HostKissInfrastructureTest(unittest.TestCase):
def test_release_builder_selects_only_full_supported_roles(self):
builder = (ROOT / "build_legacy.sh").read_text()
promotion_start = builder.index(' if [ "$ESP32_FULL_BUILD" = "1" ] \\\n || is_companion_radio_full_target "$env_name"; then')
promotion_end = builder.index('\n fi', promotion_start) + len('\n fi')
kiss_start = builder.index(' # Full LoRa images retain their normal role')
kiss_end = builder.index(' declare_build_capability_contract ', kiss_start)
policy = builder[promotion_start:promotion_end] + '\n' + builder[kiss_start:kiss_end]
# Execute the release builder's actual promotion and feature-selection
# blocks, using its target classifiers. PlatformIO must never run here.
script = r'''
source "$1/build_legacy.sh"
pio() { printf 'Unexpected PlatformIO invocation\n' >&2; return 99; }
apply_policy() {
local env_name=$1 env_platform=$2 BUILD_PROFILE_FOR_TARGET=$3
local NRF52_OTA_SENSOR_PROFILE=$4 ESP32_FULL_BUILD=$5
local PLATFORMIO_BUILD_FLAGS='-DEXISTING_FLAG=1'
PIO_ENV_PLATFORM_BY_NAME[$env_name]=$env_platform
@POLICY@
case "$PLATFORMIO_BUILD_FLAGS" in
*-DMESH_ENABLE_HOST_KISS=1*) actual=1 ;;
*) actual=0 ;;
esac
if [ "$actual" != "$6" ]; then
printf 'Incorrect KISS build policy: %s/%s/%s/%s -> %s, expected %s\n' \
"$env_name" "$env_platform" "$BUILD_PROFILE_FOR_TARGET" \
"$NRF52_OTA_SENSOR_PROFILE" "$actual" "$6" >&2
return 1
fi
}
'''.replace("@POLICY@", policy)
cases = (
("heltec_v3_repeater", "ESP32_PLATFORM", "full", "", "1", "1"),
("heltec_v4_companion_radio_full", "ESP32_PLATFORM", "standard", "", "0", "1"),
("heltec_v3_repeater", "ESP32_PLATFORM", "standard", "", "0", "0"),
("heltec_v3_sensor", "ESP32_PLATFORM", "reduced", "", "0", "0"),
("RAK_4631_companion_radio_full", "NRF52_PLATFORM", "standard", "", "0", "1"),
("RAK_4631_sensor_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "full", "0", "1"),
("RAK_4631_sensor_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "reduced", "0", "0"),
("RAK_3401_repeater_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "full", "0", "1"),
("RAK_3401_room_server_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "reduced", "0", "0"),
("RAK_4631_repeater_rak15002_sd_seeder_only", "NRF52_PLATFORM", "auto", "full", "0", "0"),
("heltec_v3_kiss_modem", "ESP32_PLATFORM", "full", "", "1", "0"),
("wio_e5_repeater", "STM32_PLATFORM", "full", "", "0", "0"),
("xiao_rp2040_repeater", "RP2040_PLATFORM", "full", "", "0", "0"),
)
for case in cases:
script += "apply_policy " + shlex.join(case) + " || exit $?\n"
result = subprocess.run(["bash", "-c", script, "host-kiss-policy", str(ROOT)],
text=True, capture_output=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("Unexpected PlatformIO invocation", result.stdout + result.stderr)
def test_actual_repeater_console_preserves_network_and_housekeeping(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
pump = body((ROOT / "examples/simple_repeater/main.cpp").read_text(),
"static void __attribute__((noinline)) serviceCommandInterfaces()")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
for enabled, cooperative, lose_owner in ((0, 0, False), (0, 1, False),
(1, 0, False), (1, 1, False),
(1, 1, True), (1, 1, "late")):
with self.subTest(enabled=enabled, cooperative=cooperative, lose_owner=lose_owner):
candidate = pump
if lose_owner == "late":
candidate = candidate.replace(
" if (kiss_can_read() && mesh::hostkiss::usbInputReserved(console.peek())) break;\n", "")
self.assertNotEqual(candidate, pump)
elif lose_owner:
candidate = candidate.replace("if (kiss_can_read() && mesh::hostkiss::serviceUsb())",
"if (false && kiss_can_read() && mesh::hostkiss::serviceUsb())")
self.assertNotEqual(candidate, pump)
with tempfile.TemporaryDirectory(prefix="host-kiss-infra-") as directory:
directory = Path(directory)
(directory / "test.cpp").write_text(FIXTURE.replace("@PUMP@", candidate), encoding="ascii")
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", *sanitizers,
"-DMESH_ENABLE_HOST_KISS=" + str(enabled),
"-DMESH_USB_CONSOLE_COOPERATIVE=" + str(cooperative), "-DETHERNET_ENABLED=1",
str(directory / "test.cpp"), "-o", str(directory / "test")],
text=True, capture_output=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(directory / "test")], text=True,
capture_output=True, timeout=10)
if lose_owner:
self.assertNotEqual(result.returncode, 0,
"Negative control did not lose KISS ownership")
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_all_infrastructure_roles_claim_before_terminal_output_and_keep_rf_loop(self):
for role in ("simple_repeater", "simple_room_server", "simple_sensor"):
with self.subTest(role=role):
source = (ROOT / "examples" / role / "main.cpp").read_text()
signature = ("static void __attribute__((noinline)) serviceCommandInterfaces()"
if role == "simple_repeater" else "void loop()")
pump = body(source, signature)
self.assertEqual(pump.count("takeUsbTerminalSessionReset()"), 1)
self.assertLess(pump.index("mesh::hostkiss::resetUsbSession()"),
pump.index("mesh::hostkiss::serviceUsb()"))
self.assertLess(pump.index("mesh::hostkiss::serviceUsb()"),
pump.index("mesh::serviceUsbTerminalPort()"))
self.assertIn("usb_ready = false;", pump)
loop = body(source, "void loop()")
self.assertIn("the_mesh.loop();", loop)
self.assertIn("if (!mesh::hostkiss::usbActive())", loop)
self.assertIn("sleep_guard(!mesh::hostkiss::usbActive()", loop)
recovery = body(source, "static bool usbLoggingRecoverySafe(")
self.assertIn("if (mesh::hostkiss::usbActive()) return false;", recovery)
def test_common_binding_routing_and_loop_use_loaded_radio_profiles(self):
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
management = (ROOT / "src/helpers/CommonCLI_Management.cpp").read_text()
begin = body(management, "void CommonCLI::beginManagement(")
self.assertIn("mesh::hostkiss::begin(fs, *radio, _radio_profiles, mesh);", begin)
command = body(common, "void CommonCLI::handleCommand(")
self.assertIn("mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)", command)
loop = body(common, "void CommonCLI::loop()")
guarded = body(loop, "if (!mesh::morseTimingActive(")
self.assertIn("mesh::hostkiss::loop();", guarded)
self.assertLess(guarded.index("_radio_profiles.loop();"),
guarded.index("mesh::hostkiss::loop();"))
if __name__ == "__main__":
unittest.main()