Compare commits

...

3 Commits

Author SHA1 Message Date
Brendan O'Leary
9f4f71fabe
Add AP mode option for file transfers (#98)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* **What is the goal of this PR?** Adds WiFi Access Point (AP) mode
support for File Transfer, allowing the device to create its own WiFi
network that users can connect to directly - useful when no existing
WiFi network is available. And in my experience is faster when the
device is right next to your laptop (but maybe further from your wifi)

* **What changes are included?**
- New `NetworkModeSelectionActivity` - an interstitial screen asking
users to choose between:
- "Join a Network" - connects to an existing WiFi network (existing
behavior)
- "Create Hotspot" - creates a WiFi access point named
"CrossPoint-Reader"
  - Modified `CrossPointWebServerActivity` to:
    - Launch the network mode selection screen before proceeding
- Support starting an Access Point with mDNS (`crosspoint.local`) and
DNS server for captive portal behavior
    - Display appropriate connection info for both modes
- Modified `CrossPointWebServer` to support starting when WiFi is in AP
mode (not just STA connected mode)

## Additional Context

* **AP Mode Details**: The device creates an open WiFi network named
"CrossPoint-Reader". Once connected, users can access the file transfer
page at `http://crosspoint.local/` or `http://192.168.4.1/`
* **DNS Captive Portal**: A DNS server redirects all domain requests to
the device's IP, enabling captive portal behavior on some devices
* **mDNS**: Hostname resolution via `crosspoint.local` is enabled for
both AP and STA modes
* **No breaking changes**: The "Join a Network" option preserves the
existing WiFi connection flow
* **Memory impact**: Minimal - the AP mode uses roughly the same
resources as STA mode
2025-12-22 17:24:14 +11:00
Dave Allie
d23020e268
OTA updates (#96)
## Summary

* Adds support for OTA
  * Gets latest firmware bin from latest GitHub release
* I have noticed it be a little flaky unpacking the JSON and
occasionally failing to start
2025-12-22 17:16:46 +11:00
Dave Allie
f4491875ab
Thoroughly deinitialise expat parsers before freeing them (#103)
## Summary

* Thoroughly deinitialise expat parsers before freeing them
* Spotted a few crashes when de-initing expat parsers
2025-12-22 17:16:39 +11:00
16 changed files with 958 additions and 56 deletions

View File

@ -214,10 +214,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
FILE* file = fopen(filepath, "r");
if (!file) {
Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath);
@ -225,10 +221,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
do {
void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);
return false;
@ -238,6 +241,9 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
if (ferror(file)) {
Serial.printf("[%lu] [EHP] File read error\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);
return false;
@ -248,12 +254,18 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);
return false;
}
} while (!done);
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);

View File

@ -16,6 +16,8 @@ bool ContainerParser::setup() {
ContainerParser::~ContainerParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_ParserFree(parser);
parser = nullptr;
}

View File

@ -22,6 +22,9 @@ bool ContentOpfParser::setup() {
ContentOpfParser::~ContentOpfParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
}
@ -40,6 +43,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
if (!buf) {
Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
@ -51,6 +57,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;

View File

@ -18,6 +18,9 @@ bool TocNcxParser::setup() {
TocNcxParser::~TocNcxParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
}
@ -35,6 +38,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) {
Serial.printf("[%lu] [TOC] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
@ -44,6 +52,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}

View File

@ -1,12 +1,28 @@
#include "CrossPointWebServerActivity.h"
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <GfxRenderer.h>
#include <InputManager.h>
#include <WiFi.h>
#include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h"
#include "config.h"
namespace {
// AP Mode configuration
constexpr const char* AP_SSID = "CrossPoint-Reader";
constexpr const char* AP_PASSWORD = nullptr; // Open network for ease of use
constexpr const char* AP_HOSTNAME = "crosspoint";
constexpr uint8_t AP_CHANNEL = 1;
constexpr uint8_t AP_MAX_CONNECTIONS = 4;
// DNS server for captive portal (redirects all DNS queries to our IP)
DNSServer* dnsServer = nullptr;
constexpr uint16_t DNS_PORT = 53;
} // namespace
void CrossPointWebServerActivity::taskTrampoline(void* param) {
auto* self = static_cast<CrossPointWebServerActivity*>(param);
self->displayTaskLoop();
@ -20,7 +36,9 @@ void CrossPointWebServerActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Reset state
state = WebServerActivityState::WIFI_SELECTION;
state = WebServerActivityState::MODE_SELECTION;
networkMode = NetworkMode::JOIN_NETWORK;
isApMode = false;
connectedIP.clear();
connectedSSID.clear();
lastHandleClientTime = 0;
@ -33,14 +51,12 @@ void CrossPointWebServerActivity::onEnter() {
&displayTaskHandle // Task handle
);
// Turn on WiFi immediately
Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis());
WiFi.mode(WIFI_STA);
// Launch WiFi selection subactivity
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
// Launch network mode selection subactivity
Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis());
enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); } // Cancel goes back to home
));
}
void CrossPointWebServerActivity::onExit() {
@ -53,14 +69,30 @@ void CrossPointWebServerActivity::onExit() {
// Stop the web server first (before disconnecting WiFi)
stopWebServer();
// Stop mDNS
MDNS.end();
// Stop DNS server if running (AP mode)
if (dnsServer) {
Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis());
dnsServer->stop();
delete dnsServer;
dnsServer = nullptr;
}
// CRITICAL: Wait for LWIP stack to flush any pending packets
Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis());
delay(500);
// Disconnect WiFi gracefully
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
delay(100); // Allow disconnect frame to be sent
if (isApMode) {
Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis());
WiFi.softAPdisconnect(true);
} else {
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
}
delay(100); // Allow disconnect frame to be sent
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis());
WiFi.mode(WIFI_OFF);
@ -89,6 +121,33 @@ void CrossPointWebServerActivity::onExit() {
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
}
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(),
mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot");
networkMode = mode;
isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
// Exit mode selection subactivity
exitActivity();
if (mode == NetworkMode::JOIN_NETWORK) {
// STA mode - launch WiFi selection
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());
WiFi.mode(WIFI_STA);
state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
} else {
// AP mode - start access point
state = WebServerActivityState::AP_STARTING;
updateRequired = true;
startAccessPoint();
}
}
void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected);
@ -96,17 +155,83 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
// Get connection info before exiting subactivity
connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP();
connectedSSID = WiFi.SSID().c_str();
isApMode = false;
exitActivity();
// Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME);
}
// Start the web server
startWebServer();
} else {
// User cancelled - go back
onGoBack();
// User cancelled - go back to mode selection
exitActivity();
state = WebServerActivityState::MODE_SELECTION;
enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); }));
}
}
void CrossPointWebServerActivity::startAccessPoint() {
Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap());
// Configure and start the AP
WiFi.mode(WIFI_AP);
delay(100);
// Start soft AP
bool apStarted;
if (AP_PASSWORD && strlen(AP_PASSWORD) >= 8) {
apStarted = WiFi.softAP(AP_SSID, AP_PASSWORD, AP_CHANNEL, false, AP_MAX_CONNECTIONS);
} else {
// Open network (no password)
apStarted = WiFi.softAP(AP_SSID, nullptr, AP_CHANNEL, false, AP_MAX_CONNECTIONS);
}
if (!apStarted) {
Serial.printf("[%lu] [WEBACT] ERROR: Failed to start Access Point!\n", millis());
onGoBack();
return;
}
delay(100); // Wait for AP to fully initialize
// Get AP IP address
const IPAddress apIP = WiFi.softAPIP();
char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", apIP[0], apIP[1], apIP[2], apIP[3]);
connectedIP = ipStr;
connectedSSID = AP_SSID;
Serial.printf("[%lu] [WEBACT] Access Point started!\n", millis());
Serial.printf("[%lu] [WEBACT] SSID: %s\n", millis(), AP_SSID);
Serial.printf("[%lu] [WEBACT] IP: %s\n", millis(), connectedIP.c_str());
// Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME);
} else {
Serial.printf("[%lu] [WEBACT] WARNING: mDNS failed to start\n", millis());
}
// Start DNS server for captive portal behavior
// This redirects all DNS queries to our IP, making any domain typed resolve to us
dnsServer = new DNSServer();
dnsServer->setErrorReplyCode(DNSReplyCode::NoError);
dnsServer->start(DNS_PORT, "*", apIP);
Serial.printf("[%lu] [WEBACT] DNS server started for captive portal\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap());
// Start the web server
startWebServer();
}
void CrossPointWebServerActivity::startWebServer() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
@ -150,6 +275,11 @@ void CrossPointWebServerActivity::loop() {
// Handle different states
if (state == WebServerActivityState::SERVER_RUNNING) {
// Handle DNS requests for captive portal (AP mode only)
if (isApMode && dnsServer) {
dnsServer->processNextRequest();
}
// Handle web server requests - call handleClient multiple times per loop
// to improve responsiveness and upload throughput
if (webServer && webServer->isRunning()) {
@ -193,35 +323,71 @@ void CrossPointWebServerActivity::displayTaskLoop() {
void CrossPointWebServerActivity::render() const {
// Only render our own UI when server is running
// WiFi selection handles its own rendering
// Subactivities handle their own rendering
if (state == WebServerActivityState::SERVER_RUNNING) {
renderer.clearScreen();
renderServerRunning();
renderer.displayBuffer();
} else if (state == WebServerActivityState::AP_STARTING) {
renderer.clearScreen();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, BOLD);
renderer.displayBuffer();
}
}
void CrossPointWebServerActivity::renderServerRunning() const {
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 5) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 30, "File Transfer", true, BOLD);
// Use consistent line spacing
constexpr int LINE_SPACING = 28; // Space between lines
std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD);
if (isApMode) {
// AP mode display - center the content block
const int startY = 55;
renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD);
std::string ssidInfo = "Network: " + connectedSSID;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network",
true, REGULAR);
// Show primary URL (hostname)
std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD);
// Show IP address as fallback
std::string ipUrl = "or http://" + connectedIP + "/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR);
} else {
// STA mode display (original behavior)
const int startY = 65;
std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD);
// Also show hostname URL
std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR);
}
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR);
}

View File

@ -7,12 +7,15 @@
#include <memory>
#include <string>
#include "NetworkModeSelectionActivity.h"
#include "activities/ActivityWithSubactivity.h"
#include "network/CrossPointWebServer.h"
// Web server activity states
enum class WebServerActivityState {
WIFI_SELECTION, // WiFi selection subactivity is active
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
WIFI_SELECTION, // WiFi selection subactivity is active (for Join Network mode)
AP_STARTING, // Starting Access Point mode
SERVER_RUNNING, // Web server is running and handling requests
SHUTTING_DOWN // Shutting down server and WiFi
};
@ -20,8 +23,10 @@ enum class WebServerActivityState {
/**
* CrossPointWebServerActivity is the entry point for file transfer functionality.
* It:
* - Immediately turns on WiFi and launches WifiSelectionActivity on enter
* - When WifiSelectionActivity completes successfully, starts the CrossPointWebServer
* - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP)
* - For STA mode: Launches WifiSelectionActivity to connect to an existing network
* - For AP mode: Creates an Access Point that clients can connect to
* - Starts the CrossPointWebServer when connected
* - Handles client requests in its loop() function
* - Cleans up the server and shuts down WiFi on exit
*/
@ -29,15 +34,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
WebServerActivityState state = WebServerActivityState::WIFI_SELECTION;
WebServerActivityState state = WebServerActivityState::MODE_SELECTION;
const std::function<void()> onGoBack;
// Network mode
NetworkMode networkMode = NetworkMode::JOIN_NETWORK;
bool isApMode = false;
// Web server - owned by this activity
std::unique_ptr<CrossPointWebServer> webServer;
// Server status
std::string connectedIP;
std::string connectedSSID;
std::string connectedSSID; // For STA mode: network name, For AP mode: AP name
// Performance monitoring
unsigned long lastHandleClientTime = 0;
@ -47,7 +56,9 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
void render() const;
void renderServerRunning() const;
void onNetworkModeSelected(NetworkMode mode);
void onWifiSelectionComplete(bool connected);
void startAccessPoint();
void startWebServer();
void stopWebServer();

View File

@ -0,0 +1,128 @@
#include "NetworkModeSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include "config.h"
namespace {
constexpr int MENU_ITEM_COUNT = 2;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"};
const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network",
"Create a WiFi network others can join"};
} // namespace
void NetworkModeSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<NetworkModeSelectionActivity*>(param);
self->displayTaskLoop();
}
void NetworkModeSelectionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Reset selection
selectedIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&NetworkModeSelectionActivity::taskTrampoline, "NetworkModeTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void NetworkModeSelectionActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void NetworkModeSelectionActivity::loop() {
// Handle back button - cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onCancel();
return;
}
// Handle confirm button - select current option
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT;
onModeSelected(mode);
return;
}
// Handle navigation
const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
updateRequired = true;
} else if (nextPressed) {
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
}
void NetworkModeSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void NetworkModeSelectionActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "File Transfer", true, BOLD);
// Draw subtitle
renderer.drawCenteredText(UI_FONT_ID, 50, "How would you like to connect?", true, REGULAR);
// Draw menu items centered on screen
constexpr int itemHeight = 50; // Height for each menu item (including description)
const int startY = (pageHeight - (MENU_ITEM_COUNT * itemHeight)) / 2 + 10;
for (int i = 0; i < MENU_ITEM_COUNT; i++) {
const int itemY = startY + i * itemHeight;
const bool isSelected = (i == selectedIndex);
// Draw selection highlight (black fill) for selected item
if (isSelected) {
renderer.fillRect(20, itemY - 2, pageWidth - 40, itemHeight - 6);
}
// Draw text: black=false (white text) when selected (on black background)
// black=true (black text) when not selected (on white background)
renderer.drawText(UI_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected);
renderer.drawText(SMALL_FONT_ID, 30, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected);
}
// Draw help text at bottom
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR);
renderer.displayBuffer();
}

View File

@ -0,0 +1,41 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "../Activity.h"
// Enum for network mode selection
enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT };
/**
* NetworkModeSelectionActivity presents the user with a choice:
* - "Join a Network" - Connect to an existing WiFi network (STA mode)
* - "Create Hotspot" - Create an Access Point that others can connect to (AP mode)
*
* The onModeSelected callback is called with the user's choice.
* The onCancel callback is called if the user presses back.
*/
class NetworkModeSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectedIndex = 0;
bool updateRequired = false;
const std::function<void(NetworkMode)> onModeSelected;
const std::function<void()> onCancel;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit NetworkModeSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(NetworkMode)>& onModeSelected,
const std::function<void()>& onCancel)
: Activity("NetworkModeSelection", renderer, inputManager), onModeSelected(onModeSelected), onCancel(onCancel) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -0,0 +1,242 @@
#include "OtaUpdateActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <WiFi.h>
#include "activities/network/WifiSelectionActivity.h"
#include "config.h"
#include "network/OtaUpdater.h"
void OtaUpdateActivity::taskTrampoline(void* param) {
auto* self = static_cast<OtaUpdateActivity*>(param);
self->displayTaskLoop();
}
void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
exitActivity();
if (!success) {
Serial.printf("[%lu] [OTA] WiFi connection failed, exiting\n", millis());
goBack();
return;
}
Serial.printf("[%lu] [OTA] WiFi connected, checking for update\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CHECKING_FOR_UPDATE;
xSemaphoreGive(renderingMutex);
updateRequired = true;
vTaskDelay(10 / portTICK_PERIOD_MS);
const auto res = updater.checkForUpdate();
if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update check failed: %d\n", millis(), res);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED;
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
}
if (!updater.isUpdateNewer()) {
Serial.printf("[%lu] [OTA] No new update available\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = NO_UPDATE;
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
}
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = WAITING_CONFIRMATION;
xSemaphoreGive(renderingMutex);
updateRequired = true;
}
void OtaUpdateActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
xTaskCreate(&OtaUpdateActivity::taskTrampoline, "OtaUpdateActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
// Turn on WiFi immediately
Serial.printf("[%lu] [OTA] Turning on WiFi...\n", millis());
WiFi.mode(WIFI_STA);
// Launch WiFi selection subactivity
Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
}
void OtaUpdateActivity::onExit() {
ActivityWithSubactivity::onExit();
// Turn off wifi
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
delay(100); // Allow disconnect frame to be sent
WiFi.mode(WIFI_OFF);
delay(100); // Allow WiFi hardware to fully power down
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void OtaUpdateActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void OtaUpdateActivity::render() {
if (subActivity) {
// Subactivity handles its own rendering
return;
}
float updaterProgress = 0;
if (state == UPDATE_IN_PROGRESS) {
Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.processedSize, updater.totalSize);
updaterProgress = static_cast<float>(updater.processedSize) / static_cast<float>(updater.totalSize);
// Only update every 2% at the most
if (static_cast<int>(updaterProgress * 50) == lastUpdaterPercentage / 2) {
return;
}
lastUpdaterPercentage = static_cast<int>(updaterProgress * 100);
}
const auto pageHeight = renderer.getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
renderer.clearScreen();
renderer.drawCenteredText(READER_FONT_ID, 10, "Update", true, BOLD);
if (state == CHECKING_FOR_UPDATE) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Checking for update...", true, BOLD);
renderer.displayBuffer();
return;
}
if (state == WAITING_CONFIRMATION) {
renderer.drawCenteredText(UI_FONT_ID, 200, "New update available!", true, BOLD);
renderer.drawText(UI_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION);
renderer.drawText(UI_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str());
renderer.drawRect(25, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Cancel")) / 2, pageHeight - 35,
"Cancel");
renderer.drawRect(130, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Update")) / 2, pageHeight - 35,
"Update");
renderer.displayBuffer();
return;
}
if (state == UPDATE_IN_PROGRESS) {
renderer.drawCenteredText(UI_FONT_ID, 310, "Updating...", true, BOLD);
renderer.drawRect(20, 350, pageWidth - 40, 50);
renderer.fillRect(24, 354, static_cast<int>(updaterProgress * static_cast<float>(pageWidth - 44)), 42);
renderer.drawCenteredText(UI_FONT_ID, 420, (std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str());
renderer.drawCenteredText(
UI_FONT_ID, 440, (std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str());
renderer.displayBuffer();
return;
}
if (state == NO_UPDATE) {
renderer.drawCenteredText(UI_FONT_ID, 300, "No update available", true, BOLD);
renderer.displayBuffer();
return;
}
if (state == FAILED) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Update failed", true, BOLD);
renderer.displayBuffer();
return;
}
if (state == FINISHED) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Update complete", true, BOLD);
renderer.drawCenteredText(UI_FONT_ID, 350, "Press and hold power button to turn back on");
renderer.displayBuffer();
state = SHUTTING_DOWN;
return;
}
}
void OtaUpdateActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (state == WAITING_CONFIRMATION) {
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = UPDATE_IN_PROGRESS;
xSemaphoreGive(renderingMutex);
updateRequired = true;
vTaskDelay(10 / portTICK_PERIOD_MS);
const auto res = updater.installUpdate([this](const size_t, const size_t) { updateRequired = true; });
if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED;
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
}
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FINISHED;
xSemaphoreGive(renderingMutex);
updateRequired = true;
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
goBack();
}
return;
}
if (state == FAILED) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
goBack();
}
return;
}
if (state == NO_UPDATE) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
goBack();
}
return;
}
if (state == SHUTTING_DOWN) {
ESP.restart();
}
}

