Skip to content
  • Date Created: 2025-12-11
  • Last Modified: 2025-12-11

Progress Log: EventQueue ↔ CommandQueue Symmetry Analysis

Task Description

Completed CommandQueue implementation revealed fundamental architectural asymmetry between command and event handling paths:

  • CommandQueue follows unified 3-layer pipeline:
  • Layer 1 (Handler): command_response_t with typed fields
  • Layer 2 (Dispatcher): device_response_t conversion via from_command()
  • Layer 3 (Serialization): JSON output via send()

  • EventQueue bypasses Layer 1/2:

  • Direct enqueue of device_response_t envelope + event_t payload
  • Single-stage serialization via send_event_response_full()
  • No typed event_response_t Layer 1 structure

This asymmetry creates maintenance burden:

  • Duplicate type definitions (event_t in both stream_data.h and event_queue.h)
  • Inconsistent error handling patterns (command validation in Layer 1, event validation scattered)
  • Different response construction paths (typed struct vs. envelope+payload tuple)

Outcome

Identified 5 major asymmetries:

1. Input Data Types (CRITICAL)

CommandQueue: Receives command_t, internally queues after parsing
EventQueue:   Receives event_queue_entry_t (pre-assembled by caller)

2. Pipeline Architecture

CommandQueue: 4-layer pipeline (Serial → Parse → Queue → Dispatch → Convert → Serialize)
EventQueue:   2-layer pipeline (Enqueue → Serialize) [Skips Layer 1/2 validation]

3. Layer 1 Structure

CommandQueue: Layer 1 = command_response_t (typed fields + payload pointer)
EventQueue:   No Layer 1 = device_response_t directly (already transport format)

4. Response Construction

CommandQueue: Handler builds typed response via success_response()/error_response()
EventQueue:   Caller builds event_queue_entry_t (response envelope + event data)

5. Utility Methods

CommandQueue: has_pending()
EventQueue:   has_pending() missing; instead: get_queued_count(), is_full(), get_overflow_count(), clear()

Learnings

Why This Matters

  1. Developer Cognitive Load: Two different patterns for similar buffering systems
  2. Testing Complexity: Different validation entry points (Layer 1 vs. caller)
  3. Maintenance Risk: Changes to one require careful consideration of the other
  4. Evolution Path: If we later add event validation/error handling, where does it live?

Root Cause Analysis

EventQueue was designed around high-frequency event streaming use case:

  • No command-like validation needed (detector produces valid sensor data)
  • No handler dispatch table required
  • Minimal processing overhead desired
  • Direct serialization sufficient

CommandQueue follows command-request-response pattern:

  • Validation critical (user input untrusted)
  • Handler dispatch required
  • Multiple response types possible
  • 3-layer architecture necessary

Design Trade-offs

Aspect CommandQueue EventQueue Impact
Flexibility High (typed Layer 1) Low (direct enqueue) Event validation hard to add
Performance Slight overhead Optimal (~1-2μs) Acceptable; validation >> queue overhead
Consistency Strict (3-layer) Loose (2-layer) Maintenance burden
Memory Fixed overhead Minimal Both acceptable on ESP32

Unified Symmetry Proposal

Introduce event_response_t Layer 1 struct:

typedef struct {
  bool is_ok;
  device_response_code_t error_code;
  char error_message[256];
  event_t event;
  uint32_t sent_at;
} event_response_t;

class EventQueue {
  // Enqueue Layer 1 response (like CommandQueue)
  static bool enqueue(const event_response_t* response);

  // execute() method (parallel to CommandQueue::execute())
  static bool execute(void);
};

Benefits:

  • Perfect symmetry with CommandQueue
  • Unified Layer 1 → Layer 2 → Layer 3 path
  • Event validation can be added to Layer 1
  • Single response construction pattern
  • Easier to understand for developers familiar with CommandQueue

Costs:

  • Requires refactoring EventQueue enqueue/flush logic
  • Slightly more code (~50-100 lines)
  • Imperceptible performance impact

Option B: Keep Current Design (Status Quo)

Document asymmetry explicitly and maintain separate code paths.

Benefits:

  • Zero refactoring
  • Minimal code
  • Optimal performance maintained

Costs:

  • Technical debt accumulates
  • Future validation features must work around asymmetry
  • Onboarding complexity for new developers

