|
@@ -0,0 +1,772 @@
|
|
|
|
|
+#include "portal.h"
|
|
|
|
|
+
|
|
|
|
|
+#include <ArduinoJson.h>
|
|
|
|
|
+#include <DNSServer.h>
|
|
|
|
|
+#include <ESPmDNS.h>
|
|
|
|
|
+#include <HTTPClient.h>
|
|
|
|
|
+#include <WebServer.h>
|
|
|
|
|
+#include <WiFi.h>
|
|
|
|
|
+
|
|
|
|
|
+#include "ambient.h"
|
|
|
|
|
+#include "battery.h"
|
|
|
|
|
+#include "config.h"
|
|
|
|
|
+#include "display.h"
|
|
|
|
|
+#include "outdoor.h"
|
|
|
|
|
+#include "power.h"
|
|
|
|
|
+#include "settings.h"
|
|
|
|
|
+
|
|
|
|
|
+namespace {
|
|
|
|
|
+
|
|
|
|
|
+WebServer g_server(80);
|
|
|
|
|
+DNSServer g_dns;
|
|
|
|
|
+bool g_captive = false;
|
|
|
|
|
+bool g_running = false;
|
|
|
|
|
+uint32_t g_rebootAtMs = 0; // non-zero once a save has scheduled a restart
|
|
|
|
|
+uint32_t g_lastRequestMs = 0;
|
|
|
|
|
+bool g_everServed = false;
|
|
|
|
|
+
|
|
|
|
|
+// Setup page. Kept to one self-contained document — no CDN, no icon fonts —
|
|
|
|
|
+// because in setup mode the phone loading it has no route to the internet.
|
|
|
|
|
+const char PAGE_HTML[] PROGMEM = R"HTML(<!doctype html>
|
|
|
|
|
+<html lang="en">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="utf-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
|
|
+<title>Temperature Display Setup</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+:root{color-scheme:light dark}
|
|
|
|
|
+*{box-sizing:border-box}
|
|
|
|
|
+body{margin:0;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
|
|
|
+background:#f4f4f5;color:#18181b}
|
|
|
|
|
+main{max-width:34rem;margin:0 auto;padding:24px 16px 48px}
|
|
|
|
|
+h1{font-size:1.4rem;margin:0 0 4px}
|
|
|
|
|
+p.sub{margin:0 0 24px;color:#71717a;font-size:.9rem}
|
|
|
|
|
+fieldset{border:1px solid #d4d4d8;border-radius:10px;background:#fff;margin:0 0 20px;padding:16px}
|
|
|
|
|
+legend{padding:0 6px;font-weight:600;font-size:.85rem;text-transform:uppercase;letter-spacing:.04em;color:#52525b}
|
|
|
|
|
+label{display:block;margin:12px 0 4px;font-size:.85rem;font-weight:600}
|
|
|
|
|
+label:first-of-type{margin-top:0}
|
|
|
|
|
+input,select{width:100%;padding:10px;font-size:1rem;border:1px solid #d4d4d8;border-radius:8px;
|
|
|
|
|
+background:#fff;color:inherit}
|
|
|
|
|
+input:focus,select:focus{outline:2px solid #2563eb;outline-offset:-1px;border-color:#2563eb}
|
|
|
|
|
+small{display:block;margin-top:4px;color:#71717a;font-size:.78rem}
|
|
|
|
|
+.row{display:flex;gap:8px}
|
|
|
|
|
+.row>*{flex:1}
|
|
|
|
|
+button{width:100%;padding:12px;font-size:1rem;font-weight:600;border:0;border-radius:8px;
|
|
|
|
|
+background:#2563eb;color:#fff;cursor:pointer}
|
|
|
|
|
+button:disabled{opacity:.5;cursor:default}
|
|
|
|
|
+button.alt{background:#e4e4e7;color:#18181b;margin-top:8px}
|
|
|
|
|
+button.link{background:none;color:#2563eb;width:auto;padding:4px 0;font-size:.85rem;text-decoration:underline}
|
|
|
|
|
+#status{display:flex;gap:8px;margin-bottom:4px}
|
|
|
|
|
+#status div{flex:1;padding:8px 6px;border:1px solid #d4d4d8;border-radius:8px;
|
|
|
|
|
+background:#fafafa;text-align:center}
|
|
|
|
|
+#status b{display:block;font-size:1.1rem}
|
|
|
|
|
+#status span{font-size:.7rem;font-weight:600;letter-spacing:.06em;
|
|
|
|
|
+text-transform:uppercase;color:#71717a}
|
|
|
|
|
+#preview{margin-top:14px;padding:14px 10px;border:1px solid #d4d4d8;border-radius:8px;
|
|
|
|
|
+background:#fafafa;text-align:center;line-height:1.25}
|
|
|
|
|
+#preview .t{font-size:2rem;font-weight:700;letter-spacing:-.01em}
|
|
|
|
|
+#preview .d{font-size:1rem;font-weight:700;margin-top:2px}
|
|
|
|
|
+#preview .cap{display:block;margin-top:8px;font-size:.7rem;font-weight:600;letter-spacing:.06em;
|
|
|
|
|
+text-transform:uppercase;color:#71717a}
|
|
|
|
|
+#msg{margin:16px 0 0;padding:12px;border-radius:8px;display:none;font-size:.9rem}
|
|
|
|
|
+#msg.ok{display:block;background:#dcfce7;color:#14532d}
|
|
|
|
|
+#msg.err{display:block;background:#fee2e2;color:#7f1d1d}
|
|
|
|
|
+#msg.busy{display:block;background:#e0e7ff;color:#1e3a8a}
|
|
|
|
|
+@media(prefers-color-scheme:dark){
|
|
|
|
|
+body{background:#18181b;color:#f4f4f5}
|
|
|
|
|
+fieldset{background:#27272a;border-color:#3f3f46}
|
|
|
|
|
+input,select{background:#18181b;border-color:#3f3f46}
|
|
|
|
|
+legend,small,p.sub{color:#a1a1aa}
|
|
|
|
|
+#preview{background:#18181b;border-color:#3f3f46}
|
|
|
|
|
+#preview .cap{color:#a1a1aa}
|
|
|
|
|
+#status div{background:#18181b;border-color:#3f3f46}
|
|
|
|
|
+#status span{color:#a1a1aa}
|
|
|
|
|
+button.alt{background:#3f3f46;color:#f4f4f5}
|
|
|
|
|
+#msg.ok{background:#14532d;color:#dcfce7}
|
|
|
|
|
+#msg.err{background:#7f1d1d;color:#fee2e2}
|
|
|
|
|
+#msg.busy{background:#1e3a8a;color:#e0e7ff}
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+<main>
|
|
|
|
|
+<h1>Temperature Display</h1>
|
|
|
|
|
+<p class="sub">Wi-Fi, clock and outdoor temperature settings.</p>
|
|
|
|
|
+
|
|
|
|
|
+<form id="f">
|
|
|
|
|
+<fieldset>
|
|
|
|
|
+<legend>Wi-Fi</legend>
|
|
|
|
|
+<label for="ssid">Network</label>
|
|
|
|
|
+<div class="row">
|
|
|
|
|
+<select id="ssidSel"><option value="">Scanning...</option></select>
|
|
|
|
|
+</div>
|
|
|
|
|
+<button type="button" class="link" id="rescan">Rescan</button>
|
|
|
|
|
+<button type="button" class="link" id="manual">Enter name manually</button>
|
|
|
|
|
+<input id="ssid" name="ssid" placeholder="Network name" hidden>
|
|
|
|
|
+<label for="password">Password</label>
|
|
|
|
|
+<input id="password" name="password" type="password" autocomplete="off" placeholder="Leave blank to keep current">
|
|
|
|
|
+<small id="pwHint"></small>
|
|
|
|
|
+</fieldset>
|
|
|
|
|
+
|
|
|
|
|
+<fieldset>
|
|
|
|
|
+<legend>Outdoor temperature</legend>
|
|
|
|
|
+<label for="outdoorUrl">Source URL</label>
|
|
|
|
|
+<input id="outdoorUrl" name="outdoorUrl" placeholder="http://192.168.1.50/temperature">
|
|
|
|
|
+<small>Returns either a bare number (<code>14.8</code>) or JSON.</small>
|
|
|
|
|
+<label for="outdoorKey">JSON field</label>
|
|
|
|
|
+<input id="outdoorKey" name="outdoorKey" placeholder="temperature">
|
|
|
|
|
+<small>The field holding the value, for a JSON response. Leave blank for a bare number. Use dots for nesting, e.g. <code>main.temp</code>.</small>
|
|
|
|
|
+<button type="button" class="alt" id="test">Test this source</button>
|
|
|
|
|
+</fieldset>
|
|
|
|
|
+
|
|
|
|
|
+<fieldset>
|
|
|
|
|
+<legend>Clock and date</legend>
|
|
|
|
|
+<label for="clockFormat">Time format</label>
|
|
|
|
|
+<select id="clockFormat">
|
|
|
|
|
+<option value="24">24 hour — 14:05</option>
|
|
|
|
|
+<option value="12">12 hour — 2:05 PM</option>
|
|
|
|
|
+</select>
|
|
|
|
|
+
|
|
|
|
|
+<label for="datePreset">Date format</label>
|
|
|
|
|
+<select id="datePreset">
|
|
|
|
|
+<option value="%A %e %B">Monday 24 August</option>
|
|
|
|
|
+<option value="%A %e %B %Y">Monday 24 August 2026</option>
|
|
|
|
|
+<option value="%A, %e %B">Monday, 24 August</option>
|
|
|
|
|
+<option value="%B %e, %Y">August 24, 2026</option>
|
|
|
|
|
+<option value="%a %e %b %Y">Mon 24 Aug 2026</option>
|
|
|
|
|
+<option value="%e %B %Y">24 August 2026</option>
|
|
|
|
|
+<option value="%d/%m/%Y">24/08/2026</option>
|
|
|
|
|
+<option value="%m/%d/%Y">08/24/2026</option>
|
|
|
|
|
+<option value="%Y-%m-%d">2026-08-24</option>
|
|
|
|
|
+<option value="%A">Monday</option>
|
|
|
|
|
+<option value="">Custom...</option>
|
|
|
|
|
+</select>
|
|
|
|
|
+<label for="dateFormat">strftime pattern</label>
|
|
|
|
|
+<input id="dateFormat" name="dateFormat">
|
|
|
|
|
+<small>%A day, %a short day, %B month, %b short month, %e date, %d date padded, %Y year, %y short year.</small>
|
|
|
|
|
+<label><input type="checkbox" id="dateUppercase" style="width:auto;margin-right:8px">Capitals</label>
|
|
|
|
|
+<div id="preview"> </div>
|
|
|
|
|
+
|
|
|
|
|
+<label for="tzPreset">Timezone</label>
|
|
|
|
|
+<select id="tzPreset">
|
|
|
|
|
+<option value="AEST-10AEDT,M10.1.0,M4.1.0/3">Australia/Hobart, Melbourne, Sydney</option>
|
|
|
|
|
+<option value="AEST-10">Australia/Brisbane</option>
|
|
|
|
|
+<option value="ACST-9:30ACDT,M10.1.0,M4.1.0/3">Australia/Adelaide</option>
|
|
|
|
|
+<option value="ACST-9:30">Australia/Darwin</option>
|
|
|
|
|
+<option value="AWST-8">Australia/Perth</option>
|
|
|
|
|
+<option value="NZST-12NZDT,M9.5.0,M4.1.0/3">Pacific/Auckland</option>
|
|
|
|
|
+<option value="GMT0BST,M3.5.0/1,M10.5.0">Europe/London</option>
|
|
|
|
|
+<option value="UTC0">UTC</option>
|
|
|
|
|
+<option value="">Custom...</option>
|
|
|
|
|
+</select>
|
|
|
|
|
+<label for="timezone">POSIX TZ string</label>
|
|
|
|
|
+<input id="timezone" name="timezone">
|
|
|
|
|
+<label for="ntpServer">NTP server</label>
|
|
|
|
|
+<input id="ntpServer" name="ntpServer" placeholder="pool.ntp.org">
|
|
|
|
|
+</fieldset>
|
|
|
|
|
+
|
|
|
|
|
+<fieldset>
|
|
|
|
|
+<legend>Battery and power</legend>
|
|
|
|
|
+<div id="status"> </div>
|
|
|
|
|
+<label><input type="checkbox" id="showBattery" style="width:auto;margin-right:8px">Show the battery indicator</label>
|
|
|
|
|
+<label><input type="checkbox" id="panelLowPower" style="width:auto;margin-right:8px">Panel low-power mode</label>
|
|
|
|
|
+<small>Reduces the screen's internal refresh rate. Suits a clock that changes once a minute. Turn off if the screen looks wrong.</small>
|
|
|
|
|
+<label><input type="checkbox" id="sleepWhenDark" style="width:auto;margin-right:8px">Sleep when the room is dark</label>
|
|
|
|
|
+<small>The screen has no backlight, so it cannot be read in the dark. Sleeping then costs nothing and saves the most power. Press KEY to wake it at once.</small>
|
|
|
|
|
+<div id="darkOpts">
|
|
|
|
|
+<label for="darkLux">Darkness threshold (lux)</label>
|
|
|
|
|
+<input id="darkLux" type="number" min="0" max="1000" step="1">
|
|
|
|
|
+<small id="luxHint"></small>
|
|
|
|
|
+<label>Quiet hours, used when no light sensor is fitted</label>
|
|
|
|
|
+<div class="row">
|
|
|
|
|
+<select id="quietStartHour"></select>
|
|
|
|
|
+<select id="quietEndHour"></select>
|
|
|
|
|
+</div>
|
|
|
|
|
+</div>
|
|
|
|
|
+</fieldset>
|
|
|
|
|
+
|
|
|
|
|
+<button type="submit" id="save">Save and restart</button>
|
|
|
|
|
+<button type="button" class="alt" id="factory">Erase all settings</button>
|
|
|
|
|
+<p id="msg"></p>
|
|
|
|
|
+</form>
|
|
|
|
|
+</main>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+var captive = false, hasPw = false;
|
|
|
|
|
+function $(id){ return document.getElementById(id); }
|
|
|
|
|
+function msg(text, cls){ var m = $('msg'); m.textContent = text; m.className = cls || ''; }
|
|
|
|
|
+
|
|
|
|
|
+// Selects the option matching value, or the trailing "Custom..." entry.
|
|
|
|
|
+function selectMatching(id, value){
|
|
|
|
|
+ var sel = $(id);
|
|
|
|
|
+ sel.value = '';
|
|
|
|
|
+ for (var i = 0; i < sel.options.length; i++) {
|
|
|
|
|
+ if (sel.options[i].value === value) { sel.value = value; return; }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function showManual(on){
|
|
|
|
|
+ $('ssid').hidden = !on;
|
|
|
|
|
+ $('ssidSel').parentElement.hidden = on;
|
|
|
|
|
+ $('manual').textContent = on ? 'Choose from list' : 'Enter name manually';
|
|
|
|
|
+}
|
|
|
|
|
+$('manual').onclick = function(){ showManual($('ssid').hidden); };
|
|
|
|
|
+
|
|
|
|
|
+$('tzPreset').onchange = function(){
|
|
|
|
|
+ if (this.value) { $('timezone').value = this.value; }
|
|
|
|
|
+ else { $('timezone').focus(); }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+$('datePreset').onchange = function(){
|
|
|
|
|
+ if (this.value) { $('dateFormat').value = this.value; preview(); }
|
|
|
|
|
+ else { $('dateFormat').focus(); }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// The device formats the preview with the same code that draws the panel, so
|
|
|
|
|
+// what is shown here is exactly what will appear on screen.
|
|
|
|
|
+var previewTimer = null;
|
|
|
|
|
+function preview(){
|
|
|
|
|
+ clearTimeout(previewTimer);
|
|
|
|
|
+ previewTimer = setTimeout(function(){
|
|
|
|
|
+ var q = 'api/preview?clock=' + ($('clockFormat').value)
|
|
|
|
|
+ + '&date=' + encodeURIComponent($('dateFormat').value)
|
|
|
|
|
+ + '&upper=' + ($('dateUppercase').checked ? '1' : '0');
|
|
|
|
|
+ fetch(q).then(function(r){ return r.json(); }).then(function(d){
|
|
|
|
|
+ $('preview').innerHTML =
|
|
|
|
|
+ '<div class="t"></div><div class="d"></div><span class="cap"></span>';
|
|
|
|
|
+ $('preview').querySelector('.t').textContent = d.time + (d.suffix ? ' ' + d.suffix : '');
|
|
|
|
|
+ $('preview').querySelector('.d').textContent = d.date;
|
|
|
|
|
+ $('preview').querySelector('.cap').textContent =
|
|
|
|
|
+ d.sample ? 'Sample — the clock is not set yet' : 'Preview';
|
|
|
|
|
+ }).catch(function(){});
|
|
|
|
|
+ }, 250);
|
|
|
|
|
+}
|
|
|
|
|
+$('clockFormat').onchange = preview;
|
|
|
|
|
+$('dateUppercase').onchange = preview;
|
|
|
|
|
+$('dateFormat').oninput = preview;
|
|
|
|
|
+
|
|
|
|
|
+// Hour pickers for the quiet-hours window.
|
|
|
|
|
+(function(){
|
|
|
|
|
+ for (var h = 0; h < 24; h++) {
|
|
|
|
|
+ var label = (h < 10 ? '0' : '') + h + ':00';
|
|
|
|
|
+ $('quietStartHour').add(new Option('from ' + label, h));
|
|
|
|
|
+ $('quietEndHour').add(new Option('to ' + label, h));
|
|
|
|
|
+ }
|
|
|
|
|
+})();
|
|
|
|
|
+
|
|
|
|
|
+function syncDarkOpts(){ $('darkOpts').hidden = !$('sleepWhenDark').checked; }
|
|
|
|
|
+$('sleepWhenDark').onchange = syncDarkOpts;
|
|
|
|
|
+
|
|
|
|
|
+function status(){
|
|
|
|
|
+ fetch('api/status').then(function(r){ return r.json(); }).then(function(s){
|
|
|
|
|
+ var cells = [];
|
|
|
|
|
+ cells.push(s.batteryValid
|
|
|
|
|
+ ? ['<b>' + s.batteryPercent + '%</b>', s.batteryVolts.toFixed(2) + ' V']
|
|
|
|
|
+ : ['<b>–</b>', 'no battery']);
|
|
|
|
|
+ if (s.hasLightSensor) {
|
|
|
|
|
+ cells.push(['<b>' + s.lux.toFixed(0) + '</b>', 'lux' + (s.dark ? ', dark' : '')]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ cells.push(['<b>–</b>', 'no light sensor']);
|
|
|
|
|
+ }
|
|
|
|
|
+ $('status').innerHTML = cells.map(function(c){
|
|
|
|
|
+ return '<div>' + c[0] + '<span>' + c[1] + '</span></div>';
|
|
|
|
|
+ }).join('');
|
|
|
|
|
+ $('luxHint').textContent = s.hasLightSensor
|
|
|
|
|
+ ? 'BH1750 detected. The room is ' + s.lux.toFixed(0) + ' lux right now.'
|
|
|
|
|
+ : 'No BH1750 found, so the quiet hours below are used instead.';
|
|
|
|
|
+ }).catch(function(){});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function scan(){
|
|
|
|
|
+ $('ssidSel').innerHTML = '<option value="">Scanning...</option>';
|
|
|
|
|
+ fetch('api/scan').then(function(r){ return r.json(); }).then(function(list){
|
|
|
|
|
+ var sel = $('ssidSel');
|
|
|
|
|
+ sel.innerHTML = '';
|
|
|
|
|
+ if (!list.length) {
|
|
|
|
|
+ sel.innerHTML = '<option value="">No networks found</option>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ list.forEach(function(n){
|
|
|
|
|
+ var o = document.createElement('option');
|
|
|
|
|
+ o.value = n.ssid;
|
|
|
|
|
+ o.textContent = n.ssid + ' (' + n.rssi + ' dBm' + (n.open ? ', open' : '') + ')';
|
|
|
|
|
+ sel.appendChild(o);
|
|
|
|
|
+ });
|
|
|
|
|
+ if ($('ssid').value) { sel.value = $('ssid').value; }
|
|
|
|
|
+ }).catch(function(){
|
|
|
|
|
+ $('ssidSel').innerHTML = '<option value="">Scan failed</option>';
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+$('rescan').onclick = scan;
|
|
|
|
|
+
|
|
|
|
|
+fetch('api/config').then(function(r){ return r.json(); }).then(function(c){
|
|
|
|
|
+ captive = c.captive;
|
|
|
|
|
+ hasPw = c.hasPassword;
|
|
|
|
|
+ $('ssid').value = c.ssid || '';
|
|
|
|
|
+ $('outdoorUrl').value = c.outdoorUrl || '';
|
|
|
|
|
+ $('outdoorKey').value = c.outdoorKey || '';
|
|
|
|
|
+ $('timezone').value = c.timezone || '';
|
|
|
|
|
+ $('ntpServer').value = c.ntpServer || '';
|
|
|
|
|
+ $('clockFormat').value = c.clock24h ? '24' : '12';
|
|
|
|
|
+ $('dateFormat').value = c.dateFormat || '';
|
|
|
|
|
+ $('dateUppercase').checked = !!c.dateUppercase;
|
|
|
|
|
+ $('showBattery').checked = !!c.showBattery;
|
|
|
|
|
+ $('panelLowPower').checked = !!c.panelLowPower;
|
|
|
|
|
+ $('sleepWhenDark').checked = !!c.sleepWhenDark;
|
|
|
|
|
+ $('darkLux').value = c.darkLux;
|
|
|
|
|
+ $('quietStartHour').value = c.quietStartHour;
|
|
|
|
|
+ $('quietEndHour').value = c.quietEndHour;
|
|
|
|
|
+ selectMatching('tzPreset', c.timezone);
|
|
|
|
|
+ selectMatching('datePreset', c.dateFormat);
|
|
|
|
|
+ syncDarkOpts();
|
|
|
|
|
+ preview();
|
|
|
|
|
+ status();
|
|
|
|
|
+ $('pwHint').textContent = hasPw
|
|
|
|
|
+ ? 'A password is already stored. Leave blank to keep it.'
|
|
|
|
|
+ : 'Leave blank if the network is open.';
|
|
|
|
|
+ if (captive) {
|
|
|
|
|
+ $('test').disabled = true;
|
|
|
|
|
+ $('test').textContent = 'Test available once connected to Wi-Fi';
|
|
|
|
|
+ }
|
|
|
|
|
+ scan();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+$('test').onclick = function(){
|
|
|
|
|
+ var url = $('outdoorUrl').value.trim();
|
|
|
|
|
+ if (!url) { msg('Enter a source URL first.', 'err'); return; }
|
|
|
|
|
+ msg('Testing...', 'busy');
|
|
|
|
|
+ $('test').disabled = true;
|
|
|
|
|
+ fetch('api/test?url=' + encodeURIComponent(url) + '&key=' + encodeURIComponent($('outdoorKey').value.trim()))
|
|
|
|
|
+ .then(function(r){ return r.json(); })
|
|
|
|
|
+ .then(function(d){
|
|
|
|
|
+ msg(d.ok ? 'Read ' + d.value.toFixed(1) + ' °C from the source.'
|
|
|
|
|
+ : 'Failed: ' + d.error, d.ok ? 'ok' : 'err');
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function(){ msg('Test request failed.', 'err'); })
|
|
|
|
|
+ .then(function(){ $('test').disabled = false; });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+$('factory').onclick = function(){
|
|
|
|
|
+ if (!confirm('Erase Wi-Fi and all settings, then restart?')) { return; }
|
|
|
|
|
+ fetch('api/reset', {method:'POST'}).then(function(){
|
|
|
|
|
+ msg('Erased. The display will restart into setup mode.', 'ok');
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+$('f').onsubmit = function(e){
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ var body = new URLSearchParams();
|
|
|
|
|
+ body.set('ssid', $('ssid').hidden ? $('ssidSel').value : $('ssid').value);
|
|
|
|
|
+ body.set('password', $('password').value);
|
|
|
|
|
+ body.set('outdoorUrl', $('outdoorUrl').value);
|
|
|
|
|
+ body.set('outdoorKey', $('outdoorKey').value);
|
|
|
|
|
+ body.set('timezone', $('timezone').value);
|
|
|
|
|
+ body.set('ntpServer', $('ntpServer').value);
|
|
|
|
|
+ body.set('clock24h', $('clockFormat').value === '24' ? '1' : '0');
|
|
|
|
|
+ body.set('dateFormat', $('dateFormat').value);
|
|
|
|
|
+ body.set('dateUppercase', $('dateUppercase').checked ? '1' : '0');
|
|
|
|
|
+ body.set('showBattery', $('showBattery').checked ? '1' : '0');
|
|
|
|
|
+ body.set('panelLowPower', $('panelLowPower').checked ? '1' : '0');
|
|
|
|
|
+ body.set('sleepWhenDark', $('sleepWhenDark').checked ? '1' : '0');
|
|
|
|
|
+ body.set('darkLux', $('darkLux').value);
|
|
|
|
|
+ body.set('quietStartHour', $('quietStartHour').value);
|
|
|
|
|
+ body.set('quietEndHour', $('quietEndHour').value);
|
|
|
|
|
+ if (!body.get('ssid')) { msg('Choose a Wi-Fi network.', 'err'); return; }
|
|
|
|
|
+ msg('Saving...', 'busy');
|
|
|
|
|
+ $('save').disabled = true;
|
|
|
|
|
+ fetch('api/save', {method:'POST', body: body})
|
|
|
|
|
+ .then(function(r){ return r.json(); })
|
|
|
|
|
+ .then(function(d){
|
|
|
|
|
+ if (d.ok) {
|
|
|
|
|
+ msg('Saved. Restarting — the display will connect to "' + body.get('ssid')
|
|
|
|
|
+ + '" and set its clock. This page will stop responding.', 'ok');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ msg('Save failed: ' + d.error, 'err');
|
|
|
|
|
+ $('save').disabled = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function(){ msg('Save request failed.', 'err'); $('save').disabled = false; });
|
|
|
|
|
+};
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+)HTML";
|
|
|
|
|
+
|
|
|
|
|
+void noteRequest()
|
|
|
|
|
+{
|
|
|
|
|
+ g_lastRequestMs = millis();
|
|
|
|
|
+ g_everServed = true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void sendJson(int code, const JsonDocument &doc)
|
|
|
|
|
+{
|
|
|
|
|
+ noteRequest();
|
|
|
|
|
+ String out;
|
|
|
|
|
+ serializeJson(doc, out);
|
|
|
|
|
+ g_server.send(code, "application/json", out);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void handleRoot()
|
|
|
|
|
+{
|
|
|
|
|
+ noteRequest();
|
|
|
|
|
+ g_server.sendHeader("Cache-Control", "no-store");
|
|
|
|
|
+ g_server.send_P(200, "text/html", PAGE_HTML);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void handleGetConfig()
|
|
|
|
|
+{
|
|
|
|
|
+ const settings::Config &c = settings::get();
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+ doc["ssid"] = c.ssid;
|
|
|
|
|
+ doc["hasPassword"] = c.password[0] != '\0'; // never send the password itself
|
|
|
|
|
+ doc["outdoorUrl"] = c.outdoorUrl;
|
|
|
|
|
+ doc["outdoorKey"] = c.outdoorKey;
|
|
|
|
|
+ doc["timezone"] = c.timezone;
|
|
|
|
|
+ doc["ntpServer"] = c.ntpServer;
|
|
|
|
|
+ doc["clock24h"] = c.clock24h;
|
|
|
|
|
+ doc["dateFormat"] = c.dateFormat;
|
|
|
|
|
+ doc["dateUppercase"] = c.dateUppercase;
|
|
|
|
|
+ doc["showBattery"] = c.showBattery;
|
|
|
|
|
+ doc["sleepWhenDark"] = c.sleepWhenDark;
|
|
|
|
|
+ doc["panelLowPower"] = c.panelLowPower;
|
|
|
|
|
+ doc["darkLux"] = c.darkLux;
|
|
|
|
|
+ doc["quietStartHour"] = c.quietStartHour;
|
|
|
|
|
+ doc["quietEndHour"] = c.quietEndHour;
|
|
|
|
|
+ doc["captive"] = g_captive;
|
|
|
|
|
+ doc["hostname"] = DEVICE_HOSTNAME;
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void handleScan()
|
|
|
|
|
+{
|
|
|
|
|
+ // Synchronous scan; a few seconds with the web server blocked is acceptable
|
|
|
|
|
+ // here and much simpler than polling an async scan from the browser.
|
|
|
|
|
+ const int n = WiFi.scanNetworks(false, true);
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+ JsonArray arr = doc.to<JsonArray>();
|
|
|
|
|
+
|
|
|
|
|
+ for (int i = 0; i < n; i++) {
|
|
|
|
|
+ const String ssid = WiFi.SSID(i);
|
|
|
|
|
+ if (ssid.isEmpty()) {
|
|
|
|
|
+ continue; // hidden network: nothing useful to show or select
|
|
|
|
|
+ }
|
|
|
|
|
+ bool duplicate = false;
|
|
|
|
|
+ for (JsonObject existing : arr) {
|
|
|
|
|
+ if (ssid == existing["ssid"].as<const char *>()) {
|
|
|
|
|
+ duplicate = true; // same network on two bands or two APs
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (duplicate) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ JsonObject o = arr.add<JsonObject>();
|
|
|
|
|
+ o["ssid"] = ssid;
|
|
|
|
|
+ o["rssi"] = WiFi.RSSI(i);
|
|
|
|
|
+ o["open"] = WiFi.encryptionType(i) == WIFI_AUTH_OPEN;
|
|
|
|
|
+ }
|
|
|
|
|
+ WiFi.scanDelete();
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void handleSave()
|
|
|
|
|
+{
|
|
|
|
|
+ settings::Config cfg = settings::get(); // start from stored values
|
|
|
|
|
+
|
|
|
|
|
+ if (!g_server.hasArg("ssid") || g_server.arg("ssid").isEmpty()) {
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+ doc["ok"] = false;
|
|
|
|
|
+ doc["error"] = "no network name given";
|
|
|
|
|
+ sendJson(400, doc);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const String newSsid = g_server.arg("ssid");
|
|
|
|
|
+ const String newPassword = g_server.arg("password");
|
|
|
|
|
+
|
|
|
|
|
+ // An empty password field means "keep the stored one" — but only for the
|
|
|
|
|
+ // same network. Switching networks with a blank field means an open network.
|
|
|
|
|
+ const bool sameNetwork = newSsid == cfg.ssid;
|
|
|
|
|
+ if (!newPassword.isEmpty() || !sameNetwork) {
|
|
|
|
|
+ snprintf(cfg.password, sizeof(cfg.password), "%s", newPassword.c_str());
|
|
|
|
|
+ }
|
|
|
|
|
+ snprintf(cfg.ssid, sizeof(cfg.ssid), "%s", newSsid.c_str());
|
|
|
|
|
+ snprintf(cfg.outdoorUrl, sizeof(cfg.outdoorUrl), "%s",
|
|
|
|
|
+ g_server.arg("outdoorUrl").c_str());
|
|
|
|
|
+ snprintf(cfg.outdoorKey, sizeof(cfg.outdoorKey), "%s",
|
|
|
|
|
+ g_server.arg("outdoorKey").c_str());
|
|
|
|
|
+ snprintf(cfg.timezone, sizeof(cfg.timezone), "%s",
|
|
|
|
|
+ g_server.arg("timezone").c_str());
|
|
|
|
|
+ snprintf(cfg.ntpServer, sizeof(cfg.ntpServer), "%s",
|
|
|
|
|
+ g_server.arg("ntpServer").c_str());
|
|
|
|
|
+ snprintf(cfg.dateFormat, sizeof(cfg.dateFormat), "%s",
|
|
|
|
|
+ g_server.arg("dateFormat").c_str());
|
|
|
|
|
+ cfg.clock24h = g_server.arg("clock24h") != "0";
|
|
|
|
|
+ cfg.dateUppercase = g_server.arg("dateUppercase") == "1";
|
|
|
|
|
+ cfg.showBattery = g_server.arg("showBattery") == "1";
|
|
|
|
|
+ cfg.panelLowPower = g_server.arg("panelLowPower") == "1";
|
|
|
|
|
+ cfg.sleepWhenDark = g_server.arg("sleepWhenDark") == "1";
|
|
|
|
|
+ cfg.darkLux = (uint16_t)g_server.arg("darkLux").toInt();
|
|
|
|
|
+ cfg.quietStartHour = (uint8_t)g_server.arg("quietStartHour").toInt();
|
|
|
|
|
+ cfg.quietEndHour = (uint8_t)g_server.arg("quietEndHour").toInt();
|
|
|
|
|
+
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+ if (!settings::save(cfg)) {
|
|
|
|
|
+ doc["ok"] = false;
|
|
|
|
|
+ doc["error"] = "could not write to flash";
|
|
|
|
|
+ sendJson(500, doc);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ doc["ok"] = true;
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+
|
|
|
|
|
+ // Restart rather than reconfigure in place: a clean boot exercises the same
|
|
|
|
|
+ // path as a power-on, so what the user sees now is what they get later.
|
|
|
|
|
+ g_rebootAtMs = millis() + 1200;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void handleTest()
|
|
|
|
|
+{
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+
|
|
|
|
|
+ if (WiFi.status() != WL_CONNECTED) {
|
|
|
|
|
+ doc["ok"] = false;
|
|
|
|
|
+ doc["error"] = "the display is not on your network yet";
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!g_server.hasArg("url")) {
|
|
|
|
|
+ doc["ok"] = false;
|
|
|
|
|
+ doc["error"] = "no URL given";
|
|
|
|
|
+ sendJson(400, doc);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const String url = g_server.arg("url");
|
|
|
|
|
+ const String key = g_server.arg("key");
|
|
|
|
|
+
|
|
|
|
|
+ HTTPClient http;
|
|
|
|
|
+ http.setTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
|
|
|
|
|
+ http.setConnectTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
|
|
|
|
|
+ if (!http.begin(url)) {
|
|
|
|
|
+ doc["ok"] = false;
|
|
|
|
|
+ doc["error"] = "URL is not valid";
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const int code = http.GET();
|
|
|
|
|
+ if (code != HTTP_CODE_OK) {
|
|
|
|
|
+ http.end();
|
|
|
|
|
+ doc["ok"] = false;
|
|
|
|
|
+ doc["error"] = code > 0 ? String("HTTP ") + code : String("could not connect");
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const String body = http.getString();
|
|
|
|
|
+ http.end();
|
|
|
|
|
+
|
|
|
|
|
+ float value = 0.0f;
|
|
|
|
|
+ if (!outdoor::parseTemperature(body, key.c_str(), value)) {
|
|
|
|
|
+ doc["ok"] = false;
|
|
|
|
|
+ doc["error"] = String("could not read a temperature from: ") + body.substring(0, 80);
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ doc["ok"] = true;
|
|
|
|
|
+ doc["value"] = value;
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Live sensor readings, so the user can set the darkness threshold against
|
|
|
|
|
+// what the room actually measures rather than by guesswork.
|
|
|
|
|
+void handleStatus()
|
|
|
|
|
+{
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+
|
|
|
|
|
+ battery::sample();
|
|
|
|
|
+ doc["batteryValid"] = battery::hasReading();
|
|
|
|
|
+ doc["batteryVolts"] = battery::volts();
|
|
|
|
|
+ doc["batteryPercent"] = battery::percent();
|
|
|
|
|
+
|
|
|
|
|
+ doc["hasLightSensor"] = ambient::hasSensor();
|
|
|
|
|
+ float lux = 0.0f;
|
|
|
|
|
+ if (ambient::readLux(lux)) {
|
|
|
|
|
+ doc["lux"] = lux;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ doc["lux"] = ambient::lastLux() < 0.0f ? 0.0f : ambient::lastLux();
|
|
|
|
|
+ }
|
|
|
|
|
+ doc["dark"] = ambient::isDark();
|
|
|
|
|
+ doc["resting"] = power::isResting();
|
|
|
|
|
+
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Renders the clock and date exactly as the panel would, so the user can see
|
|
|
|
|
+// the effect of a format change before committing it.
|
|
|
|
|
+void handlePreview()
|
|
|
|
|
+{
|
|
|
|
|
+ const bool clock24h = g_server.arg("clock") != "12";
|
|
|
|
|
+ const bool uppercase = g_server.arg("upper") == "1";
|
|
|
|
|
+ String format = g_server.arg("date");
|
|
|
|
|
+ if (format.isEmpty()) {
|
|
|
|
|
+ format = DATE_FORMAT;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Before the first NTP sync the system clock is meaningless, so preview
|
|
|
|
|
+ // against a fixed sample instead of showing 1970.
|
|
|
|
|
+ const time_t now = time(nullptr);
|
|
|
|
|
+ struct tm local;
|
|
|
|
|
+ localtime_r(&now, &local);
|
|
|
|
|
+ const bool sample = local.tm_year + 1900 < 2024;
|
|
|
|
|
+ if (sample) {
|
|
|
|
|
+ local = {};
|
|
|
|
|
+ local.tm_year = 126; // 2026
|
|
|
|
|
+ local.tm_mon = 7; // August
|
|
|
|
|
+ local.tm_mday = 24;
|
|
|
|
|
+ local.tm_wday = 1; // Monday
|
|
|
|
|
+ local.tm_yday = 235;
|
|
|
|
|
+ local.tm_hour = 14;
|
|
|
|
|
+ local.tm_min = 5;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ char timeText[8];
|
|
|
|
|
+ char suffix[4];
|
|
|
|
|
+ char dateText[64];
|
|
|
|
|
+ display::formatTimeText(local.tm_hour, local.tm_min, !clock24h,
|
|
|
|
|
+ timeText, sizeof(timeText), suffix, sizeof(suffix));
|
|
|
|
|
+ display::formatDateLine(local, format.c_str(), uppercase,
|
|
|
|
|
+ dateText, sizeof(dateText));
|
|
|
|
|
+
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+ doc["time"] = timeText;
|
|
|
|
|
+ doc["suffix"] = suffix;
|
|
|
|
|
+ doc["date"] = dateText;
|
|
|
|
|
+ doc["sample"] = sample;
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void handleReset()
|
|
|
|
|
+{
|
|
|
|
|
+ settings::clear();
|
|
|
|
|
+ JsonDocument doc;
|
|
|
|
|
+ doc["ok"] = true;
|
|
|
|
|
+ sendJson(200, doc);
|
|
|
|
|
+ g_rebootAtMs = millis() + 1200;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void handleNotFound()
|
|
|
|
|
+{
|
|
|
|
|
+ if (g_captive) {
|
|
|
|
|
+ // Redirect every stray request — including the OS connectivity probes that
|
|
|
|
|
+ // trigger the "sign in to network" prompt — at the setup page.
|
|
|
|
|
+ g_server.sendHeader("Location",
|
|
|
|
|
+ String("http://") + WiFi.softAPIP().toString() + "/", true);
|
|
|
|
|
+ g_server.send(302, "text/plain", "");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ g_server.send(404, "text/plain", "not found");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void registerHandlers()
|
|
|
|
|
+{
|
|
|
|
|
+ g_server.on("/", HTTP_GET, handleRoot);
|
|
|
|
|
+ g_server.on("/api/config", HTTP_GET, handleGetConfig);
|
|
|
|
|
+ g_server.on("/api/scan", HTTP_GET, handleScan);
|
|
|
|
|
+ g_server.on("/api/save", HTTP_POST, handleSave);
|
|
|
|
|
+ g_server.on("/api/test", HTTP_GET, handleTest);
|
|
|
|
|
+ g_server.on("/api/preview", HTTP_GET, handlePreview);
|
|
|
|
|
+ g_server.on("/api/status", HTTP_GET, handleStatus);
|
|
|
|
|
+ g_server.on("/api/reset", HTTP_POST, handleReset);
|
|
|
|
|
+ g_server.onNotFound(handleNotFound);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace
|
|
|
|
|
+
|
|
|
|
|
+namespace portal {
|
|
|
|
|
+
|
|
|
|
|
+void startAccessPoint()
|
|
|
|
|
+{
|
|
|
|
|
+ WiFi.mode(WIFI_AP_STA); // STA kept up so the network scan still works
|
|
|
|
|
+ WiFi.softAP(SETUP_AP_SSID, SETUP_AP_PASSWORD);
|
|
|
|
|
+ delay(100);
|
|
|
|
|
+
|
|
|
|
|
+ g_captive = true;
|
|
|
|
|
+ g_dns.setErrorReplyCode(DNSReplyCode::NoError);
|
|
|
|
|
+ g_dns.start(53, "*", WiFi.softAPIP()); // every name resolves to us
|
|
|
|
|
+
|
|
|
|
|
+ registerHandlers();
|
|
|
|
|
+ g_server.begin();
|
|
|
|
|
+ g_running = true;
|
|
|
|
|
+
|
|
|
|
|
+ Serial.printf("[portal] setup AP '%s' at %s\n", SETUP_AP_SSID,
|
|
|
|
|
+ WiFi.softAPIP().toString().c_str());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void startStation()
|
|
|
|
|
+{
|
|
|
|
|
+ if (!g_running) {
|
|
|
|
|
+ registerHandlers();
|
|
|
|
|
+ g_server.begin();
|
|
|
|
|
+ g_running = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Re-announced on every association, since this is also the path back from
|
|
|
|
|
+ // a dark-room rest with the radio powered down.
|
|
|
|
|
+ MDNS.end();
|
|
|
|
|
+ if (MDNS.begin(DEVICE_HOSTNAME)) {
|
|
|
|
|
+ MDNS.addService("http", "tcp", 80);
|
|
|
|
|
+ }
|
|
|
|
|
+ Serial.printf("[portal] settings at http://%s/ or http://%s.local/\n",
|
|
|
|
|
+ WiFi.localIP().toString().c_str(), DEVICE_HOSTNAME);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void loop()
|
|
|
|
|
+{
|
|
|
|
|
+ if (!g_running) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (g_captive) {
|
|
|
|
|
+ g_dns.processNextRequest();
|
|
|
|
|
+ }
|
|
|
|
|
+ g_server.handleClient();
|
|
|
|
|
+
|
|
|
|
|
+ if (g_rebootAtMs != 0 && (int32_t)(millis() - g_rebootAtMs) >= 0) {
|
|
|
|
|
+ Serial.println("[portal] restarting");
|
|
|
|
|
+ delay(50);
|
|
|
|
|
+ ESP.restart();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool isCaptive()
|
|
|
|
|
+{
|
|
|
|
|
+ return g_captive;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+uint32_t millisSinceLastRequest()
|
|
|
|
|
+{
|
|
|
|
|
+ if (!g_everServed) {
|
|
|
|
|
+ return UINT32_MAX;
|
|
|
|
|
+ }
|
|
|
|
|
+ return millis() - g_lastRequestMs;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const char *apSsid()
|
|
|
|
|
+{
|
|
|
|
|
+ return SETUP_AP_SSID;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const char *apPassword()
|
|
|
|
|
+{
|
|
|
|
|
+ return SETUP_AP_PASSWORD;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+IPAddress apIp()
|
|
|
|
|
+{
|
|
|
|
|
+ return WiFi.softAPIP();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace portal
|