Loading files
// Hotspot — the panel as a network of its own.
//
// A panel that cannot join a network, or is somewhere with none, raises an
// access point of its own ("patternflow-a1b2", WPA2), and the console is at
// http://192.168.4.1/ on it: the same pages, the same API, patterns installed
// from a phone that carries them, a Wi-Fi network added for the next place.
// Core, not a feature: Wi-Fi is the device's, and every image has this.
//
// Three modes, kept in NVS and set on /wifi: `off`; `auto` (the default —
// up while the station has had no link for PF_HOTSPOT_AUTO_AFTER_MS, down
// again once the station joins and nobody is on the hotspot); `always`.
//
// What the bench taught on 2026-09-22, each of which is a line below:
// - The channel is chosen from a scan, never fixed. Hard-coding channel 1
// put the beacon under three -33 dBm networks on channel 3 and the SSID
// flickered or never showed; the least loaded of 1/6/11 is quiet.
// - Every console service used to wait for WL_CONNECTED, so a phone joined,
// got an address, and port 80 was never open. PatternflowWifi::linkUp()
// is the condition now, and the hotspot raises the same link edge the
// station does; every begin() behind it is idempotent.
// - A station retry is a scan with the AP off the air, and the IDF's own
// auto-reconnect re-issues one after every failure. Retries are the
// tick's business while the hotspot is up: slow with nobody on it, none
// with someone on it (PatternflowWifi::tick()).
// - A phone treats a network without internet as suspect, and decides with
// a DNS lookup and an HTTP probe. Left unanswered, the lookup times out
// and the phone sits for twenty seconds before a browser on it does
// anything. So the hotspot answers DNS - every name resolves to the
// panel - and the probe fails at once with a plain 404: the verdict is
// "no internet" within a second, which is the truth, and the phone
// stays. Pretending to be the internet (a 204) was tried and put a
// Samsung into its limited-connectivity state, which drops the network.
//
// License: MIT
#pragma once
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <Preferences.h>
#include <esp_wifi.h>
#include "../net_config.h"
#include "core_wifi.h"
#include "core_http.h"
#include "core_banner.h"
namespace PatternflowHotspot {
enum Mode : uint8_t { OFF = 0, AUTO = 1, ALWAYS = 2 };
inline Mode mode = AUTO;
inline char pass[65] = PF_HOTSPOT_PASS;
inline char ssidBuf[40] = {0};
inline bool up = false;
inline int channel = 0;
inline uint32_t startedAtMs = 0;
inline uint32_t staLastUpMs = 0;
inline uint32_t starts = 0;
inline uint32_t dnsAnswered = 0;
inline uint32_t dnsFailures = 0;
inline bool routesRegistered = false;
inline WiFiUDP dns;
inline bool dnsUp = false;
inline uint32_t dnsRetryAtMs = 0;
inline uint32_t reportAtMs = 0;
inline int reportedClients = -1;
// The last probe request heard: a phone scanning, which is a phone about
// to join. Station probes (core_wifi.h) keep away from that moment.
inline uint32_t lastProbeReqMs = 0;
inline uint32_t probeReqs = 0;
inline bool eventsHooked = false;
// ── What the channel scan saw ───────────────────────────────────────────
// pickChannel() already lists every network in range, and that is the list
// a person setting the panel up wants to pick from: typing a case-sensitive
// name on a phone is how a "Wifiiii" for "wifiiii" happened (2026-09-22).
// The strongest SEEN_MAX names from the moment the hotspot came up, about
// 400 bytes - and no scan of its own, since a scan costs the internal RAM
// the console is short of (core_wifi.h).
constexpr int SEEN_MAX = 12;
inline char seenSsid[SEEN_MAX][33] = {};
inline int8_t seenRssi[SEEN_MAX] = {};
inline int seenCount = 0;
inline void noteSeen(const String& ssid, int rssi) {
if (ssid.length() == 0 || ssid.length() > 32) return; // hidden, or not a name
for (int i = 0; i < seenCount; i++) {
if (strcmp(seenSsid[i], ssid.c_str()) == 0) { // one name, several access points
if (rssi > seenRssi[i]) seenRssi[i] = (int8_t)rssi;
return;
}
}
int slot = seenCount;
if (seenCount == SEEN_MAX) { // full: replace the weakest if this is louder
slot = 0;
for (int i = 1; i < SEEN_MAX; i++) if (seenRssi[i] < seenRssi[slot]) slot = i;
if (rssi <= seenRssi[slot]) return;
} else {
seenCount++;
}
snprintf(seenSsid[slot], sizeof(seenSsid[slot]), "%s", ssid.c_str());
seenRssi[slot] = (int8_t)rssi;
}
// The join outcome last put on the LEDs (PatternflowWifi::joinSeq).
inline uint32_t announcedJoinSeq = 0;
// Every association and probe request the AP sees, on the log with a
// stamp - the bench cannot see the radio any other way, and a field
// report with this line in it answers most questions.
inline void onWifiEvent(WiFiEvent_t e, WiFiEventInfo_t info) {
switch (e) {
case ARDUINO_EVENT_WIFI_AP_PROBEREQRECVED:
lastProbeReqMs = millis();
probeReqs++;
break;
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
Serial.printf("[HOTSPOT] client joined %02x:%02x:%02x:%02x:%02x:%02x (aid %d)\n",
info.wifi_ap_staconnected.mac[0], info.wifi_ap_staconnected.mac[1],
info.wifi_ap_staconnected.mac[2], info.wifi_ap_staconnected.mac[3],
info.wifi_ap_staconnected.mac[4], info.wifi_ap_staconnected.mac[5],
(int)info.wifi_ap_staconnected.aid);
break;
case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
Serial.printf("[HOTSPOT] client left %02x:%02x:%02x:%02x:%02x:%02x\n",
info.wifi_ap_stadisconnected.mac[0], info.wifi_ap_stadisconnected.mac[1],
info.wifi_ap_stadisconnected.mac[2], info.wifi_ap_stadisconnected.mac[3],
info.wifi_ap_stadisconnected.mac[4], info.wifi_ap_stadisconnected.mac[5]);
break;
case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED:
Serial.println("[HOTSPOT] client has an address");
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: {
// Why a join failed, for the console and the LEDs. Our own
// disconnect() before every attempt reports ASSOC_LEAVE, which says
// nothing about the network being tried.
const uint8_t r = info.wifi_sta_disconnected.reason;
if (r != WIFI_REASON_ASSOC_LEAVE) PatternflowWifi::lastStaReason = r;
break;
}
default: break;
}
}
inline const char* modeName(Mode m) {
return m == OFF ? "off" : m == ALWAYS ? "always" : "auto";
}
inline bool parseMode(const String& s, Mode* out) {
if (s == "off") { *out = OFF; return true; }
if (s == "auto") { *out = AUTO; return true; }
if (s == "always") { *out = ALWAYS; return true; }
return false;
}
// "patternflow-a1b2": the shared name plus the last two bytes of the station
// MAC - the same alias the NETWORK screen and the status page show.
inline const char* name() {
if (ssidBuf[0] == 0) {
uint8_t mac[6];
WiFi.macAddress(mac);
snprintf(ssidBuf, sizeof(ssidBuf), "%s-%02x%02x", PF_OTA_HOSTNAME, mac[4], mac[5]);
}
return ssidBuf;
}
inline bool validPass(const String& p) {
return p.length() >= 8 && p.length() <= 63;
}
inline void load() {
Preferences p;
if (!p.begin("pf_hotspot", /*readOnly=*/true)) return;
uint8_t m = p.getUChar("mode", AUTO);
mode = (m <= ALWAYS) ? (Mode)m : AUTO;
String pw = p.getString("pass", PF_HOTSPOT_PASS);
if (validPass(pw)) strlcpy(pass, pw.c_str(), sizeof(pass));
p.end();
}
inline void save() {
Preferences p;
if (!p.begin("pf_hotspot", /*readOnly=*/false)) return;
p.putUChar("mode", (uint8_t)mode);
p.putString("pass", pass);
p.end();
}
// ── Channel ─────────────────────────────────────────────────────────────
//
// The radio has one channel. With the station connected the AP shares its
// channel, so that is the answer. Alone, a scan says what is on the air here
// and the least loaded of 1/6/11 wins - every network within two channels of
// a candidate loads it, weighted by how loud it is. A scan costs ~1.5 s and
// some internal RAM, once, at the moment the hotspot comes up.
inline int pickChannel() {
if (WiFi.status() == WL_CONNECTED) return WiFi.channel();
// A scan fails (-2) while a station attempt is in flight, and the tick
// may have issued one this very pass: abort it first, and try twice.
WiFi.disconnect();
delay(100);
int n = WiFi.scanNetworks(false, true, false, 120);
if (n < 0) { delay(400); n = WiFi.scanNetworks(false, true, false, 120); }
if (n < 0) {
Serial.println("[HOTSPOT] scan failed twice - channel 6");
return 6;
}
const int cand[3] = {1, 6, 11};
int load[3] = {0, 0, 0};
seenCount = 0;
for (int i = 0; i < n && i < 40; i++) {
noteSeen(WiFi.SSID(i), WiFi.RSSI(i));
const int ch = WiFi.channel(i);
int w = WiFi.RSSI(i) + 100;
if (w < 1) w = 1;
for (int k = 0; k < 3; k++) {
if (ch >= cand[k] - 2 && ch <= cand[k] + 2) load[k] += w;
}
}
WiFi.scanDelete();
int best = 0;
for (int k = 1; k < 3; k++) if (load[k] < load[best]) best = k;
Serial.printf("[HOTSPOT] %d network(s) around, load ch1=%d ch6=%d ch11=%d\n",
n, load[0], load[1], load[2]);
return cand[best];
}
// ── Up and down ─────────────────────────────────────────────────────────
inline void start() {
if (up) return;
channel = pickChannel();
// The scan just cut short the station's attempt (pickChannel() disconnects
// it first). `always` starts at boot, under that first attempt, and with
// the hotspot up the station is otherwise retried only every five minutes:
// a panel with good credentials sat off its network until then (bench,
// 2026-09-24: scan 5.7 s, station off at 15.6 s, back at 301 s). One
// attempt now, with the probe's window; if it fails, AP-only as before.
// `auto` starts only after the station has had its 15 s already.
if (mode == ALWAYS && !PatternflowWifi::isConnected() && PatternflowWifi::hasCredentials())
PatternflowWifi::requestReconnect();
WiFi.mode(WIFI_AP_STA);
if (!WiFi.softAP(name(), pass, channel, /*hidden=*/0, PF_HOTSPOT_MAX_CLIENTS)) {
Serial.println("[HOTSPOT] softAP() failed");
WiFi.mode(WIFI_STA);
return;
}
// 20 MHz: the narrower channel is the more robust one, and nothing here
// needs the width. Set before anyone joins - changing it later restarts
// the AP and drops every client.
esp_wifi_set_bandwidth(WIFI_IF_AP, WIFI_BW_HT20);
up = true;
starts++;
esp_wifi_set_event_mask(0); // probe requests are masked by default; hear them
startedAtMs = millis();
dnsUp = false;
dnsRetryAtMs = startedAtMs + 1000; // the AP netif needs a moment before a socket on it answers
// The IDF would re-scan on every failed station attempt, taking the AP
// off the air each time; PatternflowWifi::tick() paces retries instead.
WiFi.setAutoReconnect(false);
PatternflowWifi::hotspotUp = true;
PatternflowWifi::hotspotClients = 0;
PatternflowWifi::raiseLinkEdge(); // the console starts on this link too
Serial.printf("[HOTSPOT] \"%s\" pass \"%s\" ch%d at %s (%s)\n", name(), pass, channel,
WiFi.softAPIP().toString().c_str(), modeName(mode));
}
inline void stop() {
if (!up) return;
if (dnsUp) { dns.stop(); dnsUp = false; }
PatternflowWifi::hotspotUp = false; // before the mode change: stationOff() must not fire
WiFi.softAPdisconnect(true); // AP off, mode back to station-only
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
up = false;
PatternflowWifi::hotspotUp = false;
PatternflowWifi::hotspotClients = 0;
Serial.println("[HOTSPOT] down");
}
inline void begin() {
load();
if (!eventsHooked) { WiFi.onEvent(onWifiEvent); eventsHooked = true; }
staLastUpMs = millis(); // auto mode counts from boot
Serial.printf("[HOTSPOT] mode %s, name \"%s\"\n", modeName(mode), name());
}
// ── The DNS responder ───────────────────────────────────────────────────
//
// Every A query from the hotspot's own subnet is answered with the panel's
// address; other types get an empty NOERROR so the client stops asking.
// Hand-built on WiFiUDP rather than the core's DNSServer, which stopped
// answering after minutes on core 2.x (the 2026-08 portal bench); a socket
// that fails to send is simply recreated.
inline bool fromHotspot(const IPAddress& from) {
const uint32_t a = (uint32_t)from, b = (uint32_t)WiFi.softAPIP();
return (a & 0x00FFFFFF) == (b & 0x00FFFFFF);
}
inline void dnsPump() {
if (!up) return;
const uint32_t now = millis();
if (!dnsUp) {
if ((int32_t)(now - dnsRetryAtMs) < 0) return;
dnsUp = dns.begin(53);
if (!dnsUp) { dnsRetryAtMs = now + 2000; return; }
Serial.println("[HOTSPOT] dns responder up");
}
int len = dns.parsePacket();
if (len <= 0) return;
uint8_t q[512];
int n = dns.read(q, sizeof(q));
IPAddress from = dns.remoteIP();
uint16_t port = dns.remotePort();
while (dns.parsePacket() > 0) { /* drain anything queued behind it */ }
if (n < 17 || !fromHotspot(from)) return;
// The question: labels until a zero byte, then type and class.
int i = 12;
while (i < n && q[i] != 0) {
const int l = q[i];
if (l >= 0xC0 || i + l + 1 >= n) return; // compressed or malformed: ignore
i += l + 1;
}
if (i + 4 >= n) return;
i++;
const uint16_t qtype = (uint16_t)(q[i] << 8) | q[i + 1];
const uint16_t qclass = (uint16_t)(q[i + 2] << 8) | q[i + 3];
const int qend = i + 4;
const bool answerA = (qtype == 1 && qclass == 1);
uint8_t r[512 + 16];
memcpy(r, q, qend);
r[2] = 0x80 | (q[2] & 0x01); // response; recursion desired copied
r[3] = 0x80; // recursion available, no error
r[4] = 0; r[5] = 1; // one question
r[6] = 0; r[7] = answerA ? 1 : 0;
r[8] = r[9] = r[10] = r[11] = 0;
int rlen = qend;
if (answerA) {
const IPAddress ip = WiFi.softAPIP();
const uint8_t answer[16] = {0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3C,
0x00, 0x04, ip[0], ip[1], ip[2], ip[3]};
memcpy(r + rlen, answer, sizeof(answer));
rlen += sizeof(answer);
}
bool sent = dns.beginPacket(from, port) && dns.write(r, rlen) == (size_t)rlen && dns.endPacket();
if (!sent) {
dnsFailures++;
dns.stop();
dnsUp = false;
dnsRetryAtMs = now + 500;
return;
}
dnsAnswered++;
}
// ── Telling the room ────────────────────────────────────────────────────
// A person who has just sent a network from a phone often loses the phone's
// view of the panel at that very moment: the hotspot moves to the new
// network's channel, or goes down with nobody left on it. So the outcome
// goes on the LEDs too - the address to type in, or why it did not work.
// Only a network someone asked for (applyCredentials()); a join at boot
// says nothing. Three lines at most on the portrait panel, about ten
// characters each, so the address is broken after its second dot.
inline void announceJoin() {
if (PatternflowWifi::joinSeq == announcedJoinSeq) return;
announcedJoinSeq = PatternflowWifi::joinSeq;
char text[PatternflowBanner::MESSAGE_BYTES];
if (PatternflowWifi::joinState == PatternflowWifi::JOIN_OK) {
const char* ip = PatternflowWifi::joinIp.c_str();
const char* dot = strchr(ip, '.');
if (dot) dot = strchr(dot + 1, '.');
if (dot) snprintf(text, sizeof(text), "JOINED %.*s %s", (int)(dot - ip + 1), ip, dot + 1);
else snprintf(text, sizeof(text), "JOINED %s", ip);
PatternflowBanner::show(text, 60000);
} else if (PatternflowWifi::joinState == PatternflowWifi::JOIN_FAILED) {
switch (PatternflowWifi::joinWhy) {
case PatternflowWifi::WHY_PASSWORD: snprintf(text, sizeof(text), "WRONG PASSWORD"); break;
case PatternflowWifi::WHY_NOT_FOUND: snprintf(text, sizeof(text), "NETWORK NOT FOUND"); break;
default: snprintf(text, sizeof(text), "COULD NOT JOIN"); break;
}
PatternflowBanner::show(text, 20000);
}
}
// ── Policy, on the network task ─────────────────────────────────────────
inline void tick() {
const uint32_t now = millis();
announceJoin();
const bool sta = PatternflowWifi::isConnected();
if (sta) staLastUpMs = now;
if (up) PatternflowWifi::hotspotClients = WiFi.softAPgetStationNum();
bool want = false;
switch (mode) {
case OFF: want = false; break;
case ALWAYS: want = true; break;
default:
if (!sta) want = (uint32_t)(now - staLastUpMs) >= PF_HOTSPOT_AUTO_AFTER_MS;
else want = up && PatternflowWifi::hotspotClients > 0; // the station is back: keep it only for whoever is on it
break;
}
if (want && !up) start();
else if (!want && up) stop();
// Alone, the radio is the hotspot's (PatternflowWifi::stationOff()): a
// station interface that is merely present drags the AP to a crawl. But
// only once the station has had its chance - `always` at boot used to
// switch it off before its first attempt could finish, and the panel
// never joined the network it had credentials for. A probe in flight
// ends on its own schedule (core_wifi.h). millis(), not `now`: start()
// above can take the scan's six seconds, and the grace is about the
// station's time, not this tick's.
if (up && !sta && !PatternflowWifi::staProbing &&
(WiFi.getMode() & WIFI_MODE_STA) &&
(uint32_t)(millis() - staLastUpMs) >= PF_HOTSPOT_AUTO_AFTER_MS) {
PatternflowWifi::stationOff();
Serial.println("[HOTSPOT] no station link - radio is the hotspot's");
}
if (up) {
dnsPump();
// One line when the client count changes, and one every five minutes
// regardless, so a serial log of a show says who was on the hotspot.
if (PatternflowWifi::hotspotClients != reportedClients || (int32_t)(now - reportAtMs) >= 0) {
reportedClients = PatternflowWifi::hotspotClients;
reportAtMs = now + 300000;
Serial.printf("[HOTSPOT] ch%d clients=%d dns=%lu scans=%lu mode=%d heap=%u\n", channel,
PatternflowWifi::hotspotClients, (unsigned long)dnsAnswered,
(unsigned long)probeReqs, (int)WiFi.getMode(),
(unsigned)ESP.getFreeHeap());
}
}
}
inline void appendStatus(String& json);
// `"seen":[{"ssid":"..","rssi":-41},..],` strongest first, for the /wifi
// page's network-name suggestions.
inline void appendSeen(String& json) {
int order[SEEN_MAX];
for (int i = 0; i < seenCount; i++) order[i] = i;
for (int i = 1; i < seenCount; i++) { // a dozen entries: insertion sort
const int k = order[i];
int j = i - 1;
while (j >= 0 && seenRssi[order[j]] < seenRssi[k]) { order[j + 1] = order[j]; j--; }
order[j + 1] = k;
}
json += "\"seen\":[";
for (int i = 0; i < seenCount; i++) {
if (i) json += ',';
json += "{\"ssid\":\"";
for (const char* c = seenSsid[order[i]]; *c; c++) {
if (*c == '"' || *c == '\\') { json += '\\'; json += *c; }
else if ((uint8_t)*c >= 0x20) json += *c;
}
json += "\",\"rssi\":";
json += (int)seenRssi[order[i]];
json += '}';
}
json += "],";
}
// ── Settings routes ─────────────────────────────────────────────────────
// Registered on the console server like any core page, from the same edge.
// The phone's connectivity probe lands here too and gets the console's
// ordinary 404 - see the header for why that is the right answer.
inline void registerRoutes() {
if (routesRegistered) return;
if (!PatternflowWifi::linkUp()) return;
PatternflowHttp::begin(); // idempotent; whoever is first starts it
WebServer& s = PatternflowHttp::server();
// Settings: GET the state, POST mode=off|auto|always and/or pass=... .
s.on("/api/hotspot", HTTP_GET, []() {
String json;
json.reserve(768);
json += "{\"ok\":true,";
appendStatus(json);
appendSeen(json);
json += "\"pass\":\"";
json += pass;
json += "\"}";
PatternflowHttp::server().sendHeader("Cache-Control", "no-store");
PatternflowHttp::server().send(200, "application/json", json);
});
s.on("/api/hotspot", HTTP_POST, []() {
WebServer& srv = PatternflowHttp::server();
bool changed = false;
if (srv.hasArg("mode")) {
Mode m;
if (!parseMode(srv.arg("mode"), &m)) {
srv.send(400, "application/json", "{\"ok\":false,\"error\":\"mode is off, auto or always\"}");
return;
}
if (m != mode) { mode = m; changed = true; }
}
if (srv.hasArg("pass")) {
String pw = srv.arg("pass");
if (!validPass(pw)) {
srv.send(400, "application/json", "{\"ok\":false,\"error\":\"password is 8 to 63 characters\"}");
return;
}
if (pw != pass) { strlcpy(pass, pw.c_str(), sizeof(pass)); changed = true; }
}
if (changed) {
save();
// A new password or a mode that says off takes effect now; the
// policy tick brings it back up if the mode still wants it.
if (up) stop();
}
String json;
json.reserve(768);
json += "{\"ok\":true,";
appendStatus(json);
appendSeen(json);
json += "\"pass\":\"";
json += pass;
json += "\"}";
srv.sendHeader("Cache-Control", "no-store");
srv.send(200, "application/json", json);
});
routesRegistered = true;
}
// `"hotspot":{...},` for /api/status and /api/hotspot.
inline void appendStatus(String& json) {
json += "\"hotspot\":{\"mode\":\"";
json += modeName(mode);
json += "\",\"up\":";
json += up ? "true" : "false";
json += ",\"ssid\":\"";
json += name();
json += "\",\"ip\":\"";
json += up ? WiFi.softAPIP().toString() : String("");
json += "\",\"channel\":";
json += up ? channel : 0;
json += ",\"clients\":";
json += up ? PatternflowWifi::hotspotClients : 0;
json += ",\"dns\":";
json += dnsAnswered;
json += "},";
}
} // namespace PatternflowHotspot
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.