Option C: Hybrid (Middle Ground)

Keep EventQueue efficient but add event_response_t type for consistency:

  • EventQueue uses simplified validation-free event_response_t
  • CommandQueue uses full command_response_t with error handling
  • Parallel structures but distinct purposes

Benefits:

  • Some symmetry without full refactor
  • Validates events at creation, not enqueue
  • Partial developer alignment

Costs:

  • Still two patterns
  • Incomplete solution

Next Steps

  1. Decision Required: Choose Option A, B, or C above

  2. If Option A:

  3. Design event validation layer (where should event_response_t be created?)
  4. Refactor EventQueue::enqueue() signature
  5. Add EventQueue::execute() parallel to CommandQueue::execute()
  6. Update main.cpp call pattern
  7. Add unit tests for both queues

  8. If Option B:

  9. Document asymmetry in CLAUDE.md (Implementation Details section)
  10. Add architectural rationale comments to event_queue.h/cpp
  11. Mark as "intentional design" not a bug

  12. If Option C:

  13. Define lightweight event_response_t
  14. Update event creation caller pattern
  15. Document dual-response-type system

Recommendation

Option A (CommandQueue Style) is recommended because:

  1. Future-proofs event validation additions
  2. Single mental model for all queue operations
  3. Enables consistent error handling across device
  4. Aligns with unified device_response_t philosophy
  5. Maintenance burden lighter long-term despite refactor costs

The ~50-100 lines of refactoring is negligible compared to lifetime maintenance benefit.


Processing Flow Comparison: CommandQueue vs EventQueue (Option A)

CommandQueue: Complete 4-Layer Processing Pipeline

Serial Input → Receive → Parse → Queue → Dispatch → Layer 1 Convert → Layer 2 Convert → Layer 3 Serialize → Serial Output

Detailed Flow:

# Stage Function Input Type Output Type Description
1 Reception CommandQueue::receive() Serial bytes (char[]) command_t (parsed) Reads line from serial (timeout 500ms), validates length, null-terminates
2 Parsing command_parse() (internal) char[] (raw command line) command_t (structured) Splits "SET_THRESHOLD 1 512" → name="SET_THRESHOLD", args[0]="1", args[1]="512"
3 Queuing CommandQueue::enqueue() (implicit via receive) command_t FreeRTOS Queue Stores to static FreeRTOS queue (10-item buffer, 650 bytes)
4 Dequeuing CommandQueue::execute() FreeRTOS Queue command_t (dequeued) Retrieves next command from queue
5 Dispatch CommandQueue::dispatch() (internal) command_t command_response_t (Layer 1) Looks up command in dispatch table, calls handler function (e.g., handle_set_threshold())
6 Layer 1 Response handle_*() (handler function) command_t (validated args) command_response_t (typed fields) Handler executes business logic, returns typed Layer 1 response (e.g., status, threshold value, error code)
7 Layer 2 Conversion DeviceResponse::from_command() command_response_t (Layer 1) device_response_t (Layer 2) Wraps Layer 1 in transport envelope: type, status, sent_at, error_code, payload pointer
8 Layer 3 Serialization DeviceResponse::send() device_response_t (Layer 2) JSON string Merges payload fields with envelope, serializes to JSONL: {"type":"response","status":"ok",...payload...}
9 Transmission Serial.println() JSON string Serial port Writes complete JSON line to serial (115200 baud)

Total Processing Path: 9 sequential stages

Queue Depth: 1 item (receives then dequeues within same execute() call)

Responsibility Model:

  • Layer 1 (Handler): Validation, business logic, typed response
  • Layer 2 (Converter): Transport envelope wrapping
  • Layer 3 (Serializer): JSON formatting

EventQueue: Current 2-Layer Pipeline (BEFORE Option A)

Detection → Sensor Read → Build event_t → Convert to device_response_t → Queue → Flush → Serialize → Serial Output

Current Flow:

# Stage Function Input Type Output Type Description
1 Detection cosmic_detector_read() GPIO pin state cosmic_detection_t (detected flag) Polls detection pins GPIO12/19/27 (100x per cycle)
2 Sensor Read bme280_read(), adc_read(), rtc_read() I2C/ADC/RTC buses Sensor values (temp, pressure, humidity, adc, timestamp) Reads environmental sensors only if detected=true
3 Event Build (inline in main.cpp) Sensor values event_t (struct) Populates hit1, hit2, hit3, adc, temp, pressure, humidity, timestamp fields
4 Layer 1 Response event_to_device_response() event_t (raw data) device_response_t (envelope only, no Layer 1!) Bypasses Layer 1 validation - directly creates Layer 2 envelope
5 Queue Assembly (inline in main.cpp) device_response_t + event_t event_queue_entry_t (tuple) Manually assembles queue entry (envelope + payload) before enqueue
6 Queuing EventQueue::enqueue(event_queue_entry_t*) event_queue_entry_t FreeRTOS Queue Stores to static FreeRTOS queue (200-item buffer, 50 KB)
7 Flushing EventQueue::flush() FreeRTOS Queue (all items) Drained queue Dequeues ALL pending events in tight loop
8 Layer 3 Serialization send_event_response_full(entry) event_queue_entry_t JSON string Serializes: type, status, hit1, hit2, hit3, adc, optional fields
9 Transmission Serial.println() JSON string Serial port Writes complete JSON line to serial (115200 baud)

Issues with Current Flow:

  • ❌ Missing Layer 1 (event_response_t) - no validation/error structure
  • ❌ Queue carries both envelope AND payload (inefficient)
  • ❌ Asymmetric with CommandQueue pattern
  • ❌ Main.cpp responsible for envelope + payload assembly (caller burden)

EventQueue: Target 4-Layer Pipeline (AFTER Option A)

Detection → Sensor Read → Build event_t → Layer 1 Response → Queue → Execute → Layer 2 Convert → Layer 3 Serialize → Serial Output

Target Flow (Symmetric with CommandQueue):

# Stage Function Input Type Output Type Description
1 Detection cosmic_detector_read() GPIO pin state cosmic_detection_t (detected flag) Polls detection pins GPIO12/19/27 (100x per cycle)
2 Sensor Read bme280_read(), adc_read(), rtc_read() I2C/ADC/RTC buses Sensor values (temp, pressure, humidity, adc, timestamp) Reads environmental sensors only if detected=true
3 Event Build (inline in main.cpp) Sensor values event_t (struct) Populates hit1, hit2, hit3, adc, temp, pressure, humidity, timestamp fields
4 Layer 1 Response event_response_ok(&event) event_t (raw data) event_response_t (Layer 1 with validation) NEW: Creates Layer 1 response with is_ok=true, error_code=OK, sent_at=timestamp
5 Queuing EventQueue::enqueue(&event_resp) event_response_t (Layer 1 only) FreeRTOS Queue Stores Layer 1 response to static FreeRTOS queue (200-item buffer, 30 KB)
6 Dequeuing/Execute EventQueue::execute() FreeRTOS Queue event_response_t (dequeued) NEW: Dequeues one event_response_t and processes it (parallel to CommandQueue::execute())
7 Layer 2 Conversion DeviceResponse::from_event(&event_resp) event_response_t (Layer 1) device_response_t (Layer 2) NEW: Wraps Layer 1 in transport envelope: type=DEVICE_TYPE_EVENT, status based on is_ok, sent_at
8 Layer 3 Serialization DeviceResponse::send(&response) device_response_t (Layer 2 with payload pointer) JSON string UNIFIED: Merges Layer 2 envelope with event payload fields via pointer, serializes to JSONL
9 Transmission Serial.println() JSON string Serial port Writes complete JSON line to serial (115200 baud)

Benefits of Target Flow:

  • ✅ Symmetric with CommandQueue: Same 4-stage pipeline (Reception→Parsing→Queueing→Dispatching) maps to (Detection→Sensor Read→Queuing→Execute)
  • ✅ Complete 3-layer architecture: Layer 1 (Domain) → Layer 2 (Transport) → Layer 3 (Serialization)
  • ✅ Queue carries only Layer 1 (lighter, 20 KB savings)
  • ✅ Main.cpp simplified: Just call event_response_ok() + enqueue() (caller obligation reduced)
  • ✅ Validation layer exists for future error handling
  • ✅ Single mental model for all queue operations

Comparison Matrix: CommandQueue vs EventQueue (Target)

