Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/PoolMonitor/MqttUtils.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter
//
// SPDX-License-Identifier: MIT

/**
* @file MqttUtils.cpp
* @brief Implementation of MQTT payload parsing helpers.
*/

#include "MqttUtils.hpp"

#include <Arduino.h>
#include <cctype>

namespace PoolMonitor {

bool equalsIgnoreCaseAscii(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
while (*lhs != '\0' && *rhs != '\0') {
if (std::tolower(static_cast<unsigned char>(*lhs)) !=
std::tolower(static_cast<unsigned char>(*rhs))) {
return false;
}
lhs++;
rhs++;
}
return *lhs == *rhs;
}

bool parseHomeAssistantBoolState(const char* value) {
if (value == nullptr) {
return false;
}
if (equalsIgnoreCaseAscii(value, "true")
|| equalsIgnoreCaseAscii(value, "on")
|| equalsIgnoreCaseAscii(value, "1")) {
return true;
}
if (equalsIgnoreCaseAscii(value, "false")
|| equalsIgnoreCaseAscii(value, "off")
|| equalsIgnoreCaseAscii(value, "0")) {
return false;
}
Serial.printf("⚠️\tUnexpected boolean MQTT payload: %s (defaulting to false)\n", value);
return false;
}

} // namespace PoolMonitor
28 changes: 28 additions & 0 deletions src/PoolMonitor/MqttUtils.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter
//
// SPDX-License-Identifier: MIT

/**
* @file MqttUtils.hpp
* @brief Utility helpers for MQTT payload parsing in the Pool Monitor.
*/

#pragma once

namespace PoolMonitor {

/**
* @brief Case-insensitive ASCII string comparison.
* @return true when both strings are equal (ignoring ASCII case).
*/
bool equalsIgnoreCaseAscii(const char* lhs, const char* rhs);

/**
* @brief Parse boolean MQTT state payloads used by Home Assistant topics.
*
* Accepts "true"/"false" (case-insensitive), "on"/"off", "1"/"0".
* Logs a warning for unexpected values and defaults to false.
*/
bool parseHomeAssistantBoolState(const char* value);

} // namespace PoolMonitor
90 changes: 59 additions & 31 deletions src/PoolMonitor/OtaUpdater.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -341,54 +341,63 @@ bool OtaUpdater::isNewerVersion(const String &current, const String &latest) {
return false;
}

// ── OTA Download + Flash ──
// ── OTA: download response into Update stream ──

bool OtaUpdater::downloadAndApply(const String &url) {
WiFiClientSecure client;
client.setCACert(kGitHubRootCA);
client.setTimeout(10000);

HTTPClient http;
/**
* @brief Perform HTTP GET on the firmware URL and validate the response.
* @param[in] url Firmware download URL.
* @param[out] http HTTPClient to reuse (already connected).
* @param[out] totalSize Expected download size (from Content-Length).
* @param[out] stream WiFiClient stream pointer for reading the body.
* @return true if the response is valid (HTTP 200, non-empty content).
*/
static bool openFirmwareDownload(const String &url, HTTPClient &http,
WiFiClientSecure &client,
int &totalSize, WiFiClient *&stream) {
http.begin(client, url);
http.setUserAgent("PoolMonitor/1.0");
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);

int httpCode = http.GET();
if (httpCode != 200) {
Serial.printf("🛑\tOTA: Download returned HTTP %d\n", httpCode);
http.end();
return false;
}

int totalSize = http.getSize();
totalSize = http.getSize();
if (totalSize <= 0) {
Serial.println("🛑\tOTA: Invalid content size");
http.end();
return false;
}

Serial.printf("⬇️\tOTA: Download size: %d bytes\n", totalSize);

stream = http.getStreamPtr();
return true;
}