View File

@ -0,0 +1,43 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "activities/ActivityWithSubactivity.h"
#include "network/OtaUpdater.h"
class OtaUpdateActivity : public ActivityWithSubactivity {
enum State {
WIFI_SELECTION,
CHECKING_FOR_UPDATE,
WAITING_CONFIRMATION,
UPDATE_IN_PROGRESS,
NO_UPDATE,
FAILED,
FINISHED,
SHUTTING_DOWN
};
// Can't initialize this to 0 or the first render doesn't happen
static constexpr unsigned int UNINITIALIZED_PERCENTAGE = 111;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
const std::function<void()> goBack;
State state = WIFI_SELECTION;
unsigned int lastUpdaterPercentage = UNINITIALIZED_PERCENTAGE;
OtaUpdater updater;
void onWifiSelectionComplete(bool success);
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render();
public:
explicit OtaUpdateActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& goBack)
: ActivityWithSubactivity("OtaUpdate", renderer, inputManager), goBack(goBack), updater() {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -4,16 +4,19 @@
#include <InputManager.h>
#include "CrossPointSettings.h"
#include "OtaUpdateActivity.h"
#include "config.h"
// Define the static settings list
namespace {
constexpr int settingsCount = 3;
constexpr int settingsCount = 4;
const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
{"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}},
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}};
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}},
{"Check for updates", SettingType::ACTION, nullptr, {}},
};
} // namespace
void SettingsActivity::taskTrampoline(void* param) {
@ -41,7 +44,7 @@ void SettingsActivity::onEnter() {
}
void SettingsActivity::onExit() {
Activity::onExit();
ActivityWithSubactivity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -54,6 +57,11 @@ void SettingsActivity::onExit() {
}
void SettingsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Handle actions with early return
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
toggleCurrentSetting();
@ -81,7 +89,7 @@ void SettingsActivity::loop() {
}
}
void SettingsActivity::toggleCurrentSetting() const {
void SettingsActivity::toggleCurrentSetting() {
// Validate index
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
return;
@ -96,6 +104,16 @@ void SettingsActivity::toggleCurrentSetting() const {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
} else if (setting.type == SettingType::ACTION) {
if (std::string(setting.name) == "Check for updates") {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, inputManager, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
} else {
// Only toggle if it's a toggle type and has a value pointer
return;
@ -107,7 +125,7 @@ void SettingsActivity::toggleCurrentSetting() const {
void SettingsActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
@ -152,6 +170,8 @@ void SettingsActivity::render() const {
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit");
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 30, CROSSPOINT_VERSION);
// Always use standard refresh for settings screen
renderer.displayBuffer();

View File

@ -3,16 +3,15 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
#include "activities/ActivityWithSubactivity.h"
class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM };
enum class SettingType { TOGGLE, ENUM, ACTION };
// Structure to hold setting information
struct SettingInfo {
@ -22,7 +21,7 @@ struct SettingInfo {
std::vector<std::string> enumValues;
};
class SettingsActivity final : public Activity {
class SettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
@ -32,11 +31,11 @@ class SettingsActivity final : public Activity {
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void toggleCurrentSetting() const;
void toggleCurrentSetting();
public:
explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome)
: Activity("Settings", renderer, inputManager), onGoHome(onGoHome) {}
: ActivityWithSubactivity("Settings", renderer, inputManager), onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -30,12 +30,22 @@ void CrossPointWebServer::begin() {
return;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis());
// Check if we have a valid network connection (either STA connected or AP mode)
const wifi_mode_t wifiMode = WiFi.getMode();
const bool isStaConnected = (wifiMode & WIFI_MODE_STA) && (WiFi.status() == WL_CONNECTED);
const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running
if (!isStaConnected && !isInApMode) {
Serial.printf("[%lu] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode,
WiFi.status());
return;
}
// Store AP mode flag for later use (e.g., in handleStatus)
apMode = isInApMode;
Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA");
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port);
server.reset(new WebServer(port));
@ -70,7 +80,9 @@ void CrossPointWebServer::begin() {
running = true;
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str());
// Show the correct IP based on network mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str());
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
}
@ -141,10 +153,14 @@ void CrossPointWebServer::handleNotFound() const {
}
void CrossPointWebServer::handleStatus() const {
// Get correct IP based on AP vs STA mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
String json = "{";
json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\",";
json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
json += "\"ip\":\"" + ipAddr + "\",";
json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\",";
json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode
json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"uptime\":" + String(millis() / 1000);
json += "}";

View File

@ -35,6 +35,7 @@ class CrossPointWebServer {
private:
std::unique_ptr<WebServer> server = nullptr;
bool running = false;
bool apMode = false; // true when running in AP mode, false for STA mode
uint16_t port = 80;
// File scanning

169
src/network/OtaUpdater.cpp Normal file
View File

@ -0,0 +1,169 @@
#include "OtaUpdater.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <Update.h>
#include <WiFiClientSecure.h>
namespace {
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
}
OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure);
client->setInsecure();
HTTPClient http;
Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), latestReleaseUrl);
http.begin(*client, latestReleaseUrl);
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("[%lu] [OTA] HTTP error: %d\n", millis(), httpCode);
http.end();
return HTTP_ERROR;
}
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, *client);
http.end();
if (error) {
Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str());
return JSON_PARSE_ERROR;
}
if (!doc["tag_name"].is<std::string>()) {
Serial.printf("[%lu] [OTA] No tag_name found\n", millis());
return JSON_PARSE_ERROR;
}
if (!doc["assets"].is<JsonArray>()) {
Serial.printf("[%lu] [OTA] No assets found\n", millis());
return JSON_PARSE_ERROR;
}
latestVersion = doc["tag_name"].as<std::string>();
for (int i = 0; i < doc["assets"].size(); i++) {
if (doc["assets"][i]["name"] == "firmware.bin") {
otaUrl = doc["assets"][i]["browser_download_url"].as<std::string>();
otaSize = doc["assets"][i]["size"].as<size_t>();
totalSize = otaSize;
updateAvailable = true;
break;
}
}
if (!updateAvailable) {
Serial.printf("[%lu] [OTA] No firmware.bin asset found\n", millis());
return NO_UPDATE;
}
Serial.printf("[%lu] [OTA] Found update: %s\n", millis(), latestVersion.c_str());
return OK;
}
bool OtaUpdater::isUpdateNewer() {
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
return false;
}
// semantic version check (only match on 3 segments)
const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.')));
const auto updateMinor = stoi(
latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1));
const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1));
std::string currentVersion = CROSSPOINT_VERSION;
const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.')));
const auto currentMinor = stoi(currentVersion.substr(
currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1));
const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1));
if (updateMajor > currentMajor) {
return true;
}
if (updateMajor < currentMajor) {
return false;
}
if (updateMinor > currentMinor) {
return true;
}
if (updateMinor < currentMinor) {
return false;
}
if (updatePatch > currentPatch) {
return true;
}
return false;
}
const std::string& OtaUpdater::getLatestVersion() { return latestVersion; }
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
if (!isUpdateNewer()) {
return UPDATE_OLDER_ERROR;
}
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure);
client->setInsecure();
HTTPClient http;
Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), otaUrl.c_str());
http.begin(*client, otaUrl.c_str());
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("[%lu] [OTA] Download failed: %d\n", millis(), httpCode);
http.end();
return HTTP_ERROR;
}
// 2. Get length and stream
const size_t contentLength = http.getSize();
if (contentLength != otaSize) {
Serial.printf("[%lu] [OTA] Invalid content length\n", millis());
http.end();
return HTTP_ERROR;
}
// 3. Begin the ESP-IDF Update process
if (!Update.begin(otaSize)) {
Serial.printf("[%lu] [OTA] Not enough space. Error: %s\n", millis(), Update.errorString());
http.end();
return INTERNAL_UPDATE_ERROR;
}
this->totalSize = otaSize;
Serial.printf("[%lu] [OTA] Update started\n", millis());
Update.onProgress([this, onProgress](const size_t progress, const size_t total) {
this->processedSize = progress;
this->totalSize = total;
onProgress(progress, total);
});
const size_t written = Update.writeStream(*client);
http.end();
if (written == otaSize) {
Serial.printf("[%lu] [OTA] Successfully written %u bytes\n", millis(), written);
} else {
Serial.printf("[%lu] [OTA] Written only %u/%u bytes. Error: %s\n", millis(), written, otaSize,
Update.errorString());
return INTERNAL_UPDATE_ERROR;
}
if (Update.end() && Update.isFinished()) {
Serial.printf("[%lu] [OTA] Update complete\n", millis());
return OK;
} else {
Serial.printf("[%lu] [OTA] Error Occurred: %s\n", millis(), Update.errorString());
return INTERNAL_UPDATE_ERROR;
}
}

30
src/network/OtaUpdater.h Normal file
View File

@ -0,0 +1,30 @@
#pragma once
#include <functional>
#include <string>
class OtaUpdater {
bool updateAvailable = false;
std::string latestVersion;
std::string otaUrl;
size_t otaSize = 0;
public:
enum OtaUpdaterError {
OK = 0,
NO_UPDATE,
HTTP_ERROR,
JSON_PARSE_ERROR,
UPDATE_OLDER_ERROR,
INTERNAL_UPDATE_ERROR,
OOM_ERROR,
};
size_t processedSize = 0;
size_t totalSize = 0;
OtaUpdater() = default;
bool isUpdateNewer();
const std::string& getLatestVersion();
OtaUpdaterError checkForUpdate();
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
};