Aspect CommandQueue (Current) EventQueue (Target) Status
Input Source Serial: char[] line Detection: event_t struct Different sources, same pattern
Stage 1 receive() + parse()command_t Sensor read + build → event_t Functionally equivalent
Stage 2 enqueue() → FreeRTOS queue event_response_ok() → Layer 1 struct Different structures, same Layer 1 role
Stage 3 execute() → dequeue + dispatch execute() → dequeue + convert SAME METHOD NAME
Layer 1 Type command_response_t event_response_t Parallel structures ✅
Layer 1 Builder success_response() / error_response() event_response_ok() / event_response_error() Parallel helpers ✅
Layer 2 Conversion DeviceResponse::from_command() DeviceResponse::from_event() Parallel converters ✅
Layer 3 Serialization DeviceResponse::send() DeviceResponse::send() UNIFIED
Queue Item Type command_t event_response_t Both Layer 1 ✅
Queue Size 10 items, 650 bytes 200 items, 30 KB EventQueue larger (high-frequency bursts)
Asymmetry N/A (CommandQueue is reference) 🟡 RESOLVED in Target Design goal met ✅

Processing Flow Diagram

BEFORE (Asymmetric):
┌─────────────────────────────────────────────────────────────┐
│ COMMAND PATH (4 layers)                                      │
├─────────────────────────────────────────────────────────────┤
│ Serial → Receive → Parse → Queue → Dispatch → Layer1 → Layer2 → Layer3 → JSON
│         (command_t)              (handler)   (typed)   (envelope) (serialize)
└─────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│ EVENT PATH (2 layers) ❌                             │
├──────────────────────────────────────────────────────┤
│ Detect → Read → Build → Queue → Serialize → JSON
│ (event_t)     (envelope+payload)  (no Layer1!)
└──────────────────────────────────────────────────────┘
        ASYMMETRIC ❌

AFTER (Option A - Symmetric):
┌──────────────────────────────────────────────────────────────┐
│ COMMAND PATH (4 layers)                                       │
├──────────────────────────────────────────────────────────────┤
│ Serial → Receive → Parse → Queue → Execute → Layer1 → Layer2 → Layer3 → JSON
│         (command_t)        (dispatch) (typed)   (envelope) (serialize)
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│ EVENT PATH (4 layers) ✅                                      │
├──────────────────────────────────────────────────────────────┤
│ Detect → Read → Build → Layer1 → Queue → Execute → Layer2 → Layer3 → JSON
│ (event_t)      (event_response_t)     (convert) (envelope) (serialize)
└──────────────────────────────────────────────────────────────┘
        SYMMETRIC ✅

Implementation Path: Option A (CommandQueue Style)

Data Flow Target

event_t (detection)
    ↓
EventQueue::enqueue(event_response_t)
    ↓ (Layer 2 conversion)
DeviceResponse::from_event(event_response_t)
    ↓ (Layer 2 envelope)
device_response_t
    ↓ (Layer 3 serialization)
send_event_response() → JSON output

Include Dependencies

event_queue.h

Add to includes section:

#include "config.h"
#if ENABLE_DEVICE_RESPONSE
#include "device_response_types.h"  // For device_response_code_t enum
#endif

Rationale: event_response_t struct uses device_response_code_t enum. Follows same pattern as command_queue.h for circular dependency avoidance. Separates types (device_response_types.h) from implementations to prevent circular includes:

  • event_queue.h → includes device_response_types.h (types only)
  • device_response.h → includes event_queue.h (safe, no circular dependency)

event_response.h

Add to includes section:

#include <ArduinoJson.h>
#include "device_response_types.h"  // For enums (DEVICE_TYPE_EVENT, etc.)
#include "event_queue.h"            // For event_response_t type
#include "device_response.h"        // For device_response_t type

Rationale: Needs types from device_response_types.h before event_queue.h is included.


Type Definitions (Additions)

1. event_response_t (NEW Layer 1 type)

Add to event_queue.h:

/**
 * @brief Event response (Layer 1 - domain layer)
 *
 * Represents detection event with validation, error handling, and metadata.
 * Symmetric with command_response_t for unified 3-layer architecture.
 *
 * Data flow:
 *   event_t → event_response_t (Layer 1) → device_response_t (Layer 2) → JSON (Layer 3)
 */
