Expose broker publish acknowledgments for MQTT validation

This commit is contained in:
mikecarper committed 2026-10-10 15:01:14 -07:00
1 parent 7acc637468
commit ef80f29337
6 files changed
+452 -9

No files matched your search

+1
View File
@@ -196,6 +196,7 @@ jobs:
python3 -B test/test_mqtt_connection_admission.py -v
python3 -B test/test_mqtt_handshake_scheduler.py -v
python3 -B test/test_mqtt_diagnostics.py -v
python3 -B test/test_mqtt_publish_ack.py -v
- name: Verify DNS lease restoration and bounded diagnostics
run: python3 -B test/test_network_dns.py -v
+44 -9
View File
@@ -53,6 +53,9 @@ room-server and companion images that enable it.
- Require byte-addressable internal memory for string/JSON/queue buffer
fallbacks. The internal-memory capability alone also admits instruction RAM
that only supports aligned 32-bit access.
- Count broker QoS acknowledgments separately from accepted writes and expose
them as `pub_ack` in `get mqttN.diag`. This lets hardware tests verify
delivery without treating an expired outbox entry as an acknowledgment.
- Add DMA free/largest readings to `get mqtt.stats` and diagnostic logs.
Correct the operator-facing TLS error labels against SDK constants.
@@ -69,14 +72,46 @@ PSRAM and internal-only modes, fragmented heap, exhausted PSRAM, delayed
connection callbacks, retry timing and bounded replies. They do not execute
an ESP32 AES peripheral or qualify physical WiFi behavior.
All 1,794 native tests and the 31 memory-budget tests passed. The focused
MQTT tests are also connected to the GitHub unit-test workflow.
All 42 focused Python MQTT tests, 1,794 native tests and 31 memory-budget tests
passed. The focused MQTT tests are also connected to the GitHub unit-test
workflow.
The VM reached both reported broker TLS endpoints with certificate
verification enabled. That is an endpoint check, not an ESP32 MQTT test.
Three Full MQTT repeater builds with USB packet logging passed on 10 October
2026 at revision `7acc63746`. Their build logs report:
MercerWoodMesh reported offline and its SSH connection timed out during this
run. No live V4 flash, MQTT publication or soak result is claimed. The next
hardware check must exercise the reported WSS presets together, verify rising
publish counters and DMA headroom, then include a broker/WiFi reconnection
and power-saving idle run with no reset or AES allocation error.
| Build | Application flash bytes | Available internal heap bytes | Required heap bytes | Largest internal region bytes |
| --- | ---: | ---: | ---: | ---: |
| Heltec V4 | 2,182,125 | 205,080 | 198,432 | 172,312 |
| Heltec V3 | 2,125,149 | 211,296 | 209,184 | 178,528 |
| Station G2 | 2,017,685 | 201,280 | 200,480 | 168,512 |
Application flash is the PlatformIO size-check reading. Internal heap is
linked capacity before dynamic allocation, not free heap measured after boot.
All three RAM gates passed the startup policy; they do not qualify maximum
TLS/WSS/outbox load. The default MQTT startup allowance remains 24,576 bytes,
with runtime admission for additional connections. See
[firmware memory checks](../research/firmware_memory_budget.md#mqtt-startup-minimum-and-runtime-qualification)
for the separate maximum-slot estimate and qualification limits.
Live TLS 1.2 certificate chains from 12 audited preset endpoints were
verified with the exact CA PEMs extracted from `MQTTPresets.h` using a native
host build of the pinned mbedTLS 2.28.7 submodule from ESP-IDF 4.4.7, commit
`2b8e772fc1cb0732cda3bae7d1e9d6f4cfaf63d9`. Hostname verification was enabled.
All 11 GTS Root R4 endpoints and the ISRG Root X1 Cascadia endpoint returned
verification result 0 and flags `0x00000000`. Wrong-hostname and unrelated-anchor
negative checks were rejected. WAEV also passed with the server root omitted
from its supplied chain.
The existing cross-signed GTS Root R4 PEM is a valid trust anchor for that
pinned mbedTLS verifier. Ordinary OpenSSL root-only verification rejected it,
while partial-chain verification with the same PEM passed all 11 GTS endpoints.
This host-probe policy difference does not establish a firmware CA failure or
require a CA replacement for the reported AES allocation errors. The verifier
used the native default mbedTLS configuration; this establishes pinned X.509
chain and trust-anchor behavior, not an ESP32 hardware handshake.
MercerWoodMesh is now reachable and live V4 testing is in progress. Live flash,
MQTT publication, reconnect and soak outcomes remain pending. The hardware
check must exercise the reported WSS presets together, verify rising publish
counters and DMA headroom, then include a broker/WiFi reconnection and
power-saving idle run with no reset or AES allocation error.
@@ -657,6 +657,11 @@ unsigned long PsychicMqttClient::getPublishOk()
return _publish_ok;
}
unsigned long PsychicMqttClient::getPublishAck()
{
return _publish_ack.load(std::memory_order_relaxed);
}
unsigned long PsychicMqttClient::getPublishErr()
{
return _publish_err;
@@ -832,6 +837,7 @@ void PsychicMqttClient::_onMessage(esp_mqtt_event_handle_t &event)
void PsychicMqttClient::_onPublish(esp_mqtt_event_handle_t &event)
{
_publish_ack.fetch_add(1, std::memory_order_relaxed);
ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED, msg_id=%d", event->msg_id);
for (uint8_t i = 0; i < _onPublishUserCallbackCount; ++i)
{
@@ -31,6 +31,7 @@
* SOFTWARE.
*/
#include <atomic>
#include <functional>
#include "Arduino.h"
@@ -471,6 +472,13 @@ public:
*/
unsigned long getPublishOk();
/**
* @brief Cumulative QoS 1/2 publishes acknowledged by the broker during
* this client's lifetime. Counts MQTT_EVENT_PUBLISHED, not accepted writes
* or outbox expiry. MeshCore's QoS 1 status messages complete on PUBACK.
*/
unsigned long getPublishAck();
/**
* @brief Cumulative count of publishes that failed (negative return from the
* synchronous write or async enqueue -- socket error / network timeout). Monotonic.
@@ -495,6 +503,8 @@ private:
// Cumulative publish accept/fail counts (any QoS, sync or async path).
unsigned long _publish_ok = 0;
unsigned long _publish_err = 0;
// Written by the SDK event task and read by the bridge/CLI task.
std::atomic<unsigned long> _publish_ack{0};
// Multipart message reassembly. _buffer is lazily allocated at connect() time
// to match the configured buffer size, then reused for the client's lifetime.
+13
View File
@@ -721,6 +721,19 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
static_cast<unsigned long>(MQTTConnectionHealth::clampFailureTotal(cf)));
}
// Keep error details first and never clip an acknowledgment count into a
// different number. Accepted/enqueued publishes and outbox expiry do not
// advance this broker acknowledgment counter.
if (slot.client) {
char ack_text[32];
const int ack_len = snprintf(ack_text, sizeof(ack_text), ", pub_ack:%lu",
slot.client->getPublishAck());
const int remaining = pos < (int)bufsize ? (int)bufsize - 1 - pos : 0;
if (ack_len > 0 && ack_len < (int)sizeof(ack_text) && ack_len <= remaining) {
replyAppendf(buf, bufsize, &pos, "%s", ack_text);
}
}
// Appended last so it never displaces connection diagnostics. replyAppendf
// clamps rather than overflows, but a clipped type list is worse than no
// list: "...,13,14," parses as a real, different allowlist, and this is the
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""Exercise the real MQTT publish-event path and bounded slot diagnostics on host.
The adapter uses its production header; SDK calls are controllable stubs. Only
the unrelated receive/error handlers are replaced. No PlatformIO or broker is
required, and both installed ESP-IDF configuration layouts are exercised.
"""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
ADAPTER = "lib/PsychicMqttClient/src/PsychicMqttClient.cpp"
BRIDGE = "src/helpers/bridges/MQTTBridge.cpp"
def method(path, signature):
source = (ROOT / path).read_text(encoding="utf-8")
start = source.index(signature)
opening = source.index("{", start)
depth = 1
pos = opening + 1
while depth:
depth += (source[pos] == "{") - (source[pos] == "}")
pos += 1
return source[start:pos] + "\n"
ARDUINO_HEADER = r'''
#pragma once
#include <cstddef>
#include <cstdint>
unsigned long millis();
void vTaskDelay(unsigned);
constexpr unsigned portTICK_PERIOD_MS = 1;
'''
MQTT_HEADER = r'''
#pragma once
using esp_err_t = int;
using esp_event_base_t = const char*;
using esp_mqtt_client_handle_t = void*;
constexpr int ESP_OK = 0, ESP_ERR_INVALID_STATE = 1, ESP_ERR_NO_MEM = 2;
enum {
MQTT_EVENT_ANY = -1, MQTT_EVENT_CONNECTED, MQTT_EVENT_DISCONNECTED,
MQTT_EVENT_SUBSCRIBED, MQTT_EVENT_UNSUBSCRIBED, MQTT_EVENT_PUBLISHED,
MQTT_EVENT_DATA, MQTT_EVENT_ERROR
};
struct esp_mqtt_client_config_t {
const char* uri;
int buffer_size;
struct { struct { const char* uri; } address; } broker;
struct { int size; } buffer;
};
struct esp_mqtt_error_codes_t { int unused; };
struct esp_mqtt_event_t { int event_id, msg_id; bool session_present; };
using esp_mqtt_event_handle_t = esp_mqtt_event_t*;
'''
PREAMBLE = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <utility>
// Preinclude the standard headers so this seam only opens adapter visibility.
#define private public
#include "PsychicMqttClient.h"
#undef private
#include "helpers/MQTTReplyFormat.h"
#include "helpers/MQTTConnectionHealth.h"
#include "helpers/MQTTPacketFilter.h"
#include "helpers/bridges/MQTTErrorLabels.h"
template<typename... Args> void test_log(Args&&...) {}
static const char* TAG = "test";
#define ESP_LOGE(...) test_log(__VA_ARGS__)
#define ESP_LOGW(...) test_log(__VA_ARGS__)
#define ESP_LOGI(...) test_log(__VA_ARGS__)
#define ESP_LOGV(...) test_log(__VA_ARGS__)
#define ESP_ERROR_CHECK_WITHOUT_ABORT(value) ((void)(value))
static unsigned long now_ms = 121000;
unsigned long millis() { return now_ms; }
void vTaskDelay(unsigned) { assert(false && "stub disconnect must deliver its event"); }
const char* esp_err_to_name(int) { return "mock"; }
static int publish_result = 0, enqueue_result = 41, outbox_size = 0;
static int publish_calls = 0, enqueue_calls = 0, config_calls = 0;
static int start_calls = 0, reconnect_calls = 0, receive_events = 0, error_events = 0;
using EventHandler = void(*)(void*, esp_event_base_t, int32_t, void*);
static EventHandler sdk_event_handler = nullptr;
static void* sdk_handler_args = nullptr;
void* esp_mqtt_client_init(esp_mqtt_client_config_t*) {
return reinterpret_cast<void*>(1);
}
int esp_mqtt_client_register_event(void*, int, EventHandler handler, void* args) {
sdk_event_handler = handler;
sdk_handler_args = args;
return ESP_OK;
}
int esp_mqtt_client_destroy(void*) { return ESP_OK; }
int esp_mqtt_set_config(void*, esp_mqtt_client_config_t*) {
++config_calls;
return ESP_OK;
}
int esp_mqtt_client_start(void*) { ++start_calls; return ESP_OK; }
int esp_mqtt_client_reconnect(void*) { ++reconnect_calls; return ESP_OK; }
int esp_mqtt_client_stop(void*) { return ESP_OK; }
int esp_mqtt_client_disconnect(void*) {
assert(sdk_event_handler != nullptr);
esp_mqtt_event_t event{MQTT_EVENT_DISCONNECTED, 0, false};
sdk_event_handler(sdk_handler_args, "MQTT", event.event_id, &event);
return ESP_OK;
}
int esp_mqtt_client_subscribe(void*, const char*, int) { return 7; }
int esp_mqtt_client_get_outbox_size(void*) { return outbox_size; }
int esp_mqtt_client_publish(void*, const char*, const char*, int, int, bool) {
++publish_calls;
return publish_result;
}
int esp_mqtt_client_enqueue(void*, const char*, const char*, int, int, bool, bool store) {
assert(store);
++enqueue_calls;
return enqueue_result;
}
void PsychicMqttClient::_onMessage(esp_mqtt_event_handle_t&) { ++receive_events; }
void PsychicMqttClient::_onError(esp_mqtt_event_handle_t&) { ++error_events; }
constexpr int RUNTIME_MQTT_SLOTS = 2;
struct MQTTPrefs {
uint16_t mqtt_slot_packet_filter[RUNTIME_MQTT_SLOTS] = {
MQTTPacketFilter::kAllPacketTypes, MQTTPacketFilter::kAllPacketTypes};
};
struct MQTTBridge {
struct MQTTSlot {
PsychicMqttClient* client = nullptr;
bool enabled = true, connected = true, circuit_breaker_tripped = false;
const void* preset = nullptr;
char host[8] = "broker";
uint32_t disconnect_count = 0, connect_failures = 0, start_failures = 0;
unsigned long first_disconnect_time = 0, last_error_time = 0;
int32_t last_tls_err = 0, last_tls_stack_err = 0;
int last_sock_errno = 0;
};
MQTTSlot _slots[RUNTIME_MQTT_SLOTS];
bool _initialized = true;
bool ready = true;
MQTTPrefs* _obs = nullptr;
bool isSlotReady(int) const { return ready; }
static const char* tlsErrorStr(int32_t);
static void formatSlotDiagReply(char*, size_t, int);
};
static MQTTBridge* s_mqtt_bridge_instance = nullptr;
'''
MAIN = r'''
static void dispatch(PsychicMqttClient& client, int event_id, int msg_id = 0) {
esp_mqtt_event_t event{event_id, msg_id, false};
PsychicMqttClient::_onMqttEventStatic(&client, "MQTT", event_id, &event);
}
int main() {
PsychicMqttClient client;
assert(client.getPublishAck() == 0);
client._mqtt_cfg.uri = "mqtt://broker";
client._mqtt_cfg.broker.address.uri = "mqtt://broker";
assert(client.connect() == ESP_OK);
dispatch(client, MQTT_EVENT_CONNECTED);
assert(client.connected() && client.getPublishAck() == 0);
// Acceptance, synchronous success, failure, and outbox drops are distinct
// from a broker acknowledgment. The real publish method exercises both paths.
assert(client.publish("topic", 1, false, "body", 4, true) == 41);
assert(client.publish("topic", 0, false, "body", 4, false) == 0);
assert(client.getPublishOk() == 2 && client.getPublishAck() == 0);
enqueue_result = -1;
publish_result = -1;
assert(client.publish("topic", 1, false, "body", 4, true) == -1);
assert(client.publish("topic", 0, false, "body", 4, false) == -1);
assert(client.getPublishErr() == 2 && client.getPublishAck() == 0);
client.setOutboxLimit(10);
outbox_size = 10;
assert(client.publish("topic", 0, false, "body", 4, true) == -2);
assert(client.getOutboxDrops() == 1 && client.getPublishAck() == 0);
assert(publish_calls == 2 && enqueue_calls == 2);
// A published event counts even without user callbacks. User callbacks see
// the updated total and additional callbacks do not multiply the counter.
dispatch(client, MQTT_EVENT_PUBLISHED, 41);
assert(client.getPublishAck() == 1);
int callbacks = 0, extra_callbacks = 0;
assert(&client.onPublish([&](int msg_id) {
++callbacks;
assert(msg_id == 41 + callbacks);
assert(client.getPublishAck() == static_cast<unsigned long>(1 + callbacks));
}) == &client);
dispatch(client, MQTT_EVENT_PUBLISHED, 42);
assert(client.getPublishAck() == 2 && callbacks == 1);
client.onPublish([&](int msg_id) {
++extra_callbacks;
assert(msg_id == 43 && client.getPublishAck() == 3);
});
dispatch(client, MQTT_EVENT_PUBLISHED, 43);
assert(client.getPublishAck() == 3 && callbacks == 2 && extra_callbacks == 1);
// Connection churn and unrelated events cannot clear or advance the total.
const int unrelated_events[] = {
MQTT_EVENT_DISCONNECTED, MQTT_EVENT_CONNECTED, MQTT_EVENT_SUBSCRIBED,
MQTT_EVENT_UNSUBSCRIBED, MQTT_EVENT_DATA, MQTT_EVENT_ERROR, 12345};
for (int event_id : unrelated_events) {
dispatch(client, event_id);
assert(client.getPublishAck() == 3);
}
assert(receive_events == 1 && error_events == 1);
assert(client.publish("topic", 0, false, "body", 4, true) == -1);
assert(client.getPublishAck() == 3);
client.forceStop();
assert(client.getPublishAck() == 3 && !client.isStarted());
assert(client.connect() == ESP_OK && client.getPublishAck() == 3);
assert(client.reconnect() == ESP_OK && client.getPublishAck() == 3);
client._config_dirty = true;
assert(client.connect() == ESP_OK && client.getPublishAck() == 3);
assert(start_calls == 3 && reconnect_calls == 1 && config_calls == 1);
PsychicMqttClient fresh;
assert(fresh.getPublishAck() == 0);
char reply[160];
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 0);
assert(strcmp(reply, "> mqtt1: bridge not running") == 0);
assert(strstr(reply, "pub_ack") == nullptr);
MQTTBridge bridge;
MQTTPrefs prefs;
bridge._obs = &prefs;
bridge._slots[0].client = &client;
s_mqtt_bridge_instance = &bridge;
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 0);
assert(strcmp(reply, "> mqtt1: ok, no errors, pub_ack:3") == 0);
bridge._slots[0].client = &fresh;
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 0);
assert(strcmp(reply, "> mqtt1: ok, no errors, pub_ack:0") == 0);
bridge._slots[0].client = &client;
bridge._slots[0].connect_failures = 2;
bridge._slots[0].start_failures = 3;
prefs.mqtt_slot_packet_filter[0] = (1U << 2) | (1U << 3);
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 0);
assert(strcmp(reply, "> mqtt1: ok, no errors, cf:5, pub_ack:3, filter:2,3") == 0);
// Without an allocated client there is no client-lifetime acknowledgment
// total. Early-return replies also retain their established wording.
bridge._slots[1].connected = false;
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 1);
assert(strcmp(reply, "> mqtt2: no client, no error info") == 0);
bridge._slots[1].enabled = false;
bridge._slots[1].host[0] = '\0';
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 1);
assert(strcmp(reply, "> mqtt2: not configured") == 0);
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), RUNTIME_MQTT_SLOTS);
assert(strcmp(reply, "> invalid slot") == 0);
bridge._slots[0] = MQTTBridge::MQTTSlot{};
bridge._slots[0].client = &client;
prefs.mqtt_slot_packet_filter[0] = MQTTPacketFilter::kAllPacketTypes;
client._publish_ack.store(4294967295UL, std::memory_order_relaxed);
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 0);
const char* healthy_prefix = "> mqtt1: ok, no errors";
const char* ack_field = ", pub_ack:4294967295";
assert(strcmp(reply, "> mqtt1: ok, no errors, pub_ack:4294967295") == 0);
const size_t complete_size = strlen(healthy_prefix) + strlen(ack_field) + 1;
for (size_t capacity = 1; capacity <= complete_size + 1; ++capacity) {
unsigned char bounded[168];
memset(bounded, 0xA5, sizeof(bounded));
char* output = reinterpret_cast<char*>(bounded);
MQTTBridge::formatSlotDiagReply(output, capacity, 0);
assert(memchr(output, '\0', capacity) != nullptr);
for (size_t i = capacity; i < sizeof(bounded); ++i) assert(bounded[i] == 0xA5);
if (capacity < complete_size) {
assert(strstr(output, "pub_") == nullptr);
assert(strncmp(output, healthy_prefix, capacity - 1) == 0);
} else {
assert(strcmp(output, "> mqtt1: ok, no errors, pub_ack:4294967295") == 0);
}
}
unsigned char untouched[16];
memset(untouched, 0xA5, sizeof(untouched));
MQTTBridge::formatSlotDiagReply(reinterpret_cast<char*>(untouched), 0, 0);
for (unsigned char byte : untouched) assert(byte == 0xA5);
MQTTBridge::formatSlotDiagReply(nullptr, 16, 0);
// Connection diagnostics and failure totals keep priority in the 160-byte
// LoRa reply. A long error tail must omit the whole ACK field, not its digits.
auto& slot = bridge._slots[0];
slot.connected = false;
slot.disconnect_count = 4294967295U;
slot.first_disconnect_time = 1;
slot.last_error_time = 1;
slot.last_tls_err = 0x8003;
slot.last_tls_stack_err = -0x7F00;
slot.last_sock_errno = -2147483647;
slot.connect_failures = 4294967295U;
slot.start_failures = 4294967295U;
now_ms = 4294967295UL;
unsigned char long_reply[168];
memset(long_reply, 0xA5, sizeof(long_reply));
MQTTBridge::formatSlotDiagReply(reinterpret_cast<char*>(long_reply), 160, 0);
const char* diagnostic = reinterpret_cast<char*>(long_reply);
assert(strstr(diagnostic, "unsupported protocol family (0x8003)") != nullptr);
assert(strstr(diagnostic, "mbedtls:-0x7F00") != nullptr);
assert(strstr(diagnostic, "sock:-2147483647") != nullptr);
assert(strstr(diagnostic, "cf:2147483647") != nullptr);
assert(strstr(diagnostic, "pub_") == nullptr);
assert(memchr(diagnostic, '\0', 160) != nullptr);
for (size_t i = 160; i < sizeof(long_reply); ++i) assert(long_reply[i] == 0xA5);
}
'''
class MqttPublishAckTests(unittest.TestCase):
def test_production_publish_events_and_slot_diagnostics(self):
compiler = shutil.which("g++")
if not compiler:
self.skipTest("g++ is unavailable")
source = PREAMBLE + "\n".join(method(ADAPTER, signature) for signature in (
"PsychicMqttClient::PsychicMqttClient()",
"PsychicMqttClient::~PsychicMqttClient()",
"PsychicMqttClient &PsychicMqttClient::onPublish(",
"bool PsychicMqttClient::connected()",
"esp_err_t PsychicMqttClient::applyConfig()",
"esp_err_t PsychicMqttClient::connect()",
"esp_err_t PsychicMqttClient::reconnect()",
"void PsychicMqttClient::disconnect()",
"void PsychicMqttClient::forceStop()",
"int PsychicMqttClient::subscribe(",
"int PsychicMqttClient::publish(",
"PsychicMqttClient &PsychicMqttClient::setOutboxLimit(",
"unsigned long PsychicMqttClient::getOutboxDrops()",
"unsigned long PsychicMqttClient::getPublishOk()",
"unsigned long PsychicMqttClient::getPublishErr()",
"unsigned long PsychicMqttClient::getPublishAck()",
"void PsychicMqttClient::_onMqttEventStatic(",
"void PsychicMqttClient::_onMqttEvent(",
"void PsychicMqttClient::_onConnect(",
"void PsychicMqttClient::_onDisconnect(",
"void PsychicMqttClient::_onSubscribe(",
"void PsychicMqttClient::_onUnsubscribe(",
"void PsychicMqttClient::_onPublish(",
)) + "\n".join(method(BRIDGE, signature) for signature in (
"const char* MQTTBridge::tlsErrorStr(",
"void MQTTBridge::formatSlotDiagReply(",
)) + MAIN
with tempfile.TemporaryDirectory(prefix="meshcore-mqtt-publish-ack-") as directory:
fixture = Path(directory)
(fixture / "Arduino.h").write_text(ARDUINO_HEADER, encoding="ascii")
(fixture / "mqtt_client.h").write_text(MQTT_HEADER, encoding="ascii")
(fixture / "esp_crt_bundle.h").write_text("#pragma once\n", encoding="ascii")
test_source = fixture / "publish_ack.cpp"
test_source.write_text(source, encoding="ascii")
for idf in (4, 5):
with self.subTest(idf=idf):
executable = fixture / f"idf{idf}"
# publish() already compares the SDK's signed outbox size
# against size_t; this unrelated warning is outside the seam.
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-sign-compare",
"-DARDUINO_ARCH_ESP32=1", f"-DESP_IDF_VERSION_MAJOR={idf}",
"-I", str(fixture),
"-I", str(ROOT / "lib/PsychicMqttClient/src"),
"-I", str(ROOT / "src"), str(test_source), "-o", str(executable)]
result = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(executable)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()