#include "CompanionMqttSetupPortal.h" #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE) #include #include #include #include #include #include #include #include namespace { static const char* NVS_NAMESPACE = "mesh-mqtt"; static const char* NVS_VERSION_KEY = "version"; static const char* NVS_PREFS_KEY = "prefs"; static const size_t MAX_FORM_BODY = 2048; struct PortalImpl { WiFiServer server{80}; MQTTPrefs* prefs = nullptr; MQTTPrefs page_prefs{}; TaskHandle_t task = nullptr; volatile bool* active = nullptr; volatile bool saved = false; portMUX_TYPE prefs_lock = portMUX_INITIALIZER_UNLOCKED; }; template static bool hasTerminator(const char (&value)[N]) { return memchr(value, 0, N) != nullptr; } static bool storedStringsAreValid(const MQTTPrefs& prefs) { if (!hasTerminator(prefs.mqtt_origin) || !hasTerminator(prefs.mqtt_iata) || !hasTerminator(prefs.wifi_ssid) || !hasTerminator(prefs.wifi_password) || !hasTerminator(prefs.timezone_string) || !hasTerminator(prefs.mqtt_owner_public_key) || !hasTerminator(prefs.mqtt_email) || !hasTerminator(prefs.mqtt_ntp_server) || !hasTerminator(prefs.snmp_community) || !hasTerminator(prefs.alert_psk_hex) || !hasTerminator(prefs.alert_hashtag) || !hasTerminator(prefs.alert_region)) { return false; } for (int i = 0; i < MAX_MQTT_SLOTS; i++) { if (!hasTerminator(prefs.mqtt_slot_preset[i]) || !hasTerminator(prefs.mqtt_slot_host[i]) || !hasTerminator(prefs.mqtt_slot_username[i]) || !hasTerminator(prefs.mqtt_slot_password[i]) || !hasTerminator(prefs.mqtt_slot_token[i]) || !hasTerminator(prefs.mqtt_slot_topic[i]) || !hasTerminator(prefs.mqtt_slot_audience[i])) { return false; } } return true; } static bool readLine(WiFiClient& client, char* line, size_t capacity) { size_t length = 0; const uint32_t deadline = millis() + 5000; while (client.connected()) { while (client.available()) { int c = client.read(); if (c < 0) break; if (c == '\n') { if (length && line[length - 1] == '\r') length--; line[length] = 0; return true; } if (length + 1 < capacity) line[length++] = static_cast(c); } if (static_cast(millis() - deadline) >= 0) break; delay(1); } return false; } static int hexValue(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; return -1; } static bool decodeFormValue(const char* encoded, size_t encoded_len, char* output, size_t output_size) { if (!output || output_size == 0) return false; size_t out = 0; for (size_t i = 0; i < encoded_len; i++) { char c = encoded[i]; if (c == '+') { c = ' '; } else if (c == '%' && i + 2 < encoded_len) { const int hi = hexValue(encoded[i + 1]); const int lo = hexValue(encoded[i + 2]); if (hi < 0 || lo < 0) return false; c = static_cast((hi << 4) | lo); i += 2; } if (c == 0 || out + 1 >= output_size) return false; output[out++] = c; } output[out] = 0; return true; } static bool getFormField(const char* body, const char* name, char* output, size_t output_size) { const size_t name_len = strlen(name); const char* field = body; while (field && *field) { const char* end = strchr(field, '&'); const size_t field_len = end ? static_cast(end - field) : strlen(field); if (field_len >= name_len + 1 && field[name_len] == '=' && strncmp(field, name, name_len) == 0) { return decodeFormValue(field + name_len + 1, field_len - name_len - 1, output, output_size); } field = end ? end + 1 : nullptr; } if (output && output_size) output[0] = 0; return false; } static bool hasFormField(const char* body, const char* name) { char value[8]; return getFormField(body, name, value, sizeof(value)); } static void sendHeader(WiFiClient& client, int code, const char* reason, const char* content_type = "text/html") { client.printf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\n" "Connection: close\r\nCache-Control: no-store\r\n\r\n", code, reason, content_type); } static void printHtmlEscaped(WiFiClient& client, const char* value) { if (!value) return; while (*value) { switch (*value++) { case '&': client.print(F("&")); break; case '<': client.print(F("<")); break; case '>': client.print(F(">")); break; case '\"': client.print(F(""")); break; case '\'': client.print(F("'")); break; default: client.write(static_cast(value[-1])); break; } } } static void printValueAttribute(WiFiClient& client, const char* value) { client.print(F(" value=\"")); printHtmlEscaped(client, value); client.print('\"'); } static void snapshotPagePrefs(PortalImpl* impl, MQTTPrefs& snapshot) { portENTER_CRITICAL(&impl->prefs_lock); snapshot = impl->page_prefs; portEXIT_CRITICAL(&impl->prefs_lock); } static uint16_t presetPort(const MQTTPresetDef& preset) { const char* scheme = strstr(preset.server_url, "://"); if (!scheme) return 0; const char* host = scheme + 3; const char* colon = strchr(host, ':'); if (colon) { char* end = nullptr; const unsigned long port = strtoul(colon + 1, &end, 10); if (end != colon + 1 && port <= 65535) return static_cast(port); } if (strncmp(preset.server_url, "mqtt://", 7) == 0) return 1883; if (strncmp(preset.server_url, "mqtts://", 8) == 0) return 8883; if (strncmp(preset.server_url, "ws://", 5) == 0) return 80; return 443; } static void sendConfigPage(WiFiClient& client, const MQTTPrefs& prefs) { const char* selected_preset = prefs.mqtt_slot_preset[0][0] ? prefs.mqtt_slot_preset[0] : MQTT_PRESET_CUSTOM; sendHeader(client, 200, "OK"); client.print(F(R"HTML( MeshCore MQTT setup

