From cb3c92a8da9b94451093dfd2be9ee623ded05a14 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 26 Oct 2026 16:57:49 -0700 Subject: [PATCH] Add saved raw radio2 KISS host sessions to Full firmware --- .github/workflows/run-unit-tests.yml | 4 + build_legacy.sh | 14 + docs/cli_commands.md | 33 ++ docs/kiss_modem_protocol.md | 66 +++ docs/radio_profiles.md | 8 + examples/companion_radio/MyMesh.cpp | 11 + examples/companion_radio/MyMesh.h | 4 + examples/companion_radio/main.cpp | 81 +++- examples/simple_repeater/main.cpp | 32 +- examples/simple_room_server/main.cpp | 29 +- examples/simple_sensor/main.cpp | 30 +- src/Dispatcher.cpp | 16 + src/Dispatcher.h | 14 + src/RadioProfiles.h | 4 +- src/helpers/CommonCLI.cpp | 7 + src/helpers/CommonCLI_Management.cpp | 8 + src/helpers/HostKiss.cpp | 456 +++++++++++++++++++ src/helpers/HostKiss.h | 37 ++ src/helpers/HostKissCodec.h | 144 ++++++ src/helpers/HostKissConfig.h | 20 + src/helpers/HostKissHooks.h | 17 + src/helpers/HostKissSettings.cpp | 76 ++++ src/helpers/HostKissSettings.h | 27 ++ src/helpers/MeshtextConfig.h | 3 +- src/helpers/RadioProfileCLI.cpp | 37 +- src/helpers/RadioProfileCLI.h | 2 +- src/helpers/UsbBinaryTextGuard.h | 57 +++ src/helpers/UsbLogging.cpp | 90 +++- src/helpers/UsbLogging.h | 16 +- src/helpers/radiolib/CustomLLCC68Wrapper.h | 11 +- src/helpers/radiolib/CustomLR1110Wrapper.h | 11 +- src/helpers/radiolib/CustomLR1121Wrapper.h | 11 +- src/helpers/radiolib/CustomLR2021Wrapper.h | 11 +- src/helpers/radiolib/CustomSTM32WLxWrapper.h | 8 +- src/helpers/radiolib/CustomSX1262Wrapper.h | 11 +- src/helpers/radiolib/CustomSX1268Wrapper.h | 11 +- src/helpers/radiolib/CustomSX1276Wrapper.h | 9 +- src/helpers/radiolib/RadioLibWrappers.cpp | 31 +- src/helpers/radiolib/RadioLibWrappers.h | 2 +- test/test_companion_packet_stream.py | 3 +- test/test_companion_usb_default.py | 2 + test/test_companion_usb_input.py | 2 + test/test_companion_usb_logging_reply.py | 2 + test/test_esp32_tinyusb_role_hygiene.py | 9 +- test/test_host_kiss.py | 449 ++++++++++++++++++ test/test_host_kiss_infrastructure.py | 256 +++++++++++ test/test_host_kiss_radio.py | 246 ++++++++++ test/test_host_kiss_usb.py | 386 ++++++++++++++++ test/test_nrf52_framework_logging.py | 7 +- test/test_nrf52_usb_client.py | 3 +- 50 files changed, 2750 insertions(+), 74 deletions(-) create mode 100644 src/helpers/HostKiss.cpp create mode 100644 src/helpers/HostKiss.h create mode 100644 src/helpers/HostKissCodec.h create mode 100644 src/helpers/HostKissConfig.h create mode 100644 src/helpers/HostKissHooks.h create mode 100644 src/helpers/HostKissSettings.cpp create mode 100644 src/helpers/HostKissSettings.h create mode 100644 src/helpers/UsbBinaryTextGuard.h create mode 100644 test/test_host_kiss.py create mode 100644 test/test_host_kiss_infrastructure.py create mode 100644 test/test_host_kiss_radio.py create mode 100644 test/test_host_kiss_usb.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a3251e5f6..5cdcf1452 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -578,6 +578,10 @@ jobs: python3 -B test/test_companion_channel_publication.py -v python3 -B test/test_companion_tx_raw_alias.py -v python3 -B test/test_companion_traffic_routing.py -v + python3 -B test/test_host_kiss_radio.py -v + python3 -B test/test_host_kiss_infrastructure.py -v + python3 -B test/test_host_kiss.py -v + python3 -B test/test_host_kiss_usb.py -v python3 -B test/test_ethernet_kiss.py -v python3 -B test/test_ethernet_radio.py -v python3 -B test/test_companion_ethernet.py -v diff --git a/build_legacy.sh b/build_legacy.sh index 9fd63086c..86808509b 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -5504,6 +5504,20 @@ build_firmware_one_profile() { apply_rak4631_rak15002_sd_store_profile "$env_name" apply_radio_overrides apply_firmware_profile_overrides + # Full LoRa images retain their normal role while a connected host uses + # an explicitly enabled raw radio2 KISS session. Reduced profiles omit it. + if [ "$BUILD_PROFILE_FOR_TARGET" = "full" ] \ + || { [ "$env_platform" = "NRF52_PLATFORM" ] \ + && [ "${NRF52_OTA_SENSOR_PROFILE:-}" = "full" ] \ + && is_nrf52_sensor_ota_pair_target "$env_name"; }; then + case "$env_platform" in + ESP32_PLATFORM|NRF52_PLATFORM) + if ! is_kiss_modem_target "$env_name"; then + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_ENABLE_HOST_KISS=1" + fi + ;; + esac + fi declare_build_capability_contract "$env_name" "$env_platform" if [ "$ESP32_FULL_BUILD" = "1" ] || is_esp32_companion_radio_full_target "$env_name"; then diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ff403430f..472431ae4 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1212,6 +1212,39 @@ station port 5003. It does not add an SSH server, use `tx.ssh` tags, or bridge Ethernet into MeshCore. Conflicting radio2 owners are not overwritten. See [Ethernet setup, host commands and limits](radio_profiles.md#esp32-companion-ethernet-over-lora-kiss). +## Full firmware raw radio2 KISS modem + +```text +get kiss +set kiss.transport usb +set kiss on +set kiss off +``` + +Full ESP32 and nRF52 LoRa images can keep their normal MeshCore role on +radio1 while a connected computer sends and receives raw packets on radio2. +The setting is saved and defaults to **off**. First configure an explicit +saved `radio2` tuple in `rxtx` mode. `get kiss` reports the transport, state, +packet counters and any radio2 conflict. Setting changes require a local +USB, BLE or network management connection. + +With USB selected, the normal CLI stays available until the computer sends +a KISS frame delimiter (`0xC0`). That session then owns USB; functional text +and debug logging are suppressed without changing their saved preferences. +KISS RETURN (`C0 FF C0`) or a detected USB disconnect returns to the CLI. + +On ESP32, `set kiss.transport tcp` selects a separate KISS listener on the +station WiFi address, port **5004**. It does not start WiFi or listen on the +setup AP. One computer can connect at a time. This is an unauthenticated raw +modem endpoint for a trusted LAN, like the existing Ethernet KISS service. + +KISS port 0 DATA maps only to radio2. Packets are 1-255 bytes, unchanged over +the air, using the normal private LoRa sync word, explicit headers, CRC and +normal IQ. The host must honor transmit pacing. This interface does not add +RNode framing, fragmentation or Reticulum routing; those remain host tasks. +See [Full host KISS framing and limits](kiss_modem_protocol.md#full-firmware-host-kiss) +for the wire protocol and session rules. + ## Set repeater, room-server, and sensor reply profiles ```text diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index 2fb95bca4..7480985ad 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -12,6 +12,72 @@ fast-switches between `radio` and `radio2`; it does not create a second radio. ## Serial Configuration +### Full firmware host KISS + +Full ESP32 and nRF52 LoRa firmware includes a separate, default-off raw +radio2 host service. It preserves the device's normal Companion, Repeater, +Room Server or Sensor role on radio1. It is enabled in the Full sensor/OTA +profile on nRF52; reduced infrastructure images omit the service. + +Configure the desired saved radio2 RF tuple in `rxtx` mode, then use: + +```text +set kiss.transport usb +set kiss on +get kiss +``` + +Both choices are saved. `set kiss off` stops the service and releases its +runtime radio2 protocol lease without changing the saved radio2 tuple. +Temporary radio settings and Meshtext/Ethernet radio2 owners block admission. +The service never chooses a frequency, takes another service's radio2 lease, +crosses raw packets onto radio1, or feeds them into MeshCore parsing/bridges. + +USB remains the normal CLI until an initial KISS FEND (`C0`). During the raw +session, USB Companion framing and text/debug output are parked. Send the +standard KISS RETURN frame (`C0 FF C0`) to leave the session, then issue CLI +commands normally. A detected USB disconnect/reset also ends the session. +UART-to-USB adapters without a disconnect signal may require RETURN. +If the radio2 settings change mid-session, USB stays quarantined until RETURN +or disconnect, so leftover binary bytes cannot become CLI commands. + +ESP32 also supports `set kiss.transport tcp`. Enable station WiFi separately +and connect to its address on TCP **5004**. The service accepts one client, +does not bind the setup AP, and has no authentication. Use a trusted LAN. +WiFi loss, RETURN, setting changes and invalidated radio2 generations close +the connection and discard queued host work. A second client cannot displace +an existing owner. USB remains available for management in TCP mode. + +The Full service uses standard KISS escaping and the following frames: + +| Type | Direction | Payload and meaning | +| --- | --- | --- | +| `00` | Both | Raw DATA, 1-255 bytes, exclusively radio2 | +| `0F` | Device to host | `01`: READY, initially and after each completed TX | +| `06` | Device to host | `F8 01`: TX succeeded; `F8 00`: TX failed | +| `FF` | Host to device | Empty payload: RETURN to CLI/close TCP | + +Other ports and parameter/control commands are ignored. RF parameters are +configured through the ordinary saved radio2 CLI, not RNode control commands. +Wait for READY before sending the next packet. The device retains at most +two queued host frames plus one in-flight transmission, uses bounded I/O, +and preserves normal CAD, duty budget and MeshCore transmit fairness. +`get kiss` includes RX/TX/failure/drop/malformed counters. A drop counter can +include received packets that could not fit in the bounded host output queue. + +The raw RF payload has no additional header. It uses the ordinary private +LoRa sync word (`0x12`, represented as `0x1424` by SX126x), explicit headers, +CRC and normal IQ. Peers must match RF parameters, including preamble timing +appropriate for the time-shared receiver. Only one physical radio is present. + +This is a generic host modem. Reticulum/RNode interoperability requires host +adaptation for RNode's one-byte RF header, fragmentation/reassembly of larger +packets and transmit pacing. The Full image runs no Reticulum router and +imports no RNode firmware code. The separate dedicated KISS firmware below +retains its original two-port interface and binary configuration extensions. + +### Dedicated modem serial configuration + 115200 baud, 8N1, no flow control. ## Frame Format diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 6cd2796e8..32f1589c3 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -23,6 +23,14 @@ exposes equivalent RAM-only binary `SetRadio2`/`SetTempRadio2` commands and logical KISS Data ports, documented in [the KISS modem protocol](kiss_modem_protocol.md). It does not use the Mesh CLI's persistent profile file or Mesh retry queues. +Full ESP32 and nRF52 LoRa images also offer an optional +[raw host KISS service](kiss_modem_protocol.md#full-firmware-host-kiss). +`set kiss on` enables it, default off, while radio1 retains its normal role. +It borrows an explicitly configured saved `radio2` profile for the host's +session. Meshtext, Ethernet and temporary radio2 owners cannot share that +lease. This service is a raw modem; the connected computer handles its own +network protocol and routing. + ## Quick setup Keep the primary channel configured with `radio`, then add the second: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c56c4c5e0..bd8fbb88a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -28,6 +28,7 @@ #include #include "NotificationSettingsFile.h" #include "MyMesh.h" +#include #if defined(RAK_4631) && defined(COMPANION_RADIO_FULL) \ || (defined(STM32_PLATFORM) && defined(STM32WLxx) && COMPANION_FEATURE_TEXT_TERMINAL) #include "CompanionHelpWriter.h" @@ -3388,6 +3389,9 @@ void MyMesh::begin(bool has_display, bool radio_available, #endif configureRadioFromPrefs(); +#if MESH_ENABLE_HOST_KISS + mesh::hostkiss::begin(_store->getPrimaryFS(), *_radio, _radio_profiles, *this); +#endif #if MESH_ENABLE_TX_BEACONS beginBeacons(); #endif @@ -10789,6 +10793,9 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, // router is also used by USB/BLE/browser commands without a timestamp. if (mesh::esp32::handlePowerTestCommand(command, reply, reply_capacity, sender_timestamp == 0)) return true; +#endif +#if MESH_ENABLE_HOST_KISS + if (mesh::hostkiss::handleCommand(command, reply, reply_capacity, sender_timestamp == 0)) return true; #endif if (mesh::power::handleRegulatorCommand(command, reply, reply_capacity)) return true; #if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) @@ -11164,6 +11171,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } void MyMesh::checkCLIRescueCmd() { + if (mesh::isUsbBinaryStreamOwned()) return; Stream& output = mesh::usbTerminalPort(); Stream& input = mesh::usbCompanionPort(); size_t len = strnlen(cli_command, sizeof(cli_command)); @@ -11525,6 +11533,9 @@ void MyMesh::loop() { #endif if (!mesh::morseTimingActive(_radio)) _radio_profiles.loop(); +#if MESH_ENABLE_HOST_KISS + if (!mesh::morseTimingActive(_radio)) mesh::hostkiss::loop(); +#endif #if MESH_ENABLE_TX_BEACONS if (!mesh::morseTimingActive(_radio)) loopBeacons(); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index c7e0774a4..95fb91510 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -34,6 +34,7 @@ #include #if MESH_ENABLE_ETHERNET_TUNNEL #include +#include #include #endif namespace mesh { class BeaconManager; } @@ -443,6 +444,9 @@ protected: // Direct-only OTA inherits the Dispatcher's drop/no-op foreign callbacks. bool allowForeignRadioTransmit() const override { return _radio_available && _scheduled_reboot_at == 0 && ( +#if MESH_ENABLE_HOST_KISS + mesh::hostkiss::radioActive() || +#endif #if MESH_ENABLE_MESHTEXT_CHAT _mt_active || #endif diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index c4022f39a..aaad11ab5 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -18,6 +18,7 @@ #include #include #include "MyMesh.h" +#include #include #include "CompanionBluetooth.h" #include "CompanionWireless.h" @@ -1050,9 +1051,21 @@ static bool serviceUsbMotaResponseBoundary() { // loss edge, before any protocol parser runs. No line, login, recipient, queued // text, or mOTA attachment may survive into the next host session. static bool usb_terminal_host_reset_completion_pending = false; +#if MESH_ENABLE_HOST_KISS +static bool host_kiss_usb_parked = false; +static bool host_kiss_usb_restore_enabled = false; +#endif static uint32_t usb_terminal_host_reset_retry_at = 0; static void resetUsbTerminalHostSession() { +#if MESH_ENABLE_HOST_KISS + mesh::hostkiss::resetUsbSession(); + if (host_kiss_usb_parked) { + mesh::setUsbBinaryStreamOwned(false); + if (host_kiss_usb_restore_enabled && interface_manager.isEnabled()) usb_serial_interface.enable(); + host_kiss_usb_parked = host_kiss_usb_restore_enabled = false; + } +#endif #if COMPANION_FEATURE_READER mesh::cancelReaderCommand(&mesh::usbTerminalPort()); #endif @@ -1096,6 +1109,49 @@ static void resetUsbTerminalHostSession() { #endif } +// A saved KISS feature takes USB only after an explicit KISS delimiter. +// Park this one Companion transport; BLE/WiFi and the primary mesh stay live. +static bool serviceCompanionHostKissUsb() { +#if MESH_ENABLE_HOST_KISS + const bool claimed = mesh::hostkiss::serviceUsb(); + if (claimed && mesh::hostkiss::usbActive() && !host_kiss_usb_parked) { + host_kiss_usb_restore_enabled = usb_serial_interface.isEnabled(); + cancelUsbSerialOperations(); +#if COMPANION_FEATURE_READER + mesh::cancelReaderCommand(&mesh::usbTerminalPort()); +#endif + the_mesh.resetUsbHostSessionInput(); +#if COMPANION_FEATURE_USB_MOTA_SOURCE + if (usb_mota_mode) leaveUsbMotaMode(false); + mesh::ota::OtaContext::serialFolderSource().resetSessionState(); +#endif + if (the_mesh.isTerminalMode()) leaveUsbTerminalMode(false); + usb_binary_startup_probe.cancel(); + usb_ascii_session_default.cancel(); + clearUsbTerminalLine(); + usb_terminal_discard_line = usb_logging_terminal_mode = false; + mesh::discardUsbTerminalOutput(); + usb_serial_interface.disable(); + host_kiss_usb_parked = true; + } + if (host_kiss_usb_parked && !claimed && !mesh::hostkiss::usbActive()) { + mesh::setUsbBinaryStreamOwned(false); + host_kiss_usb_parked = false; + if (host_kiss_usb_restore_enabled && interface_manager.isEnabled()) usb_serial_interface.enable(); + host_kiss_usb_restore_enabled = false; + clearUsbTerminalLine(); + usb_terminal_discard_line = false; + usb_serial_interface.resetSessionState(); + usb_ascii_session_default.request(usb_serial_interface.getCompletedFrameCount()); + } + if (host_kiss_usb_parked && claimed && !mesh::hostkiss::usbActive()) + mesh::setUsbBinaryStreamOwned(true); // RETURN retires at the next poll. + return claimed; +#else + return false; +#endif +} + static void serviceUsbTerminalHostSessionReset() { #if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \ || MESH_ESP32_TINYUSB_NONBLOCKING @@ -1392,6 +1448,7 @@ static void serviceUsbTerminal() { } static void serviceUsbLoggingOwnership(bool logging_enabled) { + if (mesh::hostkiss::usbActive()) return; // A saved logging-on preference makes the one available TTY behave like a // logging repeater: plaintext diagnostics plus an input-capable CLI. Put the // Companion interface into passthrough before it can mix framed traffic with @@ -4199,6 +4256,7 @@ static void serviceUsbLoggingOnlySession() { } static bool usbLoggingRecoverySafe(void*) { + if (mesh::hostkiss::usbActive()) return false; if (board.isOTAUpdateRunning() || board.isRadioTestActive() || radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor() || !the_mesh.canRecoverUsbLogging()) return false; @@ -4245,9 +4303,12 @@ void loop() { serviceUsbLoggingOnlySession(); #if defined(ENABLE_USB_INTERFACE) serviceUsbTerminalHostSessionReset(); - serviceUsbAsciiSessionDefault(); - serviceUsbLoggingReplyBarrier(); - serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch. + const bool host_kiss_usb_claimed = serviceCompanionHostKissUsb(); + if (!host_kiss_usb_claimed) { + serviceUsbAsciiSessionDefault(); + serviceUsbLoggingReplyBarrier(); + serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch. + } #endif // Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection // edge avoids losing the marker before the host has opened the port. @@ -4258,13 +4319,15 @@ void loop() { mesh::serviceUsbTerminalPort(); #endif #if defined(ENABLE_USB_INTERFACE) - expireUsbBinaryStartupProbeBeforeDispatch(); + if (!host_kiss_usb_claimed) { + expireUsbBinaryStartupProbeBeforeDispatch(); #if COMPANION_FEATURE_USB_MOTA_SOURCE - serviceUsbMotaResponseBoundary(); + serviceUsbMotaResponseBoundary(); #endif + } #endif #if defined(ENABLE_USB_INTERFACE) - syncUsbTerminalInputEditing(); + if (!host_kiss_usb_claimed) syncUsbTerminalInputEditing(); #endif the_mesh.loop(); #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) @@ -4286,8 +4349,10 @@ void loop() { serviceCompanionRadioRecovery(); #endif #if defined(ENABLE_USB_INTERFACE) - serviceUsbTerminal(); - syncUsbTerminalInputEditing(); + if (!host_kiss_usb_claimed) { + serviceUsbTerminal(); + syncUsbTerminalInputEditing(); + } // Commands can synchronously enqueue a multi-line reply. Give it the // remaining CDC FIFO capacity now; later loops retain any unwritten suffix. mesh::serviceUsbTerminalPort(); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 89025aa73..f74103a43 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -17,6 +17,7 @@ #endif #include #include +#include #include #include #include @@ -476,14 +477,31 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { bool usb_ready = true; #if MESH_USB_CONSOLE_COOPERATIVE mesh::serviceUsbLoggingPort(); - mesh::serviceUsbTerminalPort(); if (mesh::takeUsbTerminalSessionReset()) { +#if MESH_ENABLE_HOST_KISS + mesh::hostkiss::resetUsbSession(); +#endif command[0] = 0; command_overflow = false; the_mesh.cancelPendingSerialOutput(); } +#endif +#if MESH_ENABLE_HOST_KISS + // Claim binary input before a pending terminal listing can append text. + // Keep the rest of this function alive for Ethernet and board housekeeping. + if (mesh::hostkiss::serviceUsb()) { + command[0] = 0; + command_overflow = false; +#if MESH_USB_CONSOLE_COOPERATIVE + the_mesh.cancelPendingSerialOutput(); +#endif + usb_ready = false; + } +#endif +#if MESH_USB_CONSOLE_COOPERATIVE + if (usb_ready) mesh::serviceUsbTerminalPort(); // A busy USB session cleanup may defer the CLI, never the radio loop. - usb_ready = mesh::tryCompleteUsbTerminalSessionReset(); + usb_ready = usb_ready && mesh::tryCompleteUsbTerminalSessionReset(); // Large listings advance from MyMesh::loop without blocking radio service. usb_ready = usb_ready && !the_mesh.hasPendingSerialOutput() && mesh::canAcceptUsbConsoleCommand(); @@ -614,6 +632,9 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { } static bool usbLoggingRecoverySafe(void*) { +#if MESH_ENABLE_HOST_KISS + if (mesh::hostkiss::usbActive()) return false; +#endif if (board.isOTAUpdateRunning() || board.isRadioTestActive() || radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor() || !the_mesh.canRecoverUsbLogging()) return false; @@ -670,6 +691,9 @@ void loop() { rtc_clock.tick(); if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot(); #if MESH_USB_CONSOLE_COOPERATIVE +#if MESH_ENABLE_HOST_KISS + if (!mesh::hostkiss::usbActive()) +#endif mesh::serviceUsbTerminalPort(); #endif @@ -702,6 +726,10 @@ void loop() { && sleep_guard(!board.isUsbDataConnected(), mesh::powerdiag::Reason::UsbSession) && sleep_guard(!mesh::isUsbLoggingWatchdogArmed(), mesh::powerdiag::Reason::UsbRecovery) && sleep_guard(!mesh::wireless::control().pending(), mesh::powerdiag::Reason::Wireless); +#if MESH_ENABLE_HOST_KISS + can_power_save = can_power_save + && sleep_guard(!mesh::hostkiss::usbActive(), mesh::powerdiag::Reason::UsbSession); +#endif #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index acfa4f63b..06c64d512 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -17,6 +17,7 @@ #endif #include #include +#include #include #include #if MESH_PACKET_LOGGING @@ -331,6 +332,9 @@ void setup() { } static bool usbLoggingRecoverySafe(void*) { +#if MESH_ENABLE_HOST_KISS + if (mesh::hostkiss::usbActive()) return false; +#endif if (board.isOTAUpdateRunning() || board.isRadioTestActive() || radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor() || !the_mesh.canRecoverUsbLogging()) return false; @@ -355,12 +359,26 @@ void loop() { bool usb_ready = true; #if MESH_USB_CONSOLE_COOPERATIVE mesh::serviceUsbLoggingPort(); - mesh::serviceUsbTerminalPort(); if (mesh::takeUsbTerminalSessionReset()) { +#if MESH_ENABLE_HOST_KISS + mesh::hostkiss::resetUsbSession(); +#endif command[0] = 0; the_mesh.cancelPendingSerialOutput(); } - usb_ready = mesh::tryCompleteUsbTerminalSessionReset(); +#endif +#if MESH_ENABLE_HOST_KISS + if (mesh::hostkiss::serviceUsb()) { + command[0] = 0; +#if MESH_USB_CONSOLE_COOPERATIVE + the_mesh.cancelPendingSerialOutput(); +#endif + usb_ready = false; + } +#endif +#if MESH_USB_CONSOLE_COOPERATIVE + if (usb_ready) mesh::serviceUsbTerminalPort(); + usb_ready = usb_ready && mesh::tryCompleteUsbTerminalSessionReset(); usb_ready = usb_ready && !the_mesh.hasPendingSerialOutput() && mesh::canAcceptUsbConsoleCommand(); #endif @@ -449,6 +467,9 @@ void loop() { board.loop(); if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot(); #if MESH_USB_CONSOLE_COOPERATIVE +#if MESH_ENABLE_HOST_KISS + if (!mesh::hostkiss::usbActive()) +#endif mesh::serviceUsbTerminalPort(); #endif #ifdef TBEAM_1W @@ -477,6 +498,10 @@ void loop() { && sleep_guard(!board.isUsbDataConnected(), mesh::powerdiag::Reason::UsbSession) && sleep_guard(!mesh::isUsbLoggingWatchdogArmed(), mesh::powerdiag::Reason::UsbRecovery) && sleep_guard(!mesh::wireless::control().pending(), mesh::powerdiag::Reason::Wireless); +#if MESH_ENABLE_HOST_KISS + can_power_save = can_power_save + && sleep_guard(!mesh::hostkiss::usbActive(), mesh::powerdiag::Reason::UsbSession); +#endif #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index a6f185ac4..51b671112 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER @@ -325,6 +326,9 @@ void setup() { } static bool usbLoggingRecoverySafe(void*) { +#if MESH_ENABLE_HOST_KISS + if (mesh::hostkiss::usbActive()) return false; +#endif if (board.isOTAUpdateRunning() || board.isRadioTestActive() || radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor() || !the_mesh.canRecoverUsbLogging()) return false; @@ -343,9 +347,22 @@ void loop() { bool usb_ready = true; #if MESH_USB_CONSOLE_COOPERATIVE mesh::serviceUsbLoggingPort(); - mesh::serviceUsbTerminalPort(); - if (mesh::takeUsbTerminalSessionReset()) command[0] = 0; - usb_ready = mesh::tryCompleteUsbTerminalSessionReset() + if (mesh::takeUsbTerminalSessionReset()) { +#if MESH_ENABLE_HOST_KISS + mesh::hostkiss::resetUsbSession(); +#endif + command[0] = 0; + } +#endif +#if MESH_ENABLE_HOST_KISS + if (mesh::hostkiss::serviceUsb()) { + command[0] = 0; + usb_ready = false; + } +#endif +#if MESH_USB_CONSOLE_COOPERATIVE + if (usb_ready) mesh::serviceUsbTerminalPort(); + usb_ready = usb_ready && mesh::tryCompleteUsbTerminalSessionReset() && mesh::canAcceptUsbConsoleCommand(); #endif Stream& console = mesh::usbConsolePort(); @@ -405,6 +422,9 @@ void loop() { rtc_clock.tick(); if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot(); #if MESH_USB_CONSOLE_COOPERATIVE +#if MESH_ENABLE_HOST_KISS + if (!mesh::hostkiss::usbActive()) +#endif mesh::serviceUsbTerminalPort(); #endif #ifdef HAS_EXTERNAL_WATCHDOG @@ -430,6 +450,10 @@ void loop() { && sleep_guard(!morse_timing, mesh::powerdiag::Reason::Morse) && sleep_guard(!board.isUsbDataConnected(), mesh::powerdiag::Reason::UsbSession) && sleep_guard(!mesh::isUsbLoggingWatchdogArmed(), mesh::powerdiag::Reason::UsbRecovery); +#if MESH_ENABLE_HOST_KISS + can_power_save = can_power_save + && sleep_guard(!mesh::hostkiss::usbActive(), mesh::powerdiag::Reason::UsbSession); +#endif #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 31ccd216d..b90f9f4fe 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -1,4 +1,5 @@ #include "Dispatcher.h" +#include "helpers/HostKissHooks.h" #include "helpers/ota/OtaTiming.h" #if MESH_ENABLE_OTA_KISS_DIRECT #include "helpers/ota/OtaDirectWire.h" @@ -289,6 +290,9 @@ bool Dispatcher::supportsForeignRadioProtocol(RadioProfileProtocol protocol) con #endif #if MESH_ENABLE_ETHERNET_TUNNEL case RadioProfileProtocol::Ethernet: return _radio->supportsEthernetTunnel(); +#endif +#if MESH_ENABLE_HOST_KISS + case RadioProfileProtocol::Kiss: return _radio->supportsHostKiss(); #endif default: return false; } @@ -370,6 +374,9 @@ bool Dispatcher::queueForeignRadioFrame(const uint8_t* bytes, size_t length, void Dispatcher::finishForeignRadioFrame(bool success, bool account_airtime) { if (!hasForeignRadioFrame()) return; const bool direct_ota = foreign_radio_protocol == RadioProfileProtocol::MeshCore; +#if MESH_ENABLE_HOST_KISS + const bool host_kiss = foreign_radio_protocol == RadioProfileProtocol::Kiss; +#endif if (account_airtime) { const uint32_t elapsed = (uint32_t)_ms->getMillis() - foreign_radio_start; total_air_time += elapsed; @@ -400,6 +407,10 @@ void Dispatcher::finishForeignRadioFrame(bool success, bool account_airtime) { // either transport's application counters or trigger its callbacks. if (direct_ota) { if (success) ++n_sent_direct; +#if MESH_ENABLE_HOST_KISS + } else if (host_kiss) { + hostkiss::sendComplete(success); +#endif } else onForeignRadioSendComplete(success); } @@ -1086,6 +1097,11 @@ void Dispatcher::checkRecv() { && _radio->receiveProfileGeneration() == profiles->generation[profile]) { rx_air_time += _radio->getProfileAirtime(profile, len); ++_radio->profiles()->rx_packets[profile]; +#if MESH_ENABLE_HOST_KISS + if (profiles->params(profile).protocol == RadioProfileProtocol::Kiss) { + hostkiss::receive(raw, (size_t)len, profile, profiles->generation[profile], snr, rssi); + } else +#endif onForeignRadioFrame(raw, (size_t)len, profile, profiles->generation[profile], snr, rssi); } #endif diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 64c72c8df..a145719c3 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -114,6 +114,9 @@ public: virtual bool validateProfile(const RadioProfileParams&) const { return false; } virtual bool supportsMeshtext() const { return false; } virtual bool supportsEthernetTunnel() const { return false; } +#if MESH_ENABLE_HOST_KISS + virtual bool supportsHostKiss() const { return false; } +#endif #if MESH_ENABLE_TX_BEACONS // Check the requested antenna power at the prospective beacon frequency. // Validation must not retune the radio or alter its current power setting. @@ -678,6 +681,17 @@ public: beacon_wake_at = now + delay; beacon_wake_enabled = enabled; } +#endif +#if MESH_ENABLE_HOST_KISS + // The raw host modem can use radio2 only. It shares the bounded scheduler + // slot and normal TX admission; it never falls back onto radio1. + bool submitHostKissFrame(const uint8_t* bytes, size_t length, uint32_t generation) { + return queueForeignRadioFrame(bytes, length, 1, generation, RadioProfileProtocol::Kiss); + } + bool cancelHostKissFrame() { return discardQueuedForeignRadioFrame(RadioProfileProtocol::Kiss); } + bool hostKissFramePending() const { + return hasForeignRadioFrame() && foreign_radio_protocol == RadioProfileProtocol::Kiss; + } #endif Radio* getProfileRadio() { return _radio; } bool isDualRadioActive() const { return _radio->profiles() && _radio->profiles()->enabled(); } diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index d5fe3b90e..1d90aa824 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -9,7 +9,7 @@ namespace mesh { enum class RadioProfileMode : uint8_t { Off = 0, Rx = 1, RxTx = 2 }; enum class RadioCrossMode : uint8_t { Auto = 0, On = 1, Off = 2 }; -enum class RadioProfileProtocol : uint8_t { MeshCore = 0, Meshtext = 1, Ethernet = 2 }; +enum class RadioProfileProtocol : uint8_t { MeshCore = 0, Meshtext = 1, Ethernet = 2, Kiss = 3 }; // These are modulation profiles of ONE transceiver, never additional radios. struct RadioProfileParams { @@ -150,7 +150,7 @@ class RadioProfiles { if (!(p.freq >= 137 && p.freq <= 2500) || p.sf < 5 || p.sf > 12 || p.cr < 5 || p.cr > 8 || (p.protocol != RadioProfileProtocol::MeshCore && p.protocol != RadioProfileProtocol::Meshtext - && p.protocol != RadioProfileProtocol::Ethernet) + && p.protocol != RadioProfileProtocol::Ethernet && p.protocol != RadioProfileProtocol::Kiss) || (p.preamble != 0 && (p.preamble < 8 || p.preamble > MaxPreamble))) return false; // The first ten entries are the shared SX126x/SX127x bandwidth subset. static constexpr float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 224346ecc..a7ae5a238 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -13,6 +13,7 @@ #endif #include "PrefsSaveReplyGuard.h" #include "UsbLoggingWatchdog.h" +#include "HostKiss.h" #include "PowerSavingDiagnostics.h" #include "HilStartupTrace.h" #if defined(ESP32_PLATFORM) @@ -182,6 +183,9 @@ void CommonCLI::loop() { servicePendingTxPower(); servicePendingRxSettings(); _radio_profiles.loop(); +#if MESH_ENABLE_HOST_KISS + mesh::hostkiss::loop(); +#endif loopManagement(); #if MESH_ENABLE_TX_BEACONS loopBeacons(); @@ -3486,6 +3490,9 @@ bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp, void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply); mesh::cli::normalizeCommandVerb(command); +#if MESH_ENABLE_HOST_KISS + if (mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)) return; +#endif if (mesh::power::handleBatteryCalibrationCommand(*_board, command, reply, 160, [this](float multiplier, char* result, size_t capacity) { return applyAndSaveAdcMultiplier(multiplier, result, capacity); diff --git a/src/helpers/CommonCLI_Management.cpp b/src/helpers/CommonCLI_Management.cpp index 8099e1d07..93100c880 100644 --- a/src/helpers/CommonCLI_Management.cpp +++ b/src/helpers/CommonCLI_Management.cpp @@ -1,5 +1,9 @@ #include "ManagementFeatureConfig.h" #include "CommonCLI.h" +#include "HostKissConfig.h" +#if MESH_ENABLE_HOST_KISS +#include "HostKiss.h" +#endif #include #if MESH_ENABLE_MANAGEMENT_REPORTING #include "ManagementReporter.h" @@ -264,6 +268,10 @@ static bool resolveDataScope(DataRouteState& route, TransportKey& scope, void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) { _management_mesh = &mesh; _management_fs = fs; +#if MESH_ENABLE_HOST_KISS + if (auto* radio = _callbacks->getProfileRadio()) + mesh::hostkiss::begin(fs, *radio, _radio_profiles, mesh); +#endif #if MESH_ENABLE_TX_BEACONS beginBeacons(); #endif diff --git a/src/helpers/HostKiss.cpp b/src/helpers/HostKiss.cpp new file mode 100644 index 000000000..5172b72fa --- /dev/null +++ b/src/helpers/HostKiss.cpp @@ -0,0 +1,456 @@ +#include "HostKiss.h" +#if MESH_ENABLE_HOST_KISS + +#include "HostKissCodec.h" +#include "HostKissSettings.h" +#include "RadioProfileCLI.h" +#include "UsbLogging.h" +#include +#include +#include +#include +#if defined(ESP32) +#include +#include +#include +#endif + +namespace mesh { namespace hostkiss { +namespace { +constexpr size_t ByteBudget = 128; +constexpr uint16_t TcpPort = 5004; +Settings settings; +Radio* radio = nullptr; +RadioProfileCLI* profiles = nullptr; +Dispatcher* dispatcher = nullptr; +Codec* codec = nullptr; // All frame buffers are absent while the setting is off. +bool usb_session = false, suspended = false, leased = false, transmitting = false; +bool ready_pending = false, completion_pending = false, completion_success = false; +uint8_t quarantine_return = 0; +uint32_t profile_generation = 0, intent_generation = 0; +uint32_t received = 0, transmitted = 0, failed = 0, discarded = 0; +#if defined(ESP32) +struct Tcp { + WiFiServer server; + WiFiClient client; + uint32_t address = 0, last_attempt = 0; + bool listening = false, attempted = false, connected = false; + Tcp() : server(TcpPort, 2) {} +}; +Tcp* tcp = nullptr; +#endif + +bool tcpConnected() { +#if defined(ESP32) + return tcp && tcp->connected; +#else + return false; +#endif +} +bool active() { return usb_session || tcpConnected(); } + +void releaseRadio() { + if (dispatcher) dispatcher->cancelHostKissFrame(); + transmitting = false; + const auto* p = radio ? radio->profiles() : nullptr; + // This service is the sole owner of the Kiss overlay. Its generation may + // already have changed; let RadioProfileCLI reconcile that change itself. + // An unrelated foreign protocol must never be cleared on our way out. + if (leased && profiles && p && p->secondary.params.protocol == RadioProfileProtocol::Kiss) + profiles->clearSecondaryProtocol(); + leased = false; +} + +void resetFrames() { + if (codec) codec->reset(); + ready_pending = completion_pending = false; + quarantine_return = 0; +} + +void closeSession() { + releaseRadio(); + resetFrames(); + usb_session = suspended = false; + setUsbBinaryStreamOwned(false); +#if defined(ESP32) + if (tcp) { tcp->client.stop(); tcp->connected = false; } +#endif +} + +const char* admission() { + if (!radio || !profiles || !dispatcher || !radio->supportsHostKiss()) return "unsupported radio"; + if (!profiles->manualSecondaryConfigured() + || profiles->savedSecondaryConfig().mode != RadioProfileMode::RxTx) return "configure saved radio2 rxtx"; + if (profiles->savedSecondaryConfig().params.protocol != RadioProfileProtocol::MeshCore) + return "radio2 protocol reserved"; + const auto* p = radio->profiles(); + if (!p || !profiles->secondaryRuntimeAvailable()) return "temporary or pending radio settings"; + if (p->secondary.mode != RadioProfileMode::RxTx || p->primary_temporary || p->secondary_temporary) + return "radio2 unavailable"; + if (p->secondary.params.protocol != (leased ? RadioProfileProtocol::Kiss : RadioProfileProtocol::MeshCore)) + return "radio2 owned by another service"; + return nullptr; +} + +bool leaseRadio() { + if (admission() || !profiles->useSecondaryProtocol(RadioProfileProtocol::Kiss)) return false; + const auto* p = radio->profiles(); + if (!p || p->secondary.params.protocol != RadioProfileProtocol::Kiss) return false; + leased = true; + profile_generation = p->generation[1]; + intent_generation = profiles->secondaryIntentGeneration(); + return true; +} + +bool current() { + if (!leased || !active() || suspended || admission()) return false; + const auto* p = radio->profiles(); + return p && p->generation[1] == profile_generation + && profiles->secondaryIntentGeneration() == intent_generation; +} + +const char* state(const Selection& selected, const char* reason) { + if (!selected.enabled) return "off"; + if (suspended) return "suspended"; + if (!codec) return "unavailable"; + if (active()) return "connected"; + if (reason) return "blocked"; +#if defined(ESP32) + if (selected.transport == Transport::Tcp) { + if (!(WiFi.getMode() & WIFI_STA) || WiFi.status() != WL_CONNECTED + || !uint32_t(WiFi.localIP())) return "waiting-wifi"; + return tcp && tcp->listening ? "listening" : "starting"; + } +#endif + return "ready"; +} + +void invalidateSession() { + ++discarded; + releaseRadio(); + resetFrames(); + if (usb_session) { + // Keep binary input quarantined until RETURN or a USB session boundary. + // Releasing it to the ASCII parser could interpret a stale binary tail. + suspended = true; + } else { + closeSession(); +#if defined(ESP32) + // Drop accepts queued in the old profile session, not only its owner. + if (tcp) { tcp->server.end(); tcp->listening = tcp->attempted = false; } +#endif + } +} + +void queueControls() { + if (!codec) return; + // Completion must survive a full RX queue, rather than being silently lost. + if (completion_pending && codec->queuedOutput() < Codec::QueueDepth) { + codec->queueTxDone(completion_success); + completion_pending = false; + ready_pending = true; + } + if (ready_pending && codec->queuedOutput() < Codec::QueueDepth) { + codec->queueReady(); + ready_pending = false; + } +} + +void pumpTransmit() { + if (!current() || transmitting || completion_pending || dispatcher->hostKissFramePending()) return; + const uint8_t* bytes; + size_t length; + if (codec->peekTransmit(bytes, length) + && dispatcher->submitHostKissFrame(bytes, length, profile_generation)) { + codec->consumeTransmit(); + transmitting = true; + } +} + +void stopTransport() { + closeSession(); +#if defined(ESP32) + if (tcp) tcp->server.end(); + delete tcp; + tcp = nullptr; +#endif +} + +bool allocate(const Selection& selection, Codec*& next_codec +#if defined(ESP32) + , Tcp*& next_tcp +#endif + ) { + next_codec = codec; +#if defined(ESP32) + next_tcp = tcp; +#endif + if (!selection.enabled) return true; + if (!next_codec) next_codec = new (std::nothrow) Codec; + if (!next_codec) return false; +#if defined(ESP32) + if (selection.transport == Transport::Tcp && !next_tcp) { + next_tcp = new (std::nothrow) Tcp; + if (!next_tcp) { + if (next_codec != codec) delete next_codec; + next_codec = codec; + return false; + } + } +#endif + return true; +} + +#if defined(ESP32) +void loopTcp() { + if (!tcp) return; + const bool station = (WiFi.getMode() & WIFI_STA) && WiFi.status() == WL_CONNECTED; + const uint32_t address = station ? uint32_t(WiFi.localIP()) : 0; + if (!address && !tcp->address && !tcp->listening && !tcp->connected) return; + if (!address || address != tcp->address) { + closeSession(); + tcp->server.end(); + tcp->listening = tcp->attempted = false; + tcp->address = address; + if (!address) return; + // Never bind this unauthenticated modem to the setup AP. + tcp->server = WiFiServer(IPAddress(address), TcpPort, 2); + } + const uint32_t now = millis(); + if (tcp->listening && !bool(tcp->server)) { + tcp->server.end(); + tcp->listening = tcp->attempted = false; + } + if (!tcp->listening && (!tcp->attempted || uint32_t(now - tcp->last_attempt) >= 1000)) { + tcp->last_attempt = now; + tcp->attempted = true; + tcp->server.begin(); + tcp->listening = bool(tcp->server); + } + if (tcp->connected && !tcp->client.connected()) closeSession(); + if (tcp->listening) { + WiFiClient incoming = tcp->server.available(); + if (incoming) { + if (tcp->connected || admission()) incoming.stop(); + else { + resetFrames(); + if (!leaseRadio()) incoming.stop(); + else { + tcp->client = incoming; + tcp->client.setNoDelay(true); + tcp->connected = true; + suspended = false; + ready_pending = true; + } + } + } + } + if (!tcp->connected) return; + queueControls(); + const uint8_t* bytes; + size_t length; + if (codec->peekOutput(bytes, length)) { + if (length > ByteBudget) length = ByteBudget; + const int written = ::send(tcp->client.fd(), bytes, length, MSG_DONTWAIT); + if (written > 0) codec->consumeOutput(size_t(written)); + else if (written < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + closeSession(); return; + } + } + for (size_t i = 0; i < ByteBudget && codec->canAcceptByte(); ++i) { + uint8_t byte; + const int got = ::recv(tcp->client.fd(), &byte, 1, MSG_DONTWAIT); + if (got == 1) { + codec->feed(byte); + if (codec->returned()) { closeSession(); break; } + } else { + if (!got || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) closeSession(); + break; + } + } +} +#endif +} // namespace + +void begin(FILESYSTEM* fs, Radio& r, RadioProfileCLI& p, Dispatcher& d) { + stopTransport(); + delete codec; + codec = nullptr; + radio = &r; + profiles = &p; + dispatcher = &d; + received = transmitted = failed = discarded = 0; + settings.begin(fs); + const auto& selected = settings.selection(); + if (!selected.enabled || !r.supportsHostKiss()) return; +#if !defined(ESP32) + if (selected.transport == Transport::Tcp) return; +#endif + Codec* next_codec = nullptr; +#if defined(ESP32) + Tcp* next_tcp = nullptr; + if (allocate(selected, next_codec, next_tcp)) { codec = next_codec; tcp = next_tcp; } +#else + if (allocate(selected, next_codec)) codec = next_codec; +#endif +} + +bool handleCommand(const char* command, char* reply, size_t capacity, bool local) { + const bool get = !strncmp(command, "get ", 4); + const bool set = !strncmp(command, "set ", 4); + if (!get && !set) return false; + const char* key = command + 4; + bool transport = !strncmp(key, "kiss.transport", 14) + && (!key[14] || key[14] == ' '); + const bool status = !strncmp(key, "kiss", 4) && (!key[4] || key[4] == ' '); + if (!transport && !status) return false; + const char* value = key + (transport ? 14 : 4); + if (get) { + if (*value) { snprintf(reply, capacity, "Error: use get kiss%s", transport ? ".transport" : ""); return true; } + const auto& selected = settings.selection(); + if (transport) snprintf(reply, capacity, "> %s", selected.transport == Transport::Usb ? "usb" : "tcp"); + else { + const char* reason = admission(); + snprintf(reply, capacity, + "> %s transport=%s state=%s rx=%lu tx=%lu fail=%lu drop=%lu bad=%lu%s%s", + selected.enabled ? "on" : "off", selected.transport == Transport::Usb ? "usb" : "tcp:5004", + state(selected, reason), + (unsigned long)received, (unsigned long)transmitted, (unsigned long)failed, + (unsigned long)(discarded + (codec ? codec->droppedFrames() : 0)), + (unsigned long)(codec ? codec->malformedFrames() : 0), + selected.enabled && !active() && reason ? " reason=" : "", + selected.enabled && !active() && reason ? reason : ""); + } + return true; + } + if (!local) { snprintf(reply, capacity, "Error: KISS settings require a local connection"); return true; } + while (*value == ' ') ++value; + Selection candidate = settings.selection(); + if (transport) { + if (!strcmp(value, "usb")) candidate.transport = Transport::Usb; + else if (!strcmp(value, "tcp")) candidate.transport = Transport::Tcp; + else { snprintf(reply, capacity, "Error: use set kiss.transport usb|tcp"); return true; } +#if !defined(ESP32) + if (candidate.transport == Transport::Tcp) { snprintf(reply, capacity, "Error: TCP KISS requires ESP32 station WiFi"); return true; } +#endif + } else if (!strcmp(value, "on")) candidate.enabled = true; + else if (!strcmp(value, "off")) candidate.enabled = false; + else { snprintf(reply, capacity, "Error: use set kiss on|off"); return true; } + if (candidate.enabled && (!radio || !radio->supportsHostKiss())) { + snprintf(reply, capacity, "Error: raw radio2 KISS is unsupported by this radio"); return true; + } + if (candidate == settings.selection() && settings.healthy()) { snprintf(reply, capacity, "OK - KISS unchanged"); return true; } + Codec* next_codec; +#if defined(ESP32) + Tcp* next_tcp; + if (!allocate(candidate, next_codec, next_tcp)) { +#else + if (!allocate(candidate, next_codec)) { +#endif + snprintf(reply, capacity, "Error: insufficient memory; KISS unchanged"); return true; + } + // Persist before disrupting the old stream. A failed save leaves its exact + // radio lease and pending frames intact. + if (!settings.save(candidate)) { + if (next_codec != codec) delete next_codec; +#if defined(ESP32) + if (next_tcp != tcp) delete next_tcp; +#endif + snprintf(reply, capacity, "Error: KISS save failed; service unchanged"); return true; + } + closeSession(); + codec = next_codec; +#if defined(ESP32) + tcp = next_tcp; + if (tcp && (!candidate.enabled || candidate.transport != Transport::Tcp)) { + tcp->server.end(); delete tcp; tcp = nullptr; + } +#endif + if (!candidate.enabled) { delete codec; codec = nullptr; } + snprintf(reply, capacity, candidate.enabled + ? "OK - KISS on (%s); get kiss for status" : "OK - KISS off", + candidate.transport == Transport::Usb ? "USB: send KISS FEND to start" : "station TCP 5004"); + return true; +} + +void loop() { + if (!settings.selection().enabled || !codec) return; + if (active() && !suspended && !current()) invalidateSession(); +#if defined(ESP32) + if (settings.selection().transport == Transport::Tcp) loopTcp(); +#endif + pumpTransmit(); +} + +bool serviceUsb() { + if (!settings.selection().enabled || settings.selection().transport != Transport::Usb) return false; + Stream& port = usbCompanionPort(); + if (!usb_session) { + if (!port.available() || port.peek() != Codec::Fend) return false; + resetFrames(); + usb_session = true; + suspended = !codec || !leaseRadio(); + setUsbBinaryStreamOwned(true); + ready_pending = !suspended; + } + // USB text writers may have been admitted just before the binary claim. + // Their nonblocking retirement must finish before READY or host bytes move. + if (!isUsbBinaryStreamReady()) return true; + if (!codec) { + // Allocation/capability failures must also keep binary bytes out of CLI. + // This three-state RETURN recognizer needs no packet buffer. + for (size_t i = 0; i < ByteBudget && port.available(); ++i) { + const int value = port.read(); + if (value < 0) break; + if (value == Codec::Fend) { + if (quarantine_return == 2) { closeSession(); return true; } + quarantine_return = 1; + } else if (value == 0xff && quarantine_return == 1) quarantine_return = 2; + else quarantine_return = 0; + } + return true; + } + if (!suspended && !current()) invalidateSession(); + if (!suspended) { + queueControls(); + const uint8_t* bytes; + size_t length; + if (codec->peekOutput(bytes, length)) { + if (length > ByteBudget) length = ByteBudget; + codec->consumeOutput(port.write(bytes, length)); + } + } + for (size_t i = 0; i < ByteBudget && port.available() && codec->canAcceptByte(); ++i) { + const int value = port.read(); + if (value < 0) break; + codec->feed(uint8_t(value)); + if (codec->returned()) { closeSession(); return true; } + if (suspended) codec->consumeTransmit(); // Never queue TX while suspended. + } + pumpTransmit(); + return true; +} + +bool usbActive() { return usb_session; } +bool radioActive() { return current(); } +void resetUsbSession() { if (usb_session) closeSession(); } + +void receive(const uint8_t* bytes, size_t length, uint8_t profile, + uint32_t generation, float, float) { + if (!codec || profile != 1 || generation != profile_generation || !current()) return; + if (codec->queueReceive(bytes, length)) ++received; +} + +void sendComplete(bool success) { + // A completion from an invalidated session must not reach its replacement. + if (!transmitting) return; + transmitting = false; + if (!current()) return; + if (success) ++transmitted; + else ++failed; + completion_success = success; + completion_pending = true; +} + +}} // namespace mesh::hostkiss +#endif diff --git a/src/helpers/HostKiss.h b/src/helpers/HostKiss.h new file mode 100644 index 000000000..d83974c9a --- /dev/null +++ b/src/helpers/HostKiss.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#if MESH_ENABLE_HOST_KISS +#include +namespace mesh { +class Radio; +class RadioProfileCLI; +class Dispatcher; +namespace hostkiss { +void begin(FILESYSTEM* fs, Radio& radio, RadioProfileCLI& profiles, Dispatcher& dispatcher); +bool handleCommand(const char* command, char* reply, size_t capacity, bool local); +void loop(); +bool serviceUsb(); +bool usbActive(); +bool radioActive(); +void resetUsbSession(); +void receive(const uint8_t* bytes, size_t length, uint8_t profile, + uint32_t generation, float snr, float rssi); +void sendComplete(bool success); +}} +#else +namespace mesh { namespace hostkiss { +template inline void begin(F*, R&, P&, D&) {} +inline bool handleCommand(const char*, char*, size_t, bool) { return false; } +inline void loop() {} +inline bool serviceUsb() { return false; } +inline bool usbActive() { return false; } +inline bool radioActive() { return false; } +inline void resetUsbSession() {} +inline void receive(const uint8_t*, size_t, uint8_t, uint32_t, float, float) {} +inline void sendComplete(bool) {} +}} +#endif diff --git a/src/helpers/HostKissCodec.h b/src/helpers/HostKissCodec.h new file mode 100644 index 000000000..1cb6076c1 --- /dev/null +++ b/src/helpers/HostKissCodec.h @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include + +namespace mesh { namespace hostkiss { + +// Generic KISS framing, independently implemented. No RNode commands or +// over-air fragmentation are implied by the host stream. +class Codec { + public: + static constexpr uint8_t Fend = 0xc0, Fesc = 0xdb, Tfend = 0xdc, Tfesc = 0xdd; + static constexpr size_t MaximumFrame = 255, QueueDepth = 2; + static constexpr size_t MaximumEncoded = MaximumFrame * 2 + 3; + static constexpr uint8_t Ready = 0x0f, TxDone = 0xf8; + private: + struct Frame { uint8_t bytes[MaximumFrame]; uint16_t length = 0; } tx_[QueueDepth]; + struct Output { uint8_t bytes[MaximumEncoded]; uint16_t length = 0; } out_[QueueDepth]; + uint8_t tx_head_ = 0, tx_count_ = 0, out_head_ = 0, out_count_ = 0; + uint8_t input_[MaximumFrame]; + uint16_t input_size_ = 0, output_offset_ = 0; + uint8_t command_ = 0; + bool framed_ = false, has_command_ = false, escaped_ = false, discard_ = false; + bool return_ = false; + uint32_t malformed_ = 0, dropped_ = 0; + + void malformed() { if (!discard_) ++malformed_; discard_ = true; } + bool queueOutput(uint8_t command, const uint8_t* bytes, size_t length) { + if (length > MaximumFrame || out_count_ >= QueueDepth || (!bytes && length)) { + ++dropped_; return false; + } + auto& frame = out_[(out_head_ + out_count_) % QueueDepth]; + size_t at = 0; + frame.bytes[at++] = Fend; + // Commands used by this service never need escaping. + frame.bytes[at++] = command; + for (size_t i = 0; i < length; ++i) { + if (bytes[i] == Fend || bytes[i] == Fesc) { + frame.bytes[at++] = Fesc; + frame.bytes[at++] = bytes[i] == Fend ? Tfend : Tfesc; + } else frame.bytes[at++] = bytes[i]; + } + frame.bytes[at++] = Fend; + frame.length = uint16_t(at); + ++out_count_; + return true; + } + public: + void reset() { + tx_head_ = tx_count_ = out_head_ = out_count_ = 0; + input_size_ = output_offset_ = 0; + framed_ = has_command_ = escaped_ = discard_ = return_ = false; + } + bool canAcceptByte() const { return tx_count_ < QueueDepth && !return_; } + // False leaves ownership of this byte with the transport (backpressure). + bool feed(uint8_t byte) { + if (!canAcceptByte()) return false; + if (byte == Fend) { + if (framed_ && has_command_ && !discard_) { + if (escaped_) malformed(); + else if (command_ == 0xff) { + if (!input_size_) return_ = true; + else malformed(); + } else if (command_ == 0) { + if (!input_size_) malformed(); + else { + auto& frame = tx_[(tx_head_ + tx_count_) % QueueDepth]; + memcpy(frame.bytes, input_, input_size_); + frame.length = input_size_; + ++tx_count_; + } + } + } + framed_ = true; + has_command_ = escaped_ = discard_ = false; + input_size_ = 0; + return true; + } + if (!framed_ || discard_) return true; + if (!has_command_) { + has_command_ = true; + command_ = byte; + // Port 0 DATA always means radio2. Other ports and unsupported KISS + // parameter commands cannot alter the radio or reach MeshCore radio1. + if (command_ != 0 && command_ != 0xff) discard_ = true; + return true; + } + if (escaped_) { + escaped_ = false; + if (byte == Tfend) byte = Fend; + else if (byte == Tfesc) byte = Fesc; + else { malformed(); return true; } + } else if (byte == Fesc) { + escaped_ = true; + return true; + } + if (input_size_ >= MaximumFrame) { malformed(); return true; } + input_[input_size_++] = byte; + return true; + } + bool returned() const { return return_; } + bool peekTransmit(const uint8_t*& bytes, size_t& length) const { + if (!tx_count_) { bytes = nullptr; length = 0; return false; } + bytes = tx_[tx_head_].bytes; + length = tx_[tx_head_].length; + return true; + } + void consumeTransmit() { + if (tx_count_) { tx_head_ = (tx_head_ + 1) % QueueDepth; --tx_count_; } + } + bool queueReceive(const uint8_t* bytes, size_t length) { + if (!length) { ++dropped_; return false; } + return queueOutput(0, bytes, length); + } + bool queueReady() { const uint8_t value = 1; return queueOutput(Ready, &value, 1); } + bool queueTxDone(bool success) { + // Match the existing standalone modem's SetHardware extension. A command + // byte of 0xf8 would instead mean port 15, command 8 to a generic parser. + const uint8_t value[] = {TxDone, uint8_t(success ? 1 : 0)}; + return queueOutput(0x06, value, sizeof(value)); + } + bool peekOutput(const uint8_t*& bytes, size_t& length) const { + if (!out_count_) { bytes = nullptr; length = 0; return false; } + bytes = out_[out_head_].bytes + output_offset_; + length = out_[out_head_].length - output_offset_; + return true; + } + void consumeOutput(size_t length) { + if (!out_count_) return; + const size_t remaining = out_[out_head_].length - output_offset_; + if (length >= remaining) { + output_offset_ = 0; + out_head_ = (out_head_ + 1) % QueueDepth; + --out_count_; + } else output_offset_ += uint16_t(length); + } + uint8_t queuedTransmit() const { return tx_count_; } + uint8_t queuedOutput() const { return out_count_; } + uint32_t malformedFrames() const { return malformed_; } + uint32_t droppedFrames() const { return dropped_; } +}; + +}} // namespace mesh::hostkiss diff --git a/src/helpers/HostKissConfig.h b/src/helpers/HostKissConfig.h new file mode 100644 index 000000000..fc23210c7 --- /dev/null +++ b/src/helpers/HostKissConfig.h @@ -0,0 +1,20 @@ +#pragma once + +// A host-assisted raw radio2 modem in Full firmware. Keep this independent of +// chip/Arduino headers: all translation units must choose identical layouts. +// Full infrastructure builds opt in explicitly; reduced builds stay unchanged. +#ifndef MESH_ENABLE_HOST_KISS + #if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) \ + && defined(COMPANION_RADIO_FULL) && COMPANION_RADIO_FULL \ + && !(defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) + #define MESH_ENABLE_HOST_KISS 1 + #else + #define MESH_ENABLE_HOST_KISS 0 + #endif +#endif + +// ESP-NOW-only images have no independent LoRa modem for this transport. +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + #undef MESH_ENABLE_HOST_KISS + #define MESH_ENABLE_HOST_KISS 0 +#endif diff --git a/src/helpers/HostKissHooks.h b/src/helpers/HostKissHooks.h new file mode 100644 index 000000000..79f149b45 --- /dev/null +++ b/src/helpers/HostKissHooks.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include "HostKissConfig.h" + +#if MESH_ENABLE_HOST_KISS +namespace mesh { +namespace hostkiss { +// Called by the Dispatcher only after radio2 protocol/generation validation. +// This is the sole RX consumer, before MeshCore logs, parsing and bridges. +void receive(const uint8_t* bytes, size_t length, uint8_t profile, + uint32_t generation, float snr, float rssi); +void sendComplete(bool success); +} +} +#endif diff --git a/src/helpers/HostKissSettings.cpp b/src/helpers/HostKissSettings.cpp new file mode 100644 index 000000000..ab72ef903 --- /dev/null +++ b/src/helpers/HostKissSettings.cpp @@ -0,0 +1,76 @@ +#include "HostKissSettings.h" +#if MESH_ENABLE_HOST_KISS +#include "CheckedSettingsFile.h" +#include "FilePresence.h" +#include "PersistentStoreFormat.h" +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif + +namespace mesh { namespace hostkiss { +namespace { +constexpr char Path[] = "/host_kiss"; +constexpr size_t ImageSize = 12; +void encode(uint8_t (&image)[ImageSize], const Selection& value) { + memset(image, 0, sizeof(image)); + memcpy(image, "HKS1", 4); + image[4] = value.enabled ? 1 : 0; + image[5] = uint8_t(value.transport); + storage::writeLE32(image + 8, storage::updateCRC32(0xffffffff, image, 8)); +} +bool valid(const uint8_t (&image)[ImageSize]) { + return memcmp(image, "HKS1", 4) == 0 && image[4] <= 1 && image[5] <= 1 + && image[6] == 0 && image[7] == 0 + && storage::readLE32(image + 8) == storage::updateCRC32(0xffffffff, image, 8); +} +} +bool Settings::begin(FILESYSTEM* fs) { + fs_ = fs; + healthy_ = false; + selection_ = {}; + if (!fs_) return false; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) || defined(ESP32) + if (!ContactFileTransaction::recover(fs_, Path, filePresence)) return false; +#endif + bool present = false; + if (!filePresence(fs_, Path, present)) return false; + if (!present) { healthy_ = true; return true; } + uint8_t image[ImageSize]; + const auto result = storage::readFixedSettingsFileResult(fs_, Path, image, sizeof(image)); + if (result == storage::FixedSettingsRead::Unreadable) return false; + healthy_ = true; // A complete malformed record may be explicitly replaced. + if (result != storage::FixedSettingsRead::Complete || !valid(image)) return false; + selection_.enabled = image[4] != 0; + selection_.transport = Transport(image[5]); + return true; +} +bool Settings::save(const Selection& selection) { + if (!healthy_ || !fs_ || uint8_t(selection.transport) > 1) return false; + uint8_t image[ImageSize]; + encode(image, selection); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter writer(fs_, Path); + const bool wrote = writer && writer.write(image, sizeof(image)) == sizeof(image); + const bool saved = writer.commit(wrote); + if (writer.outcome() == AtomicFileWriter::Outcome::Indeterminate) healthy_ = false; + if (!saved) return false; +#else + ContactFileTransaction writer(fs_, Path, filePresence); + if (!writer || writer.write(image, sizeof(image)) != sizeof(image)) { + if (writer.outcome() == ContactFileTransaction::Outcome::Indeterminate) healthy_ = false; + return false; + } + writer.commit(); + if (writer.outcome() == ContactFileTransaction::Outcome::NotCommitted) return false; +#endif + uint8_t verified[ImageSize]; + healthy_ = storage::readFixedSettingsFile(fs_, Path, verified, sizeof(verified)) + && memcmp(image, verified, sizeof(image)) == 0; + if (!healthy_) return false; + selection_ = selection; + return true; +} +}} // namespace mesh::hostkiss +#endif diff --git a/src/helpers/HostKissSettings.h b/src/helpers/HostKissSettings.h new file mode 100644 index 000000000..2837eb6e6 --- /dev/null +++ b/src/helpers/HostKissSettings.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#if MESH_ENABLE_HOST_KISS +#include + +namespace mesh { namespace hostkiss { +enum class Transport : uint8_t { Usb = 0, Tcp = 1 }; +struct Selection { + bool enabled = false; + Transport transport = Transport::Usb; + bool operator==(const Selection& value) const { + return enabled == value.enabled && transport == value.transport; + } +}; +class Settings { + FILESYSTEM* fs_ = nullptr; + bool healthy_ = false; + Selection selection_; + public: + bool begin(FILESYSTEM* fs); + bool save(const Selection& selection); + const Selection& selection() const { return selection_; } + bool healthy() const { return healthy_; } +}; +}} +#endif diff --git a/src/helpers/MeshtextConfig.h b/src/helpers/MeshtextConfig.h index 33251a4fb..5fe6c29fd 100644 --- a/src/helpers/MeshtextConfig.h +++ b/src/helpers/MeshtextConfig.h @@ -2,6 +2,7 @@ #include "BeaconFeatureConfig.h" #include "EthernetTunnelConfig.h" +#include "HostKissConfig.h" #include "ota/OtaDirectConfig.h" // Full Companion includes channel chat on both supported platforms. Keep this @@ -37,5 +38,5 @@ // dispatcher slot. Direct OTA retains its temporary MeshCore profile; foreign // transports keep their existing radio2-only ownership. #ifndef MESH_ENABLE_FOREIGN_RADIO - #define MESH_ENABLE_FOREIGN_RADIO (MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_OTA_KISS_DIRECT) + #define MESH_ENABLE_FOREIGN_RADIO (MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_OTA_KISS_DIRECT || MESH_ENABLE_HOST_KISS) #endif diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index cc87a7580..d5bec064c 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -184,7 +184,7 @@ bool RadioProfileCLI::prepareSavedImage(const char* path, const RadioProfileConf uint16_t preamble, RadioCrossMode cross, int8_t manual_secondary) { if (!fs_ || hold_) return false; uint8_t image[ImageSize] = {'R', '2', 1, (uint8_t)config.mode}; -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS // Version 2 keeps the 24-byte layout and CRC; bit 7 records explicit radio2 // intent. Other settings can save an unused Off slot without claiming it. image[2] = 2; @@ -408,7 +408,7 @@ void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool in recoverable_corrupt_store_ = false; manual_secondary_ = false; secondary_intent_generation_ = 0; -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS auto_secondary_requested_ = secondary_protocol_overlay_ = false; owns_secondary_runtime_ = external_secondary_override_ = false; #endif @@ -512,7 +512,7 @@ void RadioProfileCLI::publish() { ? (validReplySetting(reply_setting_) ? reply_setting_ & 7 : RADIO_TX_BOTH) : RADIO_TX_AUTO; profiles->reply_force = infrastructure_replies_ && validReplySetting(reply_setting_) && (reply_setting_ & 8); -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS protectExternalSecondary(); if (external_secondary_override_) return; #endif @@ -522,7 +522,7 @@ void RadioProfileCLI::publish() { if (hasReplyMutation() && !remote_delivered_) return; publish_pending_ = false; RadioProfileConfig config = temp_active_ ? temporary_ : saved_; -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (auto_secondary_requested_ && canAutoSecondary()) config = auto_secondary_; else if (secondary_protocol_overlay_ && manualSecondaryConfigured() && !secondaryRuntimeBusy()) { config.params.protocol = secondary_protocol_; @@ -530,7 +530,7 @@ void RadioProfileCLI::publish() { } #endif profiles->setSecondary(config, temp_active_); -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS owns_secondary_runtime_ = auto_secondary_requested_ || secondary_protocol_overlay_; if (owns_secondary_runtime_) { owned_secondary_snapshot_ = config; @@ -539,7 +539,7 @@ void RadioProfileCLI::publish() { #endif } -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS bool RadioProfileCLI::runtimeSecondaryUnchanged() const { if (!owns_secondary_runtime_ || !radio_ || !radio_->profiles()) return true; const auto* profiles = radio_->profiles(); @@ -564,7 +564,7 @@ bool RadioProfileCLI::hasSecondarySchedule() const { } bool RadioProfileCLI::secondaryRuntimeBusy() const { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (external_secondary_override_ || !runtimeSecondaryUnchanged()) return true; #endif if (!fs_ || !radio_) return true; @@ -576,14 +576,14 @@ bool RadioProfileCLI::secondaryRuntimeBusy() const { void RadioProfileCLI::noteSecondaryManualChange() { ++secondary_intent_generation_; -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS auto_secondary_requested_ = secondary_protocol_overlay_ = false; owns_secondary_runtime_ = external_secondary_override_ = false; #endif } bool RadioProfileCLI::canAutoSecondary() const { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (manual_secondary_ || saved_.mode != RadioProfileMode::Off || secondaryRuntimeBusy()) return false; const auto& current = radio_->profiles()->secondary; return current.mode == RadioProfileMode::Off || (auto_secondary_requested_ @@ -594,7 +594,7 @@ bool RadioProfileCLI::canAutoSecondary() const { } bool RadioProfileCLI::requestAutoSecondary(const RadioProfileConfig& config) { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (!canAutoSecondary() || config.mode == RadioProfileMode::Off || (uint8_t)config.mode > (uint8_t)RadioProfileMode::RxTx || !radio_->validateProfile(config.params)) return false; @@ -614,7 +614,7 @@ bool RadioProfileCLI::requestAutoSecondary(const RadioProfileConfig& config) { } void RadioProfileCLI::clearAutoSecondary() { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (!auto_secondary_requested_) return; protectExternalSecondary(); auto_secondary_requested_ = false; @@ -624,7 +624,7 @@ void RadioProfileCLI::clearAutoSecondary() { } bool RadioProfileCLI::autoSecondaryActive() const { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (!auto_secondary_requested_ || !canAutoSecondary()) return false; const auto& current = radio_->profiles()->secondary; return !radio_->profiles()->secondary_temporary && current.mode == auto_secondary_.mode @@ -635,7 +635,7 @@ bool RadioProfileCLI::autoSecondaryActive() const { } bool RadioProfileCLI::useSecondaryProtocol(RadioProfileProtocol protocol) { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (protocol == RadioProfileProtocol::MeshCore) { clearSecondaryProtocol(); return true; @@ -646,6 +646,9 @@ bool RadioProfileCLI::useSecondaryProtocol(RadioProfileProtocol protocol) { #endif #if MESH_ENABLE_ETHERNET_TUNNEL protocol == RadioProfileProtocol::Ethernet || +#endif +#if MESH_ENABLE_HOST_KISS + protocol == RadioProfileProtocol::Kiss || #endif false; if (!supported_protocol || !manualSecondaryConfigured() @@ -669,7 +672,7 @@ bool RadioProfileCLI::useSecondaryProtocol(RadioProfileProtocol protocol) { } void RadioProfileCLI::clearSecondaryProtocol() { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (!secondary_protocol_overlay_) return; protectExternalSecondary(); secondary_protocol_overlay_ = false; @@ -1146,7 +1149,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, return true; } if ((base || temporary) && verb == Get && !*args) { -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (base && autoSecondaryActive()) { formatConfig(reply, capacity, auto_secondary_, false, 0, true); return true; @@ -1161,7 +1164,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, temporary, temp_pending_ ? pending_duration_ms_ : temp_remaining_ms_); return true; } -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS if (base && verb == Set && !strcmp(args, "auto")) { // This clears only saved permission, never an active/pending lease or a // schedule. Remote acknowledgement staging must not weaken that contract. @@ -1322,7 +1325,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, noteSecondaryManualChange(); pending_temporary_ = config; pending_duration_ms_ = minutes * 60000UL; temp_start_ms_ = millis() + 2000; temp_pending_ = true; -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS publish_pending_ = true; publish_after_ms_ = temp_start_ms_; #endif } diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index 1bdc2aec0..a53d1aa33 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -34,7 +34,7 @@ class RadioProfileCLI { // Saved Off can be an explicit veto, rather than an unused secondary slot. bool manual_secondary_ = false; uint32_t secondary_intent_generation_ = 0; -#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS RadioProfileConfig auto_secondary_; bool auto_secondary_requested_ = false; bool secondary_protocol_overlay_ = false; diff --git a/src/helpers/UsbBinaryTextGuard.h b/src/helpers/UsbBinaryTextGuard.h new file mode 100644 index 000000000..566ca2d81 --- /dev/null +++ b/src/helpers/UsbBinaryTextGuard.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +namespace mesh { + +// Guard cached text Stream references as well as new port lookups. Binary +// owners write through their separate raw facade and are never wrapped here. +class UsbBinaryTextGuard : public Stream { + std::atomic _port{nullptr}; + bool (*_owned)(); + bool (*_enter)(); + void (*_leave)(); + bool enter() { return _enter ? _enter() : !_owned(); } + void leave() { if (_leave) _leave(); } +public: + explicit UsbBinaryTextGuard(bool (*owned)(), bool (*enter)() = nullptr, + void (*leave)() = nullptr) + : _owned(owned), _enter(enter), _leave(leave) { } + void use(Stream& port) { _port.store(&port, std::memory_order_release); } + int available() override { + auto* port = _port.load(std::memory_order_acquire); + return !_owned() && port ? port->available() : 0; + } + int read() override { + auto* port = _port.load(std::memory_order_acquire); + return !_owned() && port ? port->read() : -1; + } + int peek() override { + auto* port = _port.load(std::memory_order_acquire); + return !_owned() && port ? port->peek() : -1; + } + void flush() override { + if (!enter()) return; + auto* port = _port.load(std::memory_order_acquire); + if (port) port->flush(); + leave(); + } + int availableForWrite() override { + auto* port = _port.load(std::memory_order_acquire); + return !_owned() && port ? port->availableForWrite() : 0; + } + size_t write(uint8_t byte) override { return write(&byte, 1); } + size_t write(const uint8_t* data, size_t length) override { + if (!data) return 0; + // Suppressed text is deliberately consumed, never deferred into the next + // CLI session. The caller must not retry it after the raw owner releases. + if (!enter()) return length; + auto* port = _port.load(std::memory_order_acquire); + const size_t written = port ? port->write(data, length) : 0; + leave(); + return written; + } +}; + +} // namespace mesh diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index f5e01fd63..50d2d3850 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -1,5 +1,8 @@ #include "UsbLogging.h" #include "UsbLoggingClientActivity.h" +#if MESH_ENABLE_HOST_KISS +#include "UsbBinaryTextGuard.h" +#endif #if defined(ARDUINO) #include @@ -91,6 +94,19 @@ static std::atomic usb_logging_enabled{true}; #endif static std::atomic usb_logging_preference_known{false}; static std::atomic usb_debug_enabled{false}; +#if MESH_ENABLE_HOST_KISS +static std::atomic usb_binary_stream_owned{false}; +static std::atomic usb_text_writers{0}; + +static bool beginUsbTextWrite() { + if (usb_binary_stream_owned.load()) return false; + usb_text_writers.fetch_add(1); + if (!usb_binary_stream_owned.load()) return true; + usb_text_writers.fetch_sub(1); + return false; +} +static void endUsbTextWrite() { usb_text_writers.fetch_sub(1); } +#endif static std::atomic usb_logging_tx_progress{0}; static std::atomic usb_logging_tx_waiting{false}; #if MESH_ESP32_TINYUSB_NONBLOCKING \ @@ -364,7 +380,11 @@ static void serviceEsp32TinyUsbPorts() { esp32_tinyusb_terminal_reported_dropped_bytes = esp32_tinyusb_terminal_dropped_bytes.load(std::memory_order_relaxed); } - if (canAccessEsp32TinyUsb(nullptr)) { + if (isUsbBinaryStreamOwned()) { + esp32_tinyusb_text_queue.discardPending(); + esp32_tinyusb_terminal_reported_dropped_bytes = + esp32_tinyusb_terminal_dropped_bytes.load(std::memory_order_relaxed); + } else if (canAccessEsp32TinyUsb(nullptr)) { esp32_tinyusb_text_queue.service(); const uint32_t dropped = esp32_tinyusb_terminal_dropped_bytes.load(std::memory_order_relaxed); @@ -1245,6 +1265,31 @@ static WholeRecordNonBlockingStream<> #endif #endif +#if MESH_ENABLE_HOST_KISS +bool isUsbBinaryStreamOwned() { + return usb_binary_stream_owned.load(); +} + +void setUsbBinaryStreamOwned(bool owned) { + usb_binary_stream_owned.store(owned); + if (owned) { + discardUsbTerminalOutput(); +#if MESH_ESP32_HWCDC_SESSION_GUARD + invalidateEsp32SdkLogging(); +#endif + } + setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled()); +} + +bool isUsbBinaryStreamReady() { + // The producer may have obtained its cached Stream before ownership changed. + // Never wait for it here: the raw session retries this gate next loop. + if (!isUsbBinaryStreamOwned() || usb_text_writers.load() != 0) return false; + discardUsbTerminalOutput(); + return !hasPendingUsbTerminalOutput(); +} +#endif + bool isUsbLoggingEnabled() { return usb_logging_enabled.load(std::memory_order_relaxed); } @@ -1598,7 +1643,7 @@ bool resetUsbCompanionTransport() { return true; } -Stream& usbLoggingPort() { +static Stream& rawUsbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) if (!isUsbLoggingPacketStream()) { if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started @@ -1620,6 +1665,16 @@ Stream& usbLoggingPort() { #endif } +Stream& usbLoggingPort() { +#if MESH_ENABLE_HOST_KISS + static UsbBinaryTextGuard guarded(isUsbBinaryStreamOwned, beginUsbTextWrite, endUsbTextWrite); + guarded.use(rawUsbLoggingPort()); + return guarded; +#else + return rawUsbLoggingPort(); +#endif +} + Stream& usbDebugPort() { if (!isUsbDebugLoggingEnabled()) return null_usb_logging_stream; return usbLoggingPort(); @@ -1655,7 +1710,7 @@ Stream& usbMotaPort() { #endif } -Stream& usbTerminalPort(bool enabled) { +static Stream& rawUsbTerminalPort(bool enabled) { if (!enabled) return null_usb_logging_stream; #if MESH_ESP32_TINYUSB_NONBLOCKING return buffered_esp32_tinyusb_terminal_port; @@ -1666,7 +1721,7 @@ Stream& usbTerminalPort(bool enabled) { #endif } -Stream& usbConsolePort() { +static Stream& rawUsbConsolePort() { #if MESH_ESP32_TINYUSB_NONBLOCKING return buffered_esp32_tinyusb_terminal_port; #elif MESH_ESP32_HWCDC_SESSION_GUARD @@ -1678,7 +1733,29 @@ Stream& usbConsolePort() { #endif } +Stream& usbTerminalPort(bool enabled) { +#if MESH_ENABLE_HOST_KISS + static UsbBinaryTextGuard guarded(isUsbBinaryStreamOwned, beginUsbTextWrite, endUsbTextWrite); + if (!enabled) return null_usb_logging_stream; + guarded.use(rawUsbTerminalPort(true)); + return guarded; +#else + return rawUsbTerminalPort(enabled); +#endif +} + +Stream& usbConsolePort() { +#if MESH_ENABLE_HOST_KISS + static UsbBinaryTextGuard guarded(isUsbBinaryStreamOwned, beginUsbTextWrite, endUsbTextWrite); + guarded.use(rawUsbConsolePort()); + return guarded; +#else + return rawUsbConsolePort(); +#endif +} + bool canAcceptUsbConsoleCommand() { + if (isUsbBinaryStreamOwned()) return false; #if MESH_ESP32_TINYUSB_NONBLOCKING return buffered_esp32_tinyusb_terminal_port.availableForWrite() >= static_cast(esp32_tinyusb_functional_reserve); @@ -1709,6 +1786,11 @@ void serviceUsbTerminalPort() { primary_usb_terminal_discard_pending.store(true, std::memory_order_release); return; } + if (isUsbBinaryStreamOwned()) { + if (!buffered_primary_usb_terminal_port.tryDiscardPending()) + primary_usb_terminal_discard_pending.store(true, std::memory_order_release); + return; + } buffered_primary_usb_terminal_port.service(); #elif MESH_ESP32_HWCDC_SESSION_GUARD serviceEsp32HwcdcTxKick(); diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index bb4e0b2c0..7f05c2309 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -1,6 +1,7 @@ #pragma once #include "UsbLoggingStatus.h" +#include "HostKissConfig.h" // Canonical USB-capable images compile at least one of these two diagnostics. // A master USB output gate and a separate debug-verbosity gate keep packet @@ -53,6 +54,19 @@ namespace mesh { +// A raw host session owns the primary USB byte stream without changing the +// saved packet/debug logging preference. Functional text and diagnostics must +// stay out of that binary stream, including writers retaining a Stream reference. +#if MESH_ENABLE_HOST_KISS +bool isUsbBinaryStreamOwned(); +void setUsbBinaryStreamOwned(bool owned); +bool isUsbBinaryStreamReady(); +#else +inline bool isUsbBinaryStreamOwned() { return false; } +inline void setUsbBinaryStreamOwned(bool) { } +inline bool isUsbBinaryStreamReady() { return true; } +#endif + #ifndef MESH_ESP32_USB_TX_BUFFER_SIZE #define MESH_ESP32_USB_TX_BUFFER_SIZE 4096 #endif @@ -83,7 +97,7 @@ void setUsbLoggingEnabled(bool enabled); bool isUsbDebugEnabled(); void setUsbDebugEnabled(bool enabled); inline bool isUsbDebugLoggingEnabled() { - return isUsbLoggingEnabled() && isUsbDebugEnabled(); + return !isUsbBinaryStreamOwned() && isUsbLoggingEnabled() && isUsbDebugEnabled(); } // Kept as a platform-neutral persistence hook. Single-TTY builds need no diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index d1cbdbae8..6fde46bb0 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -19,6 +19,9 @@ public: #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -26,10 +29,14 @@ public: } protected: -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { if (protocol != mesh::RadioProfileProtocol::MeshCore - && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + && protocol != mesh::RadioProfileProtocol::Meshtext +#if MESH_ENABLE_HOST_KISS + && protocol != mesh::RadioProfileProtocol::Kiss +#endif + ) return false; const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 5d79bba83..5a4d41406 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -35,6 +35,9 @@ public: #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } +#endif void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } @@ -48,10 +51,14 @@ protected: void setDriverSwitchFeatures(uint8_t features) override { static_cast(_radio)->setProfileSwitchFeatures(features); } -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { if (protocol != mesh::RadioProfileProtocol::MeshCore - && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + && protocol != mesh::RadioProfileProtocol::Meshtext +#if MESH_ENABLE_HOST_KISS + && protocol != mesh::RadioProfileProtocol::Kiss +#endif + ) return false; const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomLR1121Wrapper.h b/src/helpers/radiolib/CustomLR1121Wrapper.h index 20970174d..da72ead5b 100644 --- a/src/helpers/radiolib/CustomLR1121Wrapper.h +++ b/src/helpers/radiolib/CustomLR1121Wrapper.h @@ -24,6 +24,9 @@ public: bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } +#endif +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } #endif void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } @@ -34,10 +37,14 @@ public: } protected: -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { if (protocol != mesh::RadioProfileProtocol::MeshCore - && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + && protocol != mesh::RadioProfileProtocol::Meshtext +#if MESH_ENABLE_HOST_KISS + && protocol != mesh::RadioProfileProtocol::Kiss +#endif + ) return false; const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 42af5c4de..6dfc25311 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -173,6 +173,9 @@ public: #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -198,10 +201,14 @@ protected: recordApply(stage, status); } -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { if (protocol != mesh::RadioProfileProtocol::MeshCore - && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + && protocol != mesh::RadioProfileProtocol::Meshtext +#if MESH_ENABLE_HOST_KISS + && protocol != mesh::RadioProfileProtocol::Kiss +#endif + ) return false; const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; return recordApply(mesh::lr2021::ApplyStatus::Stage::Protocol, static_cast(_radio)->setProfileProtocol(sync)) == RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 641eaf093..6882161a3 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -31,17 +31,23 @@ public: #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } +#endif #if MESH_ENABLE_MORSE_RX bool supportsMorseRx() const override { return supportsRadioDeepInit(); } #endif void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } protected: -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { uint8_t sync; if (protocol == mesh::RadioProfileProtocol::MeshCore) sync = 0x12; else if (protocol == mesh::RadioProfileProtocol::Meshtext) sync = 0x2b; +#if MESH_ENABLE_HOST_KISS + else if (protocol == mesh::RadioProfileProtocol::Kiss) sync = 0x12; +#endif else return false; return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 26f808190..85ea803c9 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -69,6 +69,9 @@ public: } ~CustomSX1262Wrapper() { unregisterInterruptAction(); } CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } +#endif bool supportsCarrierWave() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } #if MESH_ENABLE_MESHTEXT_WIRE @@ -103,7 +106,7 @@ protected: return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE && RadioLibWrapper::restoreCarrierWaveModem(); } -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { uint8_t sync = 0x12; if (protocol == mesh::RadioProfileProtocol::Meshtext) { @@ -113,11 +116,15 @@ protected: } else if (protocol == mesh::RadioProfileProtocol::Ethernet) { if (!supportsEthernetTunnel()) return false; sync = 0x16; +#endif +#if MESH_ENABLE_HOST_KISS + } else if (protocol == mesh::RadioProfileProtocol::Kiss) { + if (!supportsHostKiss()) return false; #endif } else if (protocol != mesh::RadioProfileProtocol::MeshCore) return false; // No alternate wire implementation means no modem write is needed. // Ethernet-only builds must still restore the MeshCore syncword. -#if !MESH_ENABLE_ETHERNET_WIRE +#if !MESH_ENABLE_ETHERNET_WIRE && !MESH_ENABLE_HOST_KISS if (!supportsMeshtext()) return protocol == mesh::RadioProfileProtocol::MeshCore; #endif auto* radio = static_cast(_radio); diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index ba14566f4..5875d8ab2 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -23,6 +23,9 @@ public: #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -30,10 +33,14 @@ public: } protected: -#if MESH_ENABLE_MESHTEXT_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { if (protocol != mesh::RadioProfileProtocol::MeshCore - && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + && protocol != mesh::RadioProfileProtocol::Meshtext +#if MESH_ENABLE_HOST_KISS + && protocol != mesh::RadioProfileProtocol::Kiss +#endif + ) return false; const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index ee42f7819..4e951c331 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -11,6 +11,9 @@ class CustomSX1276Wrapper : public RadioLibWrapper { public: ~CustomSX1276Wrapper() { unregisterInterruptAction(); } CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } +#if MESH_ENABLE_HOST_KISS + bool supportsHostKiss() const override { return true; } +#endif bool supportsCarrierWave() const override { return supportsRadioDeepInit(); } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } #if MESH_ENABLE_MESHTEXT_WIRE @@ -26,7 +29,7 @@ public: } protected: -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS bool applyProtocol(mesh::RadioProfileProtocol protocol) override { uint8_t sync = 0x12; if (protocol == mesh::RadioProfileProtocol::Meshtext) { @@ -36,6 +39,10 @@ protected: } else if (protocol == mesh::RadioProfileProtocol::Ethernet) { if (!supportsEthernetTunnel()) return false; sync = 0x16; +#endif +#if MESH_ENABLE_HOST_KISS + } else if (protocol == mesh::RadioProfileProtocol::Kiss) { + if (!supportsHostKiss()) return false; #endif } else if (protocol != mesh::RadioProfileProtocol::MeshCore) return false; return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index cd3461b5d..bbc20ef2b 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -615,7 +615,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() { restored = _radio->setPreambleLength(currentPreambleLength()) == RADIOLIB_ERR_NONE; } -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS restored = restored && applyProtocol(_physical_protocol); #endif if (_dbm_valid) { @@ -647,7 +647,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, const uint8_t previous_sf = _cur_sf; const uint8_t previous_cr = _cur_cr; const uint16_t previous_preamble = _physical_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS const auto previous_protocol = _physical_protocol; #endif const uint8_t previous_profile = _active_profile; @@ -664,7 +664,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, _physical_preamble = profilePreamble(0); bool success = applyParams(freq, bw, sf, cr); -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS success = success && applyProtocol(requested.protocol); if (success) _physical_protocol = requested.protocol; #endif @@ -701,7 +701,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, _profile_generation = previous_generation; _profile_refresh_required = previous_refresh; _physical_preamble = previous_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS _physical_protocol = previous_protocol; #endif _rx_ps_enabled = previous_rxps; @@ -712,7 +712,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, bool restored = had_previous_params && _radio->standby() == RADIOLIB_ERR_NONE && applyParams(previous_freq, previous_bw, previous_sf, previous_cr); -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS restored = restored && applyProtocol(previous_protocol); #endif @@ -745,6 +745,11 @@ bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const { if (!mesh::RadioProfiles::valid(p)) return false; #endif if (p.protocol == mesh::RadioProfileProtocol::Meshtext && !supportsMeshtext()) return false; +#if MESH_ENABLE_HOST_KISS + if (p.protocol == mesh::RadioProfileProtocol::Kiss && !supportsHostKiss()) return false; +#else + if (p.protocol == mesh::RadioProfileProtocol::Kiss) return false; +#endif #if MESH_ENABLE_ETHERNET_WIRE if (p.protocol == mesh::RadioProfileProtocol::Ethernet && !supportsEthernetTunnel()) return false; #else @@ -846,12 +851,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { const bool old_refresh = _profile_refresh_required; const bool old_params_valid = _params_valid; const uint16_t old_preamble = _physical_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS const auto old_protocol = _physical_protocol; #endif _physical_preamble = preamble; bool applied = applyParams(p.freq, p.bw, p.sf, p.cr); -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS // Changing only the protocol still changes the sync word. Conversely a // failed write must not publish the new generation or cached wire format. if (applied && (_physical_protocol != p.protocol || _profile_refresh_required)) { @@ -903,12 +908,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { _profile_generation = old_generation; _profile_refresh_required = old_refresh; _physical_preamble = old_preamble; -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS _physical_protocol = old_protocol; #endif bool restored = old_params_valid && _radio->standby() == RADIOLIB_ERR_NONE && applyParams(old_freq, old_bw, old_sf, old_cr); -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS restored = restored && applyProtocol(old_protocol); #endif if (!restored) restored = restoreAfterDeepInit(); @@ -1016,7 +1021,7 @@ void RadioLibWrapper::cacheBeaconRestoreParams() { _active_profile = profile; _profile_generation = _profiles.generation[profile]; _physical_preamble = profilePreamble(profile); -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS _physical_protocol = p.protocol; #endif _profile_refresh_required = true; @@ -1051,7 +1056,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit( ? 16 : (p.preamble ? p.preamble : 32); bool applied = _radio->standby() == RADIOLIB_ERR_NONE && applyParams(p.freq, p.bw, p.sf, p.cr); -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS applied = applied && applyProtocol(p.protocol); if (applied) _physical_protocol = p.protocol; #endif @@ -1094,7 +1099,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() { && _radio->standby() == RADIOLIB_ERR_NONE; beginProfileRetune(false); restored = restored && applyParams(p.freq, p.bw, p.sf, p.cr); -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS restored = restored && applyProtocol(p.protocol); #endif restored = _dbm_applied = restored && applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE; @@ -2954,7 +2959,7 @@ bool RadioLibWrapper::hilPacketTransmitAllowed() const { return mesh::hilTransmitFrequencyAllowed(_cur_freq) && _cur_freq == p.freq && _cur_bw == p.bw && _cur_sf == p.sf && _cur_cr == p.cr && _physical_preamble == profilePreamble(_active_profile) -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS && _physical_protocol == p.protocol #endif ; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index ce8bd1e12..3ea00756d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -116,7 +116,7 @@ protected: mesh::RadioProfiles _profiles; uint8_t _active_profile = 0; uint16_t _physical_preamble = 0; -#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE || MESH_ENABLE_HOST_KISS mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore; // Wire settings are a transaction with the modulation tuple. Unsupported // wrappers decline rather than claiming a profile was applied. diff --git a/test/test_companion_packet_stream.py b/test/test_companion_packet_stream.py index 084004402..81b774547 100644 --- a/test/test_companion_packet_stream.py +++ b/test/test_companion_packet_stream.py @@ -174,7 +174,8 @@ class CompanionPacketStreamTests(unittest.TestCase): functions = usb[start:end] + '\n' + '\n'.join(method(usb, sig) for sig in ( 'bool hasDedicatedUsbLoggingPort()', 'bool isDedicatedUsbLoggingPortConfigured()', 'bool usbLoggingInterfaceRestartRequired()', 'const char* usbLoggingPortDescription()', - 'void beginUsbLoggingPort()', 'Stream& usbLoggingPort()')) + 'void beginUsbLoggingPort()', 'static Stream& rawUsbLoggingPort()', + 'Stream& usbLoggingPort()')) start = mesh.index(' if (strcmp(command, "get usb.logging") == 0)') end = mesh.index('\n#endif', start) boot_start = mesh.index(' const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;') diff --git a/test/test_companion_usb_default.py b/test/test_companion_usb_default.py index e0013fe62..43eeba0c8 100644 --- a/test/test_companion_usb_default.py +++ b/test/test_companion_usb_default.py @@ -21,6 +21,8 @@ def function(signature): HARNESS = r''' +#define MESH_ENABLE_HOST_KISS 0 +#include "helpers/HostKiss.h" #include #include #include diff --git a/test/test_companion_usb_input.py b/test/test_companion_usb_input.py index 4d40c15de..863fec0a8 100644 --- a/test/test_companion_usb_input.py +++ b/test/test_companion_usb_input.py @@ -16,6 +16,8 @@ CONTEXT = (ROOT / "src/helpers/ota/OtaContext.h").read_text() HARNESS = r''' +#define MESH_ENABLE_HOST_KISS 0 +#include "helpers/HostKiss.h" #include #include #include diff --git a/test/test_companion_usb_logging_reply.py b/test/test_companion_usb_logging_reply.py index 195878feb..23cc90853 100644 --- a/test/test_companion_usb_logging_reply.py +++ b/test/test_companion_usb_logging_reply.py @@ -9,6 +9,8 @@ from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' +#define MESH_ENABLE_HOST_KISS 0 +#include "helpers/HostKiss.h" #include #include #include diff --git a/test/test_esp32_tinyusb_role_hygiene.py b/test/test_esp32_tinyusb_role_hygiene.py index a59726e87..9ad9c423c 100644 --- a/test/test_esp32_tinyusb_role_hygiene.py +++ b/test/test_esp32_tinyusb_role_hygiene.py @@ -5,6 +5,8 @@ from pathlib import Path import re import unittest +from cpp_source import body + ROOT = Path(__file__).resolve().parents[1] ROLES = ("simple_repeater", "simple_room_server") @@ -19,7 +21,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase): text = source("src/helpers/UsbLogging.cpp") self.assertRegex( text, - r"Stream& usbConsolePort\(\)\s*\{\s*" + r"static Stream& rawUsbConsolePort\(\)\s*\{\s*" r"#if MESH_ESP32_TINYUSB_NONBLOCKING\s+" r"return buffered_esp32_tinyusb_terminal_port;\s+" r"#elif MESH_ESP32_HWCDC_SESSION_GUARD\s+" @@ -28,6 +30,11 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase): r"return buffered_primary_usb_terminal_port;\s+" r"#else\s+return Serial;\s+#endif", ) + # The shared binary-owner facade wraps the same transport selection. + # With host KISS omitted, every platform still gets its original port. + facade = body(text, "Stream& usbConsolePort()") + self.assertIn("guarded.use(rawUsbConsolePort());", facade) + self.assertRegex(facade, r"#else\s+return rawUsbConsolePort\(\);\s+#endif") def test_role_output_never_bypasses_the_usb_facade(self): # A capacity check outside Serial.write() cannot make Arduino's diff --git a/test/test_host_kiss.py b/test/test_host_kiss.py new file mode 100644 index 000000000..fcd8ef8d8 --- /dev/null +++ b/test/test_host_kiss.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Run production KISS framing, persistence and session logic on the host.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +CODEC = r''' +#include +#include +#include +using mesh::hostkiss::Codec; +std::vector output(Codec& codec) { + const uint8_t* bytes;size_t length;std::vector result; + while(codec.peekOutput(bytes,length)) { + const size_t amount=length>7?7:length; + result.insert(result.end(),bytes,bytes+amount);codec.consumeOutput(amount); + } + return result; +} +void feed(Codec& codec,const std::vector& bytes) { + for(auto byte:bytes) assert(codec.feed(byte)); +} +int main() { + Codec encoder,decoder; + for(unsigned length=1;length<=255;++length) { + std::vector bytes(length); + for(unsigned i=0;i(decoded,decoded+size)==bytes); + decoder.consumeTransmit(); + } + const uint8_t escaped[]={0xc0,0xdb,0xc0}; + assert(encoder.queueReceive(escaped,3)); + assert(output(encoder)==std::vector({0xc0,0,0xdb,0xdc,0xdb,0xdd,0xdb,0xdc,0xc0})); + // Oversized, malformed escape, empty DATA, unknown command/port and RETURN + // with payload cannot be transformed into a valid radio packet. + for(auto bytes:std::vector>{{0xc0,0,0xc0}, + {0xc0,0,1,0xdb,0xc0},{0xc0,0,1,0xdb,0x11,0xc0}, + {0xc0,0x10,1,2,0xc0},{0xc0,1,99,0xc0},{0xc0,0xff,1,0xc0}}) { + decoder.reset();feed(decoder,bytes); + const uint8_t* data;size_t size;assert(!decoder.peekTransmit(data,size)&&!decoder.returned()); + } + std::vector oversized(260,1);oversized[0]=0xc0;oversized[1]=0;oversized.back()=0xc0; + decoder.reset();feed(decoder,oversized); + const uint8_t* data;size_t size;assert(!decoder.peekTransmit(data,size)); + assert(decoder.malformedFrames()>=5); + feed(decoder,{0xc0,0,17,0xc0,0,18,0xc0}); + assert(decoder.queuedTransmit()==2&&!decoder.canAcceptByte()&&!decoder.feed(0xc0)); + decoder.consumeTransmit();assert(decoder.canAcceptByte()); + assert(decoder.peekTransmit(data,size)&&size==1&&data[0]==18); + decoder.reset();feed(decoder,{1,2,3,0xc0,0xff,0xc0}); + assert(decoder.returned()&&!decoder.canAcceptByte());decoder.reset(); + assert(!decoder.returned()&&decoder.canAcceptByte()); + assert(encoder.queueReady()&&encoder.queueTxDone(false)); + assert(!encoder.queueReceive(escaped,3)&&encoder.droppedFrames()==1); + assert(output(encoder)==std::vector({0xc0,0x0f,1,0xc0,0xc0,0x06,0xf8,0,0xc0})); + assert(!encoder.queueReceive(nullptr,1)&&!encoder.queueReceive(escaped,0)); + // Fuzz framing under sanitizers with periodic draining/backpressure. + uint32_t state=17; + for(unsigned i=0;i<200000;++i) { + state=state*1664525+1013904223; + if(!decoder.canAcceptByte()) decoder.reset(); + decoder.feed(uint8_t(state>>24)); + if(i%317==0) decoder.reset(); + } +} +''' + +SETTINGS = r''' +#include +#include +#include +using namespace mesh::hostkiss; +using Bytes=std::vector; +constexpr char Path[]="/host_kiss"; +Bytes image(Selection selection) { + Bytes bytes={'H','K','S','1',uint8_t(selection.enabled),uint8_t(selection.transport),0,0,0,0,0,0}; + mesh::storage::writeLE32(bytes.data()+8,mesh::storage::updateCRC32(0xffffffff,bytes.data(),8));return bytes; +} +int main() { + Selection on{true,Transport::Usb},tcp{true,Transport::Tcp},off{false,Transport::Usb}; + GuestMemoryFS fs;Settings store; + assert(store.begin(&fs)&&store.healthy()&&store.selection()==off&&fs.writes==0); + assert(store.save(on)&&store.selection()==on&&fs.files.at(Path)==image(on)); + assert(store.save(tcp)&&store.selection()==tcp&&fs.files.at(Path)==image(tcp)); + Settings reboot;assert(reboot.begin(&fs)&&reboot.selection()==tcp); + assert(reboot.save(off)&&reboot.selection()==off); + for(unsigned size=0;size<25;++size) { + if(size==12)continue; + GuestMemoryFS broken;broken.files[Path]=image(on);broken.files[Path].resize(size); + Settings read;assert(!read.begin(&broken)&&read.selection()==off); + assert(read.save(off)); + } + for(unsigned byte=0;byte<12;++byte) for(unsigned bit=0;bit<8;++bit) { + GuestMemoryFS broken;broken.files[Path]=image(tcp);broken.files[Path][byte]^=1<10); + for(const auto& snapshot:snapshots) { + GuestMemoryFS resumed;resumed.files=snapshot;Settings recovered;assert(recovered.begin(&resumed)); + if(snapshot.count(Path)) assert(image(recovered.selection())==snapshot.at(Path)); + else if(snapshot.count("/host_kiss.bak")) assert(image(recovered.selection())==snapshot.at("/host_kiss.bak")); + else assert(recovered.selection()==off); + } + } + GuestMemoryFS scratch;scratch.files["/host_kiss.tmp"]=image(tcp); + Settings discarded;assert(discarded.begin(&scratch)&&discarded.selection()==off); +} +''' + +RADIO_STUB = r''' +#pragma once +#include +#include +#include +namespace mesh { +enum class RadioProfileProtocol:uint8_t {MeshCore=0,Meshtext=1,Ethernet=2,Kiss=3}; +enum class RadioProfileMode:uint8_t {Off=0,Rx=1,RxTx=2}; +struct RadioProfileParams {RadioProfileProtocol protocol=RadioProfileProtocol::MeshCore;}; +struct RadioProfileConfig {RadioProfileParams params;RadioProfileMode mode=RadioProfileMode::RxTx;}; +struct RadioProfiles {RadioProfileConfig secondary;uint32_t generation[2]={1,1};bool primary_temporary=false,secondary_temporary=false;}; +class Radio {public:RadioProfiles p;bool supported=true;bool supportsHostKiss()const{return supported;}RadioProfiles* profiles(){return &p;}}; +class Dispatcher {public: + bool pending=false,allow=true;unsigned submitted=0,cancelled=0;uint32_t gen=0;std::vector packet; + bool submitHostKissFrame(const uint8_t* bytes,size_t size,uint32_t generation){ + if(pending||!allow)return false;pending=true;++submitted;gen=generation;packet.assign(bytes,bytes+size);return true;} + bool cancelHostKissFrame(){++cancelled;if(!pending)return false;pending=false;return true;} + bool hostKissFramePending()const{return pending;} +}; +} +''' +PROFILE_STUB = r''' +#pragma once +#include +namespace mesh {class RadioProfileCLI { + public:Radio* radio;RadioProfileConfig saved;bool manual=true,busy=false,overlay=false;uint32_t intent=0;unsigned clear=0; + explicit RadioProfileCLI(Radio& value):radio(&value){} + bool manualSecondaryConfigured()const{return manual;} + const RadioProfileConfig& savedSecondaryConfig()const{return saved;} + bool secondaryRuntimeAvailable()const{return !busy;} + uint32_t secondaryIntentGeneration()const{return intent;} + bool useSecondaryProtocol(RadioProfileProtocol protocol){ + if(busy||!manual)return false;radio->p.secondary.params.protocol=protocol;++radio->p.generation[1];overlay=true;return true;} + void clearSecondaryProtocol(){++clear;if(overlay){radio->p.secondary=saved;++radio->p.generation[1];overlay=false;}} +};} +''' +USB_STUB = r''' +#pragma once +#include +#include +#include +#include +struct Stream { + std::deque input;std::vector output;size_t write_budget=128; + int available(){return int(input.size());}int peek(){return input.empty()?-1:input.front();} + int read(){if(input.empty())return -1;int byte=input.front();input.pop_front();return byte;} + size_t write(const uint8_t* bytes,size_t length){if(length>write_budget)length=write_budget;output.insert(output.end(),bytes,bytes+length);return length;} +}; +inline Stream serial; +inline uint32_t fake_millis=0; +inline uint32_t millis(){return fake_millis;} +namespace mesh { +inline bool binary_owned=false; +inline bool binary_ready=true; +inline Stream& usbCompanionPort(){return serial;} +inline void setUsbBinaryStreamOwned(bool value){binary_owned=value;} +inline bool isUsbBinaryStreamReady(){return binary_ready;} +} +''' + +NETWORK_STUB = r''' +#pragma once +#include +#include +#include +#include +#include +#include +#include +#define WIFI_STA 1 +#define WIFI_AP 2 +#define WL_CONNECTED 3 +#define MSG_DONTWAIT 0x40 +struct Socket {bool connected=true;std::deque input;std::vector output;size_t write_budget=128;}; +inline std::map> sockets; +inline std::deque accepts; +inline int next_socket=1; +inline int newSocket(){int id=next_socket++;sockets[id]=std::make_shared();return id;} +class IPAddress {uint32_t value_;public:IPAddress(uint32_t value=0):value_(value){}operator uint32_t()const{return value_;}}; +struct Wifi {int mode=0,status_value=0;uint32_t address=0; + int getMode()const{return mode;}int status()const{return status_value;}IPAddress localIP()const{return IPAddress(address);}}; +inline Wifi WiFi; +class WiFiClient {int id_=0;public: + WiFiClient()=default;explicit WiFiClient(int id):id_(id){} + explicit operator bool()const{return id_&&sockets.at(id_)->connected;} + bool connected()const{return bool(*this);}int fd()const{return id_;} + void stop(){if(id_)sockets.at(id_)->connected=false;id_=0;}void setNoDelay(bool){} +}; +inline uint32_t bound_address=0;inline uint16_t bound_port=0;inline unsigned listens=0; +class WiFiServer {uint32_t address_=0;uint16_t port_=0;bool started_=false; + public:WiFiServer(uint16_t port,int):port_(port){}WiFiServer(IPAddress address,uint16_t port,int):address_(address),port_(port){} + void begin(){started_=true;bound_address=address_;bound_port=port_;++listens;} + void end(){started_=false;while(!accepts.empty()){sockets.at(accepts.front())->connected=false;accepts.pop_front();}} + explicit operator bool()const{return started_;} + WiFiClient available(){if(accepts.empty())return {};int id=accepts.front();accepts.pop_front();return WiFiClient(id);} +}; +inline int send(int fd,const void* data,size_t length,int flags){ + if(flags!=MSG_DONTWAIT)__builtin_trap();auto sock=sockets.at(fd); + if(!sock->connected){errno=EPIPE;return -1;}if(length>sock->write_budget)length=sock->write_budget; + const auto* bytes=static_cast(data);sock->output.insert(sock->output.end(),bytes,bytes+length);return int(length); +} +inline int recv(int fd,void* data,size_t length,int flags){ + if(flags!=MSG_DONTWAIT||length!=1)__builtin_trap();auto sock=sockets.at(fd); + if(!sock->connected)return 0;if(sock->input.empty()){errno=EAGAIN;return -1;} + *static_cast(data)=sock->input.front();sock->input.pop_front();return 1; +} +''' + +TCP_RUNTIME = r''' +#include +#include +#include +#include +#include +#include +#include +using namespace mesh; +char reply[240]; +void command(const char* text){assert(hostkiss::handleCommand(text,reply,sizeof(reply),true));} +void input(int socket,std::initializer_list bytes){auto& q=sockets.at(socket)->input;q.insert(q.end(),bytes);} +int main(){ + GuestMemoryFS fs;Radio radio;RadioProfileCLI profiles(radio);Dispatcher dispatcher; + hostkiss::begin(&fs,radio,profiles,dispatcher); + command("set kiss.transport tcp");assert(strstr(reply,"OK")); + command("set kiss on");assert(strstr(reply,"OK")); + WiFi.mode=WIFI_AP;WiFi.status_value=WL_CONNECTED;WiFi.address=123; + hostkiss::loop();assert(listens==0); + command("get kiss");assert(strstr(reply,"waiting-wifi")); + WiFi.mode=WIFI_STA|WIFI_AP;hostkiss::loop();assert(listens==1&&bound_address==123&&bound_port==5004); + command("get kiss");assert(strstr(reply,"listening")); + const int first=newSocket();accepts.push_back(first);hostkiss::loop(); + assert(hostkiss::radioActive()&&!hostkiss::usbActive()&&!binary_owned); + assert(sockets.at(first)->output==std::vector({0xc0,0x0f,1,0xc0})); + const int rejected=newSocket();accepts.push_back(rejected);hostkiss::loop(); + assert(!sockets.at(rejected)->connected&&sockets.at(first)->connected); + input(first,{0xc0,0,1,2,3,0xc0});hostkiss::loop(); + assert(dispatcher.submitted==1&&dispatcher.packet==std::vector({1,2,3})); + dispatcher.pending=false;hostkiss::sendComplete(true);hostkiss::loop();hostkiss::loop(); + assert(sockets.at(first)->output==std::vector({0xc0,0x0f,1,0xc0,0xc0,6,0xf8,1,0xc0,0xc0,0x0f,1,0xc0})); + // Backpressure preserves every escaped output byte across partial sends. + sockets.at(first)->output.clear();sockets.at(first)->write_budget=3; + uint8_t packet[255];memset(packet,0xc0,sizeof(packet)); + hostkiss::receive(packet,sizeof(packet),1,radio.p.generation[1],0,0); + for(unsigned i=0;i<200;++i)hostkiss::loop(); + const auto& bytes=sockets.at(first)->output; + assert(bytes.size()==513&&bytes[0]==0xc0&&bytes[1]==0&&bytes.back()==0xc0); + for(unsigned i=2;i<512;i+=2)assert(bytes[i]==0xdb&&bytes[i+1]==0xdc); + // The profile change drops both the active stream and pending accepts. + const int stale=newSocket();accepts.push_back(stale);input(stale,{0xc0,0,99,0xc0}); + ++radio.p.generation[1];hostkiss::loop(); + assert(!sockets.at(first)->connected&&!sockets.at(stale)->connected&&!dispatcher.pending); + assert(dispatcher.submitted==1&&!hostkiss::radioActive()); + // A new explicit runtime profile can start a fresh stream; old RX cannot. + profiles.overlay=false;radio.p.secondary=profiles.saved;++radio.p.generation[1]; + const int second=newSocket();accepts.push_back(second);hostkiss::loop(); + assert(hostkiss::radioActive()&&sockets.at(second)->connected); + const unsigned before=dispatcher.submitted; + input(second,{0xc0,0xff,0xc0});hostkiss::loop(); + assert(!sockets.at(second)->connected&&!hostkiss::radioActive()&&dispatcher.submitted==before); + // Losing station WiFi tears down the raw radio lease and listener. + const int third=newSocket();accepts.push_back(third);hostkiss::loop();assert(hostkiss::radioActive()); + WiFi.mode=WIFI_AP;hostkiss::loop();assert(!hostkiss::radioActive()&&!sockets.at(third)->connected); + command("set kiss off");assert(strstr(reply,"OK")); +} +''' + +RUNTIME = r''' +#include +#include +#include +#include +#include +#include +#include +using namespace mesh; +void input(std::initializer_list bytes){serial.input.insert(serial.input.end(),bytes);} +void poll(){hostkiss::loop();hostkiss::serviceUsb();} +char reply[240]; +void command(const char* text,bool local=true){assert(hostkiss::handleCommand(text,reply,sizeof(reply),local));} +int main() { + GuestMemoryFS fs;Radio radio;RadioProfileCLI profiles(radio);Dispatcher dispatcher; + hostkiss::begin(&fs,radio,profiles,dispatcher); + command("get kiss");assert(strstr(reply,"> off")); + input({0xc0,0,1,0xc0});assert(!hostkiss::serviceUsb()&&serial.available()==4); + serial.input.clear(); + command("set kiss on",false);assert(strstr(reply,"local connection")); + command("set kiss on");assert(strstr(reply,"OK")); + hostkiss::Settings saved;assert(saved.begin(&fs)&&saved.selection().enabled); + input({'g','e','t',' '});assert(!hostkiss::serviceUsb()&&serial.available()==4); + serial.input.clear(); + profiles.manual=false;input({0xc0});assert(hostkiss::serviceUsb()&&hostkiss::usbActive()&&binary_owned); + assert(!hostkiss::radioActive());input({0xff,0xc0});assert(hostkiss::serviceUsb()&&!hostkiss::usbActive()); + profiles.manual=true;input({0xc0});binary_ready=false; + const size_t before_ready=serial.output.size(); + assert(hostkiss::serviceUsb()&&hostkiss::usbActive()&&binary_owned); + assert(serial.available()==1&&serial.output.size()==before_ready); + binary_ready=true;assert(hostkiss::serviceUsb()&&serial.available()==0); + assert(hostkiss::radioActive()&&radio.p.secondary.params.protocol==RadioProfileProtocol::Kiss); + assert(profiles.saved.params.protocol==RadioProfileProtocol::MeshCore); + input({0,7,0xdb,0xdc,0xdb,0xdd,0xc0});poll(); + assert(dispatcher.submitted==1&&dispatcher.packet==std::vector({7,0xc0,0xdb})); + input({0,8,0xc0});poll();assert(dispatcher.submitted==1); + dispatcher.pending=false;hostkiss::sendComplete(true);poll();assert(dispatcher.submitted==2); + // Cross-profile or old-generation RX can never leak into the host stream. + poll();poll(); + serial.output.clear();uint8_t rx[]={9}; + hostkiss::receive(rx,1,0,radio.p.generation[1],0,0); + hostkiss::receive(rx,1,1,radio.p.generation[1]-1,0,0);poll(); + assert(serial.output.empty()); + hostkiss::receive(rx,1,1,radio.p.generation[1],0,0);poll(); + assert(serial.output==std::vector({0xc0,0,9,0xc0})); + // A failed settings write preserves the active session and lease. + fs.fail_open_write=true;command("set kiss off"); + assert(strstr(reply,"save failed")&&hostkiss::usbActive()&&hostkiss::radioActive()); + fs.fail_open_write=false; + // Profile invalidation cancels stale TX but quarantines USB binary input. + radio.p.secondary.params.protocol=RadioProfileProtocol::Meshtext; + ++radio.p.generation[1];const unsigned cleared=profiles.clear; + poll();assert(hostkiss::usbActive()&&!hostkiss::radioActive()); + assert(profiles.clear==cleared&&radio.p.secondary.params.protocol==RadioProfileProtocol::Meshtext); + const unsigned submitted=dispatcher.submitted; + input({0xc0,0,99,0xc0});poll();assert(dispatcher.submitted==submitted); + input({0xc0,0xff,0xc0});assert(hostkiss::serviceUsb()); + assert(!hostkiss::usbActive()&&!binary_owned&&!dispatcher.pending); + hostkiss::sendComplete(true); // late completion cannot enter a new session + profiles.overlay=false;radio.p.secondary=profiles.saved;++radio.p.generation[1]; + input({0xc0,0,17,0xc0});poll();assert(dispatcher.submitted==submitted+1); + hostkiss::resetUsbSession();assert(!hostkiss::usbActive()&&!binary_owned&&!dispatcher.pending); + // Off must be durable, releases the overlay and cannot consume binary input. + command("set kiss off");assert(strstr(reply,"OK")&&!hostkiss::radioActive()); + assert(saved.begin(&fs)&&!saved.selection().enabled); + input({0xc0,0,8,0xc0});assert(!hostkiss::serviceUsb());serial.input.clear(); + hostkiss::begin(&fs,radio,profiles,dispatcher);command("get kiss");assert(strstr(reply,"> off")); + command("set kiss.transport tcp");assert(strstr(reply,"requires ESP32")); + assert(!hostkiss::handleCommand("get kissing",reply,sizeof(reply),true)); +} +''' + + +class HostKissTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("host C++ compiler required") + cls.work = tempfile.TemporaryDirectory(prefix="mesh-host-kiss-") + cls.path = Path(cls.work.name) + cls.mocks = cls.path / "mocks" + shutil.copytree(ROOT / "test/fixtures/guest_login/mocks", cls.mocks) + for path in cls.mocks.rglob("*"): + if path.is_file(): + path.write_text(path.read_text().replace("/guest_login", "/host_kiss"), encoding="ascii") + + @classmethod + def tearDownClass(cls): + cls.work.cleanup() + + def build_run(self, name, source, backend=None, runtime=False): + path = self.path / (name + ".cpp") + path.write_text(source, encoding="ascii") + binary = self.path / name + flags = ["-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-misleading-indentation", "-Wno-sign-compare"] + if os.name != "nt": + flags += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"] + sources = [str(path)] + includes = ["-I", str(self.mocks), "-I", str(ROOT / "src")] + if backend: + flags += ["-DMESH_ENABLE_HOST_KISS=1", "-D" + backend + "_PLATFORM"] + if backend == "ESP32": + flags += ["-DESP32"] + sources.append(str(ROOT / "src/helpers/HostKissSettings.cpp")) + if backend in ("NRF52", "STM32"): + sources.append(str(ROOT / "src/helpers/AtomicFileWriter.cpp")) + if runtime: + runtime_path = self.path / "HostKiss.cpp" + shutil.copyfile(ROOT / "src/helpers/HostKiss.cpp", runtime_path) + sources.append(str(runtime_path)) + includes += ["-I", str(ROOT / "src/helpers")] + (self.mocks / "Dispatcher.h").write_text(RADIO_STUB, encoding="ascii") + (self.mocks / "RadioProfileCLI.h").write_text(PROFILE_STUB, encoding="ascii") + (self.mocks / "UsbLogging.h").write_text(USB_STUB, encoding="ascii") + if backend == "ESP32": + (self.mocks / "WiFi.h").write_text(NETWORK_STUB, encoding="ascii") + (self.mocks / "lwip").mkdir(exist_ok=True) + (self.mocks / "lwip/sockets.h").write_text('#include \n', encoding="ascii") + result = subprocess.run([self.compiler, *flags, *includes, *sources, "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_framing_limits_escaping_flow_and_malformed_input(self): + self.build_run("codec", CODEC) + + def test_saved_settings_transaction_and_reboot_boundaries(self): + for backend in ("ESP32", "RP2040", "NRF52", "STM32"): + with self.subTest(backend=backend): + self.build_run("settings_" + backend, SETTINGS, backend) + + def test_usb_session_ownership_stale_frames_and_toggle(self): + self.build_run("runtime", RUNTIME, "NRF52", runtime=True) + + def test_tcp_station_binding_backpressure_sessions_and_toggle(self): + self.build_run("tcp_runtime", TCP_RUNTIME, "ESP32", runtime=True) + + def test_disabled_header_needs_no_platform_types(self): + self.build_run("disabled", r''' +#include +int main(){int a=0;mesh::hostkiss::begin(&a,a,a,a); + return mesh::hostkiss::serviceUsb()||mesh::hostkiss::radioActive();} +''') + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_host_kiss_infrastructure.py b/test/test_host_kiss_infrastructure.py new file mode 100644 index 000000000..8dcf6da5a --- /dev/null +++ b/test/test_host_kiss_infrastructure.py @@ -0,0 +1,256 @@ +#!/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 +#include +#include +#include +#include +#include +#include +static std::deque input; +static bool kiss_active = false, reset_pending = false, network_pending = 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 { return int(input.size()); } + 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*) {} +namespace power { void serviceRegulatorSettings() {} } +#if MESH_ENABLE_HOST_KISS +namespace hostkiss { +void resetUsbSession() { ++kiss_resets; kiss_active=false; } +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 +#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)): + with self.subTest(enabled=enabled, cooperative=cooperative, lose_owner=lose_owner): + candidate = pump + if lose_owner: + candidate = candidate.replace("if (mesh::hostkiss::serviceUsb())", + "if (false && 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() diff --git a/test/test_host_kiss_radio.py b/test/test_host_kiss_radio.py new file mode 100644 index 000000000..6a9bf0dea --- /dev/null +++ b/test/test_host_kiss_radio.py @@ -0,0 +1,246 @@ +"""Exercise raw radio2 host KISS isolation and concrete modem protocol selection.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body +from test_meshtext_transport import HARNESS as FOREIGN_HARNESS +import test_meshtext_config as feature_config +import test_ethernet_radio as ethernet_test + +ROOT = Path(__file__).resolve().parents[1] + + +class HostKissRadioTest(unittest.TestCase): + def run_cpp(self, source, *, dispatcher=False): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix="meshcore-host-kiss-") as folder: + cpp, exe = Path(folder) / "test.cpp", Path(folder) / "test" + cpp.write_text(source, encoding="ascii") + args = [compiler, "-std=c++17", "-Wall", "-Wextra", + "-DMESH_ENABLE_HOST_KISS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=0", + "-DMESH_ENABLE_MESHTEXT_WIRE=0", "-DMESH_ENABLE_ETHERNET_WIRE=0", + "-DMESH_ENABLE_TX_BEACONS=0", "-I", str(ROOT / "test/mocks"), + "-I", str(ROOT / "src")] + if os.name != "nt": + args += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + if dispatcher: + args += [str(ROOT / "src" / name) for name in ( + "Dispatcher.cpp", "Packet.cpp", "helpers/StaticPoolPacketManager.cpp")] + result = subprocess.run(args + [str(cpp), "-o", str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr[-16000:]) + env = dict(os.environ) + env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run([str(exe)], capture_output=True, text=True, env=env) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_dispatcher_host_only_routing_and_scheduler_admission(self): + fixture = FOREIGN_HARNESS.split("int main()", 1)[0] + fixture = fixture.replace("RadioProfileProtocol::Meshtext", "RadioProfileProtocol::Kiss") + fixture = fixture.replace("bool supportsMeshtext() const override", "bool supportsHostKiss() const override") + fixture = fixture.replace("return queueForeignRadioFrame(b,n,1,_radio->profiles()->generation[1]);", + "return submitHostKissFrame(b,n,_radio->profiles()->generation[1]);") + hooks = r''' +namespace mesh { namespace hostkiss { +unsigned rx=0; std::vector completions; +void receive(const uint8_t* b,size_t n,uint8_t p,uint32_t g,float,float) { + assert(n==3 && b[0]==0x11 && p==1 && g); ++rx; +} +void sendComplete(bool success) { completions.push_back(success); } +} } +int main() { + using namespace mesh::hostkiss; + { Fixture f;const auto done=completions.size();assert(f.d.queue(bytes)); + assert(f.d.hostKissFramePending() && !f.d.queue(bytes));f.tick(); + assert(f.radio.sends==1 && f.radio.active==1 && f.radio.sent==std::vector(bytes,bytes+3)); + assert(!f.d.cancelHostKissFrame());f.radio.complete=true;f.tick(20); + assert(completions.size()==done+1 && completions.back() && f.d.done.empty()); + assert(!f.d.hostKissFramePending() && f.radio.p.tx_packets[1]==1); + assert(!f.d.getNumSentFlood() && !f.d.getNumSentDirect() && f.d.getTotalAirTime()==20); + } + { Fixture f;auto before=rx;f.radio.active=1;f.radio.incoming=true;f.tick(); + assert(rx==before+1 && !f.d.foreign_rx && !f.d.mc_rx && !f.d.raw_logs); + assert(!f.d.getNumRecvFlood() && !f.d.getNumRecvDirect() && !f.d.getLastMeshCoreRecvMillis()); + assert(f.radio.processed==1 && f.radio.p.rx_packets[1]==1); + f.radio.p.secondary.mode=RadioProfileMode::Off;f.radio.incoming=true;f.tick();assert(rx==before+1); + f.radio.active=0;f.radio.p.primary.protocol=RadioProfileProtocol::Kiss; + f.radio.incoming=true;f.tick();assert(rx==before+1 && !f.d.foreign_rx && !f.d.raw_logs); + } + { Fixture f;auto done=completions.size();assert(f.d.queue(bytes));assert(f.d.cancelHostKissFrame()); + assert(completions.size()==done+1 && !completions.back() && !f.d.hostKissFramePending()); + f.tick();assert(!f.radio.sends && f.d.done.empty()); + } + { Fixture f;auto done=completions.size();assert(f.d.queue(bytes));++f.radio.p.generation[1];f.tick(); + assert(!f.radio.sends && !f.d.hostKissFramePending() && completions.size()==done+1 && !completions.back()); + } + { Fixture f;auto done=completions.size();assert(f.d.queue(bytes));f.tick();++f.radio.p.generation[1]; + f.radio.complete=true;f.tick();assert(completions.size()==done+1 && !completions.back() && f.radio.finishes==1); + } + { Fixture f;assert(!f.d.submitHostKissFrame(bytes,3,f.radio.p.generation[1]+1)); + f.radio.supported=false;assert(!f.d.queue(bytes));f.radio.supported=true; + f.radio.p.secondary.mode=RadioProfileMode::Rx;assert(!f.d.queue(bytes)); + f.radio.p.secondary.mode=RadioProfileMode::Off;assert(!f.d.queue(bytes)); + f.radio.p.secondary.mode=RadioProfileMode::RxTx;f.radio.p.secondary.params.protocol=RadioProfileProtocol::MeshCore; + assert(!f.d.queue(bytes));f.radio.p.secondary.params.protocol=RadioProfileProtocol::Kiss; + assert(!f.d.queue(nullptr));assert(!f.d.queue(bytes,0));uint8_t full[256]={};assert(!f.d.queue(full,256)); + f.radio.carrier=true;assert(!f.d.queue(bytes));f.radio.carrier=false; + f.d.allow_foreign=false;assert(!f.d.queue(bytes)); + } + { Fixture f;auto done=completions.size();f.radio.prepare=RadioParamApplyResult::BUSY; + assert(f.d.queue(bytes));f.tick();assert(!f.radio.sends && f.d.hostKissFramePending()); + f.radio.prepare=RadioParamApplyResult::APPLIED;f.tick(11);assert(f.radio.sends==1 && f.radio.active==1); + f.d.setRadioAvailable(false);assert(completions.size()==done+1 && !completions.back()); + } + { Fixture f;auto done=completions.size();f.radio.start_ok=false;assert(f.d.queue(bytes));f.tick();f.tick(2000); + assert(f.radio.sends==1 && !f.d.hostKissFramePending() && completions.size()==done+1 && !completions.back()); + } + { Fixture f;auto done=completions.size();assert(f.d.queue(bytes));f.tick();f.tick(1151); + assert(f.radio.sends==1 && f.radio.finishes==1 && f.radio.recovery==1); + assert(completions.size()==done+1 && !completions.back()); + } + { Fixture f;f.radio.receiving=true;assert(f.d.queue(bytes));f.tick();f.tick(201); + assert(!f.radio.sends && f.d.hostKissFramePending());f.radio.receiving=false;f.tick(201); + assert(f.radio.sends==1 && f.radio.active==1); + } + { Fixture f;f.d.duty_window=200;f.d.begin();assert(f.d.queue(bytes));f.tick(); + f.clock.now+=100;f.radio.complete=true;f.d.loop();assert(f.d.getRemainingTxBudget()==0); + assert(f.d.queue(bytes));f.tick(100);assert(f.radio.sends==1);f.tick(101);assert(f.radio.sends==2); + } + { Fixture f;assert(f.d.queue(bytes));f.tick();f.radio.complete=true;f.tick();assert(f.d.queue(bytes)); + auto* p=newPacket(f.d,RADIO_TX_PRIMARY);assert(f.d.sendPacket(p,0));f.tick(); + assert(f.radio.sends==2 && f.radio.active==0);f.radio.complete=true;f.tick();f.tick(); + assert(f.radio.sends==3 && f.radio.active==1); + } + { Fixture f;auto* p=newPacket(f.d);f.radio.p.cross=RadioCrossMode::On; + assert(!f.radio.p.canCross() && f.d.getTransmitProfileMask(p)==1); + assert(f.d.sendPacket(p,0));f.tick();assert(f.radio.sends==1 && f.radio.active==0); + auto* denied=newPacket(f.d,RADIO_TX_SECONDARY);assert(!f.d.sendPacket(denied,0)); + } +} +''' + self.run_cpp(fixture + hooks, dispatcher=True) + + def test_actual_protocol_selection_on_every_supported_wrapper(self): + for family in ("SX1262", "SX1268", "SX1276", "LLCC68", "STM32WLx", "LR1110", "LR1121", "LR2021"): + with self.subTest(family=family): + source = (ROOT / "src/helpers/radiolib" / ("Custom" + family + "Wrapper.h")).read_text() + select = body(source, "bool applyProtocol(mesh::RadioProfileProtocol protocol)") + select = select.replace(" override", "") + harness = r''' +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +struct Driver { + unsigned calls=0; uint8_t sync=0; int status=0; + int setProfileProtocol(uint8_t s) { ++calls;sync=s;return status; } +}; +using @CLASS@=Driver; +struct TestWrapper { + Driver driver; Driver* _radio=&driver; + bool supportsMeshtext() const { return false; } + bool supportsHostKiss() const { return true; } + int recordApply(mesh::lr2021::ApplyStatus::Stage,int code) { return code; } + @SELECT@ +}; +int main() { + using P=mesh::RadioProfileProtocol; + TestWrapper w;assert(w.applyProtocol(P::Kiss));assert(w.driver.sync==0x12 && w.driver.calls==1); + assert(w.applyProtocol(P::MeshCore));assert(w.driver.sync==0x12 && w.driver.calls==2); + assert(!w.applyProtocol(static_cast

(255)) && w.driver.calls==2); + w.driver.status=-17;assert(!w.applyProtocol(P::Kiss)); +} +''' + self.run_cpp(harness.replace("@CLASS@", "Custom" + family).replace("@SELECT@", select)) + + def test_actual_cli_protocol_lease_preserves_saved_intent_and_other_owners(self): + code = r''' +#define main existing_cli_main +#include "test/fixtures/radio_profiles/cli_test.cpp" +#undef main +int main() { + using P=mesh::RadioProfileProtocol; + Fixture f; + f.cmd("set radio2 910.5,500,8,5,rxtx,80");f.advance(2000); + const auto saved=f.fs.files["/radio_profiles"]; + const auto tuple=f.radio.p.secondary; + const auto intent=f.cli.secondaryIntentGeneration(); + const auto generation=f.radio.p.generation[1]; + assert(f.cli.manualSecondaryConfigured() && f.cli.secondaryRuntimeAvailable()); + assert(f.cli.useSecondaryProtocol(P::Kiss)); + assert(f.radio.p.secondary.params.protocol==P::Kiss && f.radio.p.generation[1]!=generation); + assert(f.radio.profilePreamble(1)==80 && f.fs.files["/radio_profiles"]==saved); + assert(f.cli.secondaryIntentGeneration()==intent && f.cli.savedSecondaryConfig().params==tuple.params); + const auto kiss_generation=f.radio.p.generation[1]; + assert(f.cli.useSecondaryProtocol(P::Kiss) && f.radio.p.generation[1]==kiss_generation); + // A running host lease is not replaceable by another foreign service. + assert(!f.cli.useSecondaryProtocol(P::Meshtext)); + assert(!f.cli.useSecondaryProtocol(P::Ethernet)); + f.cli.clearSecondaryProtocol(); + assert(f.radio.p.secondary.params==tuple.params && f.radio.p.secondary.mode==tuple.mode); + assert(f.fs.files["/radio_profiles"]==saved); + // Saved manual changes retire the host intent immediately, while preserving + // the two-second reply window before publishing the new running tuple. + assert(f.cli.useSecondaryProtocol(P::Kiss)); + f.cmd("set radio2 912.5,500,9,5,rxtx,88"); + assert(f.cli.secondaryIntentGeneration()!=intent && !f.cli.secondaryRuntimeAvailable()); + assert(f.radio.p.secondary.params.protocol==P::Kiss && f.radio.p.secondary.params.freq==910.5f); + f.cli.clearSecondaryProtocol();f.advance(1999); + assert(f.radio.p.secondary.params.protocol==P::Kiss && f.radio.p.secondary.params.freq==910.5f); + f.advance(1); + assert(f.radio.p.secondary.params.protocol==P::MeshCore && f.radio.p.secondary.params.freq==912.5f); + assert(f.radio.profilePreamble(1)==88 && f.cli.secondaryRuntimeAvailable()); + assert(f.cli.useSecondaryProtocol(P::Kiss)); + // A direct outside owner wins even if the old host later releases its + // overlay. Old host cleanup must not restore the saved tuple over it. + auto foreign=f.radio.p.secondary;foreign.params.protocol=P::Meshtext;foreign.params.freq=913.5f; + f.radio.p.setSecondary(foreign,false); + f.cli.clearSecondaryProtocol();f.advance(1); + assert(f.radio.p.secondary.params==foreign.params && !f.cli.secondaryRuntimeAvailable()); + f.cmd("set radio2 914.5,500,9,5,rxtx,88");f.advance(2000); + assert(f.cli.secondaryRuntimeAvailable() && f.cli.useSecondaryProtocol(P::Kiss)); +#if MESH_ENABLE_MESHTEXT_CHAT + f.cli.clearSecondaryProtocol();assert(f.cli.useSecondaryProtocol(P::Meshtext)); + const auto mt=f.radio.p.secondary; + assert(!f.cli.useSecondaryProtocol(P::Kiss) && f.radio.p.secondary.params==mt.params); +#endif +#if MESH_ENABLE_ETHERNET_TUNNEL + f.cli.clearSecondaryProtocol();assert(f.cli.useSecondaryProtocol(P::Ethernet)); + const auto eth=f.radio.p.secondary; + assert(!f.cli.useSecondaryProtocol(P::Kiss) && f.radio.p.secondary.params==eth.params); +#endif + Fixture absent;assert(!absent.cli.useSecondaryProtocol(P::Kiss)); + absent.cmd("set radio2 off");absent.advance(2000); + assert(!absent.cli.useSecondaryProtocol(P::Kiss)); +} +''' + runner = ethernet_test.EthernetRadioTest() + runner.setUpClass() + for foreign in (0, 1): + with self.subTest(other_foreign_services=foreign): + runner.compile_run(code, ["-DMESH_ENABLE_HOST_KISS=1", + "-DMESH_ENABLE_MESHTEXT_CHAT=" + str(foreign), + "-DMESH_ENABLE_ETHERNET_TUNNEL=" + str(foreign)], + ["src/helpers/RadioProfileCLI.cpp"], ["test/fixtures/radio_profiles/mocks"]) + + def test_feature_layout_is_consistent_with_platform_and_explicit_overrides(self): + checker = feature_config.MeshtextConfigTest() + checker.setUp() + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM"): + checker.assert_orders([platform, "COMPANION_RADIO_FULL=1"], 1, feature="MESH_ENABLE_HOST_KISS") + checker.assert_orders([platform, "COMPANION_RADIO_FULL=0"], 0, feature="MESH_ENABLE_HOST_KISS") + checker.assert_orders([platform, "COMPANION_RADIO_FULL=1", "MESH_ENABLE_HOST_KISS=0"], 0, + feature="MESH_ENABLE_HOST_KISS") + checker.assert_orders([platform, "MESH_ENABLE_HOST_KISS=1"], 1, feature="MESH_ENABLE_FOREIGN_RADIO") + checker.assert_orders([], 0, feature="MESH_ENABLE_HOST_KISS") + checker.assert_orders(["RP2040_PLATFORM", "COMPANION_RADIO_FULL=1"], 0, feature="MESH_ENABLE_HOST_KISS") + checker.assert_orders(["MESH_PRIMARY_ESPNOW=1", "MESH_ENABLE_HOST_KISS=1"], 0, + feature="MESH_ENABLE_HOST_KISS") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_host_kiss_usb.py b/test/test_host_kiss_usb.py new file mode 100644 index 000000000..fe3717bd8 --- /dev/null +++ b/test/test_host_kiss_usb.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Execute cached USB text guards and Full Companion raw-KISS handoff code.""" +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] +ARDUINO = r''' +#pragma once +#include +#include +class Stream { +public: + virtual ~Stream() = default; + virtual int available() = 0; + virtual int read() = 0; + virtual int peek() = 0; + virtual void flush() = 0; + virtual int availableForWrite() { return 0; } + virtual size_t write(uint8_t) = 0; + virtual size_t write(const uint8_t*, size_t) = 0; +}; +''' + +TEXT_FIXTURE = r''' +#include +#include +#include +#include +static std::atomic usb_binary_stream_owned{false}; +static std::atomic usb_text_writers{0}; +static bool logging = true, debug = true; +struct Port : Stream { + unsigned reads=0, peeks=0, available_calls=0, flushes=0, capacity_calls=0; + std::string output; + int available() override { ++available_calls; return 3; } + int read() override { ++reads; return 65; } + int peek() override { ++peeks; return 65; } + void flush() override { ++flushes; } + int availableForWrite() override { ++capacity_calls; return 99; } + size_t write(uint8_t value) override { return write(&value,1); } + size_t write(const uint8_t* data,size_t length) override { +#if MESH_ENABLE_HOST_KISS + if (!usb_binary_stream_owned.load()) assert(usb_text_writers.load()==1); +#endif + output.append(reinterpret_cast(data),length); return length; + } +} raw, null_port; +namespace mesh { +bool isUsbBinaryStreamOwned() { return usb_binary_stream_owned.load(); } +bool isUsbDebugLoggingEnabled() { return !isUsbBinaryStreamOwned() && logging && debug; } +@COUNTERS@ +static Stream& null_usb_logging_stream = null_port; +static Stream& rawUsbLoggingPort() { return logging ? static_cast(raw) : null_port; } +static Stream& rawUsbTerminalPort(bool enabled) { return enabled ? static_cast(raw) : null_port; } +static Stream& rawUsbConsolePort() { return raw; } +@PORTS@ +} +int main() { + using namespace mesh; + auto& cached_log=usbLoggingPort(); + auto& cached_debug=usbDebugPort(); + auto& cached_terminal=usbTerminalPort(true); + auto& cached_console=usbConsolePort(); + const uint8_t text[]={'a','b','c'}; + assert(cached_log.write(text,3)==3 && raw.output=="abc"); + usb_binary_stream_owned.store(true); +#if MESH_ENABLE_HOST_KISS + for (auto* cached: {&cached_log,&cached_debug,&cached_terminal,&cached_console}) { + assert(cached->write(text,3)==3 && cached->write(uint8_t('!'))==1); + assert(!cached->available() && cached->read()==-1 && cached->peek()==-1); + assert(!cached->availableForWrite());cached->flush(); + } + assert(raw.output=="abc" && !raw.reads && !raw.peeks && !raw.available_calls); + assert(!raw.flushes && !raw.capacity_calls && !canAcceptUsbConsoleCommand()); + // New port lookups must be guarded too, without retargeting cached writers + // to a binary facade. Binary output uses its separate raw transport. + assert(usbLoggingPort().write(text,3)==3); + assert(usbTerminalPort(true).write(text,3)==3); + assert(usbConsolePort().write(text,3)==3 && raw.output=="abc"); + assert(raw.write(reinterpret_cast("KISS"),4)==4 && raw.output=="abcKISS"); + usb_binary_stream_owned.store(false); + assert(cached_terminal.write(text,3)==3 && raw.output=="abcKISSabc"); + assert(cached_log.available()==3 && cached_log.read()==65 && cached_log.peek()==65); + cached_console.flush();assert(cached_console.availableForWrite()==99 && canAcceptUsbConsoleCommand()); + assert(raw.flushes==1 && raw.capacity_calls==1); +#else + // Feature-disabled images retain the prior raw facade and layouts. + assert(&cached_log==&raw && &cached_debug==&raw && &cached_terminal==&raw && &cached_console==&raw); + assert(cached_console.write(text,3)==3 && raw.output=="abcabc"); +#endif +} +''' + +COMPANION_FIXTURE = r''' +#include +#include +#include +#include +static bool kiss_active=false, terminal_mode=false, usb_mota_mode=false, binary_owned=false; +static bool usb_logging_reply_pending=true; +static bool host_kiss_usb_parked=false,host_kiss_usb_restore_enabled=false; +static bool usb_terminal_discard_line=false,usb_logging_terminal_mode=false; +static unsigned serial_cancels=0,route_cancels=0,forgotten_routes=0; +static unsigned input_resets=0,text_discards=0,line_clears=0,reader_cancels=0,mota_resets=0; +static unsigned ascii_calls=0,usb_binary_calls=0,network_calls=0,kiss_resets=0; +static std::deque input; +struct Port {} terminal_port; +struct Usb { + bool enabled=true,passthrough=false; + unsigned resets=0,frames=19,enables=0,disables=0; + bool isEnabled() const { return enabled; } + void enable(){enabled=true;++enables;} + void disable(){enabled=false;++disables;} + void resetSessionState(){++resets;} + unsigned getCompletedFrameCount()const{return frames;} + void setPassthroughMode(bool value){passthrough=value;} +} usb_serial_interface; +static bool ble_stream_pending=true,ble_operations_pending=true; +struct Mesh { + bool usb_stream_pending=true,usb_operations_pending=true; + void cancelSerialResponseStream(Usb* p){assert(p==&usb_serial_interface);++serial_cancels;usb_stream_pending=false;} + void cancelSerialOperationsForRoute(Usb* p){assert(p==&usb_serial_interface);++route_cancels;usb_operations_pending=false;} + void resetUsbHostSessionInput(){++input_resets;} + bool isTerminalMode()const{return terminal_mode;} +} the_mesh; +struct Manager { + bool enabled=true; + bool isEnabled()const{return enabled;} + void forgetReplyRouteForDisconnected(Usb* p){assert(p==&usb_serial_interface);++forgotten_routes;} + void loop(){++network_calls;if(usb_serial_interface.enabled && !input.empty())++usb_binary_calls;} +} interface_manager; +struct SessionProbe { + unsigned cancels=0,requests=0,last_frame=0; + void cancel(){++cancels;} + void request(unsigned frame){++requests;last_frame=frame;} +} usb_binary_startup_probe,usb_ascii_session_default; +void clearUsbTerminalLine(){++line_clears;} +void leaveUsbTerminalMode(bool banner){assert(!banner);terminal_mode=false;} +void leaveUsbMotaMode(bool resume){assert(!resume);usb_mota_mode=false;} +namespace mesh { +Port& usbTerminalPort(){return terminal_port;} +void cancelReaderCommand(Port* p){assert(p==&terminal_port);++reader_cancels;} +void discardUsbTerminalOutput(){++text_discards;} +void setUsbBinaryStreamOwned(bool value){binary_owned=value;} +namespace ota { +struct Source {void resetSessionState(){++mota_resets;}} source; +struct OtaContext {static Source& serialFolderSource(){return source;}}; +} +namespace hostkiss { +bool usbActive(){return kiss_active;} +void resetUsbSession(){++kiss_resets;if(kiss_active){kiss_active=false;binary_owned=false;}} +bool serviceUsb(){ + if(!kiss_active && !input.empty() && input.front()==0xc0){input.pop_front();kiss_active=true;binary_owned=true;} + bool claimed=kiss_active; + while(kiss_active && !input.empty()){ + auto byte=input.front();input.pop_front(); + if(byte==0xff){kiss_active=false;binary_owned=false;break;} + } + return claimed; +} +} +} +@CANCEL@ +@RESET@ +@SERVICE@ +static bool poll(){ + bool claimed=serviceCompanionHostKissUsb(); + if(!claimed && !input.empty()){++ascii_calls;input.clear();} + interface_manager.loop(); + return claimed; +} +int main(){ + input={'v','e','r','\r'};assert(!poll() && ascii_calls==1 && !serial_cancels); +#if MESH_ENABLE_HOST_KISS + terminal_mode=true;usb_mota_mode=true;usb_terminal_discard_line=usb_logging_terminal_mode=true; + input={0xc0,0x00,0x41};assert(poll()); + assert(kiss_active && host_kiss_usb_parked && host_kiss_usb_restore_enabled && !usb_serial_interface.enabled); + assert(serial_cancels==1 && route_cancels==1 && forgotten_routes==1); + assert(!the_mesh.usb_stream_pending && !the_mesh.usb_operations_pending); + assert(ble_stream_pending && ble_operations_pending && network_calls==2); + assert(!terminal_mode && !usb_terminal_discard_line && !usb_logging_terminal_mode); + assert(input_resets==1 && line_clears==1 && text_discards==1); + assert(!usb_logging_reply_pending && usb_binary_startup_probe.cancels==1 && usb_ascii_session_default.cancels==1); +#if COMPANION_FEATURE_READER + assert(reader_cancels==1); +#endif +#if COMPANION_FEATURE_USB_MOTA_SOURCE + assert(!usb_mota_mode && mota_resets==1); +#endif + assert(poll() && network_calls==3 && !usb_binary_calls && ascii_calls==1); + // RETURN ends binary ownership, but does not dispatch a trailing ASCII or + // Companion frame during this same poll. Restore is the next poll's work. + input={0xff,'v','e','r','\r'};assert(poll()); + assert(!kiss_active && host_kiss_usb_parked && !usb_serial_interface.enabled && binary_owned); + assert(ascii_calls==1 && !usb_binary_calls && !input.empty()); + assert(!poll()); + assert(!host_kiss_usb_parked && !host_kiss_usb_restore_enabled && usb_serial_interface.enabled && !binary_owned); + assert(ascii_calls==2 && !usb_binary_calls && usb_serial_interface.resets==1); + assert(usb_ascii_session_default.requests==1 && usb_ascii_session_default.last_frame==19); + // A transport already disabled by its manager must not be resurrected. + usb_serial_interface.enabled=false;input={0xc0};assert(poll()); + assert(!host_kiss_usb_restore_enabled);input={0xff};assert(poll());assert(!poll()); + assert(!usb_serial_interface.enabled && !host_kiss_usb_parked); + // Disabling the manager during the KISS lease also vetoes restoration. + usb_serial_interface.enabled=true;input={0xc0};assert(poll());interface_manager.enabled=false; + input={0xff};assert(poll());assert(!poll());assert(!usb_serial_interface.enabled); + interface_manager.enabled=true; + // A disconnect between RETURN and the next poll also releases the saved + // one-poll quarantine even though the runtime KISS session already ended. + usb_serial_interface.enabled=true;input={0xc0};assert(poll()); + input={0xff};assert(poll());assert(binary_owned && !kiss_active); + resetUsbTerminalHostSession(); + assert(!binary_owned && !host_kiss_usb_parked && usb_serial_interface.enabled && kiss_resets==1); + // An actual host-session reset cancels only USB work and retires KISS. + usb_serial_interface.enabled=true;input={0xc0};assert(poll()); + resetUsbTerminalHostSession(); + assert(!kiss_active && kiss_resets==2 && !host_kiss_usb_parked && !host_kiss_usb_restore_enabled); + assert(usb_serial_interface.enabled && !usb_serial_interface.passthrough); + assert(ble_stream_pending && ble_operations_pending && !usb_terminal_discard_line && !usb_logging_terminal_mode); +#else + assert(!serviceCompanionHostKissUsb() && usb_serial_interface.enabled); + assert(!serial_cancels && !route_cancels && !forgotten_routes && !input_resets); +#endif +} +''' + + +class HostKissUsbTest(unittest.TestCase): + def run_cpp(self, source, *, enabled, reader=0, mota=0, fail=False, extra_flags=()): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix="host-kiss-usb-") as directory: + directory = Path(directory) + (directory / "Arduino.h").write_text(ARDUINO, encoding="ascii") + (directory / "test.cpp").write_text(source, encoding="ascii") + flags = ["-std=c++17", "-Wall", "-Wextra", "-DMESH_ENABLE_HOST_KISS=" + str(enabled), + "-DMESH_USB_LOGGING_AVAILABLE=1", "-DMESH_ESP32_TINYUSB_NONBLOCKING=0", + "-DMESH_ESP32_HWCDC_SESSION_GUARD=0", "-DMESH_NRF52_USB_CONSOLE_COOPERATIVE=0", + "-DCOMPANION_FEATURE_READER=" + str(reader), + "-DCOMPANION_FEATURE_USB_MOTA_SOURCE=" + str(mota), + "-I", str(directory), "-I", str(ROOT / "src")] + if sys.platform.startswith("linux"): + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + result = subprocess.run([compiler, *flags, *extra_flags, str(directory / "test.cpp"), "-o", str(directory / "test")], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(directory / "test")], capture_output=True, text=True, timeout=10) + if fail: + self.assertNotEqual(result.returncode, 0, "Negative control failed to lose USB ownership") + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_cached_text_facades_suppress_binary_session_writes_and_reads(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + ports = "\n".join(body(source, signature) for signature in ( + "Stream& usbLoggingPort()", "Stream& usbDebugPort()", "Stream& usbTerminalPort(bool enabled)", + "Stream& usbConsolePort()", "bool canAcceptUsbConsoleCommand()")) + for enabled in (0, 1): + with self.subTest(enabled=enabled): + counters = "\n".join(body(source, signature) for signature in ( + "static bool beginUsbTextWrite()", "static void endUsbTextWrite()")) + self.run_cpp(TEXT_FIXTURE.replace("@PORTS@", ports).replace("@COUNTERS@", counters), enabled=enabled) + + def test_actual_companion_claim_return_reset_and_usb_only_cancellation(self): + source = (ROOT / "examples/companion_radio/main.cpp").read_text() + cancel = body(source, "static void cancelUsbSerialOperations()") + reset = body(source, "static void resetUsbTerminalHostSession()") + service = body(source, "static bool serviceCompanionHostKissUsb()") + for enabled, reader, mota, negative in ((0, 0, 0, False), (1, 0, 0, False), + (1, 1, 1, False), (1, 1, 1, True)): + with self.subTest(enabled=enabled, reader=reader, mota=mota, negative=negative): + candidate = service + if negative: + candidate = candidate.replace("host_kiss_usb_parked && !claimed && !mesh::hostkiss::usbActive()", + "host_kiss_usb_parked && !mesh::hostkiss::usbActive()") + self.assertNotEqual(candidate, service) + fixture = COMPANION_FIXTURE.replace("@CANCEL@", cancel).replace("@RESET@", reset).replace("@SERVICE@", candidate) + self.run_cpp(fixture, enabled=enabled, reader=reader, mota=mota, fail=negative) + + def test_actual_nrf52_text_queue_discards_backlog_without_draining_binary_stream(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + fixture = r''' +#include +#include +#include +static std::atomic usb_binary_stream_owned{false}; +static std::atomic usb_text_writers{0}; +static std::atomic primary_usb_terminal_discard_pending{false}; +static uint32_t primary_usb_terminal_seen_reset_generation=0, reset_generation=0; +static bool platform_debug=false, saved_logging=true, saved_debug=true; +uint32_t primaryUsbSessionGeneration(){return reset_generation;} +bool isUsbDebugLoggingEnabled(){return !usb_binary_stream_owned.load() && saved_logging && saved_debug;} +void setPlatformDebugOutputEnabled(bool enabled){platform_debug=enabled;} +struct Queue { + unsigned pending=3,discards=0,services=0; bool busy=false; + bool tryDiscardPending(){++discards;if(busy)return false;pending=0;return true;} + void service(){++services;pending=0;} +} buffered_primary_usb_terminal_port; +void serviceUsbTerminalPort(); +void discardUsbTerminalOutput(); +bool hasPendingUsbTerminalOutput(){return buffered_primary_usb_terminal_port.pending || primary_usb_terminal_discard_pending.load();} +@BEGIN@ +@END@ +@OWNED@ +@SET@ +@READY@ +@SERVICE@ +@DISCARD@ +int main(){ + // An admitted writer can outlive the ownership store. Even with no queued + // bytes or busy queue lock, READY must wait for that producer to leave. + assert(beginUsbTextWrite() && usb_text_writers.load()==1); + setUsbBinaryStreamOwned(true); + assert(!isUsbBinaryStreamReady() && !beginUsbTextWrite() && usb_text_writers.load()==1); + endUsbTextWrite(); + assert(usb_text_writers.load()==0 && isUsbBinaryStreamReady()); + setUsbBinaryStreamOwned(false); + buffered_primary_usb_terminal_port.pending=3; + buffered_primary_usb_terminal_port.busy=true; + setUsbBinaryStreamOwned(true); + assert(!isUsbBinaryStreamReady()); + assert(isUsbBinaryStreamOwned() && !platform_debug && saved_logging && saved_debug); + assert(primary_usb_terminal_discard_pending.load() && !buffered_primary_usb_terminal_port.services); + assert(buffered_primary_usb_terminal_port.pending==3); + serviceUsbTerminalPort();assert(!buffered_primary_usb_terminal_port.services); + buffered_primary_usb_terminal_port.busy=false; + serviceUsbTerminalPort(); + assert(!buffered_primary_usb_terminal_port.pending && !buffered_primary_usb_terminal_port.services); + assert(!primary_usb_terminal_discard_pending.load() && isUsbBinaryStreamReady()); + // New host/reset generations discard rather than expose a queued old line. + buffered_primary_usb_terminal_port.pending=4;++reset_generation; + serviceUsbTerminalPort(); + assert(primary_usb_terminal_seen_reset_generation==reset_generation); + assert(!buffered_primary_usb_terminal_port.pending && !buffered_primary_usb_terminal_port.services); + setUsbBinaryStreamOwned(false);assert(!isUsbBinaryStreamOwned() && platform_debug); + serviceUsbTerminalPort();assert(buffered_primary_usb_terminal_port.services==1); +} +''' + for token, signature in (("@BEGIN@", "static bool beginUsbTextWrite()"), + ("@END@", "static void endUsbTextWrite()"), + ("@READY@", "bool isUsbBinaryStreamReady()"), + ("@OWNED@", "bool isUsbBinaryStreamOwned()"), + ("@SET@", "void setUsbBinaryStreamOwned(bool owned)"), + ("@SERVICE@", "void serviceUsbTerminalPort()"), + ("@DISCARD@", "void discardUsbTerminalOutput()")): + fixture = fixture.replace(token, body(source, signature)) + self.run_cpp(fixture, enabled=1, extra_flags=("-DNRF52_PLATFORM=1", "-DUSE_TINYUSB=1")) + negative = fixture.replace(" || usb_text_writers.load() != 0", "") + self.assertNotEqual(negative, fixture) + self.run_cpp(negative, enabled=1, fail=True, + extra_flags=("-DNRF52_PLATFORM=1", "-DUSE_TINYUSB=1")) + + def test_actual_loop_holds_ascii_and_binary_parser_gates_through_return_poll(self): + source = (ROOT / "examples/companion_radio/main.cpp").read_text() + loop = body(source, "\nvoid loop()") + self.assertLess(loop.index("serviceUsbTerminalHostSessionReset();"), + loop.index("serviceCompanionHostKissUsb();")) + self.assertLess(loop.index("serviceCompanionHostKissUsb();"), loop.index("mesh::serviceUsbLoggingPort();")) + for call in ("serviceUsbAsciiSessionDefault();", "serviceUsbLoggingReplyBarrier();", + "expireUsbBinaryStartupProbeBeforeDispatch();", "serviceUsbMotaResponseBoundary();", + "serviceUsbTerminal();"): + prefix = loop[:loop.index(call)] + self.assertIn("if (!host_kiss_usb_claimed)", prefix[prefix.rfind("#if defined(ENABLE_USB_INTERFACE)"):]) + self.assertIn("the_mesh.loop();", loop) + self.assertIn("interface_manager.loop();", loop) + self.assertIn("if (mesh::hostkiss::usbActive()) return false;", + body(source, "static bool usbLoggingRecoverySafe(")) + rescue = body((ROOT / "examples/companion_radio/MyMesh.cpp").read_text(), "void MyMesh::checkCLIRescueCmd()") + self.assertLess(rescue.index("if (mesh::isUsbBinaryStreamOwned()) return;"), + rescue.index("mesh::usbCompanionPort()")) + mymesh = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + admission = body(mymesh, "bool allowForeignRadioTransmit() const") + self.assertIn("_radio_available", admission) + self.assertIn("_scheduled_reboot_at == 0", admission) + self.assertIn("mesh::hostkiss::radioActive()", admission) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_framework_logging.py b/test/test_nrf52_framework_logging.py index 7eed2af3d..53d330dec 100644 --- a/test/test_nrf52_framework_logging.py +++ b/test/test_nrf52_framework_logging.py @@ -64,6 +64,7 @@ class Stream { """ RUNTIME = r''' +#define MESH_ENABLE_HOST_KISS 0 #include #include #include @@ -202,8 +203,10 @@ class Nrf52FrameworkLoggingTest(unittest.TestCase): source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() header = (ROOT / "src/helpers/UsbLogging.h").read_text() parts = { - "DEBUG_GATE": extract_braced(header, "inline bool isUsbDebugLoggingEnabled("), - "LOGGING_PORT": extract_braced(source, "Stream& usbLoggingPort("), + "DEBUG_GATE": extract_braced(header, "inline bool isUsbBinaryStreamOwned(") + "\n" + + extract_braced(header, "inline bool isUsbDebugLoggingEnabled("), + "LOGGING_PORT": "\n".join(extract_braced(source, signature) for signature in ( + "static Stream& rawUsbLoggingPort(", "Stream& usbLoggingPort(")), "DEBUG_PORT": extract_braced(source, "Stream& usbDebugPort("), } hook = extract_braced(source, 'extern "C" size_t meshNrf52FrameworkDebugWrite(') diff --git a/test/test_nrf52_usb_client.py b/test/test_nrf52_usb_client.py index 9da26cff0..87560df6e 100644 --- a/test/test_nrf52_usb_client.py +++ b/test/test_nrf52_usb_client.py @@ -330,7 +330,8 @@ int main() { } ''' source = source.replace('@NULL@', function(USB, 'class NullUsbLoggingStream') + ';') - source = source.replace('@PORT@', function(USB, 'Stream& usbTerminalPort(')) + source = source.replace('@PORT@', '\n'.join(function(USB, signature) for signature in ( + 'static Stream& rawUsbTerminalPort(', 'Stream& usbTerminalPort('))) source = source.replace('@WAITING@', function(header, 'bool isTerminalWaitingForInput(')) source = source.replace('@METHODS@', '\n'.join(function(mesh, signature) for signature in ( 'Stream& MyMesh::terminalOutput(', 'void MyMesh::printTerminalBanner(',