typedef struct {
  /**
   * Validation status: true for valid event, false for error
   * Set to false if event_t data fails validation
   */
  bool is_ok;

  /**
   * Error code (present when is_ok=false)
   * Maps to device_response_code_t (1-5)
   */
  device_response_code_t error_code;

  /**
   * Error message (present when is_ok=false, max 255 chars)
   * Human-readable description of validation failure
   */
  char error_message[256];

  /**
   * Raw detection event data (timestamp captured at detection time)
   * Only valid when is_ok=true
   */
  event_t event;

  /**
   * Timestamp of event capture (seconds)
   * Unix time if ENABLE_RTC=1, or device uptime if ENABLE_RTC=0
   * Captured at detection moment, not at serialization time
   */
  uint32_t sent_at;
} event_response_t;

Method Signatures

NEW Methods (to add to EventQueue class)

Method Signature Purpose Location
has_pending() static bool has_pending(void) Check if events awaiting execution (parallel to CommandQueue) event_queue.h line ~XXX
execute() static bool execute(void) Dequeue and execute (process one event) event_queue.h line ~XXX

MODIFIED Methods (signature changes required)

Old Signature New Signature Change Rationale
enqueue(event_queue_entry_t*) enqueue(event_response_t*) Accept Layer 1, not pre-assembled Layer 2 Symmetric with CommandQueue::receive() pattern
flush() (delete) Remove 2-layer draining Replaced by execute() pattern

DELETED Methods

Method Reason
get_queued_count() Use has_pending() consistency check instead
is_full() Not needed (non-blocking enqueue returns false)
get_overflow_count() Can be added later if needed for monitoring
clear() Use internal queue reset, not public API
flush() Replaced by execute() for single-item processing

Internal Variables (Implementation)

NEW Static Variables (in event_queue.cpp)

/**
 * @brief Queue item counter for Layer 1 responses (event_response_t items)
 * Maps to: EventQueue::has_pending(), EventQueue::execute()
 */
static uint8_t g_event_queue_pending_count;

/**
 * @brief Single-item dequeue buffer for Layer 1 responses
 * Holds event_response_t for execute() processing
 */
static event_response_t g_event_dequeue_buffer;

MODIFIED Static Variables

Variable Old Type New Type Change Reason
g_event_queue_buffer event_queue_entry_t[] event_response_t[] Item type changes from Layer 2 to Layer 1 Deferred envelope assembly until Layer 2
g_event_queue_size sizeof(event_queue_entry_t) * 200 sizeof(event_response_t) * 200 Size calculation updates Layer 1 struct is smaller (no device_response_t overhead)

Function Implementations

NEW Functions (add to event_queue.cpp)

/**
 * @brief Create success event response (Layer 1 helper)
 * Parallel to CommandQueue's success_response()
 */
inline event_response_t event_response_ok(const event_t* event) {
  event_response_t resp = {};
  resp.is_ok = true;
  resp.error_code = DEVICE_CODE_OK;
  resp.sent_at = device_get_timestamp();
  if (event) {
    memcpy(&resp.event, event, sizeof(event_t));
  }
  return resp;
}

/**
 * @brief Create error event response (Layer 1 helper)
 * Parallel to CommandQueue's error_response()
 */
inline event_response_t event_response_error(
    device_response_code_t code, const char* message) {
  event_response_t resp = {};
  resp.is_ok = false;
  resp.error_code = code;
  resp.sent_at = device_get_timestamp();
  if (message) {
    strncpy(resp.error_message, message, sizeof(resp.error_message) - 1);
    resp.error_message[sizeof(resp.error_message) - 1] = '\0';
  }
  return resp;
}

REFACTORED Functions (signature change)

Function Old New Change
EventQueue::enqueue() (event_queue_entry_t*) (event_response_t*) Accept Layer 1 typed response
EventQueue::execute() N/A (new) Dequeue → dispatch (not needed) → Layer 2 convert → Layer 3 serialize Process one event

DELETED Functions (remove from event_queue.cpp)

Function Reason
flush() Replaced by execute() (process one event per call, not drain all)
get_queued_count() Use has_pending() instead
is_full() Check return value of enqueue()
get_overflow_count() Remove monitoring API (can add as DEBUG build only later)
clear() Make internal only (not public API)

Conversion Functions

NEW: DeviceResponse::from_event() (Layer 2 converter)

Add to device_response.h:

