harden USB telemetry transport
This commit is contained in:
@@ -73,12 +73,16 @@ records into versioned `TRK1` frames, isolating acquisition from brief transport
|
|||||||
stalls. CRC, packet and sample sequences, timestamps, and cumulative
|
stalls. CRC, packet and sample sequences, timestamps, and cumulative
|
||||||
loss/overrun counters make permanent loss detectable by the receiver.
|
loss/overrun counters make permanent loss detectable by the receiver.
|
||||||
|
|
||||||
The current ESP-IDF USB VFS path reports physical disconnects, but a connected
|
USB telemetry now uses ESP-IDF's interrupt-driven USB Serial/JTAG driver behind
|
||||||
host that stops draining can time out below stdio and still appear successful to
|
a transport-neutral state machine. A complete frame is submitted atomically to
|
||||||
firmware. The host can detect resulting loss from packet/sample sequences and
|
the driver ring and remains pending across bounded drain timeouts; firmware does
|
||||||
CRC framing, but the device cannot count that case. A direct USB driver with
|
not resubmit it ambiguously or dequeue another frame. The 512-sample queue
|
||||||
bounded drain waits, and ultimately receiver acknowledgements with replay, are
|
therefore also protects a connected endpoint that temporarily stops draining.
|
||||||
deferred to the common USB/BLE transport layer.
|
|
||||||
|
USB drain confirms that bytes left the device endpoint, not that the capture
|
||||||
|
application persisted them. Packet/sample sequences and CRC expose loss after
|
||||||
|
the fact. End-to-end receiver acknowledgements and replay remain part of the BLE
|
||||||
|
transport milestone.
|
||||||
|
|
||||||
Measured end-to-end framing overhead is about 2.47 kB/s at 100 Hz, or 8.47
|
Measured end-to-end framing overhead is about 2.47 kB/s at 100 Hz, or 8.47
|
||||||
MiB/hour before BLE link overhead.
|
MiB/hour before BLE link overhead.
|
||||||
@@ -132,10 +136,12 @@ Status bits:
|
|||||||
|
|
||||||
The binary capture tool auto-detects a single `/dev/cu.usbmodem*` device, stores
|
The binary capture tool auto-detects a single `/dev/cu.usbmodem*` device, stores
|
||||||
only CRC-valid frames, renders CSV, and reports packet, sample, timing, status,
|
only CRC-valid frames, renders CSV, and reports packet, sample, timing, status,
|
||||||
drop, overrun, timestamp-saturation, and trailing-partial-byte totals:
|
drop, overrun, timestamp-saturation, and trailing-partial-byte totals. An
|
||||||
|
optional `--wire` path preserves every received byte, including startup text and
|
||||||
|
damaged or partial frames, for forensic comparison:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python tools/capture_binary.py
|
python tools/capture_binary.py --wire captures/session.wire
|
||||||
```
|
```
|
||||||
|
|
||||||
An existing `.trk` stream can be decoded again without hardware:
|
An existing `.trk` stream can be decoded again without hardware:
|
||||||
@@ -144,5 +150,8 @@ An existing `.trk` stream can be decoded again without hardware:
|
|||||||
python tools/decode_binary.py captures/session.trk captures/session.csv
|
python tools/decode_binary.py captures/session.trk captures/session.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Live capture and offline decoding use the same integrity tracker, including
|
||||||
|
wrap-aware packet/sample gap classification.
|
||||||
|
|
||||||
`tools/capture_serial.py` remains available only for decoding captures from the
|
`tools/capture_serial.py` remains available only for decoding captures from the
|
||||||
older CSV-v3 firmware snapshots.
|
older CSV-v3 firmware snapshots.
|
||||||
|
|||||||
@@ -88,15 +88,23 @@ the oldest retained data is sent first. If the queue fills, acquisition drops
|
|||||||
new samples rather than overwriting older ones; sequence gaps and the cumulative
|
new samples rather than overwriting older ones; sequence gaps and the cumulative
|
||||||
lost-sample counter expose that permanent loss.
|
lost-sample counter expose that permanent loss.
|
||||||
|
|
||||||
That retry guarantee requires the transport's success result to mean that the
|
The shared transport state machine distinguishes three nonfatal states. `RETRY`
|
||||||
complete frame was accepted for eventual delivery. The current USB Serial/JTAG
|
means zero bytes were accepted and the complete frame may be submitted again.
|
||||||
VFS/stdio path does not fully satisfy that contract: if the host remains
|
`PENDING` means the backend owns an in-flight frame, so firmware may only poll
|
||||||
connected but stops draining, its internal timeout can discard bytes while the
|
that transfer. `COMPLETE` permits the output task to reuse its packet buffer and
|
||||||
stdio write appears successful. CRC and sequence checks make that loss visible
|
consume more samples. This prevents a timeout after partial progress from
|
||||||
to a receiver, but it does not increment the device's drop counter. A direct
|
causing an ambiguous whole-frame duplicate.
|
||||||
driver path with bounded transmit-drain waits can report this condition; an
|
|
||||||
application acknowledgement and replay window is required for end-to-end
|
The direct USB Serial/JTAG backend atomically copies a complete frame into its TX
|
||||||
delivery confirmation.
|
ring, then polls a bounded transmit-drain wait. A timeout remains `PENDING`; it
|
||||||
|
does not trigger resubmission. This closes the VFS/stdio path's silent-discard
|
||||||
|
case for a connected host that stops draining.
|
||||||
|
|
||||||
|
USB drain is not end-to-end application delivery confirmation. A host process
|
||||||
|
may attach after earlier frames have already left the endpoint, or fail after
|
||||||
|
the endpoint accepts them. CRC and sequence checks make resulting loss visible,
|
||||||
|
but an application acknowledgement and replay window are still required to
|
||||||
|
guarantee receipt.
|
||||||
|
|
||||||
Receivers report bytes left in an incomplete trailing frame when capture ends.
|
Receivers report bytes left in an incomplete trailing frame when capture ends.
|
||||||
Those bytes cannot pass CRC validation and are not silently admitted as samples.
|
Those bytes cannot pass CRC validation and are not silently admitted as samples.
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ bytes even though stdio reports a successful write. Firmware therefore cannot
|
|||||||
retain that particular frame or increment its drop counter. The receiver still
|
retain that particular frame or increment its drop counter. The receiver still
|
||||||
detects the loss through CRC resynchronization and packet/sample sequence gaps.
|
detects the loss through CRC resynchronization and packet/sample sequence gaps.
|
||||||
|
|
||||||
ESP-IDF's direct USB Serial/JTAG driver provides bounded writes and an explicit
|
This limitation was subsequently closed at the device/endpoint boundary by the
|
||||||
transmit-drain wait, allowing a connected stall to become observable to the
|
shared transport state machine and direct USB Serial/JTAG driver described in
|
||||||
transport policy. That is a useful improvement for the common transport layer.
|
`transport-layer-validation-2026-08-17.md`. It is not proof of receiver delivery;
|
||||||
It is not proof of receiver delivery; application acknowledgements and replay
|
application acknowledgements and replay are still needed for that stronger
|
||||||
are needed for that stronger guarantee and are planned with BLE integration.
|
guarantee and are planned with BLE integration.
|
||||||
|
|
||||||
## Final hardware capture
|
## Final hardware capture
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Direct USB Transport Validation — 2026-08-17
|
||||||
|
|
||||||
|
This milestone moved framed telemetry off stdio and the USB Serial/JTAG VFS data
|
||||||
|
path. The output task now targets a transport-neutral state machine backed by
|
||||||
|
ESP-IDF's interrupt-driven USB Serial/JTAG driver.
|
||||||
|
|
||||||
|
## Transaction contract
|
||||||
|
|
||||||
|
- `RETRY`: the backend accepted zero bytes, so whole-frame resubmission is safe.
|
||||||
|
- `PENDING`: the backend owns an in-flight frame. Only completion polling is
|
||||||
|
allowed; the caller must retain and not modify the packet buffer.
|
||||||
|
- `COMPLETE`: the backend's completion criterion is satisfied and the caller may
|
||||||
|
reuse the packet buffer.
|
||||||
|
- `FATAL`: a programming or backend invariant failed. The output task stops
|
||||||
|
consuming the sample queue rather than silently discarding its in-flight data.
|
||||||
|
|
||||||
|
For USB, submission uses a 512-byte TX ring and a bounded 50 ms write. ESP-IDF's
|
||||||
|
ring-buffer send is all-or-nothing for each `TRK1` frame. Once accepted, bounded
|
||||||
|
50 ms `usb_serial_jtag_wait_tx_done()` calls continue returning `PENDING` until
|
||||||
|
the host drains the endpoint. A timeout never resubmits the frame.
|
||||||
|
|
||||||
|
The VFS is switched to driver mode after readable startup output so any
|
||||||
|
unexpected diagnostic output cannot race the driver's ISR by accessing the
|
||||||
|
hardware FIFO directly. Firmware logs are disabled before binary telemetry
|
||||||
|
tasks start, as before.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The host C transport fixture compiles the production state machine with
|
||||||
|
`-Wall -Wextra -Werror` and verifies:
|
||||||
|
|
||||||
|
- zero-accept submissions remain retryable;
|
||||||
|
- accepted transfers become pending;
|
||||||
|
- repeated pending polls never call submission again;
|
||||||
|
- completion returns the sender to idle;
|
||||||
|
- backend-confirmed safe retry returns to idle;
|
||||||
|
- invalid arguments and unknown backend states fail closed.
|
||||||
|
|
||||||
|
The assembled ESP32-C3 prototype produced a normal 1,728-sample direct-driver
|
||||||
|
capture with no packet gaps, sample gaps, resets, CRC failures, reported drops,
|
||||||
|
loop overruns, trailing partial bytes, or timestamp-saturation frames.
|
||||||
|
|
||||||
|
For the connected-stall case, the USB endpoint was left enumerated without a
|
||||||
|
serial reader long enough to overflow the 512-sample acquisition queue. When the
|
||||||
|
reader opened, delivery preserved the oldest block through sequence 511 and
|
||||||
|
resumed at sequence 1,706. The one observed gap and the cumulative device drop
|
||||||
|
count both equal 1,194 samples. The timestamp difference across that gap is
|
||||||
|
11,950,000 us, exactly 1,195 sample intervals. Packet gaps, CRC failures, loop
|
||||||
|
overruns, trailing partial bytes, and timestamp-saturation flags are zero.
|
||||||
|
|
||||||
|
That exact validated stream is tracked as
|
||||||
|
`tests/fixtures/direct_usb_stall.trk`, SHA-256
|
||||||
|
`40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558`, and
|
||||||
|
its signature is asserted by the regression suite.
|
||||||
|
|
||||||
|
## Remaining delivery boundary
|
||||||
|
|
||||||
|
The stall capture begins at sample sequence 8. The flashing process still had
|
||||||
|
the serial endpoint open long enough to drain sequences 0 through 7 before the
|
||||||
|
capture application attached. This is not silent device-side loss; it precisely
|
||||||
|
demonstrates the boundary of USB drain confirmation. Only an application-level
|
||||||
|
ACK can prove that the intended receiver received and persisted a frame.
|
||||||
|
|
||||||
|
The planned BLE backend will use the same `PENDING` ownership rule while waiting
|
||||||
|
for acknowledgements, retain unacknowledged frames for replay, and expose
|
||||||
|
per-cause transport/queue counters separately.
|
||||||
|
|
||||||
|
## Host evidence handling
|
||||||
|
|
||||||
|
Live capture and offline decode now share one integrity tracker for packet and
|
||||||
|
sample gaps/resets, timestamp anomalies, sensor status, saturation flags, drops,
|
||||||
|
and acquisition overruns. Sequence classification is wrap-aware. The capture
|
||||||
|
tool also accepts `--wire PATH` to preserve every serial byte before parsing,
|
||||||
|
including startup text, corrupt frames, and trailing fragments.
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "trikke_sensor_main.c" "trikke_protocol.c"
|
SRCS "trikke_sensor_main.c" "trikke_protocol.c" "trikke_transport.c"
|
||||||
|
"trikke_usb_transport.c"
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
REQUIRES adxl345 l3g4200d esp_timer esp_driver_gpio esp_driver_i2c
|
REQUIRES adxl345 l3g4200d esp_timer esp_driver_gpio esp_driver_i2c
|
||||||
esp_driver_usb_serial_jtag vfs
|
esp_driver_usb_serial_jtag vfs
|
||||||
|
|||||||
+39
-15
@@ -14,6 +14,8 @@
|
|||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "l3g4200d.h"
|
#include "l3g4200d.h"
|
||||||
#include "trikke_protocol.h"
|
#include "trikke_protocol.h"
|
||||||
|
#include "trikke_transport.h"
|
||||||
|
#include "trikke_usb_transport.h"
|
||||||
|
|
||||||
// Seeed Studio XIAO ESP32-C3: D4/SDA = GPIO6, D5/SCL = GPIO7.
|
// Seeed Studio XIAO ESP32-C3: D4/SDA = GPIO6, D5/SCL = GPIO7.
|
||||||
#define TRIKKE_I2C_PORT I2C_NUM_0
|
#define TRIKKE_I2C_PORT I2C_NUM_0
|
||||||
@@ -54,6 +56,8 @@ typedef struct {
|
|||||||
QueueHandle_t sample_queue;
|
QueueHandle_t sample_queue;
|
||||||
atomic_uint_least32_t dropped_sample_count;
|
atomic_uint_least32_t dropped_sample_count;
|
||||||
atomic_uint_least32_t loop_overrun_count;
|
atomic_uint_least32_t loop_overrun_count;
|
||||||
|
trikke_transport_t transport;
|
||||||
|
trikke_usb_transport_t usb_transport;
|
||||||
} trikke_context_t;
|
} trikke_context_t;
|
||||||
|
|
||||||
static trikke_context_t s_context;
|
static trikke_context_t s_context;
|
||||||
@@ -157,22 +161,26 @@ static void acquisition_task(void *argument)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool write_binary_packet(const uint8_t *packet, size_t packet_size)
|
|
||||||
{
|
|
||||||
const size_t written = fwrite(packet, 1, packet_size, stdout);
|
|
||||||
const int flush_result = fflush(stdout);
|
|
||||||
const bool complete = written == packet_size && flush_result == 0;
|
|
||||||
if (!complete) {
|
|
||||||
clearerr(stdout);
|
|
||||||
}
|
|
||||||
return complete;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void write_binary_packet_until_sent(
|
static void write_binary_packet_until_sent(
|
||||||
|
trikke_context_t *context,
|
||||||
|
trikke_transport_sender_t *sender,
|
||||||
const uint8_t *packet,
|
const uint8_t *packet,
|
||||||
size_t packet_size)
|
size_t packet_size)
|
||||||
{
|
{
|
||||||
while (!write_binary_packet(packet, packet_size)) {
|
while (true) {
|
||||||
|
const trikke_transport_status_t status =
|
||||||
|
trikke_transport_sender_step(
|
||||||
|
sender, &context->transport, packet, packet_size);
|
||||||
|
if (status == TRIKKE_TRANSPORT_COMPLETE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status == TRIKKE_TRANSPORT_FATAL) {
|
||||||
|
// Preserve the in-flight packet and stop consuming the queue. A
|
||||||
|
// fatal backend invariant is not safely recoverable or retryable.
|
||||||
|
while (true) {
|
||||||
|
vTaskDelay(portMAX_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(TRIKKE_TRANSPORT_RETRY_DELAY_MS));
|
vTaskDelay(pdMS_TO_TICKS(TRIKKE_TRANSPORT_RETRY_DELAY_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,12 +193,14 @@ static void output_task(void *argument)
|
|||||||
uint8_t packet[TRIKKE_WIRE_MAX_PACKET_SIZE] = {0};
|
uint8_t packet[TRIKKE_WIRE_MAX_PACKET_SIZE] = {0};
|
||||||
uint32_t packet_sequence = 0;
|
uint32_t packet_sequence = 0;
|
||||||
uint32_t sample_packet_count = 0;
|
uint32_t sample_packet_count = 0;
|
||||||
|
trikke_transport_sender_t sender;
|
||||||
|
trikke_transport_sender_init(&sender);
|
||||||
|
|
||||||
size_t packet_size = trikke_encode_metadata_packet(
|
size_t packet_size = trikke_encode_metadata_packet(
|
||||||
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
|
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
|
||||||
atomic_load(&context->dropped_sample_count),
|
atomic_load(&context->dropped_sample_count),
|
||||||
atomic_load(&context->loop_overrun_count), &TRIKKE_METADATA);
|
atomic_load(&context->loop_overrun_count), &TRIKKE_METADATA);
|
||||||
write_binary_packet_until_sent(packet, packet_size);
|
write_binary_packet_until_sent(context, &sender, packet, packet_size);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
trikke_wire_sample_t samples[TRIKKE_WIRE_MAX_RECORDS] = {0};
|
trikke_wire_sample_t samples[TRIKKE_WIRE_MAX_RECORDS] = {0};
|
||||||
@@ -227,14 +237,15 @@ static void output_task(void *argument)
|
|||||||
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
|
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
|
||||||
atomic_load(&context->dropped_sample_count),
|
atomic_load(&context->dropped_sample_count),
|
||||||
atomic_load(&context->loop_overrun_count), &TRIKKE_METADATA);
|
atomic_load(&context->loop_overrun_count), &TRIKKE_METADATA);
|
||||||
write_binary_packet_until_sent(packet, packet_size);
|
write_binary_packet_until_sent(
|
||||||
|
context, &sender, packet, packet_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
packet_size = trikke_encode_sample_packet(
|
packet_size = trikke_encode_sample_packet(
|
||||||
packet, sizeof(packet), packet_sequence++,
|
packet, sizeof(packet), packet_sequence++,
|
||||||
atomic_load(&context->dropped_sample_count),
|
atomic_load(&context->dropped_sample_count),
|
||||||
atomic_load(&context->loop_overrun_count), samples, sample_count);
|
atomic_load(&context->loop_overrun_count), samples, sample_count);
|
||||||
write_binary_packet_until_sent(packet, packet_size);
|
write_binary_packet_until_sent(context, &sender, packet, packet_size);
|
||||||
++sample_packet_count;
|
++sample_packet_count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +335,17 @@ void app_main(void)
|
|||||||
// binary frames whenever a payload byte equals LF.
|
// binary frames whenever a payload byte equals LF.
|
||||||
usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF);
|
usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF);
|
||||||
|
|
||||||
|
err = trikke_usb_transport_init(
|
||||||
|
&s_context.usb_transport, &s_context.transport);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "USB transport initialization failed: %s",
|
||||||
|
esp_err_to_name(err));
|
||||||
|
l3g4200d_deinit(&s_context.gyroscope);
|
||||||
|
adxl345_deinit(&s_context.accelerometer);
|
||||||
|
i2c_del_master_bus(bus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
s_context.sample_queue =
|
s_context.sample_queue =
|
||||||
xQueueCreate(TRIKKE_SAMPLE_QUEUE_DEPTH, sizeof(trikke_wire_sample_t));
|
xQueueCreate(TRIKKE_SAMPLE_QUEUE_DEPTH, sizeof(trikke_wire_sample_t));
|
||||||
if (s_context.sample_queue == NULL) {
|
if (s_context.sample_queue == NULL) {
|
||||||
@@ -331,6 +353,7 @@ void app_main(void)
|
|||||||
l3g4200d_deinit(&s_context.gyroscope);
|
l3g4200d_deinit(&s_context.gyroscope);
|
||||||
adxl345_deinit(&s_context.accelerometer);
|
adxl345_deinit(&s_context.accelerometer);
|
||||||
i2c_del_master_bus(bus);
|
i2c_del_master_bus(bus);
|
||||||
|
trikke_usb_transport_deinit(&s_context.usb_transport);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +374,7 @@ void app_main(void)
|
|||||||
l3g4200d_deinit(&s_context.gyroscope);
|
l3g4200d_deinit(&s_context.gyroscope);
|
||||||
adxl345_deinit(&s_context.accelerometer);
|
adxl345_deinit(&s_context.accelerometer);
|
||||||
i2c_del_master_bus(bus);
|
i2c_del_master_bus(bus);
|
||||||
|
trikke_usb_transport_deinit(&s_context.usb_transport);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#include "trikke_transport.h"
|
||||||
|
|
||||||
|
static bool status_is_valid(trikke_transport_status_t status)
|
||||||
|
{
|
||||||
|
return status >= TRIKKE_TRANSPORT_COMPLETE &&
|
||||||
|
status <= TRIKKE_TRANSPORT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void trikke_transport_sender_init(trikke_transport_sender_t *sender)
|
||||||
|
{
|
||||||
|
if (sender != NULL) {
|
||||||
|
sender->pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trikke_transport_status_t trikke_transport_sender_step(
|
||||||
|
trikke_transport_sender_t *sender,
|
||||||
|
const trikke_transport_t *transport,
|
||||||
|
const uint8_t *packet,
|
||||||
|
size_t packet_size)
|
||||||
|
{
|
||||||
|
if (sender == NULL || transport == NULL || transport->begin == NULL ||
|
||||||
|
transport->poll == NULL || packet == NULL || packet_size == 0) {
|
||||||
|
return TRIKKE_TRANSPORT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trikke_transport_status_t status = sender->pending
|
||||||
|
? transport->poll(transport->context)
|
||||||
|
: transport->begin(transport->context, packet, packet_size);
|
||||||
|
if (!status_is_valid(status)) {
|
||||||
|
sender->pending = false;
|
||||||
|
return TRIKKE_TRANSPORT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == TRIKKE_TRANSPORT_PENDING) {
|
||||||
|
sender->pending = true;
|
||||||
|
} else {
|
||||||
|
// RETRY from poll is allowed only when the backend has discarded or
|
||||||
|
// otherwise resolved the old transfer and knows resubmission is safe.
|
||||||
|
sender->pending = false;
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
TRIKKE_TRANSPORT_COMPLETE = 0,
|
||||||
|
TRIKKE_TRANSPORT_RETRY,
|
||||||
|
TRIKKE_TRANSPORT_PENDING,
|
||||||
|
TRIKKE_TRANSPORT_FATAL,
|
||||||
|
} trikke_transport_status_t;
|
||||||
|
|
||||||
|
typedef trikke_transport_status_t (*trikke_transport_begin_fn)(
|
||||||
|
void *context,
|
||||||
|
const uint8_t *packet,
|
||||||
|
size_t packet_size);
|
||||||
|
|
||||||
|
typedef trikke_transport_status_t (*trikke_transport_poll_fn)(void *context);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void *context;
|
||||||
|
trikke_transport_begin_fn begin;
|
||||||
|
trikke_transport_poll_fn poll;
|
||||||
|
} trikke_transport_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool pending;
|
||||||
|
} trikke_transport_sender_t;
|
||||||
|
|
||||||
|
void trikke_transport_sender_init(trikke_transport_sender_t *sender);
|
||||||
|
|
||||||
|
// Advances one bounded transport operation. The packet storage must remain valid
|
||||||
|
// and unchanged from the first PENDING result through COMPLETE. While pending,
|
||||||
|
// only poll is called: an ambiguous timeout can never duplicate a frame.
|
||||||
|
trikke_transport_status_t trikke_transport_sender_step(
|
||||||
|
trikke_transport_sender_t *sender,
|
||||||
|
const trikke_transport_t *transport,
|
||||||
|
const uint8_t *packet,
|
||||||
|
size_t packet_size);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#include "trikke_usb_transport.h"
|
||||||
|
|
||||||
|
#include "driver/usb_serial_jtag.h"
|
||||||
|
#include "driver/usb_serial_jtag_vfs.h"
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
|
||||||
|
#define TRIKKE_USB_TX_BUFFER_SIZE 512
|
||||||
|
#define TRIKKE_USB_RX_BUFFER_SIZE 128
|
||||||
|
#define TRIKKE_USB_OPERATION_TIMEOUT_MS 50
|
||||||
|
|
||||||
|
static trikke_transport_status_t usb_begin_packet(
|
||||||
|
void *context,
|
||||||
|
const uint8_t *packet,
|
||||||
|
size_t packet_size)
|
||||||
|
{
|
||||||
|
const trikke_usb_transport_t *usb = context;
|
||||||
|
if (usb == NULL || !usb->initialized || packet == NULL || packet_size == 0 ||
|
||||||
|
packet_size > TRIKKE_USB_TX_BUFFER_SIZE) {
|
||||||
|
return TRIKKE_TRANSPORT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int written = usb_serial_jtag_write_bytes(
|
||||||
|
packet, packet_size,
|
||||||
|
pdMS_TO_TICKS(TRIKKE_USB_OPERATION_TIMEOUT_MS));
|
||||||
|
if (written == (int)packet_size) {
|
||||||
|
return TRIKKE_TRANSPORT_PENDING;
|
||||||
|
}
|
||||||
|
if (written == 0) {
|
||||||
|
return TRIKKE_TRANSPORT_RETRY;
|
||||||
|
}
|
||||||
|
return TRIKKE_TRANSPORT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static trikke_transport_status_t usb_poll_packet(void *context)
|
||||||
|
{
|
||||||
|
const trikke_usb_transport_t *usb = context;
|
||||||
|
if (usb == NULL || !usb->initialized) {
|
||||||
|
return TRIKKE_TRANSPORT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esp_err_t err = usb_serial_jtag_wait_tx_done(
|
||||||
|
pdMS_TO_TICKS(TRIKKE_USB_OPERATION_TIMEOUT_MS));
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
return TRIKKE_TRANSPORT_COMPLETE;
|
||||||
|
}
|
||||||
|
if (err == ESP_ERR_TIMEOUT) {
|
||||||
|
return TRIKKE_TRANSPORT_PENDING;
|
||||||
|
}
|
||||||
|
return TRIKKE_TRANSPORT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t trikke_usb_transport_init(
|
||||||
|
trikke_usb_transport_t *usb,
|
||||||
|
trikke_transport_t *transport)
|
||||||
|
{
|
||||||
|
if (usb == NULL || transport == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
if (usb->initialized) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
usb_serial_jtag_driver_config_t config = {
|
||||||
|
.tx_buffer_size = TRIKKE_USB_TX_BUFFER_SIZE,
|
||||||
|
.rx_buffer_size = TRIKKE_USB_RX_BUFFER_SIZE,
|
||||||
|
};
|
||||||
|
const esp_err_t err = usb_serial_jtag_driver_install(&config);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
usb->initialized = true;
|
||||||
|
transport->context = usb;
|
||||||
|
transport->begin = usb_begin_packet;
|
||||||
|
transport->poll = usb_poll_packet;
|
||||||
|
|
||||||
|
// Route any unexpected VFS output through the installed driver as well, so
|
||||||
|
// it cannot race the driver's ISR by touching the hardware FIFO directly.
|
||||||
|
usb_serial_jtag_vfs_use_driver();
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void trikke_usb_transport_deinit(trikke_usb_transport_t *usb)
|
||||||
|
{
|
||||||
|
if (usb == NULL || !usb->initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usb_serial_jtag_vfs_use_nonblocking();
|
||||||
|
usb_serial_jtag_driver_uninstall();
|
||||||
|
usb->initialized = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "trikke_transport.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool initialized;
|
||||||
|
} trikke_usb_transport_t;
|
||||||
|
|
||||||
|
esp_err_t trikke_usb_transport_init(
|
||||||
|
trikke_usb_transport_t *usb,
|
||||||
|
trikke_transport_t *transport);
|
||||||
|
|
||||||
|
void trikke_usb_transport_deinit(trikke_usb_transport_t *usb);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Vendored
+21
-5
@@ -1,9 +1,11 @@
|
|||||||
# Hardware outage fixtures
|
# Hardware outage fixtures
|
||||||
|
|
||||||
These captures came from the assembled XIAO ESP32-C3 prototype. A temporary
|
These captures came from the assembled XIAO ESP32-C3 prototype. The files
|
||||||
validation build made the packet writer report failure while acquisition kept
|
contain only complete, CRC-valid `TRK1` frames.
|
||||||
running; that failure injection was removed before the production firmware was
|
|
||||||
built and flashed. The files contain only complete, CRC-valid `TRK1` frames.
|
The two forced-outage captures used a temporary validation build that made the
|
||||||
|
packet writer report failure while acquisition kept running. That injection was
|
||||||
|
removed before production firmware was built and flashed.
|
||||||
|
|
||||||
- `forced_outage_3s.trk` — SHA-256
|
- `forced_outage_3s.trk` — SHA-256
|
||||||
`01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6`.
|
`01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6`.
|
||||||
@@ -15,10 +17,24 @@ built and flashed. The files contain only complete, CRC-valid `TRK1` frames.
|
|||||||
138 samples were dropped after the 512-entry queue filled, the cumulative
|
138 samples were dropped after the 512-entry queue filled, the cumulative
|
||||||
drop count reached 138, and the corresponding timestamp delta is exactly
|
drop count reached 138, and the corresponding timestamp delta is exactly
|
||||||
1,390,000 us.
|
1,390,000 us.
|
||||||
|
- `direct_usb_stall.trk` — SHA-256
|
||||||
|
`40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558`.
|
||||||
|
This came from the direct USB driver build after leaving the enumerated USB
|
||||||
|
endpoint without a serial reader long enough to overflow the acquisition
|
||||||
|
queue. It contains 864 samples. The retained block ends at sequence 511,
|
||||||
|
delivery resumes at 1,706, and both the sole 1,194-sample gap and the device's
|
||||||
|
cumulative drop counter equal 1,194. There are no packet gaps, CRC failures,
|
||||||
|
loop overruns, trailing bytes, or timestamp-saturation flags.
|
||||||
|
|
||||||
|
The first captured sample is sequence 8 because the flashing process still
|
||||||
|
had the endpoint open long enough to drain sequences 0 through 7 before the
|
||||||
|
capture application opened. That is deliberate evidence of the remaining
|
||||||
|
distinction: USB endpoint drain is observable, but application receipt
|
||||||
|
requires the planned acknowledgement/replay layer.
|
||||||
|
|
||||||
`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
|
`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
|
||||||
fragmented chunks, and asserts these signatures so the hardware evidence remains
|
fragmented chunks, and asserts these signatures so the hardware evidence remains
|
||||||
executable regression data. To inspect either file manually:
|
executable regression data. To inspect a file manually:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 tools/decode_binary.py tests/fixtures/forced_outage_3s.trk /tmp/outage.csv
|
python3 tools/decode_binary.py tests/fixtures/forced_outage_3s.trk /tmp/outage.csv
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -10,6 +10,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
from trikke_protocol import ( # noqa: E402
|
from trikke_protocol import ( # noqa: E402
|
||||||
|
IntegrityTracker,
|
||||||
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
|
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
|
||||||
PACKET_TYPE_METADATA,
|
PACKET_TYPE_METADATA,
|
||||||
PACKET_TYPE_SAMPLES,
|
PACKET_TYPE_SAMPLES,
|
||||||
@@ -50,6 +51,34 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
cls.encoded = fixture.stdout
|
cls.encoded = fixture.stdout
|
||||||
|
|
||||||
|
transport_executable = Path(cls.tempdir.name) / "transport_fixture"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
compiler,
|
||||||
|
"-std=c11",
|
||||||
|
"-Wall",
|
||||||
|
"-Wextra",
|
||||||
|
"-Werror",
|
||||||
|
"-I",
|
||||||
|
str(ROOT / "main"),
|
||||||
|
str(ROOT / "main" / "trikke_transport.c"),
|
||||||
|
str(ROOT / "tests" / "transport_fixture.c"),
|
||||||
|
"-o",
|
||||||
|
str(transport_executable),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
transport_fixture = subprocess.run(
|
||||||
|
[str(transport_executable)], capture_output=True
|
||||||
|
)
|
||||||
|
if transport_fixture.returncode != 0:
|
||||||
|
stderr = transport_fixture.stderr.decode(errors="replace").strip()
|
||||||
|
raise AssertionError(
|
||||||
|
"transport fixture exited "
|
||||||
|
f"{transport_fixture.returncode}: {stderr}"
|
||||||
|
)
|
||||||
|
cls.transport_fixture_passed = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls) -> None:
|
def tearDownClass(cls) -> None:
|
||||||
cls.tempdir.cleanup()
|
cls.tempdir.cleanup()
|
||||||
@@ -103,6 +132,18 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
self.assertEqual((2, 1, 258), tuple(row[14:17]))
|
self.assertEqual((2, 1, 258), tuple(row[14:17]))
|
||||||
self.assertEqual(3, row[-1])
|
self.assertEqual(3, row[-1])
|
||||||
|
|
||||||
|
def test_transport_state_machine_contract(self) -> None:
|
||||||
|
self.assertTrue(self.transport_fixture_passed)
|
||||||
|
|
||||||
|
def test_integrity_sequence_wrap_classification(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
(0, 0), IntegrityTracker._classify_sequence(0xFFFFFFFF, 0)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(2, 0), IntegrityTracker._classify_sequence(0xFFFFFFFE, 1)
|
||||||
|
)
|
||||||
|
self.assertEqual((0, 1), IntegrityTracker._classify_sequence(1000, 0))
|
||||||
|
|
||||||
def test_crc_failure_resynchronizes_to_next_frame(self) -> None:
|
def test_crc_failure_resynchronizes_to_next_frame(self) -> None:
|
||||||
first_size = 36 + 48
|
first_size = 36 + 48
|
||||||
damaged = bytearray(self.encoded[:first_size])
|
damaged = bytearray(self.encoded[:first_size])
|
||||||
@@ -141,6 +182,7 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
"forced_outage_3s.trk": {
|
"forced_outage_3s.trk": {
|
||||||
"sha256": "01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6",
|
"sha256": "01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6",
|
||||||
"sample_count": 2144,
|
"sample_count": 2144,
|
||||||
|
"first_sequence": 0,
|
||||||
"last_sequence": 2143,
|
"last_sequence": 2143,
|
||||||
"max_dropped": 0,
|
"max_dropped": 0,
|
||||||
"gaps": [],
|
"gaps": [],
|
||||||
@@ -148,10 +190,19 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
"forced_outage_7s.trk": {
|
"forced_outage_7s.trk": {
|
||||||
"sha256": "2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3",
|
"sha256": "2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3",
|
||||||
"sample_count": 1840,
|
"sample_count": 1840,
|
||||||
|
"first_sequence": 0,
|
||||||
"last_sequence": 1977,
|
"last_sequence": 1977,
|
||||||
"max_dropped": 138,
|
"max_dropped": 138,
|
||||||
"gaps": [(511, 650, 1_390_000)],
|
"gaps": [(511, 650, 1_390_000)],
|
||||||
},
|
},
|
||||||
|
"direct_usb_stall.trk": {
|
||||||
|
"sha256": "40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558",
|
||||||
|
"sample_count": 864,
|
||||||
|
"first_sequence": 8,
|
||||||
|
"last_sequence": 2065,
|
||||||
|
"max_dropped": 1194,
|
||||||
|
"gaps": [(511, 1706, 11_950_000)],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, contract in expected.items():
|
for name, contract in expected.items():
|
||||||
@@ -175,7 +226,7 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
|
|
||||||
samples = [sample for frame in frames for sample in frame.samples]
|
samples = [sample for frame in frames for sample in frame.samples]
|
||||||
self.assertEqual(contract["sample_count"], len(samples))
|
self.assertEqual(contract["sample_count"], len(samples))
|
||||||
self.assertEqual(0, samples[0].sequence)
|
self.assertEqual(contract["first_sequence"], samples[0].sequence)
|
||||||
self.assertEqual(contract["last_sequence"], samples[-1].sequence)
|
self.assertEqual(contract["last_sequence"], samples[-1].sequence)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
contract["max_dropped"],
|
contract["max_dropped"],
|
||||||
@@ -191,6 +242,21 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
integrity = IntegrityTracker()
|
||||||
|
for frame in frames:
|
||||||
|
integrity.observe(frame)
|
||||||
|
self.assertEqual(0, integrity.packet_gap_count)
|
||||||
|
self.assertEqual(0, integrity.packet_reset_count)
|
||||||
|
self.assertEqual(
|
||||||
|
contract["max_dropped"], integrity.sample_gap_count
|
||||||
|
)
|
||||||
|
self.assertEqual(0, integrity.sample_reset_count)
|
||||||
|
self.assertEqual(
|
||||||
|
contract["max_dropped"],
|
||||||
|
integrity.final_dropped_sample_count,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, integrity.final_loop_overrun_count)
|
||||||
|
|
||||||
gaps = [
|
gaps = [
|
||||||
(
|
(
|
||||||
left.sequence,
|
left.sequence,
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#include "trikke_transport.h"
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
trikke_transport_status_t begin_status;
|
||||||
|
trikke_transport_status_t poll_status;
|
||||||
|
unsigned int begin_calls;
|
||||||
|
unsigned int poll_calls;
|
||||||
|
const uint8_t *packet;
|
||||||
|
size_t packet_size;
|
||||||
|
} mock_transport_t;
|
||||||
|
|
||||||
|
static int fail(int code, const char *message)
|
||||||
|
{
|
||||||
|
fprintf(stderr, "transport fixture failure %d: %s\n", code, message);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
static trikke_transport_status_t mock_begin(
|
||||||
|
void *context,
|
||||||
|
const uint8_t *packet,
|
||||||
|
size_t packet_size)
|
||||||
|
{
|
||||||
|
mock_transport_t *mock = context;
|
||||||
|
++mock->begin_calls;
|
||||||
|
mock->packet = packet;
|
||||||
|
mock->packet_size = packet_size;
|
||||||
|
return mock->begin_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
static trikke_transport_status_t mock_poll(void *context)
|
||||||
|
{
|
||||||
|
mock_transport_t *mock = context;
|
||||||
|
++mock->poll_calls;
|
||||||
|
return mock->poll_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
const uint8_t packet[] = {0x54, 0x52, 0x4B, 0x31};
|
||||||
|
mock_transport_t mock = {
|
||||||
|
.begin_status = TRIKKE_TRANSPORT_RETRY,
|
||||||
|
.poll_status = TRIKKE_TRANSPORT_PENDING,
|
||||||
|
};
|
||||||
|
const trikke_transport_t transport = {
|
||||||
|
.context = &mock,
|
||||||
|
.begin = mock_begin,
|
||||||
|
.poll = mock_poll,
|
||||||
|
};
|
||||||
|
trikke_transport_sender_t sender;
|
||||||
|
trikke_transport_sender_init(&sender);
|
||||||
|
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_RETRY ||
|
||||||
|
sender.pending || mock.begin_calls != 1 || mock.poll_calls != 0 ||
|
||||||
|
mock.packet != packet || mock.packet_size != sizeof(packet)) {
|
||||||
|
return fail(1, "zero-accept submission must remain retryable");
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.begin_status = TRIKKE_TRANSPORT_PENDING;
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_PENDING ||
|
||||||
|
!sender.pending || mock.begin_calls != 2 || mock.poll_calls != 0) {
|
||||||
|
return fail(2, "accepted submission must become pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_PENDING ||
|
||||||
|
!sender.pending || mock.begin_calls != 2 || mock.poll_calls != 1) {
|
||||||
|
return fail(3, "pending transfer must poll without resubmission");
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.poll_status = TRIKKE_TRANSPORT_COMPLETE;
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_COMPLETE ||
|
||||||
|
sender.pending || mock.begin_calls != 2 || mock.poll_calls != 2) {
|
||||||
|
return fail(4, "completed transfer must return to idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.begin_status = TRIKKE_TRANSPORT_COMPLETE;
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_COMPLETE ||
|
||||||
|
sender.pending || mock.begin_calls != 3) {
|
||||||
|
return fail(5, "synchronous completion contract");
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.begin_status = TRIKKE_TRANSPORT_PENDING;
|
||||||
|
mock.poll_status = TRIKKE_TRANSPORT_RETRY;
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_PENDING ||
|
||||||
|
trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_RETRY ||
|
||||||
|
sender.pending) {
|
||||||
|
return fail(6, "backend-confirmed safe retry must return to idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
NULL, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_FATAL ||
|
||||||
|
trikke_transport_sender_step(
|
||||||
|
&sender, NULL, packet, sizeof(packet)) != TRIKKE_TRANSPORT_FATAL ||
|
||||||
|
trikke_transport_sender_step(
|
||||||
|
&sender, &transport, NULL, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_FATAL ||
|
||||||
|
trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, 0) != TRIKKE_TRANSPORT_FATAL) {
|
||||||
|
return fail(7, "invalid arguments must fail closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.begin_status = (trikke_transport_status_t)99;
|
||||||
|
if (trikke_transport_sender_step(
|
||||||
|
&sender, &transport, packet, sizeof(packet)) !=
|
||||||
|
TRIKKE_TRANSPORT_FATAL ||
|
||||||
|
sender.pending) {
|
||||||
|
return fail(8, "unknown backend status must fail closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+46
-65
@@ -6,6 +6,7 @@ import csv
|
|||||||
import glob
|
import glob
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import ExitStack
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,9 +14,9 @@ import serial
|
|||||||
|
|
||||||
from trikke_protocol import (
|
from trikke_protocol import (
|
||||||
CSV_COLUMNS,
|
CSV_COLUMNS,
|
||||||
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
|
|
||||||
PACKET_TYPE_METADATA,
|
PACKET_TYPE_METADATA,
|
||||||
Frame,
|
Frame,
|
||||||
|
IntegrityTracker,
|
||||||
Metadata,
|
Metadata,
|
||||||
StreamParser,
|
StreamParser,
|
||||||
sample_to_csv_row,
|
sample_to_csv_row,
|
||||||
@@ -28,6 +29,11 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--baud", type=int, default=115200)
|
parser.add_argument("--baud", type=int, default=115200)
|
||||||
parser.add_argument("--output", type=Path, help="validated binary .trk output")
|
parser.add_argument("--output", type=Path, help="validated binary .trk output")
|
||||||
parser.add_argument("--csv", type=Path, help="decoded CSV output")
|
parser.add_argument("--csv", type=Path, help="decoded CSV output")
|
||||||
|
parser.add_argument(
|
||||||
|
"--wire",
|
||||||
|
type=Path,
|
||||||
|
help="optional byte-for-byte serial capture, including startup text",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +63,8 @@ def main() -> int:
|
|||||||
csv_output = args.csv or output.with_suffix(".csv")
|
csv_output = args.csv or output.with_suffix(".csv")
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
csv_output.parent.mkdir(parents=True, exist_ok=True)
|
csv_output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if args.wire is not None:
|
||||||
|
args.wire.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
stop_requested = False
|
stop_requested = False
|
||||||
|
|
||||||
@@ -72,83 +80,46 @@ def main() -> int:
|
|||||||
pending_frames: list[Frame] = []
|
pending_frames: list[Frame] = []
|
||||||
sample_count = 0
|
sample_count = 0
|
||||||
metadata_count = 0
|
metadata_count = 0
|
||||||
packet_gap_count = 0
|
integrity = IntegrityTracker()
|
||||||
packet_reset_count = 0
|
|
||||||
sample_gap_count = 0
|
|
||||||
sample_reset_count = 0
|
|
||||||
timing_anomaly_count = 0
|
|
||||||
timestamp_saturation_frame_count = 0
|
|
||||||
accel_stale_count = 0
|
|
||||||
accel_overrun_count = 0
|
|
||||||
gyro_stale_count = 0
|
|
||||||
gyro_overrun_count = 0
|
|
||||||
previous_packet_sequence = None
|
|
||||||
previous_sample_sequence = None
|
|
||||||
previous_timestamp_us = None
|
|
||||||
final_dropped_count = 0
|
|
||||||
final_loop_overrun_count = 0
|
|
||||||
serial_error: serial.SerialException | None = None
|
serial_error: serial.SerialException | None = None
|
||||||
|
|
||||||
def render_frame(frame: Frame, writer: csv.writer) -> None:
|
def render_frame(frame: Frame, writer: csv.writer) -> None:
|
||||||
nonlocal sample_count, sample_gap_count, sample_reset_count
|
nonlocal sample_count
|
||||||
nonlocal timing_anomaly_count
|
|
||||||
nonlocal accel_stale_count, accel_overrun_count
|
|
||||||
nonlocal gyro_stale_count, gyro_overrun_count
|
|
||||||
nonlocal previous_sample_sequence, previous_timestamp_us
|
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
pending_frames.append(frame)
|
pending_frames.append(frame)
|
||||||
return
|
return
|
||||||
for sample in frame.samples:
|
for sample in frame.samples:
|
||||||
if previous_sample_sequence is not None:
|
|
||||||
expected = (previous_sample_sequence + 1) & 0xFFFFFFFF
|
|
||||||
if sample.sequence != expected:
|
|
||||||
if sample.sequence > expected:
|
|
||||||
sample_gap_count += sample.sequence - expected
|
|
||||||
else:
|
|
||||||
sample_reset_count += 1
|
|
||||||
if previous_timestamp_us is not None:
|
|
||||||
if sample.timestamp_us - previous_timestamp_us != 10_000:
|
|
||||||
timing_anomaly_count += 1
|
|
||||||
if not sample.accel_status & 0x80:
|
|
||||||
accel_stale_count += 1
|
|
||||||
if sample.accel_status & 0x01:
|
|
||||||
accel_overrun_count += 1
|
|
||||||
if not sample.gyro_status & 0x08:
|
|
||||||
gyro_stale_count += 1
|
|
||||||
if sample.gyro_status & 0x80:
|
|
||||||
gyro_overrun_count += 1
|
|
||||||
writer.writerow(sample_to_csv_row(sample, metadata, frame.loop_overrun_count))
|
writer.writerow(sample_to_csv_row(sample, metadata, frame.loop_overrun_count))
|
||||||
previous_sample_sequence = sample.sequence
|
|
||||||
previous_timestamp_us = sample.timestamp_us
|
|
||||||
sample_count += 1
|
sample_count += 1
|
||||||
if sample_count % 500 == 0:
|
if sample_count % 500 == 0:
|
||||||
print(f" {sample_count} samples captured", flush=True)
|
print(f" {sample_count} samples captured", flush=True)
|
||||||
|
|
||||||
print(f"Recording {port} to {output} and {csv_output}; press Ctrl-C to stop")
|
print(f"Recording {port} to {output} and {csv_output}; press Ctrl-C to stop")
|
||||||
try:
|
try:
|
||||||
with serial.Serial(port, args.baud, timeout=0.25) as sensor, output.open(
|
with ExitStack() as stack:
|
||||||
"wb"
|
sensor = stack.enter_context(
|
||||||
) as raw_capture, csv_output.open("w", encoding="utf-8", newline="") as decoded:
|
serial.Serial(port, args.baud, timeout=0.25)
|
||||||
|
)
|
||||||
|
raw_capture = stack.enter_context(output.open("wb"))
|
||||||
|
decoded = stack.enter_context(
|
||||||
|
csv_output.open("w", encoding="utf-8", newline="")
|
||||||
|
)
|
||||||
|
wire_capture = (
|
||||||
|
stack.enter_context(args.wire.open("wb"))
|
||||||
|
if args.wire is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
writer = csv.writer(decoded)
|
writer = csv.writer(decoded)
|
||||||
writer.writerow(CSV_COLUMNS)
|
writer.writerow(CSV_COLUMNS)
|
||||||
while not stop_requested:
|
while not stop_requested:
|
||||||
chunk = sensor.read(4096)
|
chunk = sensor.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
|
if wire_capture is not None:
|
||||||
|
wire_capture.write(chunk)
|
||||||
for frame in parser.feed(chunk):
|
for frame in parser.feed(chunk):
|
||||||
raw_capture.write(frame.raw)
|
raw_capture.write(frame.raw)
|
||||||
final_dropped_count = frame.dropped_sample_count
|
integrity.observe(frame)
|
||||||
final_loop_overrun_count = frame.loop_overrun_count
|
|
||||||
if previous_packet_sequence is not None:
|
|
||||||
expected = (previous_packet_sequence + 1) & 0xFFFFFFFF
|
|
||||||
if frame.packet_sequence != expected:
|
|
||||||
if frame.packet_sequence > expected:
|
|
||||||
packet_gap_count += frame.packet_sequence - expected
|
|
||||||
else:
|
|
||||||
packet_reset_count += 1
|
|
||||||
previous_packet_sequence = frame.packet_sequence
|
|
||||||
if frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED:
|
|
||||||
timestamp_saturation_frame_count += 1
|
|
||||||
if frame.packet_type == PACKET_TYPE_METADATA:
|
if frame.packet_type == PACKET_TYPE_METADATA:
|
||||||
metadata = frame.metadata
|
metadata = frame.metadata
|
||||||
metadata_count += 1
|
metadata_count += 1
|
||||||
@@ -159,16 +130,21 @@ def main() -> int:
|
|||||||
render_frame(frame, writer)
|
render_frame(frame, writer)
|
||||||
raw_capture.flush()
|
raw_capture.flush()
|
||||||
decoded.flush()
|
decoded.flush()
|
||||||
|
if wire_capture is not None:
|
||||||
|
wire_capture.flush()
|
||||||
except serial.SerialException as exc:
|
except serial.SerialException as exc:
|
||||||
print(f"Serial error: {exc}", file=sys.stderr)
|
print(f"Serial error: {exc}", file=sys.stderr)
|
||||||
serial_error = exc
|
serial_error = exc
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"Stopped after {sample_count} samples and {metadata_count} metadata frames; "
|
f"Stopped after {sample_count} samples and {metadata_count} metadata frames; "
|
||||||
f"packet_gaps={packet_gap_count}, packet_resets={packet_reset_count}, "
|
f"packet_gaps={integrity.packet_gap_count}, "
|
||||||
f"sample_gaps={sample_gap_count}, sample_resets={sample_reset_count}, "
|
f"packet_resets={integrity.packet_reset_count}, "
|
||||||
f"timing_anomalies={timing_anomaly_count}, "
|
f"sample_gaps={integrity.sample_gap_count}, "
|
||||||
f"timestamp_saturation_frames={timestamp_saturation_frame_count}, "
|
f"sample_resets={integrity.sample_reset_count}, "
|
||||||
|
f"timing_anomalies={integrity.timing_anomaly_count}, "
|
||||||
|
"timestamp_saturation_frames="
|
||||||
|
f"{integrity.timestamp_saturation_frame_count}, "
|
||||||
f"startup_crc_rejects={parser.startup_crc_errors}, "
|
f"startup_crc_rejects={parser.startup_crc_errors}, "
|
||||||
f"stream_crc_errors={parser.crc_errors}, "
|
f"stream_crc_errors={parser.crc_errors}, "
|
||||||
f"header_errors={parser.header_errors}, "
|
f"header_errors={parser.header_errors}, "
|
||||||
@@ -176,12 +152,17 @@ def main() -> int:
|
|||||||
f"trailing_partial_bytes={parser.buffered_bytes}"
|
f"trailing_partial_bytes={parser.buffered_bytes}"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"Status totals: accel_stale={accel_stale_count}, "
|
f"Status totals: accel_stale={integrity.accel_stale_count}, "
|
||||||
f"accel_overrun={accel_overrun_count}, gyro_stale={gyro_stale_count}, "
|
f"accel_overrun={integrity.accel_overrun_count}, "
|
||||||
f"gyro_overrun={gyro_overrun_count}, dropped={final_dropped_count}, "
|
f"gyro_stale={integrity.gyro_stale_count}, "
|
||||||
f"acquisition_loop_overruns={final_loop_overrun_count}"
|
f"gyro_overrun={integrity.gyro_overrun_count}, "
|
||||||
|
f"dropped={integrity.final_dropped_sample_count}, "
|
||||||
|
f"acquisition_loop_overruns={integrity.final_loop_overrun_count}"
|
||||||
)
|
)
|
||||||
print(f"Saved {output} and {csv_output}")
|
saved = f"Saved {output} and {csv_output}"
|
||||||
|
if args.wire is not None:
|
||||||
|
saved += f"; raw wire saved to {args.wire}"
|
||||||
|
print(saved)
|
||||||
if serial_error is not None:
|
if serial_error is not None:
|
||||||
return 1
|
return 1
|
||||||
return 0 if metadata is not None else 4
|
return 0 if metadata is not None else 4
|
||||||
|
|||||||
+16
-5
@@ -7,9 +7,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from trikke_protocol import (
|
from trikke_protocol import (
|
||||||
CSV_COLUMNS,
|
CSV_COLUMNS,
|
||||||
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
|
|
||||||
PACKET_TYPE_METADATA,
|
PACKET_TYPE_METADATA,
|
||||||
Frame,
|
Frame,
|
||||||
|
IntegrityTracker,
|
||||||
StreamParser,
|
StreamParser,
|
||||||
sample_to_csv_row,
|
sample_to_csv_row,
|
||||||
)
|
)
|
||||||
@@ -40,13 +40,12 @@ def main() -> int:
|
|||||||
|
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
sample_count = 0
|
sample_count = 0
|
||||||
timestamp_saturation_frame_count = 0
|
integrity = IntegrityTracker()
|
||||||
with args.output.open("w", encoding="utf-8", newline="") as target:
|
with args.output.open("w", encoding="utf-8", newline="") as target:
|
||||||
writer = csv.writer(target)
|
writer = csv.writer(target)
|
||||||
writer.writerow(CSV_COLUMNS)
|
writer.writerow(CSV_COLUMNS)
|
||||||
for frame in frames:
|
for frame in frames:
|
||||||
if frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED:
|
integrity.observe(frame)
|
||||||
timestamp_saturation_frame_count += 1
|
|
||||||
if frame.packet_type == PACKET_TYPE_METADATA:
|
if frame.packet_type == PACKET_TYPE_METADATA:
|
||||||
if frame.metadata is not None:
|
if frame.metadata is not None:
|
||||||
metadata = frame.metadata
|
metadata = frame.metadata
|
||||||
@@ -62,7 +61,19 @@ def main() -> int:
|
|||||||
f"startup_crc_rejects={stream.startup_crc_errors}, "
|
f"startup_crc_rejects={stream.startup_crc_errors}, "
|
||||||
f"stream_crc_errors={stream.crc_errors}, header_errors={stream.header_errors}, "
|
f"stream_crc_errors={stream.crc_errors}, header_errors={stream.header_errors}, "
|
||||||
f"skipped_nonframe_bytes={stream.skipped_bytes}, "
|
f"skipped_nonframe_bytes={stream.skipped_bytes}, "
|
||||||
f"timestamp_saturation_frames={timestamp_saturation_frame_count}, "
|
f"packet_gaps={integrity.packet_gap_count}, "
|
||||||
|
f"packet_resets={integrity.packet_reset_count}, "
|
||||||
|
f"sample_gaps={integrity.sample_gap_count}, "
|
||||||
|
f"sample_resets={integrity.sample_reset_count}, "
|
||||||
|
f"timing_anomalies={integrity.timing_anomaly_count}, "
|
||||||
|
"timestamp_saturation_frames="
|
||||||
|
f"{integrity.timestamp_saturation_frame_count}, "
|
||||||
|
f"accel_stale={integrity.accel_stale_count}, "
|
||||||
|
f"accel_overrun={integrity.accel_overrun_count}, "
|
||||||
|
f"gyro_stale={integrity.gyro_stale_count}, "
|
||||||
|
f"gyro_overrun={integrity.gyro_overrun_count}, "
|
||||||
|
f"dropped={integrity.final_dropped_sample_count}, "
|
||||||
|
f"acquisition_loop_overruns={integrity.final_loop_overrun_count}, "
|
||||||
f"trailing_partial_bytes={stream.buffered_bytes}; saved {args.output}"
|
f"trailing_partial_bytes={stream.buffered_bytes}; saved {args.output}"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -84,6 +84,75 @@ class Frame:
|
|||||||
raw: bytes
|
raw: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntegrityTracker:
|
||||||
|
packet_gap_count: int = 0
|
||||||
|
packet_reset_count: int = 0
|
||||||
|
sample_gap_count: int = 0
|
||||||
|
sample_reset_count: int = 0
|
||||||
|
timing_anomaly_count: int = 0
|
||||||
|
timestamp_saturation_frame_count: int = 0
|
||||||
|
accel_stale_count: int = 0
|
||||||
|
accel_overrun_count: int = 0
|
||||||
|
gyro_stale_count: int = 0
|
||||||
|
gyro_overrun_count: int = 0
|
||||||
|
final_dropped_sample_count: int = 0
|
||||||
|
final_loop_overrun_count: int = 0
|
||||||
|
_previous_packet_sequence: int | None = None
|
||||||
|
_previous_sample_sequence: int | None = None
|
||||||
|
_previous_timestamp_us: int | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify_sequence(
|
||||||
|
previous: int,
|
||||||
|
current: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
expected = (previous + 1) & 0xFFFFFFFF
|
||||||
|
forward_distance = (current - expected) & 0xFFFFFFFF
|
||||||
|
if forward_distance == 0:
|
||||||
|
return (0, 0)
|
||||||
|
if forward_distance < 0x80000000:
|
||||||
|
return (forward_distance, 0)
|
||||||
|
return (0, 1)
|
||||||
|
|
||||||
|
def observe(self, frame: Frame) -> None:
|
||||||
|
if self._previous_packet_sequence is not None:
|
||||||
|
gaps, resets = self._classify_sequence(
|
||||||
|
self._previous_packet_sequence, frame.packet_sequence
|
||||||
|
)
|
||||||
|
self.packet_gap_count += gaps
|
||||||
|
self.packet_reset_count += resets
|
||||||
|
self._previous_packet_sequence = frame.packet_sequence
|
||||||
|
|
||||||
|
if frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED:
|
||||||
|
self.timestamp_saturation_frame_count += 1
|
||||||
|
self.final_dropped_sample_count = frame.dropped_sample_count
|
||||||
|
self.final_loop_overrun_count = frame.loop_overrun_count
|
||||||
|
|
||||||
|
for sample in frame.samples:
|
||||||
|
if self._previous_sample_sequence is not None:
|
||||||
|
gaps, resets = self._classify_sequence(
|
||||||
|
self._previous_sample_sequence, sample.sequence
|
||||||
|
)
|
||||||
|
self.sample_gap_count += gaps
|
||||||
|
self.sample_reset_count += resets
|
||||||
|
if (
|
||||||
|
self._previous_timestamp_us is not None
|
||||||
|
and sample.timestamp_us - self._previous_timestamp_us != 10_000
|
||||||
|
):
|
||||||
|
self.timing_anomaly_count += 1
|
||||||
|
if not sample.accel_status & 0x80:
|
||||||
|
self.accel_stale_count += 1
|
||||||
|
if sample.accel_status & 0x01:
|
||||||
|
self.accel_overrun_count += 1
|
||||||
|
if not sample.gyro_status & 0x08:
|
||||||
|
self.gyro_stale_count += 1
|
||||||
|
if sample.gyro_status & 0x80:
|
||||||
|
self.gyro_overrun_count += 1
|
||||||
|
self._previous_sample_sequence = sample.sequence
|
||||||
|
self._previous_timestamp_us = sample.timestamp_us
|
||||||
|
|
||||||
|
|
||||||
def _lround(value: float) -> int:
|
def _lround(value: float) -> int:
|
||||||
"""Match C lroundf: nearest integer, halfway cases away from zero."""
|
"""Match C lroundf: nearest integer, halfway cases away from zero."""
|
||||||
return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)
|
return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)
|
||||||
|
|||||||
Reference in New Issue
Block a user