269 lines
12 KiB
Python
269 lines
12 KiB
Python
"""Exercise logging handoffs with real Companion USB parsing and routing."""
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
HARNESS = r'''
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
#include "helpers/ArduinoSerialInterface.h"
|
|
#include "helpers/MultiSerialInterface.h"
|
|
#include "helpers/UsbAsciiBinarySwitch.h"
|
|
#include "helpers/CLICommandUtils.h"
|
|
#include "helpers/CompanionHardwareCommandCompat.h"
|
|
#define MESH_USB_LOGGING_AVAILABLE 1
|
|
#define COMPANION_FEATURE_USB_MOTA_SOURCE 0
|
|
#define COMPANION_FEATURE_NETWORK_TERMINAL 1
|
|
static bool connected=true, dedicated=false, logging=false, network=false;
|
|
static bool usb_logging_network_parked=false, usb_logging_terminal_mode=false;
|
|
static bool usb_protocol_initialized=true, usb_terminal_discard_line=false;
|
|
static bool usb_logging_reply_hold=false, usb_logging_reply_staged=false;
|
|
static bool usb_logging_reply_pending=false, usb_logging_reply_state=false;
|
|
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
|
|
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
|
|
struct UsbStream : Stream {
|
|
std::vector<uint8_t> rx, tx;
|
|
size_t cursor=0;
|
|
int capacity=4096, max_write=4096;
|
|
int available() override { return static_cast<int>(rx.size()-cursor); }
|
|
int read() override { return cursor<rx.size()?rx[cursor++]:-1; }
|
|
int peek() override { return cursor<rx.size()?rx[cursor]:-1; }
|
|
int availableForWrite() override { return capacity; }
|
|
size_t write(const uint8_t* p,size_t n) override {
|
|
if(n>static_cast<size_t>(capacity)) n=capacity;
|
|
if(n>static_cast<size_t>(max_write)) n=max_write;
|
|
tx.insert(tx.end(),p,p+n);
|
|
return n;
|
|
}
|
|
void receive(const char* command) {
|
|
size_t n=strlen(command)+1;
|
|
rx={'<',static_cast<uint8_t>(n),0,mesh::companion::kRunCliCommand};
|
|
rx.insert(rx.end(),command,command+n-1);
|
|
cursor=0;
|
|
}
|
|
} stream;
|
|
struct Remote : BaseSerialInterface {
|
|
bool enabled=false;
|
|
std::vector<uint8_t> rx, tx;
|
|
void enable() override { enabled=true; }
|
|
void disable() override { enabled=false; }
|
|
bool isEnabled() const override { return enabled; }
|
|
bool isConnected() const override { return true; }
|
|
bool isReadBusy() const override { return false; }
|
|
bool isWriteBusy() const override { return false; }
|
|
size_t writeFrame(const uint8_t* p,size_t n) override {
|
|
tx.insert(tx.end(),p,p+n); return n;
|
|
}
|
|
size_t checkRecvFrame(uint8_t* out) override {
|
|
const size_t n=rx.size();
|
|
if(n) memcpy(out,rx.data(),n);
|
|
rx.clear(); return n;
|
|
}
|
|
} ble;
|
|
ArduinoSerialInterface usb_serial_interface;
|
|
MultiSerialInterface interface_manager;
|
|
static bool clientConnected() { return connected; }
|
|
static Stream& usbTerminalOutput() { return stream; }
|
|
static bool isNetworkTerminalActive() { return network; }
|
|
static void clearUsbTerminalLine() {}
|
|
static unsigned banners=0, cancellations=0;
|
|
namespace mesh {
|
|
bool hasDedicatedUsbLoggingPort() { return dedicated; }
|
|
bool isUsbLoggingEnabled() { return logging; }
|
|
void setUsbLoggingEnabled(bool value) { logging=value; }
|
|
void discardUsbTerminalOutput() {}
|
|
Stream& usbCompanionPort() { return stream; }
|
|
bool saveUsbLoggingBootPreference(bool) { return true; }
|
|
bool usbLoggingInterfaceRestartRequired() { return false; }
|
|
bool isUsbLoggingPacketStream() { return false; }
|
|
}
|
|
namespace CompanionMqttSetupPortal {
|
|
static bool saveEnabled(bool) { return true; }
|
|
}
|
|
static uint32_t futureMillis(uint32_t value) { return millis()+value; }
|
|
struct MyMesh {
|
|
struct { unsigned usb_logging_enabled=0; } _prefs;
|
|
BaseSerialInterface* _serial=&interface_manager;
|
|
bool terminal=false, _mqtt_enabled=false;
|
|
unsigned _scheduled_reboot_at=0;
|
|
uint8_t cmd_frame[MAX_FRAME_SIZE+1]{}, out_frame[MAX_FRAME_SIZE+4]{};
|
|
char reply_buf[166]{};
|
|
bool isTerminalMode() const { return terminal; }
|
|
void cancelSerialResponseStream(BaseSerialInterface* route) {
|
|
if (interface_manager.isReplyRouteFor(route)) ++cancellations;
|
|
}
|
|
void cancelSerialOperationsForRoute(BaseSerialInterface*) {}
|
|
void enterTerminalMode(bool show_banner) {
|
|
terminal=true;
|
|
if(show_banner) { ++banners; usbTerminalOutput().print("BANNER"); }
|
|
}
|
|
bool savePrefs() { return true; }
|
|
void stopMQTT() {}
|
|
void applyUsbLoggingState(bool);
|
|
void beginUsbLoggingReplyBarrier(BaseSerialInterface*);
|
|
void endUsbLoggingReplyBarrier(bool);
|
|
void writeErrFrame(uint8_t) { assert(false); }
|
|
bool handleCommand(const char* command,uint32_t,char* reply) {
|
|
size_t reply_size=160;
|
|
@SETTERS@
|
|
strcpy(reply,"query"); return true;
|
|
}
|
|
void handleCmdFrame(size_t len) {
|
|
static constexpr uint8_t RESP_CODE_CLI_REPLY=29, ERR_CODE_ILLEGAL_ARG=1;
|
|
@CLI_FRAME@
|
|
}
|
|
} the_mesh;
|
|
static void leaveUsbTerminalMode(bool) { the_mesh.terminal=false; }
|
|
@FUNCTIONS@
|
|
static void reset() {
|
|
connected=true; dedicated=false; logging=false; network=false;
|
|
usb_logging_network_parked=usb_logging_terminal_mode=false;
|
|
usb_logging_reply_hold=usb_logging_reply_staged=false;
|
|
usb_logging_reply_pending=usb_logging_reply_state=false;
|
|
stream=UsbStream(); ble=Remote(); the_mesh=MyMesh();
|
|
usb_serial_interface=ArduinoSerialInterface();
|
|
interface_manager=MultiSerialInterface();
|
|
usb_serial_interface.begin(stream);
|
|
usb_serial_interface.setConnectedCheck(clientConnected);
|
|
usb_serial_interface.enableFlowControl(true);
|
|
assert(interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface));
|
|
assert(interface_manager.addInterface(InterfaceType::Bluetooth,&ble));
|
|
interface_manager.enable(); banners=cancellations=0;
|
|
}
|
|
static void command(const char* text) {
|
|
stream.receive(text);
|
|
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
|
|
assert(len>0 && interface_manager.captureReplyRoute()==&usb_serial_interface);
|
|
the_mesh.handleCmdFrame(len);
|
|
}
|
|
static void checkReply() {
|
|
assert(stream.tx.size()>=4 && stream.tx[0]=='>');
|
|
const size_t size=stream.tx[1]+256*stream.tx[2];
|
|
assert(stream.tx.size()>=size+3 && stream.tx[3]==29);
|
|
assert(ble.tx.empty());
|
|
const std::string reply(stream.tx.begin()+4,stream.tx.begin()+size+3);
|
|
assert(reply.find("saved")!=std::string::npos);
|
|
}
|
|
int main() {
|
|
// Real CLI setters, not a mock apply call: both variants retain the request.
|
|
for(const char* setting : {"set usb.logging on","set logging.output both"}) {
|
|
reset(); command(setting);
|
|
assert(!logging && !the_mesh.terminal && usb_logging_reply_pending);
|
|
checkReply();
|
|
const size_t frame_bytes=stream.tx.size();
|
|
serviceUsbLoggingReplyBarrier();
|
|
assert(logging && the_mesh.terminal && !usb_logging_reply_pending);
|
|
assert(stream.tx.size()==frame_bytes+6);
|
|
assert(std::string(stream.tx.begin()+frame_bytes,stream.tx.end())=="BANNER");
|
|
assert(ble.tx.empty());
|
|
}
|
|
// Stall and partial writes never block the mesh or allow a banner to splice
|
|
// into a framed response; the complete reply precedes the logging handoff.
|
|
reset(); stream.capacity=0; command("set usb.logging on");
|
|
assert(stream.tx.empty() && usb_logging_reply_pending && !logging);
|
|
for(unsigned i=0;i<10000;++i) serviceUsbLoggingReplyBarrier();
|
|
assert(!logging && !the_mesh.terminal && stream.tx.empty());
|
|
stream.capacity=7; stream.max_write=3;
|
|
for(unsigned i=0;i<1000 && usb_logging_reply_pending;++i) {
|
|
serviceUsbLoggingReplyBarrier();
|
|
if(usb_logging_reply_pending) assert(!logging && !the_mesh.terminal);
|
|
}
|
|
assert(logging); checkReply();
|
|
// An incomplete *next* input does not hold the already admitted reply.
|
|
reset(); stream.capacity=0; command("set usb.logging on");
|
|
stream.rx={'<',5,0,1}; stream.cursor=0;
|
|
uint8_t input[176]; assert(usb_serial_interface.checkRecvFrame(input)==0);
|
|
assert(usb_serial_interface.isReadBusy());
|
|
stream.capacity=4096; serviceUsbLoggingReplyBarrier();
|
|
assert(logging && the_mesh.terminal); checkReply();
|
|
// A new read-only USB request while stalled must not erase the old intent.
|
|
reset(); stream.capacity=0; command("set usb.logging on");
|
|
command("get version"); assert(usb_logging_reply_pending && !logging);
|
|
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(logging);
|
|
// Disconnect/reset/ASCII takeover cancels this session's queued intent.
|
|
reset(); stream.capacity=0; command("set usb.logging on");
|
|
connected=false; serviceUsbLoggingReplyBarrier();
|
|
connected=true; stream.capacity=4096; serviceUsbLoggingReplyBarrier();
|
|
assert(!logging && !usb_logging_reply_pending && ble.tx.empty());
|
|
reset(); stream.capacity=0; command("set usb.logging on");
|
|
cancelUsbSerialOperations(); usb_serial_interface.resetSessionState();
|
|
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
|
|
// A queue unable to accept the acknowledgement cannot start the handoff.
|
|
reset(); stream.capacity=0;
|
|
const uint8_t required[]={29,1};
|
|
for(unsigned i=0;i<4;++i) assert(usb_serial_interface.writeFrame(required,2)==2);
|
|
command("set usb.logging on");
|
|
assert(!usb_logging_reply_pending && !logging && ble.tx.empty());
|
|
// A later explicit setting supersedes an earlier pending on request.
|
|
reset(); stream.capacity=0; command("set usb.logging on");
|
|
the_mesh.applyUsbLoggingState(false);
|
|
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
|
|
// Dedicated CDC and commands from another interface don't defer its reply.
|
|
reset(); dedicated=true; command("set usb.logging on");
|
|
assert(logging && !usb_logging_reply_pending && !the_mesh.terminal); checkReply();
|
|
reset(); the_mesh.beginUsbLoggingReplyBarrier(&ble);
|
|
the_mesh.applyUsbLoggingState(true); the_mesh.endUsbLoggingReplyBarrier(true);
|
|
assert(logging && !usb_logging_reply_pending && the_mesh.terminal);
|
|
// A real BLE framed command retains its route even when it parks USB.
|
|
// Network terminal ownership likewise survives this logging update.
|
|
for(bool network_owner : {false,true}) {
|
|
reset(); network=network_owner;
|
|
const char* setting="set usb.logging on";
|
|
ble.rx={mesh::companion::kRunCliCommand};
|
|
ble.rx.insert(ble.rx.end(),setting,setting+strlen(setting));
|
|
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
|
|
assert(len>0 && interface_manager.captureReplyRoute()==&ble);
|
|
the_mesh.handleCmdFrame(len);
|
|
assert(logging && !usb_logging_reply_pending && ble.tx[0]==29);
|
|
assert(interface_manager.captureReplyRoute()==&ble && cancellations==0);
|
|
assert(the_mesh.terminal!=network_owner && network==network_owner);
|
|
assert(usb_serial_interface.isPassthroughMode());
|
|
}
|
|
}
|
|
'''
|
|
|
|
|
|
class CompanionLoggingReplyTest(unittest.TestCase):
|
|
def test_logging_acknowledgement_precedes_single_tty_handoff(self):
|
|
main = (ROOT / 'examples/companion_radio/main.cpp').read_text()
|
|
mesh = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
|
|
signatures = (
|
|
'static void cancelUsbSerialOperations() {',
|
|
'static void enterUsbTerminalMode(bool show_banner = true) {',
|
|
'static void enterUsbLoggingTerminalMode() {',
|
|
'static void serviceUsbLoggingOwnership(bool logging_enabled) {',
|
|
'void MyMesh::applyUsbLoggingState(bool enabled) {',
|
|
'void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface* route) {',
|
|
'void MyMesh::endUsbLoggingReplyBarrier(bool reply_queued) {',
|
|
'static void serviceUsbLoggingReplyBarrier() {',
|
|
)
|
|
source = HARNESS.replace('@FUNCTIONS@', '\n'.join(
|
|
extract_braced(main, signature) for signature in signatures))
|
|
setters = '\n'.join(extract_braced(mesh, signature) for signature in (
|
|
'if (strncmp(command, "set usb.logging", 15) == 0',
|
|
'if (strncmp(command, "set logging.output ", 19) == 0)',
|
|
))
|
|
source = source.replace('@SETTERS@', setters).replace('@CLI_FRAME@',
|
|
extract_braced(mesh, 'if (mesh::companion::isRunCliFrame(cmd_frame[0], len))'))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
cpp, exe = Path(directory) / 'reply.cpp', Path(directory) / 'reply.exe'
|
|
cpp.write_text(source)
|
|
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
|
|
'-Wno-unused-parameter', '-Wno-sign-compare',
|
|
'-I' + str(ROOT / 'test/mocks'), '-I' + str(ROOT / 'src'),
|
|
str(cpp), str(ROOT / 'src/helpers/ArduinoSerialInterface.cpp'),
|
|
'-o', str(exe)], check=True)
|
|
subprocess.run([str(exe)], check=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|