/**
 * @brief Convert event_response_t (Layer 1) to device_response_t (Layer 2)
 *
 * Layer 2 converter: Bridges Layer 1 validation results to Layer 2 transport envelope.
 * Parallel to DeviceResponse::from_command().
 *
 * Processing:
 * - Sets device_response_t.type = DEVICE_TYPE_EVENT
 * - Sets status based on event_response_t.is_ok
 * - Copies error_code and error_message if present
 * - Copies sent_at timestamp
 * - Event payload stored separately (passed to send_event_response_full)
 *
 * @param event_resp Layer 1 event response
 * @return device_response_t Transport envelope ready for serialization
 */
inline device_response_t DeviceResponse::from_event(const event_response_t* event_resp) {
  device_response_t resp = {};
  resp.type = DEVICE_TYPE_EVENT;
  resp.status = event_resp->is_ok ? DEVICE_STATUS_OK : DEVICE_STATUS_ERROR;
  resp.error_code = event_resp->error_code;
  resp.sent_at = event_resp->sent_at;

  if (!event_resp->is_ok && event_resp->error_message[0]) {
    strncpy(resp.error_message, event_resp->error_message,
            sizeof(resp.error_message) - 1);
    resp.error_message[sizeof(resp.error_message) - 1] = '\0';
  }

  return resp;
}

UNIFIED Layer 3 Serializer: DeviceResponse::send() (Complete Symmetry - Option 1)

For complete Layer 3 symmetry, both CommandQueue and EventQueue use the same unified DeviceResponse::send() function.

CURRENT Implementation (Separate Serializers):

// CommandQueue path: Uses unified DeviceResponse::send()
// in device_response.cpp
void DeviceResponse::send(const device_response_t* response) {
  JsonDocument doc;
  doc["type"] = (response->type == DEVICE_TYPE_RESPONSE) ? "response" : "event";
  doc["status"] = (response->status == DEVICE_STATUS_OK) ? "ok" : "error";
  doc["sent_at"] = response->sent_at;
  if (response->payload) {
    for (JsonPairConst pair : JsonObjectConst(response->payload->as<JsonObject>())) {
      doc[pair.key()] = pair.value();
    }
  }
  serializeJson(doc, Serial);
  Serial.println();
}

// EventQueue path: Uses separate send_event_response_full()
// in event_response.cpp
void send_event_response_full(const event_queue_entry_t* entry) {
  JsonDocument doc;
  doc["type"] = "event";
  doc["status"] = (entry->response.status == DEVICE_STATUS_OK) ? "ok" : "error";
  doc["sent_at"] = entry->response.sent_at;
  // ... manually populate hit1, hit2, hit3, adc, optional fields ...
  serializeJson(doc, Serial);
  Serial.println();
}

// ❌ PROBLEM: Two separate paths for similar work

IMPROVED Implementation (Unified Serializer):

// BOTH CommandQueue AND EventQueue use unified DeviceResponse::send()
// in device_response.cpp
void DeviceResponse::send(const device_response_t* response) {
  JsonDocument doc;

  // Common envelope (all message types)
  doc["type"] = (response->type == DEVICE_TYPE_EVENT) ? "event" : "response";
  doc["status"] = (response->status == DEVICE_STATUS_OK) ? "ok" : "error";
  doc["sent_at"] = response->sent_at;

  // Error fields (if present)
  if (response->status == DEVICE_STATUS_ERROR) {
    doc["error_code"] = response->error_code;
    doc["error_message"] = response->error_message;
  }

  // Payload fields (merged from response->payload pointer)
  // Works for BOTH command responses and event responses
  if (response->payload) {
    for (JsonPairConst pair : JsonObjectConst(response->payload->as<JsonObject>())) {
      doc[pair.key()] = pair.value();
    }
  }

  // Serialize and transmit
  serializeJson(doc, Serial);
  Serial.println();
}

// ✅ SOLUTION: Single path for all message types

EventQueue::execute() Implementation (Layer 2 → Layer 3):

