Add hotel Wifi connection

This commit is contained in:
jpirnay
2026-04-21 17:15:56 +02:00
parent d4c475470b
commit b7928a86e3
3 changed files with 149 additions and 1 deletions
@@ -1,8 +1,10 @@
#include "WifiSelectionActivity.h"
#include <GfxRenderer.h>
#include <HTTPClient.h>
#include <I18n.h>
#include <Logging.h>
#include <NetworkClient.h>
#include <WiFi.h>
#include <esp_mac.h>
@@ -13,6 +15,7 @@
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/QrUtils.h"
namespace {
@@ -263,6 +266,30 @@ void WifiSelectionActivity::attemptConnection() {
}
}
bool WifiSelectionActivity::checkCaptivePortal() {
// Probe a known HTTP endpoint that returns 204 on open internet.
// Captive portals intercept this and return a redirect (3xx) or 200 with a login page.
NetworkClient client;
HTTPClient http;
http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
http.setTimeout(5000);
if (!http.begin(client, "http://connectivitycheck.gstatic.com/generate_204")) {
return false;
}
const int code = http.GET();
String location = http.getLocation();
http.end();
if (code == 204) {
return false; // Open internet, no captive portal
}
// Any redirect or unexpected 200 means a captive portal is intercepting.
captivePortalUrl = location.length() > 0 ? location.c_str() : "http://connectivitycheck.gstatic.com/generate_204";
LOG_DBG("WIFI", "Captive portal detected (HTTP %d), URL: %s", code, captivePortalUrl.c_str());
return true;
}
void WifiSelectionActivity::checkConnectionStatus() {
if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING) {
return;
@@ -285,6 +312,13 @@ void WifiSelectionActivity::checkConnectionStatus() {
WIFI_STORE.setLastConnectedSsid(selectedSSID);
}
// Check for captive portal before declaring success
if (checkCaptivePortal()) {
state = WifiSelectionState::CAPTIVE_PORTAL;
requestUpdate();
return;
}
// If we entered a new password, ask if user wants to save it
// Otherwise, immediately complete so parent can start web server
if (!usedSavedPassword && !enteredPassword.empty()) {
@@ -404,6 +438,24 @@ void WifiSelectionActivity::loop() {
return;
}
// Handle captive portal state - user must authorize on another device
if (state == WifiSelectionState::CAPTIVE_PORTAL) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// User says they've completed browser auth - proceed as connected
if (!usedSavedPassword && !enteredPassword.empty()) {
state = WifiSelectionState::SAVE_PROMPT;
savePromptSelection = 0;
requestUpdate();
} else {
onComplete(true);
}
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
WiFi.disconnect();
startWifiScan();
}
return;
}
// Handle connected state (should not normally be reached - connection
// completes immediately)
if (state == WifiSelectionState::CONNECTED) {
@@ -539,6 +591,9 @@ void WifiSelectionActivity::render(RenderLock&&) {
case WifiSelectionState::FORGET_PROMPT:
renderForgetPrompt();
break;
case WifiSelectionState::CAPTIVE_PORTAL:
renderCaptivePortal();
break;
}
renderer.displayBuffer();
@@ -719,6 +774,90 @@ void WifiSelectionActivity::renderForgetPrompt() const {
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void WifiSelectionActivity::renderCaptivePortal() const {
const auto& metrics = UITheme::getInstance().getMetrics();
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
const int pageWidth = renderer.getScreenWidth();
const int maxWidth = pageWidth - metrics.contentSidePadding * 2;
const int lh12 = renderer.getLineHeight(UI_12_FONT_ID);
const int lh10 = renderer.getLineHeight(UI_10_FONT_ID);
const int lhSmall = renderer.getLineHeight(SMALL_FONT_ID);
const int sp = metrics.verticalSpacing;
constexpr int QR_SIZE = 320;
// Pre-compute URL line count so we can vertically centre everything
const char* url = captivePortalUrl.c_str();
int urlLineCount = 0;
{
int rem = static_cast<int>(captivePortalUrl.size());
int off = 0;
while (rem > 0) {
int lo = 1, hi = rem;
while (lo < hi) {
const int mid = (lo + hi + 1) / 2;
char tmp[512];
snprintf(tmp, sizeof(tmp), "%.*s", mid, url + off);
if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth)
lo = mid;
else
hi = mid - 1;
}
urlLineCount++;
off += lo;
rem -= lo;
}
}
const int totalHeight = lh12 + sp // title
+ lh10 // hint line 1
+ lh10 + sp // hint line 2
+ QR_SIZE + sp // QR code
+ urlLineCount * lhSmall;
// contentRect covers the full screen minus button hints; subtract the header
// and sub-header that render() always draws above us.
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight;
const int contentBottom = contentRect.y + contentRect.height;
int y = contentTop + (contentBottom - contentTop - totalHeight) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_DETECTED), true, EpdFontFamily::BOLD);
y += lh12 + sp;
renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_1));
y += lh10;
renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_HINT_2));
y += lh10 + sp;
const int qrX = contentRect.x + (contentRect.width - QR_SIZE) / 2;
QrUtils::drawQrCode(renderer, Rect{qrX, y, QR_SIZE, QR_SIZE}, captivePortalUrl);
y += QR_SIZE + sp;
// Split URL into as many lines as needed
int remaining = static_cast<int>(captivePortalUrl.size());
int offset = 0;
while (remaining > 0) {
int lo = 1, hi = remaining;
while (lo < hi) {
const int mid = (lo + hi + 1) / 2;
char tmp[512];
snprintf(tmp, sizeof(tmp), "%.*s", mid, url + offset);
if (renderer.getTextWidth(SMALL_FONT_ID, tmp) <= maxWidth)
lo = mid;
else
hi = mid - 1;
}
char line[512];
snprintf(line, sizeof(line), "%.*s", lo, url + offset);
renderer.drawCenteredText(SMALL_FONT_ID, y, line);
y += lhSmall;
offset += lo;
remaining -= lo;
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CAPTIVE_PORTAL_DONE), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void WifiSelectionActivity::onComplete(const bool connected) {
ActivityResult result;
result.isCancelled = !connected;
@@ -28,7 +28,8 @@ enum class WifiSelectionState {
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
FORGET_PROMPT, // Asking user if they want to forget the network
CAPTIVE_PORTAL // Connected but network requires web-based login
};
/**
@@ -87,14 +88,18 @@ class WifiSelectionActivity final : public Activity {
void renderSavePrompt() const;
void renderConnectionFailed() const;
void renderForgetPrompt() const;
void renderCaptivePortal() const;
void startWifiScan();
void processWifiScanResults();
void selectNetwork(int index);
void attemptConnection();
void checkConnectionStatus();
bool checkCaptivePortal();
std::string getSignalStrengthIndicator(int32_t rssi) const;
std::string captivePortalUrl;
void onComplete(bool connected);
public: