Compare commits

...

12 Commits

Author SHA1 Message Date
Daniel Poulter
06f9784b5c
Merge e4bfcf0d77 into 21277e03eb 2026-01-15 08:53:35 -05:00
Luke Stein
21277e03eb
docs: Update User Guide to reflect release 0.14.0 (#376)
Some checks failed
CI / build (push) Has been cancelled
2026-01-15 23:27:17 +11:00
Luke Stein
4eef2b5793
feat: Add MAC address display to WiFi Networks screen (#381)
## Summary

* Implements #380, allowing the user to see the device's MAC address in
order to register on wifi networks

## Additional Context

* Although @markatlnk suggested showing on the settings screen, I
implemented display at the bottom of the WiFi Networks selection screen
(accessed via "File Transfer" > "Join a Network") since I think it makes
more sense there.
* Tested on my own device


![IMG_2873](https://github.com/user-attachments/assets/b82a20dc-41a0-4b21-81f1-20876aa2c6b0)


---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-15 23:26:39 +11:00
Nathan James
5a55fa1c6e
fix: also apply longPressChapterSkip setting to xtc reader (#378)
## Summary

* This builds upon the helpful PR
https://github.com/crosspoint-reader/crosspoint-reader/pull/341, and
adds support for the setting to also apply to the XTC reader, which I
believed has just been missed and was not intentionally left out.
* XTC does not have chapter support yet, but it does skip 10 pages when
long-pressed, and so I think this is useful.

---

### AI Usage

Did you use AI tools to help write this code? No
2026-01-15 23:25:18 +11:00
Maeve Andrews
c98ba142e8
fix: draw button hints correctly if orientation is not portrait (#363)
~~Quick~~ fix for
https://github.com/daveallie/crosspoint-reader/issues/362

(this just applies to the chapter selection menu:)

~~If the orientation is portrait, hints as we know them make sense to
draw. If the orientation is inverted, we'd have to change the order of
the labels (along with everything's position), and if it's one of the
landscape choices, we'd have to render the text and buttons vertically.
All those other cases will be more complicated.~~

~~Punt on this for now by only rendering if portrait.~~

Update: this now draws the hints at the physical button position no
matter what the orientation is, by temporarily changing orientation to
portrait.

---------

Co-authored-by: Maeve Andrews <maeve@git.mail.maeveandrews.com>
2026-01-15 23:23:36 +11:00
Jonas Diemer
c1c94c0112
Feature: Show img alt text (#168)
Let's start small by showing the ALT text of IMG. This is rudimentary,
but avoids those otherwise completely blank chapters.

I feel we will need this even when we can render images if that
rendering takes >1s - I would then prefer rendering optional and showing
the ALT text first.
2026-01-15 23:21:46 +11:00
Dave Allie
eb84bcee7c
chore: Pin links2004/WebSockets version 2026-01-15 23:15:30 +11:00
efenner
d45f355e87
feat: Add EPUB table omitted placeholder (#372)
## Summary

* **What is the goal of this PR?**: Fix the bug I reported in
https://github.com/daveallie/crosspoint-reader/issues/292
* **What changes are included?**: Instead of silently dropping table
content in EPUBs., replace with an italicized '[Table omitted]' message
where tables appear.

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY **_

---------

Co-authored-by: Evan Fenner <evan@evanfenner.com>
Co-authored-by: Warp <agent@warp.dev>
2026-01-15 23:14:59 +11:00
dpoulter
e4bfcf0d77 feature: adding a default wifi option 2026-01-12 12:28:58 +01:00
dpoulter
a2d4e6936f feature: adding a default wifi option 2026-01-11 10:43:27 +01:00
dpoulter
145c7338a9 feature: adding a default wifi option 2026-01-11 10:37:00 +01:00
dpoulter
cd1a441d2b feature: adding a default wifi option 2026-01-11 10:29:48 +01:00
17 changed files with 734 additions and 49 deletions

View File

@ -96,6 +96,10 @@ The Settings screen allows you to configure the device's behavior. There are a f
- Left, Right, Back, Confirm
- Left, Back, Confirm, Right
- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading.
- **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter.
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
- "Page Scroll" - Long-pressing scrolls a page up/down
- Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading.
- **Reader Font Family**: Choose the font used for reading:
- "Bookerly" (default) - Amazon's reading font
- "Noto Sans" - Google's sans-serif font
@ -144,6 +148,9 @@ If the **Short Power Button Click** setting is set to "Page Turn", you can also
* **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release.
* **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release.
This feature can be disabled in **[Settings](#35-settings)** to help avoid changing chapters by mistake.
### System Navigation
* **Return to Book Selection:** Press **Back** to close the book and return to the **[Book Selection](#32-book-selection)** screen.
* **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.

View File

@ -25,7 +25,7 @@ constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]);
const char* IMAGE_TAGS[] = {"img"};
constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);
const char* SKIP_TAGS[] = {"head", "table"};
const char* SKIP_TAGS[] = {"head"};
constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]);
bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; }
@ -63,13 +63,44 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return;
}
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// TODO: Start processing image tags
// Special handling for tables - show placeholder text instead of dropping silently
if (strcmp(name, "table") == 0) {
// Add placeholder text
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
if (self->currentTextBlock) {
self->currentTextBlock->addWord("[Table omitted]", EpdFontFamily::ITALIC);
}
// Skip table contents
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// TODO: Start processing image tags
std::string alt;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "alt") == 0) {
alt = "[Image: " + std::string(atts[i + 1]) + "]";
}
}
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
self->depth += 1;
self->characterData(userData, alt.c_str(), alt.length());
} else {
// Skip for now
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
}
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
// start skip
self->skipUntilDepth = self->depth;

