mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-14 07:21:30 +10:00
* Fix serial protobuf corruption on short writes SerialConsole shared raw debug output and framed protobuf traffic on one HWCDC stream. Raw text could interleave inside an active frame when API logging was disabled. Separately, HWCDC deliberately returns a short write after bounded backpressure; StreamAPI abandoned that frame after PhoneAPI had already advanced, so the next 0x94c3 header landed inside the previous declared payload. Suppress all unframed output after protobuf mode starts and honor the existing config-replay log pause. For HWCDC, retain short frame tails in the persistent tx buffers and finish them on later loop passes before dequeuing another FromRadio packet. Logs remain best-effort and only start when their complete frame fits; main packets are deferred rather than dropped if a synchronous log is pending. Do not call HWCDC flush for framed serial output, since its no-progress path can discard queued bytes. TCP and non-HWCDC transports keep their existing behavior. Validated on Cardputer ADV (200-node DB) and Heltec Tracker V2: 400 initial full-DB sessions plus 140 final sessions across both API-log settings, zero malformed frames/timeouts/incomplete DBs; 2-20s forced reader stalls resume with complete DBs; abrupt stalled-client replacement 5/5 per board; 150s post-close reboot counts stable. Builds pass for Cardputer, Heltec, tbeam, and rak4631. * Add serial frame continuation regression tests Extract the HWCDC pending/deferred frame state machine into a small transport-independent helper so native tests exercise the same production logic used by SerialConsole. Keep framing, persistent buffer ownership and locking in SerialConsole. Cover short-tail continuation, deferred main-frame ordering, best-effort log admission, bounded zero-progress calls, generic StreamAPI failure semantics, PhoneAPI advancement gating, framed-log gating and raw output suppression. The coverage suite passes 31/31 suites and 582/582 tests. * Address review: guard flush, assert deferred invariant, dedup framing - Make SerialConsole::flush() a no-op in protobuf mode: HWCDC::flush()'s no-progress path discards queued TX bytes, which would tear a framed stream when the sleep path flushes with a stalled host. - Assert the single required-frame producer invariant in StreamFrameWriter::writeFrame() instead of silently dropping a second required frame. - Hoist 0x94C3 header construction into StreamAPI::buildFrameHeader() so SerialConsole no longer re-hardcodes the framing constants. * Test retained serial tail across client replacement Model a replacement client arriving while an older required frame has an unwritten tail. Require the old frame to complete before the new frame starts, so a new 0x94C3 header can never land inside the old declared payload. * Document serial frame APIs and regression tests Add concise Doxygen comments for the frame continuation hooks, production helper, native test doubles, and regression scenarios. Document why retained frame tails intentionally survive client disconnects: HWCDC may still hold the accepted prefix, so dropping metadata could insert a new frame header inside the old declared payload.
278 lines
10 KiB
C++
278 lines
10 KiB
C++
#include "StreamAPI.h"
|
|
#include "PowerFSM.h"
|
|
#include "RTC.h"
|
|
#include "Throttle.h"
|
|
#include "concurrency/LockGuard.h"
|
|
#include "configuration.h"
|
|
|
|
#define START1 0x94
|
|
#define START2 0xc3
|
|
#define HEADER_LEN 4
|
|
|
|
/// Poll the underlying stream, drain output, and update connection state.
|
|
int32_t StreamAPI::runOncePart()
|
|
{
|
|
auto result = readStream();
|
|
writeStream();
|
|
checkConnectionTimeout();
|
|
return result;
|
|
}
|
|
|
|
/// Consume supplied input bytes, drain output, and update connection state.
|
|
int32_t StreamAPI::runOncePart(char *buf, uint16_t bufLen)
|
|
{
|
|
auto result = readStream(buf, bufLen);
|
|
writeStream();
|
|
checkConnectionTimeout();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Read any rx chars from the link and call handleRecStream
|
|
*/
|
|
int32_t StreamAPI::readStream(const char *buf, uint16_t bufLen)
|
|
{
|
|
if (bufLen < 1) {
|
|
// Nothing available this time, if the computer has talked to us recently, poll often, otherwise let CPU sleep a long time
|
|
bool recentRx = Throttle::isWithinTimespanMs(lastRxMsec, 2000);
|
|
return recentRx ? 5 : 250;
|
|
} else {
|
|
handleRecStream(buf, bufLen);
|
|
// we had bytes available this time, so assume we might have them next time also
|
|
lastRxMsec = millis();
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* call getFromRadio() and deliver encapsulated packets to the Stream
|
|
*/
|
|
void StreamAPI::writeStream()
|
|
{
|
|
if (canWrite) {
|
|
// A transport that retained a short frame must complete it before
|
|
// getFromRadio() advances the PhoneAPI state to the next packet.
|
|
if (!finishPendingFrame())
|
|
return;
|
|
|
|
uint32_t len;
|
|
do {
|
|
// Send every packet we can
|
|
len = getFromRadio(txBuf + HEADER_LEN);
|
|
if (len != 0 && !emitTxBuffer(len))
|
|
break;
|
|
} while (len);
|
|
}
|
|
}
|
|
|
|
/// Parse supplied bytes through the framed ToRadio receive state machine.
|
|
int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen)
|
|
{
|
|
uint16_t index = 0;
|
|
while (bufLen > index) { // Currently we never want to block
|
|
int cInt = buf[index++];
|
|
if (cInt < 0)
|
|
break; // We ran out of characters (even though available said otherwise) - this can happen on rf52 adafruit
|
|
// arduino
|
|
|
|
uint8_t c = (uint8_t)cInt;
|
|
|
|
// Use the read pointer for a little state machine, first look for framing, then length bytes, then payload
|
|
size_t ptr = rxPtr;
|
|
|
|
rxPtr++; // assume we will probably advance the rxPtr
|
|
rxBuf[ptr] = c; // store all bytes (including framing)
|
|
|
|
// console->printf("rxPtr %d ptr=%d c=0x%x\n", rxPtr, ptr, c);
|
|
|
|
if (ptr == 0) { // looking for START1
|
|
if (c != START1)
|
|
rxPtr = 0; // failed to find framing
|
|
} else if (ptr == 1) { // looking for START2
|
|
if (c != START2)
|
|
rxPtr = 0; // failed to find framing
|
|
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
|
|
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
|
|
|
|
// console->printf("len %d\n", len);
|
|
|
|
if (ptr == HEADER_LEN - 1) {
|
|
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
|
|
// protobuf also)
|
|
if (len > MAX_TO_FROM_RADIO_SIZE)
|
|
rxPtr = 0; // length is bogus, restart search for framing
|
|
}
|
|
|
|
if (rxPtr != 0) // Is packet still considered 'good'?
|
|
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
|
|
rxPtr = 0; // start over again on the next packet
|
|
|
|
// If we didn't just fail the packet and we now have the right # of bytes, parse it
|
|
handleToRadio(rxBuf + HEADER_LEN, len);
|
|
}
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Read any rx chars from the link and call handleToRadio
|
|
*/
|
|
int32_t StreamAPI::readStream()
|
|
{
|
|
if (!stream->available()) {
|
|
// Nothing available this time, if the computer has talked to us recently, poll often, otherwise let CPU sleep a long time
|
|
bool recentRx = Throttle::isWithinTimespanMs(lastRxMsec, 2000);
|
|
return recentRx ? 5 : 250;
|
|
} else {
|
|
while (stream->available()) { // Currently we never want to block
|
|
int cInt = stream->read();
|
|
if (cInt < 0)
|
|
break; // We ran out of characters (even though available said otherwise) - this can happen on rf52 adafruit
|
|
// arduino
|
|
|
|
uint8_t c = (uint8_t)cInt;
|
|
|
|
// Use the read pointer for a little state machine, first look for framing, then length bytes, then payload
|
|
size_t ptr = rxPtr;
|
|
|
|
rxPtr++; // assume we will probably advance the rxPtr
|
|
rxBuf[ptr] = c; // store all bytes (including framing)
|
|
|
|
// console->printf("rxPtr %d ptr=%d c=0x%x\n", rxPtr, ptr, c);
|
|
|
|
if (ptr == 0) { // looking for START1
|
|
if (c != START1)
|
|
rxPtr = 0; // failed to find framing
|
|
} else if (ptr == 1) { // looking for START2
|
|
if (c != START2)
|
|
rxPtr = 0; // failed to find framing
|
|
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
|
|
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
|
|
|
|
// console->printf("len %d\n", len);
|
|
|
|
if (ptr == HEADER_LEN - 1) {
|
|
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
|
|
// protobuf also)
|
|
if (len > MAX_TO_FROM_RADIO_SIZE)
|
|
rxPtr = 0; // length is bogus, restart search for framing
|
|
}
|
|
|
|
if (rxPtr != 0) // Is packet still considered 'good'?
|
|
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
|
|
rxPtr = 0; // start over again on the next packet
|
|
|
|
// If we didn't just fail the packet and we now have the right # of bytes, parse it
|
|
handleToRadio(rxBuf + HEADER_LEN, len);
|
|
}
|
|
}
|
|
}
|
|
|
|
// we had bytes available this time, so assume we might have them next time also
|
|
lastRxMsec = millis();
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// Encode the stream marker and big-endian payload length.
|
|
size_t StreamAPI::buildFrameHeader(uint8_t *buf, size_t payloadLen)
|
|
{
|
|
buf[0] = START1;
|
|
buf[1] = START2;
|
|
buf[2] = (payloadLen >> 8) & 0xff;
|
|
buf[3] = payloadLen & 0xff;
|
|
return payloadLen + HEADER_LEN;
|
|
}
|
|
|
|
/**
|
|
* Send the current txBuffer over our stream
|
|
*/
|
|
/// Write one framed payload using the transport's failure semantics.
|
|
bool StreamAPI::writeFrame(uint8_t *buf, size_t len, bool bestEffort)
|
|
{
|
|
(void)bestEffort;
|
|
if (len == 0 || !canWrite)
|
|
return false;
|
|
|
|
const size_t totalLen = buildFrameHeader(buf, len);
|
|
// Serialize write-readiness checks, writes and write-failure handling
|
|
// against concurrent stream writes/close.
|
|
concurrency::LockGuard guard(&streamLock);
|
|
if (!canWriteFrame(totalLen))
|
|
return false;
|
|
|
|
size_t written = stream->write(buf, totalLen);
|
|
if (written == totalLen) {
|
|
stream->flush();
|
|
return true;
|
|
}
|
|
|
|
onFrameWriteFailed(totalLen, written);
|
|
return false;
|
|
}
|
|
|
|
/// Emit the prepared main PhoneAPI payload as required output.
|
|
bool StreamAPI::emitTxBuffer(size_t len)
|
|
{
|
|
return writeFrame(txBuf, len, false);
|
|
}
|
|
|
|
/// Emit the initial reboot notification as a framed FromRadio payload.
|
|
void StreamAPI::emitRebooted()
|
|
{
|
|
// In case we send a FromRadio packet
|
|
memset(&fromRadioScratch, 0, sizeof(fromRadioScratch));
|
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_rebooted_tag;
|
|
fromRadioScratch.rebooted = true;
|
|
|
|
// LOG_DEBUG("Emitting reboot packet for serial shell");
|
|
emitTxBuffer(pb_encode_to_bytes(txBuf + HEADER_LEN, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratch));
|
|
}
|
|
|
|
/// Encode and emit one protobuf LogRecord using the dedicated log buffers.
|
|
void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg)
|
|
{
|
|
// A retained short log frame still points into txBufLog, so do not overwrite it.
|
|
if (!canEncodeLogRecord())
|
|
return;
|
|
|
|
// IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here - those
|
|
// belong to the main packet-emission path and a LOG_ firing during
|
|
// `writeStream()` would corrupt an in-flight encode. We keep a
|
|
// dedicated `fromRadioScratchLog` + `txBufLog` for log records and
|
|
// only serialize the actual `stream->write` call via `streamLock` so
|
|
// a concurrent packet emission doesn't interleave bytes on the wire.
|
|
memset(&fromRadioScratchLog, 0, sizeof(fromRadioScratchLog));
|
|
fromRadioScratchLog.which_payload_variant = meshtastic_FromRadio_log_record_tag;
|
|
fromRadioScratchLog.log_record.level = level;
|
|
|
|
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true);
|
|
fromRadioScratchLog.log_record.time = rtc_sec;
|
|
strncpy(fromRadioScratchLog.log_record.source, src, sizeof(fromRadioScratchLog.log_record.source) - 1);
|
|
|
|
auto num_printed =
|
|
vsnprintf(fromRadioScratchLog.log_record.message, sizeof(fromRadioScratchLog.log_record.message) - 1, format, arg);
|
|
if (num_printed > 0 && fromRadioScratchLog.log_record.message[num_printed - 1] ==
|
|
'\n') // Strip any ending newline, because we have records for framing instead.
|
|
fromRadioScratchLog.log_record.message[num_printed - 1] = '\0';
|
|
|
|
size_t len =
|
|
pb_encode_to_bytes(txBufLog + HEADER_LEN, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratchLog);
|
|
writeFrame(txBufLog, len, true);
|
|
}
|
|
|
|
/// Hookable to find out when connection changes
|
|
void StreamAPI::onConnectionChanged(bool connected)
|
|
{
|
|
// FIXME do reference counting instead
|
|
|
|
if (connected) { // To prevent user confusion, turn off bluetooth while using the serial port api
|
|
powerFSM.trigger(EVENT_SERIAL_CONNECTED);
|
|
} else {
|
|
// FIXME, we get no notice of serial going away, we should instead automatically generate this event if we haven't
|
|
// received a packet in a while
|
|
powerFSM.trigger(EVENT_SERIAL_DISCONNECTED);
|
|
}
|
|
}
|