Files
MeshCore/test/test_mqtt_network_timeout.py
T

380 lines
18 KiB
Python

#!/usr/bin/env python3
"""Exercise production MQTT configuration for both SDK layouts and RAM profiles.
The real optimizer and adapter setters run on the host. SDK field layouts are
stand-ins; this checks the timeout written to SDK configuration, not a handshake.
"""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_mqtt_transport_results import method
from test_mqtt_publish_ack import (ARDUINO_HEADER, MQTT_HEADER,
PREAMBLE as ADAPTER_PREAMBLE)
ROOT = Path(__file__).resolve().parents[1]
PREAMBLE = r'''
#include <cassert>
#include <cstddef>
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTConnectionPolicy.h"
struct esp_mqtt_client_config_t {
#if ESP_IDF_VERSION_MAJOR == 5
struct { int keepalive = 0, message_retransmit_timeout = 0; } session;
struct { int timeout_ms = 0; } network;
struct { int size = 0, out_size = 0; } buffer;
struct { int stack_size = 0, priority = 0; } task;
#else
int keepalive = 0, message_retransmit_timeout = 0;
int network_timeout_ms = 0, buffer_size = 0;
int task_stack = 0, task_prio = 0;
#endif
};
struct PsychicMqttClient {
esp_mqtt_client_config_t _mqtt_cfg;
bool _config_dirty = false;
size_t _outbox_limit = 0;
PsychicMqttClient& setKeepAlive(int);
PsychicMqttClient& setMessageRetransmitTimeout(int);
PsychicMqttClient& setBufferSize(int);
PsychicMqttClient& setNetworkTimeout(int);
PsychicMqttClient& setOutboxLimit(size_t);
PsychicMqttClient& setTaskStackAndPriority(int, int);
esp_mqtt_client_config_t* getMqttConfig();
};
struct MQTTBridge {
void optimizeMqttClientConfig(PsychicMqttClient*, bool);
};
'''
MAIN = r'''
int main() {
MQTTBridge bridge;
bridge.optimizeMqttClientConfig(nullptr, true);
PsychicMqttClient client;
// Reconfiguration must restore the connection budget after a shorter value,
// for both small user/password and large JWT CONNECT buffers.
for (bool needs_large : {false, true, false}) {
client.setNetworkTimeout(2500);
client.setTaskStackAndPriority(2048, 1);
client._config_dirty = false;
bridge.optimizeMqttClientConfig(&client, needs_large);
assert(client._config_dirty);
#if ESP_IDF_VERSION_MAJOR == 5
const int timeout = client._mqtt_cfg.network.timeout_ms;
const int buffer_size = client._mqtt_cfg.buffer.size;
const int keepalive = client._mqtt_cfg.session.keepalive;
const int stack = client._mqtt_cfg.task.stack_size;
const int priority = client._mqtt_cfg.task.priority;
assert(client._mqtt_cfg.buffer.out_size == buffer_size);
assert(client._mqtt_cfg.session.message_retransmit_timeout == 15000);
#else
const int timeout = client._mqtt_cfg.network_timeout_ms;
const int buffer_size = client._mqtt_cfg.buffer_size;
const int keepalive = client._mqtt_cfg.keepalive;
const int stack = client._mqtt_cfg.task_stack;
const int priority = client._mqtt_cfg.task_prio;
assert(client._mqtt_cfg.message_retransmit_timeout == 15000);
#endif
assert(timeout == 10000);
assert(timeout == MQTTConnectionPolicy::kNetworkTimeoutMs);
assert(stack == 6144 && priority == 5);
assert(stack == MQTTConnectionAdmission::kClientTaskStackBytes);
assert(priority == MQTTConnectionAdmission::kClientTaskPriority);
#ifdef BOARD_HAS_PSRAM
assert(buffer_size == 896 && keepalive == 45);
assert(client._outbox_limit == 16384);
#else
assert(buffer_size == 896 && keepalive == 75);
assert(client._outbox_limit == 8192);
#endif
}
}
'''
RECONFIGURE_PREAMBLE = r'''
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTConnectionPolicy.h"
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
#define pdMS_TO_TICKS(value) (value)
constexpr int RUNTIME_MQTT_SLOTS = 2, MQTT_AUTH_JWT = 1;
constexpr uint32_t MALLOC_CAP_INTERNAL = 1, MALLOC_CAP_DMA = 2;
static size_t dma_free = 100000, dma_largest = 20000;
size_t heap_caps_get_free_size(uint32_t caps) {
assert(caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); return dma_free;
}
size_t heap_caps_get_largest_free_block(uint32_t caps) {
assert(caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); return dma_largest;
}
struct { size_t getPsramSize() const {
#ifdef BOARD_HAS_PSRAM
return 2097152;
#else
return 0;
#endif
} } ESP;
constexpr const char* MQTT_PRESET_NONE = "none";
constexpr const char* MQTT_PRESET_CUSTOM = "custom";
struct MQTTPresetDef { const char* server_url; int auth_type; };
static const MQTTPresetDef plain_preset{"mqtt://broker", 0};
static const MQTTPresetDef jwt_preset{"mqtt://broker", MQTT_AUTH_JWT};
const MQTTPresetDef* findMQTTPreset(const char* name) {
if (strcmp(name, "plain") == 0) return &plain_preset;
if (strcmp(name, "jwt") == 0) return &jwt_preset;
return nullptr;
}
bool mqttPresetNeedsSlotCredentials(const MQTTPresetDef*) { return false; }
bool customEndpointComplete(const char* host, uint16_t port) { return host[0] && port; }
struct MQTTPrefs {
char mqtt_slot_host[RUNTIME_MQTT_SLOTS][64] = {"custom-broker", ""};
uint16_t mqtt_slot_port[RUNTIME_MQTT_SLOTS] = {1883, 0};
char mqtt_slot_username[RUNTIME_MQTT_SLOTS][32] = {"custom-user", ""};
char mqtt_slot_password[RUNTIME_MQTT_SLOTS][32] = {"custom-password", ""};
char mqtt_slot_audience[RUNTIME_MQTT_SLOTS][32] = {"custom-audience", ""};
};
struct MQTTBridge {
struct MQTTSlot {
PsychicMqttClient* client = nullptr;
bool enabled = false, connected = false, initial_connect_done = false;
const MQTTPresetDef* preset = nullptr;
char host[64] = {}, broker_uri[128] = {}, username[32] = {}, password[32] = {};
char audience[32] = {}, *auth_token = nullptr;
uint16_t port = 1883;
unsigned long token_expires_at = 0, last_token_renewal = 0;
unsigned long last_reconnect_attempt = 0, last_log_time = 0, last_deferred_log_ms = 0;
uint8_t reconnect_backoff = 0, max_backoff_failures = 0;
bool circuit_breaker_tripped = false;
} _slots[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _stop_requested{false}, _slot_attempt_pending[RUNTIME_MQTT_SLOTS] = {};
bool _slot_force_jwt_mint[RUNTIME_MQTT_SLOTS] = {};
bool _initialized = true;
int _max_active_slots = 2;
MQTTPrefs prefs;
MQTTPrefs* _obs = &prefs;
bool canActivateSlot(int) const { return true; }
bool isSlotReady(int, char*, size_t) const { return true; }
bool hasPendingSlotConnection() const;
bool canStartSlotConnection(int) const;
void optimizeMqttClientConfig(PsychicMqttClient*, bool);
void teardownSlot(int, bool force = false);
void applySlotPreset(int, const char*);
bool setupSlot(int index) {
auto& slot = _slots[index];
// The setup seam executes the actual optimizer, adapter init/start, and
// production admission. Token creation and network callbacks are seams.
if (!canStartSlotConnection(index)) return false;
if (!slot.client) slot.client = new PsychicMqttClient;
optimizeMqttClientConfig(slot.client,
(slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || slot.audience[0]);
slot.client->_mqtt_cfg.uri = "mqtt://broker";
slot.client->_mqtt_cfg.broker.address.uri = "mqtt://broker";
if (slot.client->connect() != ESP_OK) return false;
esp_mqtt_event_t event{MQTT_EVENT_CONNECTED, 0, false};
sdk_event_handler(sdk_handler_args, "MQTT", event.event_id, &event);
slot.initial_connect_done = slot.connected = true;
return true;
}
};
'''
RECONFIGURE_MAIN = r'''
int main() {
MQTTBridge bridge;
auto& slot = bridge._slots[0];
bridge.applySlotPreset(0, "plain");
assert(slot.client && slot.initial_connect_done && slot.client->isStarted());
assert(slot.client->_buffer_capacity == 896);
char token[8] = "old-jwt";
slot.auth_token = token;
bridge.applySlotPreset(0, "jwt");
assert(token[0] == '\0' && slot.preset == &jwt_preset);
assert(slot.client->_buffer_capacity == 896);
bridge.applySlotPreset(0, "plain");
assert(initialized_buffer_sizes == std::vector<int>{896});
assert(init_calls == 1 && destroy_calls == 0 && slot.client->_buffer_capacity == 896);
// A custom broker's audience requests JWT-size buffers through the same
// explicit-reconfiguration path and synchronizes its saved fields.
bridge.applySlotPreset(0, "custom");
assert(slot.preset == nullptr && slot.client->_buffer_capacity == 896);
assert(strcmp(slot.host, "custom-broker") == 0 && slot.port == 1883);
assert(strcmp(slot.audience, "custom-audience") == 0);
assert(strcmp(slot.username, "custom-user") == 0);
assert(strcmp(slot.password, "custom-password") == 0);
bridge.applySlotPreset(0, "none");
assert(!slot.enabled && !slot.initial_connect_done);
assert(slot.client && slot.client->isInitialized() && !slot.client->isStarted());
assert(init_calls == 1 && destroy_calls == 0);
// Reconfiguration retains the initialized client on both board profiles.
// It needs the stopped-task floor and never silently shrinks its buffers.
dma_free = 24575; dma_largest = 20000;
bridge.applySlotPreset(0, "plain");
assert(slot.client && !slot.client->isStarted() && !slot.initial_connect_done);
assert(init_calls == 1 && destroy_calls == 0);
dma_free = 24576;
assert(bridge.setupSlot(0));
assert(slot.client->_buffer_capacity == 896 && init_calls == 1);
const int destructions_before_guard = destroy_calls;
bridge.applySlotPreset(-1, "jwt");
bridge.applySlotPreset(RUNTIME_MQTT_SLOTS, "jwt");
bridge._stop_requested = true;
bridge.applySlotPreset(0, "jwt");
assert(destroy_calls == destructions_before_guard && slot.client->isStarted());
delete slot.client;
slot.client = nullptr;
}
'''
class MqttNetworkTimeoutTest(unittest.TestCase):
def fixture(self):
adapter = "lib/PsychicMqttClient/src/PsychicMqttClient.cpp"
source = PREAMBLE + "#include <initializer_list>\n"
source += "\n".join(method(adapter, signature) for signature in (
"PsychicMqttClient &PsychicMqttClient::setKeepAlive(",
"PsychicMqttClient &PsychicMqttClient::setMessageRetransmitTimeout(",
"PsychicMqttClient &PsychicMqttClient::setBufferSize(",
"PsychicMqttClient &PsychicMqttClient::setNetworkTimeout(",
"PsychicMqttClient &PsychicMqttClient::setOutboxLimit(",
"PsychicMqttClient &PsychicMqttClient::setTaskStackAndPriority(",
"esp_mqtt_client_config_t *PsychicMqttClient::getMqttConfig("))
source += method("src/helpers/bridges/MQTTBridge.cpp",
"void MQTTBridge::optimizeMqttClientConfig(")
return source + MAIN
def compile_run(self, source, expect_rejection=False):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-mqtt-timeout-") as temporary:
work = Path(temporary)
path = work / "config.cpp"
path.write_text(source, encoding="utf-8")
for major in (4, 5):
for psram in (False, True):
with self.subTest(idf_major=major, psram=psram):
binary = work / f"config-{major}-{int(psram)}"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter",
f"-DESP_IDF_VERSION_MAJOR={major}",
"-I", str(ROOT / "src"), str(path), "-o", str(binary)]
if psram:
command.insert(1, "-DBOARD_HAS_PSRAM=1")
compiled = subprocess.run(command, capture_output=True,
text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
result = subprocess.run([str(binary)], cwd=work, capture_output=True,
text=True, timeout=10)
if expect_rejection:
self.assertNotEqual(result.returncode, 0,
result.stdout + result.stderr)
self.assertIn("Assertion", result.stderr)
else:
self.assertEqual(result.returncode, 0,
result.stdout + result.stderr)
def test_production_optimizer_applies_connection_budget_to_every_config(self):
self.compile_run(self.fixture())
def test_production_preset_reconfiguration_retains_uniform_sdk_and_reassembly_buffers(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
adapter = "lib/PsychicMqttClient/src/PsychicMqttClient.cpp"
# Reuse the actual-header SDK seam; this bridge fixture needs the full
# preset reconfiguration path rather than the publish diagnostic shell.
preamble = ADAPTER_PREAMBLE.split("constexpr int RUNTIME_MQTT_SLOTS = 2;", 1)[0]
preamble = preamble.replace(
'void vTaskDelay(unsigned) { assert(false && "stub disconnect must deliver its event"); }',
'void vTaskDelay(unsigned ticks) { assert(ticks == 50); }')
source = "#define ESP_PLATFORM 1\n" + preamble + RECONFIGURE_PREAMBLE
source += "\n".join(method(adapter, signature) for signature in (
"PsychicMqttClient::PsychicMqttClient()",
"PsychicMqttClient::~PsychicMqttClient()",
"PsychicMqttClient &PsychicMqttClient::setKeepAlive(",
"PsychicMqttClient &PsychicMqttClient::setMessageRetransmitTimeout(",
"PsychicMqttClient &PsychicMqttClient::setBufferSize(",
"PsychicMqttClient &PsychicMqttClient::setNetworkTimeout(",
"PsychicMqttClient &PsychicMqttClient::setOutboxLimit(",
"PsychicMqttClient &PsychicMqttClient::setTaskStackAndPriority(",
"esp_mqtt_client_config_t *PsychicMqttClient::getMqttConfig(",
"bool PsychicMqttClient::connected()",
"esp_err_t PsychicMqttClient::applyConfig()",
"esp_err_t PsychicMqttClient::connect()",
"void PsychicMqttClient::disconnect()",
"void PsychicMqttClient::forceStop()",
"int PsychicMqttClient::subscribe(",
"void PsychicMqttClient::_onMqttEventStatic(",
"void PsychicMqttClient::_onMqttEvent(",
"void PsychicMqttClient::_onConnect(",
"void PsychicMqttClient::_onDisconnect(",
"void PsychicMqttClient::_onSubscribe(",
"void PsychicMqttClient::_onUnsubscribe(",
"void PsychicMqttClient::_onPublish(",
))
source += "\n".join(method("src/helpers/bridges/MQTTBridge.cpp", signature)
for signature in (
"bool MQTTBridge::hasPendingSlotConnection() const",
"bool MQTTBridge::canStartSlotConnection(int index) const",
"void MQTTBridge::optimizeMqttClientConfig(",
"void MQTTBridge::teardownSlot(",
"void MQTTBridge::applySlotPreset(",
)) + RECONFIGURE_MAIN
with tempfile.TemporaryDirectory(prefix="meshcore-mqtt-reconfigure-") as temporary:
work = Path(temporary)
(work / "Arduino.h").write_text(ARDUINO_HEADER, encoding="ascii")
(work / "mqtt_client.h").write_text(MQTT_HEADER, encoding="ascii")
(work / "esp_crt_bundle.h").write_text("#pragma once\n", encoding="ascii")
path = work / "reconfigure.cpp"
path.write_text(source, encoding="ascii")
for major in (4, 5):
for psram in (False, True):
with self.subTest(idf_major=major, psram=psram):
binary = work / f"reconfigure-{major}-{int(psram)}"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-DARDUINO_ARCH_ESP32=1",
f"-DESP_IDF_VERSION_MAJOR={major}",
"-I", str(work),
"-I", str(ROOT / "lib/PsychicMqttClient/src"),
"-I", str(ROOT / "src"), str(path), "-o", str(binary)]
if psram:
command.insert(1, "-DBOARD_HAS_PSRAM=1")
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_previous_publish_timeout_fails_configuration_regression(self):
source = self.fixture()
old = source.replace(
"client->setNetworkTimeout(MQTTConnectionPolicy::kNetworkTimeoutMs);",
"client->setNetworkTimeout(2500);", 1)
self.assertNotEqual(old, source)
self.compile_run(old, expect_rejection=True)
def test_production_stop_budget_covers_shared_network_timeout_for_every_slot(self):
bridge = ROOT / "src/helpers/bridges/MQTTBridge.cpp"
source = bridge.read_text(encoding="utf-8")
start = source.index("static const uint32_t MQTT_STOP_TIMEOUT_BASE_MS")
end = source.index("static inline uint32_t mqttStopTimeoutForSlots(int slots)")
fixture = '#include <cassert>\n#include "helpers/MQTTConnectionPolicy.h"\n'
fixture += source[start:end]
fixture += method("src/helpers/bridges/MQTTBridge.cpp",
"static inline uint32_t mqttStopTimeoutForSlots(int slots)")
fixture += r'''
int main() {
assert(mqttStopTimeoutForSlots(0) == 25000);
assert(mqttStopTimeoutForSlots(-1) == 25000);
assert(mqttStopTimeoutForSlots(2) == 35000);
assert(mqttStopTimeoutForSlots(5) == 65000);
assert(mqttStopTimeoutForSlots(6) == 75000);
assert(mqttStopTimeoutForSlots(6) > UINT16_MAX);
assert(mqttStopTimeoutForSlots(16) == 175000);
}
'''
self.compile_run(fixture)
if __name__ == "__main__":
unittest.main()