bool EventQueue::execute(void) {
  if (!has_pending()) return false;

  // Layer 1: Dequeue event_response_t
  event_response_t event_resp = g_event_dequeue_buffer;  // already dequeued

  // Layer 2: Convert to device_response_t
  device_response_t response = DeviceResponse::from_event(&event_resp);

  // Layer 3: Populate payload and serialize
  // Create static JsonDocument with event payload fields
  static JsonDocument event_payload;
  event_payload.clear();
  event_payload["hit1"] = event_resp.event.hit1;
  event_payload["hit2"] = event_resp.event.hit2;
  event_payload["hit3"] = event_resp.event.hit3;
  event_payload["adc"] = event_resp.event.adc;

  #if ENABLE_HITTYPE
  event_payload["hit_type"] = event_resp.event.hit_type;
  #endif

  #if ENABLE_BME280
  event_payload["tmp_c"] = event_resp.event.tmp_c;
  event_payload["atm_pa"] = event_resp.event.atm_pa;
  event_payload["hmd_pct"] = event_resp.event.hmd_pct;
  #endif

  // ... other optional fields ...

  // Set payload pointer in Layer 2 envelope
  response.payload = &event_payload;

  // Layer 3: Use unified serializer
  DeviceResponse::send(&response);

  return true;
}

Benefits of Unified Layer 3 (Option 1):

  • ✅ One serialization function for all message types
  • ✅ Consistent JSON output format (commands + events)
  • ✅ Easier to maintain and test
  • ✅ Perfect architectural symmetry
  • ✅ Extensible to future message types
  • ✅ Remove separate send_event_response_full() function (code consolidation)

Main Loop Integration

BEFORE (current 2-layer pattern)

void loop() {
  // Detection
  if (cosmic_read().detected) {
    event_t event = build_event_from_sensors();
    device_response_t resp = event_to_device_response(&event);
    // Queue carries: device_response_t + event_t as event_queue_entry_t
    EventQueue::enqueue(&event_queue_entry);
  }

  // Transmission (drains all)
  EventQueue::flush();
}

AFTER (new 4-layer pattern)

void loop() {
  // Detection: Create Layer 1 response
  if (cosmic_read().detected) {
    event_t event = build_event_from_sensors();
    event_response_t event_resp = event_response_ok(&event);
    // Queue carries: event_response_t only
    EventQueue::enqueue(&event_resp);
  }

  // Transmission: Process one event per loop iteration
  EventQueue::execute();
}

File Changes Summary

File Changes Type
include/event_queue.h Add event_response_t typedef, new has_pending(), new execute(), modify enqueue() sig, remove flush(), get_queued_count(), is_full(), get_overflow_count(), clear() Header
src/event_queue.cpp Rewrite queue mechanics (Layer 1 items), add has_pending(), add execute(), refactor enqueue(), delete flush(), delete utility methods, update buffer types Implementation
include/event_response.h Add event_response_ok(), event_response_error() helpers, DELETE send_event_response_full() signature (replaced by unified DeviceResponse::send()) Header
src/event_response.cpp Implement new helpers, DELETE send_event_response_full() implementation (replaced by unified DeviceResponse::send()) Implementation
include/device_response.h Add DeviceResponse::from_event() converter (parallel to from_command()) Header
src/device_response.cpp Implement from_event() converter, verify DeviceResponse::send() already handles both command and event types Implementation
src/main.cpp Update detection loop: event_response_ok() + enqueue(), replace flush() with execute() Implementation

Memory Impact Analysis

Metric Before (2-layer) After (4-layer) Delta Note
Queue item size event_queue_entry_t (~250B) event_response_t (~150B) -100B No Layer 2 in queue
Queue total (200 items) 50 KB 30 KB -20 KB Significant savings
Stack (enqueue) ~250B temporary ~150B temporary -100B Smaller copy
Heap 0 0 0 No change
Flash (code) ~2 KB ~3 KB +1 KB New helpers, converters

Conclusion: 20 KB RAM savings, 1 KB code growth = net +19 KB available RAM

Validation Checkpoints

After each phase, verify:

  • Phase 1: Type definitions compile without errors
  • Phase 2: Helper functions (event_response_ok/error) callable
  • Phase 3: Queue accepts event_response_t items
  • Phase 4: Conversion DeviceResponse::from_event() produces correct device_response_t
  • Phase 5: Serialization send_event_response_full() outputs complete JSON
  • Phase 6: Main loop integrates without compilation errors
  • Phase 7: Runtime test detects cosmic rays → queue → output → JSON validation
  • Phase 8: Symmetry check: CommandQueue and EventQueue patterns identical