Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,32 @@
"close": false
},
"problemMatcher": []
},
{
"label": "Framing: run",
"type": "shell",
"command": "gcc -I. -I.. -o run_framing test_framing.c log_capture.c && ./run_framing; ec=$?; rm -f ./run_framing; exit $ec",
"options": { "cwd": "${workspaceFolder}/test" },
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared",
"close": false
},
"problemMatcher": []
},
{
"label": "Framing: bless goldens",
"type": "shell",
"command": "gcc -I. -I.. -o run_framing test_framing.c log_capture.c && ./run_framing --bless; ec=$?; rm -f ./run_framing; exit $ec",
"options": { "cwd": "${workspaceFolder}/test" },
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared",
"close": false
},
"problemMatcher": []
}
],
"inputs": [
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Note about CMD 0x12 observed with payload 0x01 0x00. Added to PROTOCOL.md and new sample trace.
- Parsing for CMD 0x12 updated so `0x01 0x00` no longer reported as unexpected.
- Full decoding of CMD 0x12 status byte for Gas Heaters (HiNRG `0x0072` and ICI (`0x0074`))
- Rewrote frame level parser to use a sliding window for better resync on errors
- Track frame resync events per failure type (no-start, header/data checksum, control, length, end-byte, overflow) and show the breakdown on the status page, separate from message-level decode errors
- Unknown Messages page now tags each captured frame with its specific reason (no start, overflow, header/data checksum, length, bad framing) instead of a generic "error" chip
- **Breaking (HTTP):** `/status` JSON's `message_counts.errors` (per-type protocol error counters) and `message_counts.error_detail` (introduced in 1.6.0-rc1) are removed, replaced by the new per-type `resyncs` object (`total`, `no_start`, `header_checksum`, `bad_control`, `bad_length`, `bad_end`, `data_checksum`, `buffer_overflow`) emitted by the sliding-window frame parser. Any external consumer reading the old fields needs to switch to `resyncs`.
### Removed
### Fixed
### Deprecated
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ From VS Code inside the devcontainer: **Cmd+Shift+P → "Tasks: Run Task"** list
- **Replay: all samples** — just the replay step.
- **Replay: bless all samples** — rewrite expected output in every sample using the current decoder's output. Use after intentional decoder/logging changes, then review `git diff test/samples/` before committing.
- **Replay: single file** / **Replay: bless single file** — same but prompted for a single path.
- **Framing: run** — exercise the sliding-window frame parser against the goldens in `test/frames/`.
- **Framing: bless goldens** — regenerate `test/frames/*_output.txt` from the current parser. Use after intentional framing changes, then review `git diff test/frames/` before committing.

### Adding a regression sample

Expand All @@ -252,6 +254,19 @@ From VS Code inside the devcontainer: **Cmd+Shift+P → "Tasks: Run Task"** list
3. Run **Replay: bless single file** against it — this normalises the expected output to the current decoder.
4. Review with `git diff`, then commit. The file is now a regression test.

### Frame parser (sliding window) tests

The frame parser is tested separately from the decoder: each line of `test/frames/observed_error_frames.txt` (real bus captures) and `test/frames/synthetic_errors.txt` (synthetic, one per failure mode) is fed to the parser, and the ordered stream of resync events (`Resync: <type>`) and decoded frames is diffed against the matching `--- Case N ---` block in the `*_output.txt` golden. On a mismatch the failure prints the source location as `path:line`.

The goldens are blessable from the CLI, mirroring replay:

```bash
cd test
gcc -I. -I.. -o run_framing test_framing.c log_capture.c && ./run_framing --bless; rm -f run_framing
```

After blessing, review `git diff test/frames/` — the diff is the exact record of how parser behaviour changed.

### Improving "Unhandled" message logging

When the decoder gains support for a previously-unknown command, replaying old samples surfaces it as a mismatch (e.g. `Unhandled CMD=0xXX` → real decode). Bless the affected samples and the diff in git is the exact documentation of what improved.
Expand Down
1 change: 1 addition & 0 deletions main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ idf_component_register(
"mqtt_commands.c"
"web_handlers.c"
"tcp_bridge.c"
"framing.c"
"message_decoder.c"
"register_requester.c"
"unknown_buffer.c"
Expand Down
196 changes: 196 additions & 0 deletions main/framing.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
#include "framing.h"
#include "esp_log.h"
#include <string.h>
#include <stdio.h>

static const char *TAG = "BUS_FRAMING";

// Header validation only ever inspects bytes 0-9 (see layout in framing.h);
// cap early-failure captures to that window rather than the whole remaining
// buffer. Otherwise the captured bytes (and length) vary with how much
// trailing valid data happens to be queued, which both pollutes the
// unknown-message diagnostics with unrelated trailing frames and defeats
// dedup in unknown_buffer_record (which keys on reason + len + raw bytes).
#define FRAMING_HEADER_CAPTURE_LEN 10

// Format up to `cap` bytes of `data` as a space-separated hex string into
// `hex_str` (size `hex_str_size`). Does not append a truncation marker —
// callers that care whether `len > cap` add their own suffix to the log line.
static void framing_format_hex_dump(const uint8_t *data, int len, int cap, char *hex_str, size_t hex_str_size)
{
int hex_pos = 0;
int dump_len = MIN(len, cap);
for (int i = 0; i < dump_len && hex_pos < (int)hex_str_size - 3; i++) {
hex_pos += snprintf(&hex_str[hex_pos], hex_str_size - hex_pos, "%02X ", data[i]);
}
hex_str[hex_pos] = '\0';
}

// Capture `capture_len` bytes from the front of `fb` into out_frame/out_len
// for diagnostics, then resync by discarding a single byte and shifting the
// rest of the buffer down. The per-byte memmove is O(n), so draining a full
// garbage buffer one byte at a time is O(n^2) - acceptable only because
// BUS_MESSAGE_MAX_SIZE is small (256B).
static void framing_resync_one_byte(framing_buffer_t *fb, uint8_t *out_frame, int *out_len, int capture_len)
{
if (out_frame) {
memcpy(out_frame, fb->buffer, capture_len);
}
if (out_len) {
*out_len = capture_len;
}
memmove(fb->buffer, &fb->buffer[1], fb->len - 1);
fb->len--;
}

void framing_init(framing_buffer_t *fb)
{
if (fb) {
fb->len = 0;
}
}

bool framing_add_bytes(framing_buffer_t *fb, const uint8_t *data, int len)
{
if (!fb || !data || len <= 0) {
return false;
}
if (fb->len + len > BUS_MESSAGE_MAX_SIZE) {
return false;
}
memcpy(&fb->buffer[fb->len], data, len);
fb->len += len;
return true;
}

framing_result_t framing_process_next(framing_buffer_t *fb, uint8_t *out_frame, int *out_len)
{
if (!fb) {
return FRAMING_NEED_MORE_DATA;
}

// Need at least minimum message: START + SRC + DST + CTRL + CMD(3) + CHK + END = 12 bytes
if (fb->len < 12) {
return FRAMING_NEED_MORE_DATA;
}

// Find start byte (0x02)
int start_idx = -1;
for (int i = 0; i < fb->len; i++) {
if (fb->buffer[i] == 0x02) {
start_idx = i;
break;
}
}

// No start byte found - discard everything
if (start_idx == -1) {
char hex_str[100];
framing_format_hex_dump(fb->buffer, fb->len, 32, hex_str, sizeof(hex_str));
ESP_LOGW(TAG, "No start byte in buffer, discarding %d bytes: %s%s",
fb->len, hex_str, fb->len > 32 ? "..." : "");
// Hand the discarded bytes back so the caller can capture them.
if (out_frame) {
memcpy(out_frame, fb->buffer, fb->len);
}
if (out_len) {
*out_len = fb->len;
}
fb->len = 0;
return FRAMING_NO_START_BYTE;
}

// Discard bytes before start. This happens silently with respect to the
// resync counters/unknown buffer (those only track the byte-stepped BAD_*
// resyncs and the no-start-at-all case) - log it so a multi-byte jump like
// this is still visible somewhere.
if (start_idx > 0) {
char hex_str[100];
framing_format_hex_dump(fb->buffer, start_idx, 32, hex_str, sizeof(hex_str));
ESP_LOGW(TAG, "Discarding %d byte(s) before next start byte: %s%s",
start_idx, hex_str, start_idx > 32 ? "..." : "");
memmove(fb->buffer, &fb->buffer[start_idx], fb->len - start_idx);
fb->len -= start_idx;
}

// Check we have enough for header validation (10 bytes: 0..9)
if (fb->len < 10) {
return FRAMING_NEED_MORE_DATA;
}

// Validate header checksum: byte 9 must be sum(bytes 0-8) & 0xFF
// We test checksum first, and only then the other header bytes - this way,
// a bad length will only survive if the checksum is coincidentally correct.
uint8_t expected_hchk = 0;
for (int i = 0; i < 9; i++) {
expected_hchk += fb->buffer[i];
}
if (fb->buffer[9] != expected_hchk) {
char hex_str[100];
framing_format_hex_dump(fb->buffer, fb->len, 32, hex_str, sizeof(hex_str));
ESP_LOGW(TAG, "Invalid header checksum: calculated %02X, got %02X, data: %s%s, resyncing by 1 byte",
expected_hchk, fb->buffer[9], hex_str, fb->len > 32 ? "..." : "");
framing_resync_one_byte(fb, out_frame, out_len, MIN(fb->len, FRAMING_HEADER_CAPTURE_LEN));
return FRAMING_BAD_HEADER_CHECKSUM;
}

// Validate control bytes (positions 5-6 should be 0x80 0x00)
if (fb->buffer[5] != 0x80 || fb->buffer[6] != 0x00) {
ESP_LOGW(TAG, "Invalid control bytes: %02X %02X (expected 80 00), resyncing by 1 byte",
fb->buffer[5], fb->buffer[6]);
framing_resync_one_byte(fb, out_frame, out_len, MIN(fb->len, FRAMING_HEADER_CAPTURE_LEN));
return FRAMING_BAD_CONTROL_BYTES;
}

// Read length from byte 8
int msg_len = fb->buffer[8];
if (msg_len < 12 || msg_len > BUS_MESSAGE_MAX_SIZE) {
ESP_LOGW(TAG, "Invalid length field in header: 0x%02X (%d), resyncing by 1 byte",
msg_len, msg_len);
framing_resync_one_byte(fb, out_frame, out_len, MIN(fb->len, FRAMING_HEADER_CAPTURE_LEN));
return FRAMING_BAD_LENGTH;
}

// Check if we have received the full message yet
if (fb->len < msg_len) {
return FRAMING_NEED_MORE_DATA;
}

// Verify end byte (0x03)
if (fb->buffer[msg_len - 1] != 0x03) {
ESP_LOGW(TAG, "Invalid end byte (expected 03, got %02X) at length %d, resyncing by 1 byte",
fb->buffer[msg_len - 1], msg_len);
framing_resync_one_byte(fb, out_frame, out_len, msg_len);
return FRAMING_BAD_END_BYTE;
}

// Verify data checksum: sum(bytes 10..msg_len-3) & 0xFF
uint32_t data_sum = 0;
for (int i = 10; i < msg_len - 2; i++) {
data_sum += fb->buffer[i];
}
uint8_t calculated_dchk = data_sum & 0xFF;
if (fb->buffer[msg_len - 2] != calculated_dchk) {
ESP_LOGW(TAG, "Invalid data checksum: calculated %02X, got %02X, resyncing by 1 byte",
calculated_dchk, fb->buffer[msg_len - 2]);
framing_resync_one_byte(fb, out_frame, out_len, msg_len);
return FRAMING_BAD_DATA_CHECKSUM;
}

// Valid frame — copy out before consuming from buffer
if (out_frame) {
memcpy(out_frame, fb->buffer, msg_len);
}
if (out_len) {
*out_len = msg_len;
}

// Remove processed message from buffer
int remaining = fb->len - msg_len;
if (remaining > 0) {
memmove(fb->buffer, &fb->buffer[msg_len], remaining);
}
fb->len = remaining;

return FRAMING_FRAME_READY;
}
47 changes: 47 additions & 0 deletions main/framing.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#ifndef FRAMING_H
#define FRAMING_H

#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "config.h"

typedef enum {
FRAMING_NEED_MORE_DATA, // incomplete, wait for more bytes
FRAMING_NO_START_BYTE, // no 0x02 found, buffer cleared
FRAMING_BAD_HEADER_CHECKSUM, // header checksum mismatch, resynced by 1
FRAMING_BAD_CONTROL_BYTES, // bytes 5-6 not 0x80 0x00, resynced by 1
FRAMING_BAD_LENGTH, // length field out of range, resynced by 1
FRAMING_BAD_END_BYTE, // end byte not 0x03, resynced by 1
FRAMING_BAD_DATA_CHECKSUM, // data checksum mismatch, resynced by 1
FRAMING_FRAME_READY, // valid frame written to out_frame
} framing_result_t;

// Context structure holding the reassembly buffer
typedef struct {
uint8_t buffer[BUS_MESSAGE_MAX_SIZE];
int len;
} framing_buffer_t;

/**
* Initialize a framing buffer context.
*/
void framing_init(framing_buffer_t *fb);

/**
* Add bytes to the framing buffer.
* Returns true if bytes were successfully added, false if buffer is full.
*/
bool framing_add_bytes(framing_buffer_t *fb, const uint8_t *data, int len);

/**
* Extract one complete message from the reassembly buffer.
* On FRAMING_FRAME_READY the frame is written to out_frame and out_len; on
* every other resync result (NO_START / BAD_*) the bytes that triggered the
* resync are written there instead, so the caller can capture them.
* FRAMING_NEED_MORE_DATA means stop and wait; all other results mean call
* again immediately.
*/
framing_result_t framing_process_next(framing_buffer_t *fb, uint8_t *out_frame, int *out_len);

#endif // FRAMING_H
32 changes: 24 additions & 8 deletions main/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,36 @@ static bool decode_wrapper(const uint8_t *data, int len)
return decode_message(data, len, &s_decoder_context);
}

static void frame_error_wrapper(tcp_bridge_frame_error_t error, const uint8_t *data, int len)
static void resync_wrapper(tcp_bridge_resync_type_t type, const uint8_t *data, int len)
{
if (xSemaphoreTake(s_pool_state_mutex, pdMS_TO_TICKS(MUTEX_TIMEOUT_MS)) != pdTRUE) {
return;
}
s_pool_state.messages_error_total++;
switch (error) {
case TCP_BRIDGE_FRAME_ERR_NO_START: s_pool_state.errors_no_start++; break;
case TCP_BRIDGE_FRAME_ERR_BAD_CONTROL: s_pool_state.errors_bad_control++; break;
case TCP_BRIDGE_FRAME_ERR_NO_END: s_pool_state.errors_no_end++; break;
s_pool_state.resyncs_total++;
switch (type) {
case TCP_BRIDGE_RESYNC_NO_START: s_pool_state.resyncs_no_start++; break;
case TCP_BRIDGE_RESYNC_BAD_HEADER_CHECKSUM: s_pool_state.resyncs_bad_header_checksum++; break;
case TCP_BRIDGE_RESYNC_BAD_CONTROL: s_pool_state.resyncs_bad_control++; break;
case TCP_BRIDGE_RESYNC_BAD_LENGTH: s_pool_state.resyncs_bad_length++; break;
case TCP_BRIDGE_RESYNC_BAD_END: s_pool_state.resyncs_bad_end++; break;
case TCP_BRIDGE_RESYNC_BAD_DATA_CHECKSUM: s_pool_state.resyncs_bad_data_checksum++; break;
case TCP_BRIDGE_RESYNC_BUFFER_OVERFLOW: s_pool_state.resyncs_buffer_overflow++; break;
}
xSemaphoreGive(s_pool_state_mutex);

if (data && len > 0) {
unknown_buffer_record(data, len, true);
unknown_reason_t reason;
switch (type) {
case TCP_BRIDGE_RESYNC_NO_START: reason = UNKNOWN_REASON_NO_START; break;
case TCP_BRIDGE_RESYNC_BAD_HEADER_CHECKSUM: reason = UNKNOWN_REASON_HEADER_CHECKSUM; break;
case TCP_BRIDGE_RESYNC_BAD_CONTROL: reason = UNKNOWN_REASON_BAD_CONTROL; break;
case TCP_BRIDGE_RESYNC_BAD_LENGTH: reason = UNKNOWN_REASON_BAD_LENGTH; break;
case TCP_BRIDGE_RESYNC_BAD_END: reason = UNKNOWN_REASON_BAD_END; break;
case TCP_BRIDGE_RESYNC_BAD_DATA_CHECKSUM: reason = UNKNOWN_REASON_DATA_CHECKSUM; break;
case TCP_BRIDGE_RESYNC_BUFFER_OVERFLOW: reason = UNKNOWN_REASON_BUFFER_OVERFLOW; break;
default: reason = UNKNOWN_REASON_UNEXPECTED; break;
}
unknown_buffer_record(data, len, reason);
}
}

Expand Down Expand Up @@ -189,7 +205,7 @@ void app_main(void)
.decode_message = decode_wrapper,
.led_flash_rx = led_flash_rx,
.led_flash_tx = led_flash_tx,
.on_frame_error = frame_error_wrapper,
.on_resync = resync_wrapper,
};
esp_err_t bridge_err = tcp_bridge_start(&bridge_config);
if (bridge_err == ESP_OK) {
Expand Down
Loading