View File

@ -468,7 +468,10 @@ int GfxRenderer::getLineHeight(const int fontId) const {
}
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
const char* btn4) {
const Orientation orig_orientation = getOrientation();
setOrientation(Orientation::Portrait);
const int pageHeight = getScreenHeight();
constexpr int buttonWidth = 106;
constexpr int buttonHeight = 40;
@ -481,12 +484,15 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
// Only draw if the label is non-empty
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int x = buttonPositions[i];
fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight);
const int textWidth = getTextWidth(fontId, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]);
}
}
setOrientation(orig_orientation);
}
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {

View File

@ -84,7 +84,7 @@ class GfxRenderer {
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4);
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
private:

View File

@ -45,9 +45,9 @@ lib_deps =
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
ArduinoJson @ 7.4.2
QRCode @ 0.0.1
links2004/WebSockets @ ^2.4.1
bblanchon/ArduinoJson @ 7.4.2
ricmoo/QRCode @ 0.0.1
links2004/WebSockets @ 2.7.3
[env:default]
extends = base

View File

@ -3,6 +3,11 @@
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include <WiFi.h>
#include <freertos/task.h>
#include "activities/ActivityWithSubactivity.h"
#include "activities/network/WifiSelectionActivity.h"
// Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance;
@ -53,6 +58,10 @@ bool WifiCredentialStore::saveToFile() const {
serialization::writeString(file, obfuscatedPwd);
}
// Write default SSID
serialization::writeString(file, defaultSSID);
Serial.printf("[%lu] [WCS] Saving default SSID: %s\n", millis(), defaultSSID.c_str());
file.close();
Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size());
return true;
@ -95,6 +104,27 @@ bool WifiCredentialStore::loadFromFile() {
credentials.push_back(cred);
}
// Try to read default SSID if it exists
defaultSSID.clear();
if (file.available() >= 4) {
const uint32_t posBefore = file.position();
uint32_t len = 0;
serialization::readPod(file, len);
if (file.available() >= len && len <= 64) {
defaultSSID.resize(len);
const size_t bytesRead = file.read(reinterpret_cast<uint8_t*>(&defaultSSID[0]), len);
if (bytesRead == len) {
Serial.printf("[%lu] [WCS] Loaded default SSID: %s\n", millis(), defaultSSID.c_str());
} else {
file.seek(posBefore);
defaultSSID.clear();
}
} else {
file.seek(posBefore);
}
}
file.close();
Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size());
return true;
@ -127,6 +157,9 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) {
[&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
if (cred != credentials.end()) {
credentials.erase(cred);
if (defaultSSID == ssid) {
defaultSSID.clear();
}
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
return saveToFile();
}
@ -148,6 +181,107 @@ bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { re
void WifiCredentialStore::clearAll() {
credentials.clear();
defaultSSID.clear();
saveToFile();
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis());
}
void WifiCredentialStore::setDefaultSSID(const std::string& ssid) {
defaultSSID = ssid;
saveToFile();
Serial.printf("[%lu] [WCS] Set default SSID: %s\n", millis(), ssid.c_str());
}
bool WifiCredentialStore::connectToDefaultWifi(int timeoutMs) const {
if (defaultSSID.empty()) {
Serial.printf("[%lu] [WCS] No default SSID set\n", millis());
return false;
}
const auto* cred = findCredential(defaultSSID);
if (!cred) {
return false;
}
// Quick check: scan to see if the SSID is available before attempting connection
WiFi.mode(WIFI_STA);
WiFi.disconnect(false);
delay(100);
Serial.printf("[%lu] [WCS] Scanning for SSID: %s\n", millis(), defaultSSID.c_str());
WiFi.scanNetworks(false);
const unsigned long scanStart = millis();
int16_t scanResult = WiFi.scanComplete();
while (scanResult == WIFI_SCAN_RUNNING && millis() - scanStart < 3000) {
delay(100);
scanResult = WiFi.scanComplete();
}
if (scanResult > 0) {
bool ssidFound = false;
for (int i = 0; i < scanResult; i++) {
std::string scannedSSID = WiFi.SSID(i).c_str();
if (scannedSSID == defaultSSID) {
ssidFound = true;
break;
}
}
WiFi.scanDelete();
if (!ssidFound) {
Serial.printf("[%lu] [WCS] SSID not found in scan results, skipping connection attempt\n", millis());
return false;
}
} else {
WiFi.scanDelete();
return false;
}
WiFi.begin(defaultSSID.c_str(), cred->password.c_str());
Serial.printf("[%lu] [WCS] Connecting to default WiFi: %s\n", millis(), defaultSSID.c_str());
const unsigned long startTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startTime < static_cast<unsigned long>(timeoutMs)) {
vTaskDelay(100 / portTICK_PERIOD_MS);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[%lu] [WCS] Connected to default WiFi: %s (IP: %s)\n", millis(), defaultSSID.c_str(),
WiFi.localIP().toString().c_str());
return true;
} else {
Serial.printf("[%lu] [WCS] Failed to connect to default WiFi: %s\n", millis(), defaultSSID.c_str());
return false;
}
}
void WifiCredentialStore::ensureWifiConnected(ActivityWithSubactivity& activity, GfxRenderer& renderer,
MappedInputManager& mappedInput, const std::function<void()>& onSuccess,
const std::function<void()>& onCancel, int timeoutMs) {
if (WiFi.status() == WL_CONNECTED) {
onSuccess();
return;
}
// Try to connect using default WiFi
WifiCredentialStore::getInstance().loadFromFile();
if (WifiCredentialStore::getInstance().connectToDefaultWifi(timeoutMs)) {
Serial.printf("[%lu] [WCS] Auto-connected to WiFi\n", millis());
onSuccess();
return;
}
// Auto-connect failed - show WiFi selection list
Serial.printf("[%lu] [WCS] Auto-connect failed, showing WiFi selection\n", millis());
activity.enterNewActivity(
new WifiSelectionActivity(renderer, mappedInput, [&activity, onSuccess, onCancel](bool connected) {
activity.exitActivity();
if (connected) {
onSuccess();
} else {
onCancel();
}
}));
}