/**
* @brief Stream firmware data from WiFiClient into the Update object.
* @param stream WiFiClient with firmware body ready to read.
* @param totalSize Expected total download size.
* @param[out] progress Optional progress pointer (0-100 percentage, can be nullptr).
* @return Number of bytes successfully written to Update, or 0 on failure.
*/
static int streamToUpdate(WiFiClient *stream, int totalSize, int *progress) {
if (!Update.begin(totalSize)) {
Serial.printf("🛑\tOTA: Update.begin() failed: %s\n", Update.errorString());
http.end();
return false;
return 0;
}

// Stream download in chunks with stall protection
WiFiClient *stream = http.getStreamPtr();
uint8_t buffer[kOtaBufferSize];
uint8_t buffer[OtaUpdater::kOtaBufferSize];
int totalRead = 0;
unsigned long lastProgressMs = millis();

while (http.connected() && totalRead < totalSize) {
// Abort if no progress for kDownloadTimeoutMs (stalled connection)
if (millis() - lastProgressMs > kDownloadTimeoutMs) {
while (stream->connected() && totalRead < totalSize) {
if (millis() - lastProgressMs > OtaUpdater::kDownloadTimeoutMs) {
Serial.printf("🛑\tOTA: Download stalled for %lu ms, aborting (%d/%d)\n",
kDownloadTimeoutMs, totalRead, totalSize);
OtaUpdater::kDownloadTimeoutMs, totalRead, totalSize);
Update.end(false);
http.end();
return false;
return 0;
}

size_t available = stream->available();
Expand All @@ -407,21 +416,43 @@ bool OtaUpdater::downloadAndApply(const String &url) {
if (written != read) {
Serial.printf("🛑\tOTA: Write error at byte %d: %s\n", totalRead, Update.errorString());
Update.end(false);
http.end();
return false;
return 0;
}

totalRead += read;
lastProgressMs = millis();
progress_ = (totalRead * 100) / totalSize;
Serial.printf("⬇️\tOTA: %d%% (%d/%d)\n", progress_, totalRead, totalSize);
if (progress) {
*progress = (totalRead * 100) / totalSize;
}
Serial.printf("⬇️\tOTA: %d%% (%d/%d)\n",
progress ? *progress : 0, totalRead, totalSize);
}

return totalRead;
}

bool OtaUpdater::downloadAndApply(const String &url) {
WiFiClientSecure client;
client.setCACert(kGitHubRootCA);
client.setTimeout(10000);

HTTPClient http;
http.setUserAgent("PoolMonitor/1.0");
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);

int totalSize = 0;
WiFiClient *stream = nullptr;

if (!openFirmwareDownload(url, http, client, totalSize, stream)) {
http.end();
return false;
}

int totalRead = streamToUpdate(stream, totalSize, &progress_);
http.end();

if (totalRead != totalSize) {
Serial.printf("🛑\tOTA: Incomplete download (%d / %d)\n", totalRead, totalSize);
Update.end(false);
return false;
}

Expand All @@ -432,17 +463,14 @@ bool OtaUpdater::downloadAndApply(const String &url) {

Serial.println("✅\tOTA: Update successful! Rebooting...");

// Close Preferences so NVS writes from this wake cycle are finalized
// before restart — matches the AGENTS.md rule to always call
// preferences.end() before ESP.restart() or deep sleep.
if (prefs_ != nullptr) {
prefs_->end();
}

Serial.flush();
delay(1000);
ESP.restart();
return true; // Never actually reached
return true;
}

} // namespace PoolMonitor
5 changes: 3 additions & 2 deletions src/PoolMonitor/OtaUpdater.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ class OtaUpdater {
/// Max milliseconds without progress before aborting a stalled download.
static constexpr unsigned long kDownloadTimeoutMs = 30000UL;

/// Buffer size for OTA download chunks.
static constexpr int kOtaBufferSize = 4096;

private:
// ── GitHub API ──
static bool fetchLatestRelease();
Expand All @@ -111,8 +114,6 @@ class OtaUpdater {
static bool updateAvailable_;
static bool updateInProgress_;
static int progress_;

static constexpr int kOtaBufferSize = 4096;
};

} // namespace PoolMonitor
Loading
Loading