Skip to content

File framing.h

File List > espos_voice > include > espos_voice > protocol > framing.h

Go to the documentation of this file

/* SPDX-FileCopyrightText: 2026 Dirk Wahrheit */
/* SPDX-License-Identifier: Apache-2.0 */
#pragma once

// Wyoming wire framing โ€” encoder + incremental decoder.
//
// Wire format (reference rhasspy/wyoming 1.10.0, wyoming/event.py):
//
//     <header JSON, one line, UTF-8>\n
//     <data JSON, exactly data_length bytes>     (only when data_length > 0)
//     <payload, exactly payload_length bytes>    (only when payload_length > 0)
//
// - data_length / payload_length are BYTE counts (UTF-8), omitted entirely
//   when the data dict / payload is empty.
// - No newline after the data block or payload; the next header starts
//   immediately after the payload's last byte.
// - `version` in the header is informational only.
//
// This is a faithful C++ port of signalk-wyoming/src/protocol/framing.ts,
// tuned for an ESP32 satellite: it decodes the small header/data events the
// orchestrator sends and hands the (potentially large) PCM payload back by
// reference into the decode buffer, so audio-chunks aren't copied twice.

#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>

namespace espos_voice {

// Protocol version written into every header we send (never validated by
// readers, but wyoming logs a warning outside 1.x).
constexpr const char* kWyomingVersion = "1.10.0";

// A decoded event: type + the raw data-block bytes (empty when absent) +
// a view of the payload bytes. `payload`/`payload_len` point INTO the
// decoder's buffer and are only valid until the next feed() call โ€” copy
// them out (or consume them synchronously) before feeding more.
struct DecodedEvent {
  std::string type;
  // Raw UTF-8 JSON of the data block (or inline header data), or empty.
  std::string data_json;
  const uint8_t* payload = nullptr;
  size_t payload_len = 0;
};

// Encode one event to wire bytes, appended to `out`.
// `data_json` is the exact UTF-8 JSON for the data block ("" or "{}" => no
// data block). `payload`/`payload_len` is the optional binary payload.
void encode_event(std::vector<uint8_t>& out, const char* type,
                  const std::string& data_json, const uint8_t* payload,
                  size_t payload_len);

// Convenience: encode a header-only event (no data, no payload).
void encode_event(std::vector<uint8_t>& out, const char* type);

// Incremental push-parser. Feed it socket chunks; it invokes `on_event`
// for every complete event. Returns false on a framing violation (caller
// must drop the connection); true otherwise (including "need more bytes").
//
// Kept as a class so partial reads buffer across feed() calls. The buffer
// holds at most one in-flight event's bytes plus whatever trailed it.
class EventDecoder {
 public:
  // Callback receives a DecodedEvent whose payload view is valid only for
  // the duration of the call. Return false to abort decoding (treated like
  // a framing error by feed()).
  using EventFn = bool (*)(void* ctx, const DecodedEvent& ev);

  // Guardrails (reference asyncio default header limit is 64 KiB; we cap
  // the header line smaller and the data block generously, and stream the
  // payload so it never has to fit a fixed cap).
  static constexpr size_t kMaxHeaderBytes = 8 * 1024;
  static constexpr size_t kMaxDataBytes = 64 * 1024;
  // A payload is streamed rather than capped to a working size, but it still
  // has to be bounded: feed() adds the two lengths, and on a 32-bit target an
  // unbounded payload_length wraps that sum to something small enough to look
  // satisfied, dispatching a pointer past the buffer with a huge length.
  // 2 GiB is exactly representable as a double (these arrive as JSON numbers)
  // and leaves the sum well inside size_t on every target espOS builds for.
  static constexpr size_t kMaxPayloadBytes = size_t{2} * 1024 * 1024 * 1024;

  // feed() appends `chunk` and drains as many complete events as possible,
  // calling on_event(ctx, ev) for each. Returns false on framing error.
  bool feed(const uint8_t* chunk, size_t len, EventFn on_event, void* ctx);

  bool failed() const { return failed_; }

 private:
  bool read_header();  // returns true if a header was parsed
  void compact();

  std::vector<uint8_t> buf_;
  size_t pos_ = 0;
  bool failed_ = false;

  // Pending header state (valid once have_header_).
  bool have_header_ = false;
  std::string type_;
  std::string inline_data_json_;  // data carried inline in the header
  size_t data_len_ = 0;
  size_t payload_len_ = 0;
};

}  // namespace espos_voice