View File

@ -1,7 +1,12 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
class ActivityWithSubactivity;
class GfxRenderer;
class MappedInputManager;
struct WifiCredential {
std::string ssid;
std::string password; // Stored obfuscated in file
@ -16,6 +21,7 @@ class WifiCredentialStore {
private:
static WifiCredentialStore instance;
std::vector<WifiCredential> credentials;
std::string defaultSSID;
static constexpr size_t MAX_NETWORKS = 8;
@ -50,6 +56,16 @@ class WifiCredentialStore {
// Clear all credentials
void clearAll();
// Default network management
void setDefaultSSID(const std::string& ssid);
const std::string& getDefaultSSID() const { return defaultSSID; }
bool connectToDefaultWifi(int timeoutMs = 5000) const;
// Helper function to try connecting to default WiFi, or show WiFi selection if it fails
static void ensureWifiConnected(ActivityWithSubactivity& activity, GfxRenderer& renderer,
MappedInputManager& mappedInput, const std::function<void()>& onSuccess,
const std::function<void()>& onCancel, int timeoutMs = 10000);
};
// Helper macro to access credentials store

View File

@ -6,10 +6,10 @@
class ActivityWithSubactivity : public Activity {
protected:
std::unique_ptr<Activity> subActivity = nullptr;
void exitActivity();
void enterNewActivity(Activity* activity);
public:
void exitActivity();
void enterNewActivity(Activity* activity);
explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity(std::move(name), renderer, mappedInput) {}
void loop() override;

View File

@ -7,6 +7,7 @@
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "WifiCredentialStore.h"
#include "activities/network/WifiSelectionActivity.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
@ -154,6 +155,12 @@ void OpdsBookBrowserActivity::loop() {
void OpdsBookBrowserActivity::displayTaskLoop() {
while (true) {
// If a subactivity is active, yield CPU time but don't render
if (subActivity) {
vTaskDelay(10 / portTICK_PERIOD_MS);
continue;
}
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -374,8 +381,27 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() {
return;
}
// Not connected - launch WiFi selection screen directly
launchWifiSelection();
// Try to connect to default WiFi if available
WIFI_STORE.loadFromFile();
const bool hasDefaultSSID = !WIFI_STORE.getDefaultSSID().empty();
if (hasDefaultSSID) {
statusMessage = "Connecting to WiFi...";
updateRequired = true;
}
WIFI_STORE.ensureWifiConnected(
*this, renderer, mappedInput,
[this]() {
state = BrowserState::LOADING;
statusMessage = "Loading...";
updateRequired = true;
fetchFeed(currentPath);
},
[this]() {
// User cancelled WiFi selection - go home
onGoHome();
});
}
void OpdsBookBrowserActivity::launchWifiSelection() {

View File

@ -11,7 +11,7 @@
#include "MappedInputManager.h"
#include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h"
#include "WifiCredentialStore.h"
#include "fontIds.h"
namespace {
@ -135,14 +135,24 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
exitActivity();
if (mode == NetworkMode::JOIN_NETWORK) {
// STA mode - launch WiFi selection
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());
// STA mode - use ensureWifiConnected helper
Serial.printf("[%lu] [WEBACT] Checking WiFi connection...\n", millis());
WiFi.mode(WIFI_STA);
state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
WIFI_STORE.ensureWifiConnected(
*this, renderer, mappedInput,
[this]() {
// WiFi connected - start web server
connectedIP = WiFi.localIP().toString().c_str();
connectedSSID = WiFi.SSID().c_str();
isApMode = false;
onWifiSelectionComplete(true);
},
[this]() {
// WiFi connection cancelled - go back to mode selection
onWifiSelectionComplete(false);
});
} else {
// AP mode - start access point
state = WebServerActivityState::AP_STARTING;
@ -152,15 +162,15 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
}
void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected);
Serial.printf("[%lu] [WEBACT] WiFi connection completed, connected=%d\n", millis(), connected);
if (connected) {
// Get connection info before exiting subactivity
connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP();
connectedSSID = WiFi.SSID().c_str();
isApMode = false;
exitActivity();
// Exit any subactivity if present
if (subActivity) {
exitActivity();
}
// Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) {

View File

@ -24,7 +24,7 @@ enum class WebServerActivityState {
* CrossPointWebServerActivity is the entry point for file transfer functionality.
* It:
* - 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 STA mode: Uses ensureWifiConnected to auto-connect to default WiFi or show WiFi selection
* - For AP mode: Creates an Access Point that clients can connect to
* - Starts the CrossPointWebServer when connected
* - Handles client requests in its loop() function

View File

@ -36,6 +36,15 @@ void WifiSelectionActivity::onEnter() {
usedSavedPassword = false;
savePromptSelection = 0;
forgetPromptSelection = 0;
setDefaultPromptSelection = 0;
// Cache MAC address for display
uint8_t mac[6];
WiFi.macAddress(mac);
char macStr[32];
snprintf(macStr, sizeof(macStr), "MAC address: %02x-%02x-%02x-%02x-%02x-%02x", mac[0], mac[1], mac[2], mac[3], mac[4],
mac[5]);
cachedMacAddress = std::string(macStr);
// Trigger first update to show scanning message
updateRequired = true;
@ -229,6 +238,28 @@ void WifiSelectionActivity::attemptConnection() {
}
}
void WifiSelectionActivity::savePassword() {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.addCredential(selectedSSID, enteredPassword);
xSemaphoreGive(renderingMutex);
}
void WifiSelectionActivity::displaySetDefaultPrompt() {
WIFI_STORE.loadFromFile();
const std::string currentDefault = WIFI_STORE.getDefaultSSID();
if (selectedSSID != currentDefault) {
state = WifiSelectionState::SET_DEFAULT_PROMPT;
setDefaultPromptSelection = 0;
updateRequired = true;
}
}
void WifiSelectionActivity::setDefaultNetwork() {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.setDefaultSSID(selectedSSID);
xSemaphoreGive(renderingMutex);
}
void WifiSelectionActivity::checkConnectionStatus() {
if (state != WifiSelectionState::CONNECTING) {
return;
@ -243,16 +274,19 @@ void WifiSelectionActivity::checkConnectionStatus() {
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
connectedIP = ipStr;
// If we entered a new password, ask if user wants to save it
// Otherwise, immediately complete so parent can start web server
// If we entered a new password, save it (with or without prompt based on fromSettingsScreen)
if (!usedSavedPassword && !enteredPassword.empty()) {
state = WifiSelectionState::SAVE_PROMPT;
savePromptSelection = 0; // Default to "Yes"
updateRequired = true;
} else {
// Using saved password or open network - complete immediately
Serial.printf("[%lu] [WIFI] Connected with saved/open credentials, completing immediately\n", millis());
onComplete(true);
if (fromSettingsScreen) {
// Always save without prompting
savePassword();
displaySetDefaultPrompt();
return;
} else {
state = WifiSelectionState::SAVE_PROMPT;
savePromptSelection = 0;
updateRequired = true;
}
}
return;
}
@ -318,11 +352,9 @@ void WifiSelectionActivity::loop() {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (savePromptSelection == 0) {
// User chose "Yes" - save the password
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.addCredential(selectedSSID, enteredPassword);
xSemaphoreGive(renderingMutex);
savePassword();
displaySetDefaultPrompt();
}
// Complete - parent will start web server
onComplete(true);
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip saving, complete anyway
@ -331,6 +363,44 @@ void WifiSelectionActivity::loop() {
return;
}
// Handle set default prompt state
if (state == WifiSelectionState::SET_DEFAULT_PROMPT) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (setDefaultPromptSelection > 0) {
setDefaultPromptSelection--;
updateRequired = true;
}
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (setDefaultPromptSelection < 1) {
setDefaultPromptSelection++;
updateRequired = true;
}
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (setDefaultPromptSelection == 0) {
// User chose "Yes" - set as default
setDefaultNetwork();
}
// Disconnect if from settings screen before completing
if (fromSettingsScreen) {
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_OFF);
}
onComplete(true);
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Disconnect if from settings screen before completing
if (fromSettingsScreen) {
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_OFF);
}
onComplete(true);
}
return;
}
// Handle forget prompt state (connection failed with saved credentials)
if (state == WifiSelectionState::FORGET_PROMPT) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
@ -489,6 +559,9 @@ void WifiSelectionActivity::render() const {
case WifiSelectionState::SAVE_PROMPT:
renderSavePrompt();
break;
case WifiSelectionState::SET_DEFAULT_PROMPT:
renderSetDefaultPrompt();
break;
case WifiSelectionState::CONNECTION_FAILED:
renderConnectionFailed();
break;
@ -572,6 +645,9 @@ void WifiSelectionActivity::renderNetworkList() const {
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr);
}
// Show MAC address above the network count and legend
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 105, cachedMacAddress.c_str());
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
@ -655,6 +731,46 @@ void WifiSelectionActivity::renderSavePrompt() const {
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm");
}
void WifiSelectionActivity::renderSetDefaultPrompt() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connected!", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Set as default WiFi?");
// Draw Yes/No buttons
const int buttonY = top + 80;
constexpr int buttonWidth = 60;
constexpr int buttonSpacing = 30;
constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button
if (setDefaultPromptSelection == 0) {
renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]");
} else {
renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Yes");
}
// Draw "No" button
if (setDefaultPromptSelection == 1) {
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
} else {
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm");
}
void WifiSelectionActivity::renderConnectionFailed() const {
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_10_FONT_ID);

View File

@ -21,14 +21,15 @@ struct WifiNetworkInfo {
// WiFi selection states
enum class WifiSelectionState {
SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks
PASSWORD_ENTRY, // Entering password for selected network
CONNECTING, // Attempting to connect
CONNECTED, // Successfully connected
SAVE_PROMPT, // Asking user if they want to save the password
CONNECTION_FAILED, // Connection failed
FORGET_PROMPT // Asking user if they want to forget the network
SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks
PASSWORD_ENTRY, // Entering password for selected network
CONNECTING, // Attempting to connect
CONNECTED, // Successfully connected
SAVE_PROMPT, // Asking user if they want to save the password
SET_DEFAULT_PROMPT, // Asking user if they want to set as default
CONNECTION_FAILED, // Connection failed
FORGET_PROMPT // Asking user if they want to forget the network
};
/**
@ -62,12 +63,19 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
// Password to potentially save (from keyboard or saved credentials)
std::string enteredPassword;
// Cached MAC address string for display
std::string cachedMacAddress;
// Whether network was connected using a saved password (skip save prompt)
bool usedSavedPassword = false;
// Save/forget prompt selection (0 = Yes, 1 = No)
// Whether launched from settings screen (affects save behavior and disconnection)
bool fromSettingsScreen = false;
// Save/forget/set default prompt selection (0 = Yes, 1 = No)
int savePromptSelection = 0;
int forgetPromptSelection = 0;
int setDefaultPromptSelection = 0;
// Connection timeout
static constexpr unsigned long CONNECTION_TIMEOUT_MS = 15000;
@ -81,6 +89,7 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
void renderConnecting() const;
void renderConnected() const;
void renderSavePrompt() const;
void renderSetDefaultPrompt() const;
void renderConnectionFailed() const;
void renderForgetPrompt() const;
@ -91,10 +100,17 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
void checkConnectionStatus();
std::string getSignalStrengthIndicator(int32_t rssi) const;
// Helper methods
void savePassword();
void displaySetDefaultPrompt();
void setDefaultNetwork();
public:
explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(bool connected)>& onComplete)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {}
const std::function<void(bool connected)>& onComplete, bool fromSettingsScreen = false)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput),
onComplete(onComplete),
fromSettingsScreen(fromSettingsScreen) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -127,7 +127,7 @@ void XtcReaderActivity::loop() {
return;
}
const bool skipPages = mappedInput.getHeldTime() > skipPageMs;
const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs;
const int skipAmount = skipPages ? 10 : 1;
if (prevReleased) {

View File

@ -9,6 +9,7 @@
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "WifiConnectionsActivity.h"
#include "fontIds.h"
// Define the static settings list
@ -42,6 +43,7 @@ const SettingInfo settingsList[settingsCount] = {
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Action("Calibre Settings"),
SettingInfo::Action("WiFi Connections"),
SettingInfo::Action("Check for updates")};
} // namespace
@ -147,6 +149,14 @@ void SettingsActivity::toggleCurrentSetting() {
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "WiFi Connections") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new WifiConnectionsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Check for updates") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();

View File

@ -0,0 +1,267 @@
#include "WifiConnectionsActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include "MappedInputManager.h"
#include "WifiCredentialStore.h"
#include "activities/network/WifiSelectionActivity.h"
#include "fontIds.h"
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long IGNORE_INPUT_MS = 300;
} // namespace
void WifiConnectionsActivity::taskTrampoline(void* param) {
auto* self = static_cast<WifiConnectionsActivity*>(param);
self->displayTaskLoop();
}
void WifiConnectionsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
state = State::LIST;
selectorIndex = 0;
settingsSelection = 0;
selectedNetwork.clear();
enterTime = millis();
updateRequired = true;
WIFI_STORE.loadFromFile();
xTaskCreate(&WifiConnectionsActivity::taskTrampoline, "WifiConnectionsTask", 4096, this, 1, &displayTaskHandle);
}
void WifiConnectionsActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void WifiConnectionsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
const unsigned long timeSinceEnter = millis() - enterTime;
if (timeSinceEnter < IGNORE_INPUT_MS) {
return;
}
if (state == State::SETTINGS_MENU) {
// Check if this network is already the default
WIFI_STORE.loadFromFile();
const std::string currentDefault = WIFI_STORE.getDefaultSSID();
const bool isDefault = (selectedNetwork == currentDefault);
// Handle settings menu
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
if (settingsSelection > 0) {
settingsSelection--;
updateRequired = true;
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
if (settingsSelection < 1) {
settingsSelection++;
updateRequired = true;
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (settingsSelection == 0) {
if (isDefault) {
removeDefault();
} else {
setDefault();
}
} else {
deleteNetwork();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
cancelSettings();
}
return;
}
// Handle list navigation
const auto& credentials = WIFI_STORE.getCredentials();
const size_t totalItems = credentials.size() + 1; // +1 for "Add new connection"
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex == 0) {
// "Add new connection" selected - launch WiFi selection
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new WifiSelectionActivity(
renderer, mappedInput,
[this](bool connected) {
// Reload credentials after WiFi selection
WIFI_STORE.loadFromFile();
exitActivity();
enterTime = millis(); // Reset enter time to ignore input after subactivity exits
updateRequired = true;
},
true)); // true = fromSettingsScreen (always save password and disconnect after)
xSemaphoreGive(renderingMutex);
} else {
// Regular credential selected
handleSettings();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + totalItems) % totalItems;
} else {
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
}
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % totalItems;
} else {
selectorIndex = (selectorIndex + 1) % totalItems;
}
updateRequired = true;
}
}
void WifiConnectionsActivity::handleSettings() {
const auto& credentials = WIFI_STORE.getCredentials();
// selectorIndex 0 is "Add new connection", so credentials start at index 1
if (selectorIndex == 0 || selectorIndex > credentials.size()) {
return;
}
selectedNetwork = credentials[selectorIndex - 1].ssid;
state = State::SETTINGS_MENU;
settingsSelection = 0;
updateRequired = true;
}
void WifiConnectionsActivity::setDefault() {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.setDefaultSSID(selectedNetwork);
xSemaphoreGive(renderingMutex);
state = State::LIST;
selectedNetwork.clear();
updateRequired = true;
}
void WifiConnectionsActivity::removeDefault() {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.setDefaultSSID("");
xSemaphoreGive(renderingMutex);
state = State::LIST;
selectedNetwork.clear();
updateRequired = true;
}
void WifiConnectionsActivity::deleteNetwork() {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.removeCredential(selectedNetwork);
xSemaphoreGive(renderingMutex);
// Reload to get updated list
WIFI_STORE.loadFromFile();
const auto& credentials = WIFI_STORE.getCredentials();
const size_t totalItems = credentials.size() + 1;
if (selectorIndex >= totalItems) {
selectorIndex = totalItems - 1;
}
state = State::LIST;
selectedNetwork.clear();
updateRequired = true;
}
void WifiConnectionsActivity::cancelSettings() {
state = State::LIST;
selectedNetwork.clear();
updateRequired = true;
}
void WifiConnectionsActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void WifiConnectionsActivity::render() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "WiFi Connections", true, EpdFontFamily::BOLD);
if (state == State::SETTINGS_MENU) {
const int centerY = pageHeight / 2;
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 40, "Settings", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 20, selectedNetwork.c_str());
// Check if this network is already the default
const std::string currentDefault = WIFI_STORE.getDefaultSSID();
const bool isDefault = (selectedNetwork == currentDefault);
const char* defaultText = settingsSelection == 0 ? (isDefault ? "> Remove Default" : "> Set Default")
: (isDefault ? " Remove Default" : " Set Default");
const char* deleteText = settingsSelection == 1 ? "> Delete" : " Delete";
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 20, defaultText);
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 40, deleteText);
const auto labels = mappedInput.mapLabels("Cancel", "Confirm", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
const auto& credentials = WIFI_STORE.getCredentials();
const auto labels = mappedInput.mapLabels("« Back", selectorIndex == 0 ? "Add" : "Settings", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
const size_t totalItems = credentials.size() + 1;
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
const std::string& defaultSSID = WIFI_STORE.getDefaultSSID();
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
for (size_t i = pageStartIndex; i < totalItems && i < pageStartIndex + PAGE_ITEMS; i++) {
std::string displayText;
if (i == 0) {
displayText = "+ Add new connection";
} else {
displayText = credentials[i - 1].ssid;
if (credentials[i - 1].ssid == defaultSSID) {
displayText += " [Default]";
}
}
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40);
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex);
}
}
renderer.displayBuffer();
}

View File

@ -0,0 +1,46 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include "activities/ActivityWithSubactivity.h"
/**
* Activity for managing saved WiFi connections.
* Shows a list of saved WiFi networks and allows deletion with confirmation.
*/
class WifiConnectionsActivity final : public ActivityWithSubactivity {
public:
explicit WifiConnectionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onBack)
: ActivityWithSubactivity("WifiConnections", renderer, mappedInput), onBack(onBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
enum class State { LIST, SETTINGS_MENU };
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
State state = State::LIST;
size_t selectorIndex = 0;
int settingsSelection = 0;
std::string selectedNetwork;
unsigned long enterTime = 0;
const std::function<void()> onBack;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render();
void handleSettings();
void setDefault();
void removeDefault();
void deleteNetwork();
void cancelSettings();
};