Add saved raw radio2 KISS host sessions to Full firmware

This commit is contained in:
mikecarper committed 2026-10-26 16:57:49 -07:00
1 parent 8a8c80ee6a
commit cb3c92a8da
50 files changed
+2750 -74

No files matched your search

+4
View File
@@ -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
+14
View File
@@ -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
+33
View File
@@ -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
+66
View File
@@ -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
+8
View File
@@ -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:
+11
View File
@@ -28,6 +28,7 @@
#include <helpers/ui/StartupScreen.h>
#include "NotificationSettingsFile.h"
#include "MyMesh.h"
#include <helpers/HostKiss.h>
#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
+4
View File
@@ -34,6 +34,7 @@
#include <helpers/MeshtextConfig.h>
#if MESH_ENABLE_ETHERNET_TUNNEL
#include <helpers/EthernetTunnelSettings.h>
#include <helpers/HostKiss.h>
#include <helpers/esp32/EthernetKissServer.h>
#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
+73 -8
View File
@@ -18,6 +18,7 @@
#include <Mesh.h>
#include <helpers/BluetoothMac.h>
#include "MyMesh.h"
#include <helpers/HostKiss.h>
#include <helpers/CompanionReader.h>
#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();
+30 -2
View File
@@ -17,6 +17,7 @@
#endif
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <helpers/HostKiss.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/PowerManagementUtils.h>
#include <helpers/HilStartupTrace.h>
@@ -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)
+27 -2
View File
@@ -17,6 +17,7 @@
#endif
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <helpers/HostKiss.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/PowerManagementUtils.h>
#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)
+27 -3
View File
@@ -21,6 +21,7 @@
#include <helpers/UsbLogging.h>
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <helpers/HostKiss.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/PowerManagementUtils.h>
#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)
+16
View File
@@ -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
+14
View File
@@ -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(); }
+2 -2
View File
@@ -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,
+7
View File
@@ -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);
+8
View File
@@ -1,5 +1,9 @@
#include "ManagementFeatureConfig.h"
#include "CommonCLI.h"
#include "HostKissConfig.h"
#if MESH_ENABLE_HOST_KISS
#include "HostKiss.h"
#endif
#include <new>
#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
+456
View File
@@ -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 <Dispatcher.h>
#include <new>
#include <stdio.h>
#include <string.h>
#if defined(ESP32)
#include <WiFi.h>
#include <errno.h>
#include <lwip/sockets.h>
#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
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <helpers/HostKissConfig.h>
#include <stddef.h>
#include <stdint.h>
#if MESH_ENABLE_HOST_KISS
#include <helpers/IdentityStore.h>
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<class F, class R, class P, class D> 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
+144
View File
@@ -0,0 +1,144 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
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
+20
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#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
+76
View File
@@ -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<FILESYSTEM>)) 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<FILESYSTEM>);
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
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <helpers/HostKissConfig.h>
#if MESH_ENABLE_HOST_KISS
#include <helpers/IdentityStore.h>
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
+2 -1
View File
@@ -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
+20 -17
View File
@@ -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
}
+1 -1
View File
@@ -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;
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <Arduino.h>
#include <atomic>
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<Stream*> _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
+86 -4
View File
@@ -1,5 +1,8 @@
#include "UsbLogging.h"
#include "UsbLoggingClientActivity.h"
#if MESH_ENABLE_HOST_KISS
#include "UsbBinaryTextGuard.h"
#endif
#if defined(ARDUINO)
#include <Arduino.h>
@@ -91,6 +94,19 @@ static std::atomic<bool> usb_logging_enabled{true};
#endif
static std::atomic<bool> usb_logging_preference_known{false};
static std::atomic<bool> usb_debug_enabled{false};
#if MESH_ENABLE_HOST_KISS
static std::atomic<bool> usb_binary_stream_owned{false};
static std::atomic<uint32_t> 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<uint32_t> usb_logging_tx_progress{0};
static std::atomic<bool> 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<int>(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();
+15 -1
View File
@@ -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
+9 -2
View File
@@ -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<CustomLLCC68*>(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE;
}
+9 -2
View File
@@ -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<CustomLR1110*>(_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<CustomLR1110*>(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE;
}
+9 -2
View File
@@ -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<CustomLR1121*>(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE;
}
+9 -2
View File
@@ -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<CustomLR2021*>(_radio)->setProfileProtocol(sync)) == RADIOLIB_ERR_NONE;
+7 -1
View File
@@ -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<CustomSTM32WLx*>(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE;
}
+9 -2
View File
@@ -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<CustomSX1262*>(_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<CustomSX1262*>(_radio);
+9 -2
View File
@@ -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<CustomSX1268*>(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE;
}
+8 -1
View File
@@ -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<CustomSX1276*>(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE;
+18 -13
View File
@@ -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
;
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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;')
+2
View File
@@ -21,6 +21,8 @@ def function(signature):
HARNESS = r'''
#define MESH_ENABLE_HOST_KISS 0
#include "helpers/HostKiss.h"
#include <cassert>
#include <cstdint>
#include <initializer_list>
+2
View File
@@ -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 <cassert>
#include <algorithm>
#include <cstdio>
+2
View File
@@ -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 <cassert>
#include <cstdio>
#include <cstring>
+8 -1
View File
@@ -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
+449
View File
@@ -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 <helpers/HostKissCodec.h>
#include <cassert>
#include <vector>
using mesh::hostkiss::Codec;
std::vector<uint8_t> output(Codec& codec) {
const uint8_t* bytes;size_t length;std::vector<uint8_t> 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<uint8_t>& bytes) {
for(auto byte:bytes) assert(codec.feed(byte));
}
int main() {
Codec encoder,decoder;
for(unsigned length=1;length<=255;++length) {
std::vector<uint8_t> bytes(length);
for(unsigned i=0;i<length;++i) bytes[i]=uint8_t(i+length);
assert(encoder.queueReceive(bytes.data(),bytes.size()));
feed(decoder,output(encoder));
const uint8_t* decoded;size_t size;
assert(decoder.peekTransmit(decoded,size)&&size==bytes.size());
assert(std::vector<uint8_t>(decoded,decoded+size)==bytes);
decoder.consumeTransmit();
}
const uint8_t escaped[]={0xc0,0xdb,0xc0};
assert(encoder.queueReceive(escaped,3));
assert(output(encoder)==std::vector<uint8_t>({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<std::vector<uint8_t>>{{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<uint8_t> 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<uint8_t>({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 <helpers/HostKissSettings.h>
#include <helpers/PersistentStoreFormat.h>
#include <cassert>
using namespace mesh::hostkiss;
using Bytes=std::vector<uint8_t>;
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<<bit;
Settings read;assert(!read.begin(&broken)&&read.selection()==off);
}
for(unsigned fault=0;fault<7;++fault) {
GuestMemoryFS broken;broken.files[Path]=image(on);Settings read;assert(read.begin(&broken));
if(fault==0)broken.fail_open_write=true;
if(fault==1)broken.short_write=5;
if(fault==2)broken.corrupt_stage=true;
if(fault==3)broken.fail_stage_open=true;
if(fault==4)broken.fail_stage_read=true;
if(fault==5)broken.fail_publish=true;
if(fault==6)broken.false_publish=true;
assert(!read.save(off)&&read.selection()==on);
assert(broken.files.at(Path)==image(on));
}
for(auto old:{off,on,tcp}) {
GuestMemoryFS backing;backing.files[Path]=image(old);Settings read;assert(read.begin(&backing));
backing.capture=true;assert(read.save(old==off?tcp:off));
const auto snapshots=backing.snapshots;assert(snapshots.size()>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 <stdint.h>
#include <stddef.h>
#include <vector>
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<uint8_t> 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 <Dispatcher.h>
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 <stdint.h>
#include <stddef.h>
#include <deque>
#include <vector>
struct Stream {
std::deque<uint8_t> input;std::vector<uint8_t> 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 <stdint.h>
#include <stddef.h>
#include <deque>
#include <vector>
#include <memory>
#include <map>
#include <cerrno>
#define WIFI_STA 1
#define WIFI_AP 2
#define WL_CONNECTED 3
#define MSG_DONTWAIT 0x40
struct Socket {bool connected=true;std::deque<uint8_t> input;std::vector<uint8_t> output;size_t write_budget=128;};
inline std::map<int,std::shared_ptr<Socket>> sockets;
inline std::deque<int> accepts;
inline int next_socket=1;
inline int newSocket(){int id=next_socket++;sockets[id]=std::make_shared<Socket>();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<const uint8_t*>(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<uint8_t*>(data)=sock->input.front();sock->input.pop_front();return 1;
}
'''
TCP_RUNTIME = r'''
#include <helpers/HostKiss.h>
#include <Dispatcher.h>
#include <RadioProfileCLI.h>
#include <UsbLogging.h>
#include <WiFi.h>
#include <cassert>
#include <cstring>
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<uint8_t> 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<uint8_t>({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<uint8_t>({1,2,3}));
dispatcher.pending=false;hostkiss::sendComplete(true);hostkiss::loop();hostkiss::loop();
assert(sockets.at(first)->output==std::vector<uint8_t>({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 <helpers/HostKiss.h>
#include <helpers/HostKissSettings.h>
#include <Dispatcher.h>
#include <RadioProfileCLI.h>
#include <UsbLogging.h>
#include <cassert>
#include <cstring>
using namespace mesh;
void input(std::initializer_list<uint8_t> 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<uint8_t>({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<uint8_t>({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 <WiFi.h>\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 <helpers/HostKiss.h>
int main(){int a=0;mesh::hostkiss::begin(&a,a,a,a);
return mesh::hostkiss::serviceUsb()||mesh::hostkiss::radioActive();}
''')
if __name__ == "__main__":
unittest.main()
+256
View File
@@ -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 <cassert>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <string>
static std::deque<unsigned char> input;
static bool kiss_active = false, reset_pending = false, network_pending = false;
static 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()
+246
View File
@@ -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<bool> 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<uint8_t>(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 <cassert>
#include <RadioProfiles.h>
#include <helpers/radiolib/LR2021ApplyStatus.h>
#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<P>(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()
+386
View File
@@ -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 <cstddef>
#include <cstdint>
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 <cassert>
#include <string>
#include <atomic>
#include <helpers/UsbBinaryTextGuard.h>
static std::atomic<bool> usb_binary_stream_owned{false};
static std::atomic<uint32_t> 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<const char*>(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<Stream&>(raw) : null_port; }
static Stream& rawUsbTerminalPort(bool enabled) { return enabled ? static_cast<Stream&>(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<const uint8_t*>("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 <cassert>
#include <cstddef>
#include <cstdint>
#include <deque>
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<uint8_t> 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 <cassert>
#include <atomic>
#include <cstdint>
static std::atomic<bool> usb_binary_stream_owned{false};
static std::atomic<uint32_t> usb_text_writers{0};
static std::atomic<bool> 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()
+5 -2
View File
@@ -64,6 +64,7 @@ class Stream {
"""
RUNTIME = r'''
#define MESH_ENABLE_HOST_KISS 0
#include <atomic>
#include <cassert>
#include <cstring>
@@ -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(')
+2 -1
View File
@@ -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(',