MeshCore companion MQTT setup

Configure or update the MQTT destination for this companion.

Selecting a preset fills its endpoint details. Built-in presets use their compiled endpoint and authentication settings.
Identity and routing )HTML")); client.print(F("\n A three-letter IATA code is required unless a custom topic template or MeshRank token supplies the route. )HTML")); client.print(F("\n
Custom broker or preset credentials )HTML")); client.print(F("\n\n", prefs.mqtt_slot_port[0]); client.print(F("\n\n")); if (prefs.mqtt_slot_password[0][0]) { client.print(F("\n")); } client.print(F("\n\n
Publish )HTML")); client.printf("\n", prefs.mqtt_status_enabled ? " checked" : ""); client.printf("\n", prefs.mqtt_packets_enabled ? " checked" : ""); client.printf("\n", prefs.mqtt_raw_enabled ? " checked" : ""); client.printf("\n", prefs.mqtt_rx_enabled ? " checked" : ""); client.print(F("

This page remains available on the joined WiFi network at this node's IP address.

)HTML")); } static void sendMessage(WiFiClient& client, int code, const char* reason, const char* heading, const char* message) { sendHeader(client, code, reason); client.printf("" "

%s

%s

", heading, message); client.print("

Return to MQTT settings

"); } static bool isThreeLetterCode(const char* value) { if (!value || strlen(value) != 3) return false; for (int i = 0; i < 3; i++) { if (!isalpha(static_cast(value[i]))) return false; } return strcasecmp(value, "XXX") != 0; } static void copyString(char* dest, size_t dest_size, const char* src) { strncpy(dest, src ? src : "", dest_size - 1); dest[dest_size - 1] = 0; } static bool populatePrefs(const char* body, MQTTPrefs& prefs, char* error, size_t error_size) { char previous_preset[sizeof(prefs.mqtt_slot_preset[0])]; char previous_password[sizeof(prefs.mqtt_slot_password[0])]; char preset[24]; char origin[32]; char iata[8]; char owner[65]; char email[64]; char host[64]; char port_text[8]; char username[32]; char password[64]; char token[48]; char topic[96]; char audience[64]; char tx_text[4]; copyString(previous_preset, sizeof(previous_preset), prefs.mqtt_slot_preset[0]); copyString(previous_password, sizeof(previous_password), prefs.mqtt_slot_password[0]); if (!getFormField(body, "preset", preset, sizeof(preset))) { snprintf(error, error_size, "Select an MQTT preset or custom broker."); return false; } const MQTTPresetDef* preset_def = findMQTTPreset(preset); const bool custom = strcmp(preset, MQTT_PRESET_CUSTOM) == 0; if (!custom && !preset_def) { snprintf(error, error_size, "The selected MQTT preset is not valid."); return false; } getFormField(body, "origin", origin, sizeof(origin)); getFormField(body, "iata", iata, sizeof(iata)); getFormField(body, "owner", owner, sizeof(owner)); getFormField(body, "email", email, sizeof(email)); getFormField(body, "host", host, sizeof(host)); getFormField(body, "port", port_text, sizeof(port_text)); getFormField(body, "username", username, sizeof(username)); getFormField(body, "password", password, sizeof(password)); getFormField(body, "token", token, sizeof(token)); getFormField(body, "topic", topic, sizeof(topic)); getFormField(body, "audience", audience, sizeof(audience)); getFormField(body, "tx", tx_text, sizeof(tx_text)); if (!password[0] && !hasFormField(body, "clear_password") && strcmp(preset, previous_preset) == 0) { copyString(password, sizeof(password), previous_password); } char* port_end = nullptr; const unsigned long parsed_port = strtoul(port_text, &port_end, 10); if (!port_text[0] || !port_end || *port_end || parsed_port > 65535) { snprintf(error, error_size, "Enter a valid MQTT port from 0 through 65535."); return false; } if (custom && !host[0]) { snprintf(error, error_size, "A broker host or URI is required for a custom broker."); return false; } if (custom && parsed_port == 0 && strstr(host, "://") == nullptr) { snprintf(error, error_size, "A custom host without a URI scheme needs a nonzero port."); return false; } if (strcmp(preset, "meshrank") == 0 && !token[0]) { snprintf(error, error_size, "The MeshRank preset requires an account token."); return false; } if (preset_def && mqttPresetNeedsSlotCredentials(preset_def) && (!username[0] || !password[0])) { snprintf(error, error_size, "That preset requires a username and password."); return false; } const bool preset_needs_iata = preset_def && preset_def->topic_style == MQTT_TOPIC_MESHCORE; const bool custom_needs_iata = custom && !topic[0]; if (preset_needs_iata && !isThreeLetterCode(iata)) { snprintf(error, error_size, "That preset requires a valid three-letter IATA code."); return false; } if (custom_needs_iata && !isThreeLetterCode(iata)) { snprintf(error, error_size, "Enter a three-letter IATA code, or provide a custom topic template."); return false; } const uint8_t tx_mode = (strcmp(tx_text, "0") == 0) ? 0 : (strcmp(tx_text, "1") == 0) ? 1 : 2; char wifi_ssid[sizeof(prefs.wifi_ssid)]; char wifi_password[sizeof(prefs.wifi_password)]; copyString(wifi_ssid, sizeof(wifi_ssid), prefs.wifi_ssid); copyString(wifi_password, sizeof(wifi_password), prefs.wifi_password); applyMQTTDefaults(&prefs); copyString(prefs.wifi_ssid, sizeof(prefs.wifi_ssid), wifi_ssid); copyString(prefs.wifi_password, sizeof(prefs.wifi_password), wifi_password); for (int i = 0; i < MAX_MQTT_SLOTS; i++) { copyString(prefs.mqtt_slot_preset[i], sizeof(prefs.mqtt_slot_preset[i]), MQTT_PRESET_NONE); } copyString(prefs.mqtt_origin, sizeof(prefs.mqtt_origin), origin); copyString(prefs.mqtt_iata, sizeof(prefs.mqtt_iata), iata); for (size_t i = 0; prefs.mqtt_iata[i]; i++) { prefs.mqtt_iata[i] = static_cast(toupper(static_cast(prefs.mqtt_iata[i]))); } copyString(prefs.mqtt_owner_public_key, sizeof(prefs.mqtt_owner_public_key), owner); copyString(prefs.mqtt_email, sizeof(prefs.mqtt_email), email); copyString(prefs.mqtt_slot_preset[0], sizeof(prefs.mqtt_slot_preset[0]), preset); copyString(prefs.mqtt_slot_host[0], sizeof(prefs.mqtt_slot_host[0]), host); prefs.mqtt_slot_port[0] = static_cast(parsed_port); copyString(prefs.mqtt_slot_username[0], sizeof(prefs.mqtt_slot_username[0]), username); copyString(prefs.mqtt_slot_password[0], sizeof(prefs.mqtt_slot_password[0]), password); copyString(prefs.mqtt_slot_token[0], sizeof(prefs.mqtt_slot_token[0]), token); copyString(prefs.mqtt_slot_topic[0], sizeof(prefs.mqtt_slot_topic[0]), topic); copyString(prefs.mqtt_slot_audience[0], sizeof(prefs.mqtt_slot_audience[0]), audience); prefs.mqtt_status_enabled = hasFormField(body, "status") ? 1 : 0; prefs.mqtt_packets_enabled = hasFormField(body, "packets") ? 1 : 0; prefs.mqtt_raw_enabled = hasFormField(body, "raw") ? 1 : 0; prefs.mqtt_rx_enabled = hasFormField(body, "rx") ? 1 : 0; prefs.mqtt_tx_enabled = tx_mode; return true; } static bool handleClient(PortalImpl* impl, WiFiClient& client) { char line[256]; if (!readLine(client, line, sizeof(line))) return false; const bool post_save = strncmp(line, "POST /save ", 11) == 0; const bool get_request = strncmp(line, "GET ", 4) == 0; size_t content_length = 0; do { if (!readLine(client, line, sizeof(line))) return false; if (strncasecmp(line, "Content-Length:", 15) == 0) { content_length = static_cast(strtoul(line + 15, nullptr, 10)); } } while (line[0]); if (get_request) { sendConfigPage(client, impl->page_prefs); return false; } if (!post_save || content_length == 0 || content_length > MAX_FORM_BODY) { sendMessage(client, 400, "Bad Request", "Invalid request", "The MQTT form data was missing or too large."); return false; } char* body = static_cast(malloc(content_length + 1)); if (!body) { sendMessage(client, 503, "Service Unavailable", "Out of memory", "Reboot the companion and try again."); return false; } const size_t received = client.readBytes(body, content_length); body[received] = 0; if (received != content_length) { free(body); sendMessage(client, 408, "Request Timeout", "Incomplete request", "The complete MQTT form was not received."); return false; } MQTTPrefs candidate; snapshotPagePrefs(impl, candidate); char error[180]; const bool valid = populatePrefs(body, candidate, error, sizeof(error)); free(body); if (!valid) { sendMessage(client, 400, "Bad Request", "MQTT setup needs attention", error); return false; } if (!CompanionMqttSetupPortal::saveStoredConfig(candidate)) { sendMessage(client, 500, "Internal Server Error", "Could not save MQTT setup", "The configuration was valid but could not be written to flash."); return false; } portENTER_CRITICAL(&impl->prefs_lock); impl->page_prefs = candidate; impl->saved = true; portEXIT_CRITICAL(&impl->prefs_lock); sendMessage(client, 200, "OK", "MQTT setup saved", "The MQTT client is applying the new settings. This page remains available for later changes."); return true; } static void portalTask(void* arg) { PortalImpl* impl = static_cast(arg); while (impl->active && *impl->active && WiFi.status() == WL_CONNECTED && WiFi.getMode() == WIFI_STA) { WiFiClient client = impl->server.available(); if (client) { client.setTimeout(5000); handleClient(impl, client); client.stop(); } delay(5); } impl->server.stop(); impl->task = nullptr; if (impl->active) *impl->active = false; vTaskDelete(nullptr); } } // namespace CompanionMqttSetupPortal::CompanionMqttSetupPortal() : _active(false), _impl(nullptr) {} bool CompanionMqttSetupPortal::begin(MQTTPrefs* prefs) { if (_active) return true; if (!prefs || WiFi.status() != WL_CONNECTED || WiFi.getMode() != WIFI_STA) return false; PortalImpl* impl = static_cast(_impl); if (!impl) { impl = new PortalImpl(); if (!impl) return false; _impl = impl; } if (impl->task != nullptr) return false; impl->prefs = prefs; impl->active = &_active; impl->page_prefs = *prefs; impl->saved = false; impl->server.begin(); impl->server.setNoDelay(true); _active = true; if (xTaskCreatePinnedToCore(portalTask, "mqtt-setup", 8192, impl, 1, &impl->task, 0) != pdPASS) { _active = false; impl->server.stop(); impl->task = nullptr; return false; } Serial.printf("MQTT setup: reconnect to the joined WiFi and open http://%s/\n", WiFi.localIP().toString().c_str()); return true; } void CompanionMqttSetupPortal::stop() { _active = false; PortalImpl* impl = static_cast(_impl); if (impl) impl->server.stop(); } bool CompanionMqttSetupPortal::loop() { PortalImpl* impl = static_cast(_impl); if (!impl) return false; bool saved = false; portENTER_CRITICAL(&impl->prefs_lock); if (impl->saved && impl->prefs) { *impl->prefs = impl->page_prefs; impl->saved = false; saved = true; } portEXIT_CRITICAL(&impl->prefs_lock); return saved; } bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) { Preferences nvs; if (!nvs.begin(NVS_NAMESPACE, true)) return false; const uint16_t version = nvs.getUShort(NVS_VERSION_KEY, 0); const size_t length = nvs.getBytesLength(NVS_PREFS_KEY); const bool shape_ok = version == MQTT_PREFS_VERSION && length == sizeof(MQTTPrefs); const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &prefs, sizeof(prefs)) : 0; nvs.end(); if (read != sizeof(prefs)) return false; if (!storedStringsAreValid(prefs) || prefs.mqtt_status_enabled > 1 || prefs.mqtt_packets_enabled > 1 || prefs.mqtt_raw_enabled > 1 || prefs.mqtt_rx_enabled > 1 || prefs.mqtt_tx_enabled > 2 || prefs.wifi_power_save > 2) { return false; } const char* preset = prefs.mqtt_slot_preset[0]; const bool custom = strcmp(preset, MQTT_PRESET_CUSTOM) == 0; const MQTTPresetDef* preset_def = custom ? nullptr : findMQTTPreset(preset); if (!preset[0] || (!custom && !preset_def)) return false; if (custom) { const char* host = prefs.mqtt_slot_host[0]; if (!host[0] || (prefs.mqtt_slot_port[0] == 0 && strstr(host, "://") == nullptr) || (!prefs.mqtt_slot_topic[0][0] && !isThreeLetterCode(prefs.mqtt_iata))) { return false; } } else { if (preset_def->topic_style == MQTT_TOPIC_MESHCORE && !isThreeLetterCode(prefs.mqtt_iata)) return false; if (preset_def->topic_style == MQTT_TOPIC_MESHRANK && !prefs.mqtt_slot_token[0][0]) return false; if (mqttPresetNeedsSlotCredentials(preset_def) && (!prefs.mqtt_slot_username[0][0] || !prefs.mqtt_slot_password[0][0])) { return false; } } return true; } bool CompanionMqttSetupPortal::saveStoredConfig(const MQTTPrefs& prefs) { Preferences nvs; if (!nvs.begin(NVS_NAMESPACE, false)) return false; const size_t written = nvs.putBytes(NVS_PREFS_KEY, &prefs, sizeof(prefs)); const size_t version_written = nvs.putUShort(NVS_VERSION_KEY, MQTT_PREFS_VERSION); nvs.end(); return written == sizeof(prefs) && version_written == sizeof(uint16_t); } #endif