1052 lines
44 KiB
C
1052 lines
44 KiB
C
/*
|
|
* MTG RFID Companion - Registration web server (SoftAP)
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include "esp_log.h"
|
|
#include "esp_wifi.h"
|
|
#include "esp_netif.h"
|
|
#include "esp_http_server.h"
|
|
#include "cJSON.h"
|
|
#include "rfid_manager.h"
|
|
#include "storage_manager.h"
|
|
#include "scryfall_client.h"
|
|
#include "wifi_manager.h"
|
|
#include "dns_server.h"
|
|
#include "web_server.h"
|
|
#include "serial_log.h"
|
|
|
|
static const char *TAG = "web";
|
|
|
|
#define AP_SSID "MTG-Companion"
|
|
#define AP_PASS ""
|
|
|
|
#define HTTPD_BODY_MAX (2048)
|
|
|
|
static const char SPA_PAGE[] =
|
|
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
|
|
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
|
|
"<title>MTG RFID Companion</title><style>"
|
|
"body{font-family:system-ui,-apple-system,sans-serif;background:#141424;color:#eee;max-width:600px;margin:1.5em auto;padding:1em}"
|
|
"h1{color:#ffd75a;margin-bottom:0.2em}h2{color:#ffd75a;font-size:1.15em;margin-top:1.2em}"
|
|
".uid{font-size:1.5em;font-family:monospace;letter-spacing:2px;color:#7fd77f;word-break:break-all;background:#1e1e32;padding:.4em .6em;border-radius:6px;border:1px solid #333}"
|
|
"input,select{font-size:1em;padding:.5em .8em;border-radius:6px;border:1px solid #555;background:#222;color:#eee;width:100%;box-sizing:border-box}"
|
|
"button,.btn{font-size:.95em;padding:.5em 1em;border-radius:6px;border:1px solid #555;background:#2a2a44;color:#eee;cursor:pointer;margin:.3em 0}"
|
|
"button:hover,.btn:hover{background:#3a3a5c}"
|
|
".btn-primary{background:#ffd75a;color:#111;border:none;font-weight:bold}"
|
|
".btn-primary:hover{background:#ffe380}"
|
|
".btn-del{background:#4a1e1e;color:#ff9999;border:1px solid #772222;font-size:.85em;padding:.4em .8em;cursor:pointer;border-radius:6px}"
|
|
".btn-del:hover{background:#772222;color:#fff}"
|
|
".tabs{display:flex;gap:8px;margin-bottom:1.5em}"
|
|
".tab-btn{flex:1;font-size:1em;padding:.6em;border-radius:8px;background:#1e1e32;color:#aaa;border:1px solid #33334c;cursor:pointer;font-weight:600;text-align:center}"
|
|
".tab-btn.active{background:#ffd75a;color:#111;border-color:#ffd75a}"
|
|
".tab-pane{display:none}"
|
|
".tab-pane.active{display:block}"
|
|
".status-badge{display:inline-block;padding:.3em .7em;border-radius:12px;font-size:.85em;font-weight:bold;margin-bottom:.8em}"
|
|
".badge-ok{background:#1b4332;color:#7fd77f;border:1px solid #2d6a4f}"
|
|
".badge-bad{background:#4a1e1e;color:#ff9999;border:1px solid #772222}"
|
|
".input-grp{margin-bottom:1em}"
|
|
".input-grp label{display:block;color:#ccc;font-size:.85em;margin-bottom:.3em}"
|
|
".pwd-row{display:flex;gap:6px}"
|
|
".pwd-row input{flex:1}"
|
|
".sug{list-style:none;padding:0;margin:.4em 0;background:#1f1f33;border-radius:6px;border:1px solid #333}"
|
|
".sug li{cursor:pointer;padding:.5em .8em;border-bottom:1px solid #333}"
|
|
".sug li:hover{background:#2a2a48;color:#ffd75a}"
|
|
".ok{color:#7fd77f}.bad{color:#ff6b6b}"
|
|
".card-item{display:flex;align-items:center;gap:12px;background:#1e1e32;border:1px solid #33334c;border-radius:8px;padding:8px 12px;margin-bottom:8px}"
|
|
".card-thumb{width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid #444;background:#111;flex-shrink:0}"
|
|
".card-info{flex:1;min-width:0}"
|
|
".card-title{font-weight:bold;color:#ffd75a;font-size:1.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
|
|
".card-uid{font-family:monospace;color:#7fd77f;font-size:.85em;margin-top:2px}"
|
|
".card-none{color:#777;font-style:italic;padding:.8em 0}"
|
|
".header-row{display:flex;align-items:center;justify-content:space-between}"
|
|
"</style></head><body>"
|
|
"<h1>MTG RFID Companion</h1>"
|
|
"<p style=\"color:#aaa;margin-top:0\">Manage card tags, decks, and device Wi-Fi connectivity.</p>"
|
|
"<div class=\"tabs\">"
|
|
"<button id=\"tb-cards\" class=\"tab-btn active\" onclick=\"tab('cards')\">🃏 Card Pairing</button>"
|
|
"<button id=\"tb-wifi\" class=\"tab-btn\" onclick=\"tab('wifi')\">📶 Wi-Fi Settings</button>"
|
|
"<button id=\"tb-serial\" class=\"tab-btn\" onclick=\"tab('serial')\">💻 Serial Log</button>"
|
|
"</div>"
|
|
"<!-- Tab: Cards -->"
|
|
"<div id=\"pane-cards\" class=\"tab-pane active\">"
|
|
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
|
|
"<h2 style=\"margin-top:0\">Last Scanned Tag</h2>"
|
|
"<p class=\"uid\" id=\"uid\">waiting...</p>"
|
|
"<h2>Pair With Card Name</h2>"
|
|
"<input id=\"name\" placeholder=\"Type card name (e.g. Black Lotus)\" autocomplete=\"off\">"
|
|
"<div style=\"margin-top:.6em\"><button class=\"btn-primary\" onclick=\"bindCard()\">Pair Card</button></div>"
|
|
"<ul class=\"sug\" id=\"res\"></ul>"
|
|
"<p id=\"status\"></p>"
|
|
"</div>"
|
|
"<div class=\"header-row\">"
|
|
"<h2>Paired Cards (<span id=\"count\">0</span>)</h2>"
|
|
"<button class=\"btn\" style=\"font-size:.8em\" onclick=\"loadCards()\">Refresh</button>"
|
|
"</div>"
|
|
"<div id=\"cards-box\"><p class=\"card-none\">Loading paired cards...</p></div>"
|
|
"</div>"
|
|
"<!-- Tab: Wi-Fi -->"
|
|
"<div id=\"pane-wifi\" class=\"tab-pane\">"
|
|
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
|
|
"<h2 style=\"margin-top:0\">Active Connection</h2>"
|
|
"<div id=\"wf-badge\" class=\"status-badge badge-bad\">Checking Wi-Fi...</div>"
|
|
"<div id=\"wf-info\" style=\"font-size:.9em;color:#aaa;line-height:1.6\"></div>"
|
|
"</div>"
|
|
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
|
|
"<div class=\"header-row\">"
|
|
"<h2 style=\"margin-top:0\">Connect to Wi-Fi</h2>"
|
|
"<button class=\"btn\" style=\"font-size:.8em\" id=\"wf-scan-btn\" onclick=\"scanWifi()\">🔍 Scan Networks</button>"
|
|
"</div>"
|
|
"<div class=\"input-grp\">"
|
|
"<label>Discovered Networks</label>"
|
|
"<select id=\"wf-select\" onchange=\"pickNet()\">"
|
|
"<option value=\"\">-- Click Scan Networks above --</option>"
|
|
"</select>"
|
|
"</div>"
|
|
"<div class=\"input-grp\">"
|
|
"<label>Network Name (SSID)</label>"
|
|
"<input id=\"wf-ssid\" placeholder=\"e.g. MyHomeWifi or Mobile Hotspot\">"
|
|
"</div>"
|
|
"<div class=\"input-grp\">"
|
|
"<label>Password</label>"
|
|
"<div class=\"pwd-row\">"
|
|
"<input type=\"password\" id=\"wf-pass\" placeholder=\"Network password (empty if open)\">"
|
|
"<button type=\"button\" class=\"btn\" onclick=\"togglePass()\" style=\"margin:0;width:auto\">👁</button>"
|
|
"</div>"
|
|
"</div>"
|
|
"<div style=\"margin-top:.6em\">"
|
|
"<button class=\"btn-primary\" style=\"width:100%\" onclick=\"connectWifi()\">Connect & Save</button>"
|
|
"</div>"
|
|
"<p id=\"wf-status\" style=\"margin-top:.6em\"></p>"
|
|
"</div>"
|
|
"<div class=\"header-row\">"
|
|
"<h2>Saved Networks</h2>"
|
|
"<button class=\"btn\" style=\"font-size:.8em\" onclick=\"loadSavedWifi()\">Refresh</button>"
|
|
"</div>"
|
|
"<div id=\"saved-box\"><p class=\"card-none\">Loading saved networks...</p></div>"
|
|
"</div>"
|
|
"<!-- Tab: Serial Log -->"
|
|
"<div id=\"pane-serial\" class=\"tab-pane\">"
|
|
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
|
|
"<div class=\"header-row\" style=\"margin-bottom:.8em;flex-wrap:wrap;gap:8px\">"
|
|
"<div style=\"display:flex;align-items:center;gap:8px\">"
|
|
"<span id=\"log-badge\" class=\"status-badge badge-ok\" style=\"margin-bottom:0\">● Live</span>"
|
|
"<span id=\"log-info\" style=\"font-size:.85em;color:#aaa\">0 lines</span>"
|
|
"</div>"
|
|
"<div style=\"display:flex;gap:6px;flex-wrap:wrap\">"
|
|
"<button class=\"btn\" id=\"btn-pause\" onclick=\"toggleLogPause()\" style=\"margin:0;font-size:.85em\">Pause</button>"
|
|
"<button class=\"btn\" onclick=\"clearLogs()\" style=\"margin:0;font-size:.85em\">Clear</button>"
|
|
"<button class=\"btn\" onclick=\"copyLogs()\" style=\"margin:0;font-size:.85em\">Copy</button>"
|
|
"<button class=\"btn\" onclick=\"downloadLogs()\" style=\"margin:0;font-size:.85em\">Save</button>"
|
|
"</div>"
|
|
"</div>"
|
|
"<div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:.6em;font-size:.85em;color:#ccc;flex-wrap:wrap;gap:8px\">"
|
|
"<label style=\"display:flex;align-items:center;gap:6px;cursor:pointer\">"
|
|
"<input type=\"checkbox\" id=\"log-scroll\" checked style=\"width:auto;margin:0\"> Auto-scroll"
|
|
"</label>"
|
|
"<div style=\"display:flex;align-items:center;gap:6px\">"
|
|
"<span>Filter:</span>"
|
|
"<select id=\"log-filter\" onchange=\"renderLogs()\" style=\"width:auto;padding:.3em .6em;font-size:.85em\">"
|
|
"<option value=\"\">All levels</option>"
|
|
"<option value=\"E\">Errors (E)</option>"
|
|
"<option value=\"W\">Warnings & Errors (W/E)</option>"
|
|
"<option value=\"I\">Info, Warn, Error (I/W/E)</option>"
|
|
"</select>"
|
|
"</div>"
|
|
"</div>"
|
|
"<div id=\"term\" style=\"background:#0d0d17;border:1px solid #28283f;border-radius:6px;padding:.75em;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:1.45;height:420px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:#ddd\"></div>"
|
|
"</div>"
|
|
"</div>"
|
|
"<script>"
|
|
"function tab(t){"
|
|
"document.getElementById('tb-cards').className='tab-btn'+(t==='cards'?' active':'');"
|
|
"document.getElementById('tb-wifi').className='tab-btn'+(t==='wifi'?' active':'');"
|
|
"document.getElementById('tb-serial').className='tab-btn'+(t==='serial'?' active':'');"
|
|
"document.getElementById('pane-cards').className='tab-pane'+(t==='cards'?' active':'');"
|
|
"document.getElementById('pane-wifi').className='tab-pane'+(t==='wifi'?' active':'');"
|
|
"document.getElementById('pane-serial').className='tab-pane'+(t==='serial'?' active':'');"
|
|
"if(t==='wifi'){refreshWifiStatus();loadSavedWifi();}"
|
|
"if(t==='serial'){if(!logPolling)startLogPoll();}"
|
|
"else{stopLogPoll();}"
|
|
"}"
|
|
"var lastUid='';var suggestions=[];"
|
|
"function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}"
|
|
"function show(cls,msg){document.getElementById('status').className=cls;document.getElementById('status').textContent=msg;}"
|
|
"function poll(){fetch('/api/last_uid').then(function(r){return r.json();}).then(function(j){"
|
|
" if(j.uid&&j.uid!==lastUid){lastUid=j.uid;document.getElementById('uid').textContent=j.uid;"
|
|
" suggestions=[];document.getElementById('res').innerHTML='';show('','');}"
|
|
"}).catch(function(){});setTimeout(poll,1000);}"
|
|
"function autocomplete(){var q=document.getElementById('name').value;"
|
|
" if(q.length<3){suggestions=[];document.getElementById('res').innerHTML='';return;}"
|
|
" fetch('https://api.scryfall.com/cards/autocomplete?q='+encodeURIComponent(q))"
|
|
" .then(function(r){return r.json();}).then(function(j){"
|
|
" suggestions=(j.data||[]).slice(0,8);var h='';"
|
|
" suggestions.forEach(function(n,i){h+='<li onclick=\"pick('+i+')\">'+esc(n)+'</li>';});"
|
|
" document.getElementById('res').innerHTML=h;}).catch(function(){});}"
|
|
"function pick(i){document.getElementById('name').value=suggestions[i];document.getElementById('res').innerHTML='';}"
|
|
"document.getElementById('name').oninput=autocomplete;"
|
|
"function bindCard(){var n=document.getElementById('name').value.trim();"
|
|
" if(!lastUid||!n){show('bad','Scan a card and enter a name first');return;}"
|
|
" show('','Pairing...');"
|
|
" fetch('/api/bind',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
|
|
" body:'uid='+encodeURIComponent(lastUid)+'&name='+encodeURIComponent(n)})"
|
|
" .then(function(r){return r.json();}).then(function(j){"
|
|
" if(j.ok){show('ok','Paired '+j.uid+' -> '+j.name);document.getElementById('name').value='';"
|
|
" document.getElementById('res').innerHTML='';loadCards();}"
|
|
" else{show('bad','Error: '+j.error);}}).catch(function(e){show('bad','Network: '+e);});}"
|
|
"function unbindCard(u){if(!confirm('Unbind card '+u+'?'))return;"
|
|
" fetch('/api/unbind',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
|
|
" body:'uid='+encodeURIComponent(u)})"
|
|
" .then(function(r){return r.json();}).then(function(j){"
|
|
" if(j.ok){loadCards();show('ok','Unbound '+u);}"
|
|
" else{show('bad','Error unbinding '+u);}}).catch(function(e){show('bad','Network: '+e);});}"
|
|
"function loadCards(){"
|
|
" fetch('/api/cards').then(function(r){return r.json();}).then(function(data){"
|
|
" var box=document.getElementById('cards-box');var uids=Object.keys(data);"
|
|
" document.getElementById('count').textContent=uids.length;"
|
|
" if(uids.length===0){box.innerHTML='<p class=\"card-none\">No cards paired yet. Scan a card above to begin!</p>';return;}"
|
|
" uids.sort(function(a,b){return data[a].localeCompare(data[b]);});"
|
|
" var html='';"
|
|
" uids.forEach(function(u){"
|
|
" var nm=esc(data[u]);"
|
|
" html+='<div class=\"card-item\">';"
|
|
" html+='<img class=\"card-thumb\" src=\"/api/art?uid='+encodeURIComponent(u)+'&card='+encodeURIComponent(data[u])+'\" alt=\"art\" onerror=\"var r=this.dataset.r||0;if(r<4){this.dataset.r=++r;var el=this;setTimeout(function(){el.src=el.src.split(\\'&_t=\\')[0]+\\'&_t=\\'+Date.now();},2500);}else{this.style.display=\\'none\\';}\">';"
|
|
" html+='<div class=\"card-info\"><div class=\"card-title\">'+nm+'</div><div class=\"card-uid\">'+u+'</div></div>';"
|
|
" html+='<button class=\"btn-del\" onclick=\"unbindCard(\\''+u+'\\')\">Unbind</button>';"
|
|
" html+='</div>';"
|
|
" });"
|
|
" box.innerHTML=html;"
|
|
" }).catch(function(e){document.getElementById('cards-box').innerHTML='<p class=\"bad\">Failed to load cards: '+e+'</p>';});"
|
|
"}"
|
|
"function showWf(cls,msg){var el=document.getElementById('wf-status');el.className=cls;el.textContent=msg;}"
|
|
"function togglePass(){var f=document.getElementById('wf-pass');f.type=f.type==='password'?'text':'password';}"
|
|
"function refreshWifiStatus(){"
|
|
" fetch('/api/wifi/status').then(function(r){return r.json();}).then(function(j){"
|
|
" var b=document.getElementById('wf-badge');var d=document.getElementById('wf-info');"
|
|
" if(j.connected){"
|
|
" b.className='status-badge badge-ok';b.textContent='Connected to '+j.ssid;"
|
|
" d.innerHTML='IP Address: <strong>'+j.ip+'</strong><br>Signal: '+j.rssi+' dBm<br>Hotspot Portal: 192.168.4.1';"
|
|
" }else{"
|
|
" b.className='status-badge badge-bad';b.textContent='Disconnected';"
|
|
" d.innerHTML='Device Wi-Fi is not connected to any network.<br>Hotspot Portal: <strong>192.168.4.1</strong>';"
|
|
" }"
|
|
" }).catch(function(){});"
|
|
"}"
|
|
"function scanWifi(){"
|
|
" var btn=document.getElementById('wf-scan-btn');"
|
|
" btn.disabled=true;btn.textContent='Scanning...';"
|
|
" var sel=document.getElementById('wf-select');"
|
|
" sel.innerHTML='<option>Scanning in progress...</option>';"
|
|
" fetch('/api/wifi/scan').then(function(r){return r.json();}).then(function(list){"
|
|
" btn.disabled=false;btn.textContent='🔍 Scan Networks';"
|
|
" if(!list||list.length===0){"
|
|
" sel.innerHTML='<option value=\"\">No networks found</option>';return;"
|
|
" }"
|
|
" var h='<option value=\"\">-- Select a discovered network --</option>';"
|
|
" list.forEach(function(ap){"
|
|
" var lock=ap.auth==='Open'?'':' 🔒';"
|
|
" h+='<option value=\"'+esc(ap.ssid)+'\">'+esc(ap.ssid)+' ('+ap.rssi+' dBm, '+ap.auth+lock+')</option>';"
|
|
" });"
|
|
" sel.innerHTML=h;"
|
|
" }).catch(function(e){"
|
|
" btn.disabled=false;btn.textContent='🔍 Scan Networks';"
|
|
" sel.innerHTML='<option value=\"\">Scan failed: '+e+'</option>';"
|
|
" });"
|
|
"}"
|
|
"function pickNet(){"
|
|
" var val=document.getElementById('wf-select').value;"
|
|
" if(val){"
|
|
" document.getElementById('wf-ssid').value=val;"
|
|
" document.getElementById('wf-pass').focus();"
|
|
" }"
|
|
"}"
|
|
"function connectWifi(){"
|
|
" var s=document.getElementById('wf-ssid').value.trim();"
|
|
" var p=document.getElementById('wf-pass').value;"
|
|
" if(!s){showWf('bad','Please enter or select a network SSID');return;}"
|
|
" showWf('','Connecting to '+s+'... Please wait 5-10s.');"
|
|
" fetch('/api/wifi/connect',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
|
|
" body:'ssid='+encodeURIComponent(s)+'&pass='+encodeURIComponent(p)})"
|
|
" .then(function(r){return r.json();}).then(function(j){"
|
|
" if(j.ok){"
|
|
" showWf('ok','Saved & initiated connection to '+s+'. Checking status...');"
|
|
" loadSavedWifi();"
|
|
" setTimeout(function(){refreshWifiStatus();showWf('ok','Connected profile saved!');},6000);"
|
|
" }else{"
|
|
" showWf('bad','Failed to connect: '+(j.error||'unknown'));"
|
|
" }"
|
|
" }).catch(function(e){"
|
|
" showWf('ok','Connection initiated to '+s+'. Device is connecting...');"
|
|
" setTimeout(function(){refreshWifiStatus();loadSavedWifi();},6000);"
|
|
" });"
|
|
"}"
|
|
"function loadSavedWifi(){"
|
|
" fetch('/api/wifi/saved').then(function(r){return r.json();}).then(function(list){"
|
|
" var box=document.getElementById('saved-box');"
|
|
" if(!list||list.length===0){"
|
|
" box.innerHTML='<p class=\"card-none\">No saved networks. Connect to a network above.</p>';return;"
|
|
" }"
|
|
" var h='';"
|
|
" list.forEach(function(item){"
|
|
" h+='<div class=\"card-item\">';"
|
|
" h+='<div class=\"card-info\"><div class=\"card-title\">'+esc(item.ssid)+'</div><div class=\"card-uid\">Saved Profile</div></div>';"
|
|
" h+='<button class=\"btn-del\" onclick=\"forgetWifi(\\''+esc(item.ssid)+'\\')\">Forget</button>';"
|
|
" h+='</div>';"
|
|
" });"
|
|
" box.innerHTML=h;"
|
|
" }).catch(function(e){"
|
|
" document.getElementById('saved-box').innerHTML='<p class=\"bad\">Error loading saved networks: '+e+'</p>';"
|
|
" });"
|
|
"}"
|
|
"function forgetWifi(s){"
|
|
" if(!confirm('Forget network \"'+s+'\"?'))return;"
|
|
" fetch('/api/wifi/forget',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
|
|
" body:'ssid='+encodeURIComponent(s)})"
|
|
" .then(function(r){return r.json();}).then(function(j){"
|
|
" if(j.ok){loadSavedWifi();refreshWifiStatus();}"
|
|
" else{alert('Failed to forget '+s);}"
|
|
" }).catch(function(e){alert('Error: '+e);});"
|
|
"}"
|
|
"var logOffset=0;var logLines=[];var logPolling=false;var logPaused=false;var logTimer=null;"
|
|
"function parseLogLine(str){"
|
|
" var clean=str.replace(/\\x1b\\[[0-9;]*m/g,'');"
|
|
" var lvl='I';var m=clean.match(/^([EWIDV])\\s+\\(/);"
|
|
" if(m)lvl=m[1];"
|
|
" var html=esc(clean);"
|
|
" if(lvl==='E')html='<span style=\"color:#ff6b6b;font-weight:600\">'+html+'</span>';"
|
|
" else if(lvl==='W')html='<span style=\"color:#ffd75a\">'+html+'</span>';"
|
|
" else if(lvl==='I')html='<span style=\"color:#7fd77f\">'+html+'</span>';"
|
|
" else if(lvl==='D')html='<span style=\"color:#6be5ff\">'+html+'</span>';"
|
|
" else html='<span style=\"color:#aaa\">'+html+'</span>';"
|
|
" return {raw:clean,lvl:lvl,html:html};"
|
|
"}"
|
|
"function renderLogs(){"
|
|
" var filter=document.getElementById('log-filter').value;"
|
|
" var term=document.getElementById('term');"
|
|
" var auto=document.getElementById('log-scroll').checked;"
|
|
" var filtered=logLines.filter(function(item){"
|
|
" if(!filter)return true;"
|
|
" if(filter==='E')return item.lvl==='E';"
|
|
" if(filter==='W')return item.lvl==='W'||item.lvl==='E';"
|
|
" if(filter==='I')return item.lvl==='I'||item.lvl==='W'||item.lvl==='E';"
|
|
" return true;"
|
|
" });"
|
|
" term.innerHTML=filtered.map(function(item){return item.html;}).join('\\n');"
|
|
" document.getElementById('log-info').textContent=logLines.length+' lines';"
|
|
" if(auto)term.scrollTop=term.scrollHeight;"
|
|
"}"
|
|
"function fetchLogs(){"
|
|
" if(logPaused)return;"
|
|
" fetch('/api/logs?offset='+logOffset).then(function(r){"
|
|
" var next=r.headers.get('X-Log-Offset');"
|
|
" if(next)logOffset=parseInt(next,10);"
|
|
" return r.text();"
|
|
" }).then(function(txt){"
|
|
" if(!txt||txt.length===0)return;"
|
|
" var lines=txt.split('\\n');var added=false;"
|
|
" for(var i=0;i<lines.length;i++){"
|
|
" var l=lines[i];"
|
|
" if(l.length>0||i<lines.length-1){logLines.push(parseLogLine(l));added=true;}"
|
|
" }"
|
|
" if(logLines.length>1200)logLines=logLines.slice(logLines.length-1000);"
|
|
" if(added)renderLogs();"
|
|
" }).catch(function(){});"
|
|
"}"
|
|
"function startLogPoll(){logPolling=true;fetchLogs();if(logTimer)clearInterval(logTimer);logTimer=setInterval(fetchLogs,1200);}"
|
|
"function stopLogPoll(){logPolling=false;if(logTimer){clearInterval(logTimer);logTimer=null;}}"
|
|
"function toggleLogPause(){"
|
|
" logPaused=!logPaused;var btn=document.getElementById('btn-pause');var badge=document.getElementById('log-badge');"
|
|
" if(logPaused){btn.textContent='Resume';badge.className='status-badge badge-bad';badge.textContent='⏸ Paused';}"
|
|
" else{btn.textContent='Pause';badge.className='status-badge badge-ok';badge.textContent='● Live';fetchLogs();}"
|
|
"}"
|
|
"function clearLogs(){"
|
|
" fetch('/api/logs/clear',{method:'POST'}).catch(function(){});"
|
|
" logLines=[];document.getElementById('term').innerHTML='';"
|
|
" document.getElementById('log-info').textContent='0 lines';"
|
|
"}"
|
|
"function copyLogs(){"
|
|
" var t=logLines.map(function(item){return item.raw;}).join('\\n');"
|
|
" navigator.clipboard.writeText(t).then(function(){alert('Logs copied to clipboard');}).catch(function(){alert('Copy failed');});"
|
|
"}"
|
|
"function downloadLogs(){"
|
|
" var t=logLines.map(function(item){return item.raw;}).join('\\n');"
|
|
" var b=new Blob([t],{type:'text/plain'});var u=URL.createObjectURL(b);"
|
|
" var a=document.createElement('a');a.href=u;a.download='mtg-companion-serial.log';"
|
|
" document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u);"
|
|
"}"
|
|
"poll();loadCards();refreshWifiStatus();"
|
|
"</script></body></html>";
|
|
|
|
static httpd_handle_t s_server = NULL;
|
|
|
|
static esp_err_t handle_root(httpd_req_t *req);
|
|
static esp_err_t handle_last_uid(httpd_req_t *req);
|
|
static esp_err_t handle_bind(httpd_req_t *req);
|
|
static esp_err_t handle_cards(httpd_req_t *req);
|
|
static esp_err_t handle_art(httpd_req_t *req);
|
|
static esp_err_t handle_unbind(httpd_req_t *req);
|
|
static esp_err_t handle_captive_portal(httpd_req_t *req);
|
|
static esp_err_t handle_wifi_status(httpd_req_t *req);
|
|
static esp_err_t handle_wifi_scan(httpd_req_t *req);
|
|
static esp_err_t handle_wifi_saved(httpd_req_t *req);
|
|
static esp_err_t handle_wifi_connect(httpd_req_t *req);
|
|
static esp_err_t handle_wifi_forget(httpd_req_t *req);
|
|
static esp_err_t handle_logs(httpd_req_t *req);
|
|
static esp_err_t handle_logs_clear(httpd_req_t *req);
|
|
|
|
static const httpd_uri_t URI_ROOT = {
|
|
.uri = "/", .method = HTTP_GET, .handler = handle_root, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_LAST_UID = {
|
|
.uri = "/api/last_uid", .method = HTTP_GET, .handler = handle_last_uid, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_BIND = {
|
|
.uri = "/api/bind", .method = HTTP_POST, .handler = handle_bind, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_CARDS = {
|
|
.uri = "/api/cards", .method = HTTP_GET, .handler = handle_cards, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_ART = {
|
|
.uri = "/api/art", .method = HTTP_GET, .handler = handle_art, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_UNBIND = {
|
|
.uri = "/api/unbind", .method = HTTP_POST, .handler = handle_unbind, .user_ctx = NULL
|
|
};
|
|
|
|
/* Wi-Fi Management URIs */
|
|
static const httpd_uri_t URI_WIFI_STATUS = {
|
|
.uri = "/api/wifi/status", .method = HTTP_GET, .handler = handle_wifi_status, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_WIFI_SCAN = {
|
|
.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = handle_wifi_scan, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_WIFI_SAVED = {
|
|
.uri = "/api/wifi/saved", .method = HTTP_GET, .handler = handle_wifi_saved, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_WIFI_CONNECT = {
|
|
.uri = "/api/wifi/connect", .method = HTTP_POST, .handler = handle_wifi_connect, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_WIFI_FORGET = {
|
|
.uri = "/api/wifi/forget", .method = HTTP_POST, .handler = handle_wifi_forget, .user_ctx = NULL
|
|
};
|
|
|
|
/* Serial Log URIs */
|
|
static const httpd_uri_t URI_LOGS = {
|
|
.uri = "/api/logs", .method = HTTP_GET, .handler = handle_logs, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_LOGS_CLEAR = {
|
|
.uri = "/api/logs/clear", .method = HTTP_POST, .handler = handle_logs_clear, .user_ctx = NULL
|
|
};
|
|
|
|
/* Captive Portal Probe URIs */
|
|
static const httpd_uri_t URI_CP_APPLE = {
|
|
.uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_CP_204_1 = {
|
|
.uri = "/generate_204", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_CP_204_2 = {
|
|
.uri = "/gen_204", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_CP_WIN_1 = {
|
|
.uri = "/connecttest.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_CP_WIN_2 = {
|
|
.uri = "/ncsi.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_CP_FF_1 = {
|
|
.uri = "/canonical.html", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL
|
|
};
|
|
static const httpd_uri_t URI_CP_FF_2 = {
|
|
.uri = "/success.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL
|
|
};
|
|
|
|
static void send_json(httpd_req_t *req, int status, const char *body)
|
|
{
|
|
httpd_resp_set_status(req, status == 200 ? "200 OK" : "400 Bad Request");
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, body, -1);
|
|
}
|
|
|
|
static char *url_decode(const char *src)
|
|
{
|
|
if (!src) return NULL;
|
|
char *out = strdup(src);
|
|
if (!out) return NULL;
|
|
char *dst = out;
|
|
for (const char *s = src; *s; s++) {
|
|
if (*s == '%' && s[1] && s[2]) {
|
|
char hex[3] = { s[1], s[2], '\0' };
|
|
*dst++ = (char)strtol(hex, NULL, 16);
|
|
s += 2;
|
|
} else if (*s == '+') {
|
|
*dst++ = ' ';
|
|
} else {
|
|
*dst++ = *s;
|
|
}
|
|
}
|
|
*dst = '\0';
|
|
return out;
|
|
}
|
|
|
|
static char *get_form_param(const char *form, const char *key)
|
|
{
|
|
if (!form || !key) return NULL;
|
|
size_t klen = strlen(key);
|
|
const char *p = form;
|
|
while (p && *p) {
|
|
if (strncmp(p, key, klen) == 0 && p[klen] == '=') {
|
|
const char *val_start = p + klen + 1;
|
|
const char *val_end = strchr(val_start, '&');
|
|
size_t vlen = val_end ? (size_t)(val_end - val_start) : strlen(val_start);
|
|
char *raw = malloc(vlen + 1);
|
|
if (!raw) return NULL;
|
|
memcpy(raw, val_start, vlen);
|
|
raw[vlen] = '\0';
|
|
char *dec = url_decode(raw);
|
|
free(raw);
|
|
return dec;
|
|
}
|
|
p = strchr(p, '&');
|
|
if (p) p++;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Endpoints */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static esp_err_t handle_root(httpd_req_t *req)
|
|
{
|
|
httpd_resp_set_type(req, "text/html");
|
|
httpd_resp_send(req, SPA_PAGE, (int)sizeof(SPA_PAGE) - 1);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_last_uid(httpd_req_t *req)
|
|
{
|
|
char buf[80];
|
|
const char *uid = rfid_manager_last_uid();
|
|
if (uid == NULL || strlen(uid) == 0) {
|
|
snprintf(buf, sizeof(buf), "{\"uid\":\"\"}\n");
|
|
} else {
|
|
snprintf(buf, sizeof(buf), "{\"uid\":\"%s\"}\n", uid);
|
|
}
|
|
send_json(req, 200, buf);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_bind(httpd_req_t *req)
|
|
{
|
|
if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) {
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
char *form = malloc((size_t)req->content_len + 1);
|
|
if (form == NULL) {
|
|
send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}");
|
|
return ESP_OK;
|
|
}
|
|
int got = httpd_req_recv(req, form, (size_t)req->content_len);
|
|
if (got != req->content_len) {
|
|
free(form);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}");
|
|
return ESP_OK;
|
|
}
|
|
form[got] = '\0';
|
|
|
|
char *uid = get_form_param(form, "uid");
|
|
char *name = get_form_param(form, "name");
|
|
free(form);
|
|
|
|
if (uid) {
|
|
size_t ulen = strlen(uid);
|
|
while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) {
|
|
uid[--ulen] = '\0';
|
|
}
|
|
}
|
|
|
|
if (uid == NULL || name == NULL || strlen(uid) == 0 || strlen(name) == 0) {
|
|
free(uid);
|
|
free(name);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"missing uid or name\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t ret = storage_manager_bind_uid(uid, name);
|
|
if (ret != ESP_OK) {
|
|
free(uid);
|
|
free(name);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"persist failed\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
scryfall_fetch_request(name, uid);
|
|
char ok[192];
|
|
snprintf(ok, sizeof(ok), "{\"ok\":true,\"uid\":\"%s\",\"name\":\"%s\"}\n", uid, name);
|
|
ESP_LOGI(TAG, "paired UID %s -> %s", uid, name);
|
|
send_json(req, 200, ok);
|
|
|
|
free(uid);
|
|
free(name);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_cards(httpd_req_t *req)
|
|
{
|
|
char *text = NULL;
|
|
size_t len = 0;
|
|
if (storage_read_text(STORAGE_MAPPINGS, &text, &len) == ESP_OK && text != NULL) {
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, text, (ssize_t)len);
|
|
free(text);
|
|
} else {
|
|
send_json(req, 200, "{}\n");
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_art(httpd_req_t *req)
|
|
{
|
|
char query[64] = {0};
|
|
char uid[32] = {0};
|
|
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
|
|
httpd_query_key_value(query, "uid", uid, sizeof(uid));
|
|
}
|
|
if (uid[0] == '\0') {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing uid");
|
|
return ESP_OK;
|
|
}
|
|
|
|
char path[64];
|
|
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s.jpg", uid);
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f) {
|
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Art not found");
|
|
return ESP_OK;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "image/jpeg");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-cache, must-revalidate");
|
|
|
|
char chunk[1024];
|
|
size_t n = 0;
|
|
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
|
|
if (httpd_resp_send_chunk(req, chunk, (ssize_t)n) != ESP_OK) {
|
|
fclose(f);
|
|
return ESP_FAIL;
|
|
}
|
|
}
|
|
fclose(f);
|
|
httpd_resp_send_chunk(req, NULL, 0);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_unbind(httpd_req_t *req)
|
|
{
|
|
if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) {
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
char *form = malloc((size_t)req->content_len + 1);
|
|
if (form == NULL) {
|
|
send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}");
|
|
return ESP_OK;
|
|
}
|
|
int got = httpd_req_recv(req, form, (size_t)req->content_len);
|
|
if (got != req->content_len) {
|
|
free(form);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}");
|
|
return ESP_OK;
|
|
}
|
|
form[got] = '\0';
|
|
|
|
char *uid = get_form_param(form, "uid");
|
|
free(form);
|
|
|
|
if (uid) {
|
|
size_t ulen = strlen(uid);
|
|
while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) {
|
|
uid[--ulen] = '\0';
|
|
}
|
|
}
|
|
|
|
if (uid == NULL || strlen(uid) == 0) {
|
|
free(uid);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"missing uid\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t ret = storage_manager_unbind_uid(uid);
|
|
if (ret != ESP_OK) {
|
|
free(uid);
|
|
send_json(req, 404, "{\"ok\":false,\"error\":\"uid not found\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
char ok[128];
|
|
snprintf(ok, sizeof(ok), "{\"ok\":true,\"uid\":\"%s\"}\n", uid);
|
|
ESP_LOGI(TAG, "unbound UID %s via web", uid);
|
|
send_json(req, 200, ok);
|
|
free(uid);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Captive Portal & Wi-Fi Management Handlers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static esp_err_t handle_captive_portal(httpd_req_t *req)
|
|
{
|
|
httpd_resp_set_status(req, "302 Found");
|
|
httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/");
|
|
httpd_resp_send(req, NULL, 0);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_wifi_status(httpd_req_t *req)
|
|
{
|
|
bool conn = wifi_manager_connected();
|
|
const char *ssid = wifi_manager_get_connected_ssid();
|
|
const char *ip = wifi_manager_get_ip();
|
|
int rssi = wifi_manager_get_rssi();
|
|
|
|
char buf[160];
|
|
snprintf(buf, sizeof(buf),
|
|
"{\"connected\":%s,\"ssid\":\"%s\",\"ip\":\"%s\",\"rssi\":%d}\n",
|
|
conn ? "true" : "false",
|
|
ssid ? ssid : "",
|
|
ip ? ip : "0.0.0.0",
|
|
rssi);
|
|
send_json(req, 200, buf);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_wifi_scan(httpd_req_t *req)
|
|
{
|
|
wifi_ap_record_t *records = NULL;
|
|
uint16_t count = 0;
|
|
esp_err_t err = wifi_manager_scan_networks(&records, &count);
|
|
cJSON *root = cJSON_CreateArray();
|
|
if (err == ESP_OK && records != NULL && root != NULL) {
|
|
for (uint16_t i = 0; i < count; i++) {
|
|
cJSON *item = cJSON_CreateObject();
|
|
if (item) {
|
|
cJSON_AddStringToObject(item, "ssid", (const char *)records[i].ssid);
|
|
cJSON_AddNumberToObject(item, "rssi", records[i].rssi);
|
|
const char *auth_str = "Open";
|
|
if (records[i].authmode == WIFI_AUTH_WEP) auth_str = "WEP";
|
|
else if (records[i].authmode == WIFI_AUTH_WPA_PSK) auth_str = "WPA";
|
|
else if (records[i].authmode == WIFI_AUTH_WPA2_PSK) auth_str = "WPA2";
|
|
else if (records[i].authmode == WIFI_AUTH_WPA_WPA2_PSK) auth_str = "WPA/WPA2";
|
|
else if (records[i].authmode >= WIFI_AUTH_WPA3_PSK) auth_str = "WPA3";
|
|
cJSON_AddStringToObject(item, "auth", auth_str);
|
|
cJSON_AddItemToArray(root, item);
|
|
}
|
|
}
|
|
}
|
|
if (records) {
|
|
free(records);
|
|
}
|
|
char *out = root ? cJSON_PrintUnformatted(root) : NULL;
|
|
if (root) cJSON_Delete(root);
|
|
if (out) {
|
|
send_json(req, 200, out);
|
|
free(out);
|
|
} else {
|
|
send_json(req, 200, "[]\n");
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_wifi_saved(httpd_req_t *req)
|
|
{
|
|
wifi_profile_t profiles[WIFI_MAX_PROFILES];
|
|
int count = wifi_manager_get_saved_profiles(profiles, WIFI_MAX_PROFILES);
|
|
cJSON *root = cJSON_CreateArray();
|
|
if (root) {
|
|
for (int i = 0; i < count; i++) {
|
|
cJSON *item = cJSON_CreateObject();
|
|
if (item) {
|
|
cJSON_AddStringToObject(item, "ssid", profiles[i].ssid);
|
|
cJSON_AddItemToArray(root, item);
|
|
}
|
|
}
|
|
char *out = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
if (out) {
|
|
send_json(req, 200, out);
|
|
free(out);
|
|
return ESP_OK;
|
|
}
|
|
}
|
|
send_json(req, 200, "[]\n");
|
|
return ESP_OK;
|
|
}
|
|
|
|
typedef struct {
|
|
char ssid[WIFI_SSID_MAX_LEN + 1];
|
|
char pass[65];
|
|
} async_wifi_connect_args_t;
|
|
|
|
static void async_wifi_connect_task(void *pvParameters)
|
|
{
|
|
async_wifi_connect_args_t *args = (async_wifi_connect_args_t *)pvParameters;
|
|
if (args) {
|
|
/* Wait 500ms to allow the HTTP 200 response to be cleanly transmitted and ACKed */
|
|
vTaskDelay(pdMS_TO_TICKS(500));
|
|
ESP_LOGI(TAG, "Executing deferred connection to '%s'", args->ssid);
|
|
wifi_manager_connect_new(args->ssid, args->pass, true);
|
|
free(args);
|
|
}
|
|
vTaskDelete(NULL);
|
|
}
|
|
|
|
static esp_err_t handle_wifi_connect(httpd_req_t *req)
|
|
{
|
|
if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) {
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
char *form = malloc((size_t)req->content_len + 1);
|
|
if (form == NULL) {
|
|
send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}");
|
|
return ESP_OK;
|
|
}
|
|
int got = httpd_req_recv(req, form, (size_t)req->content_len);
|
|
if (got != req->content_len) {
|
|
free(form);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}");
|
|
return ESP_OK;
|
|
}
|
|
form[got] = '\0';
|
|
|
|
char *ssid = get_form_param(form, "ssid");
|
|
char *pass = get_form_param(form, "pass");
|
|
free(form);
|
|
|
|
if (ssid) {
|
|
size_t slen = strlen(ssid);
|
|
while (slen > 0 && (ssid[slen - 1] == ' ' || ssid[slen - 1] == '\r' || ssid[slen - 1] == '\n')) {
|
|
ssid[--slen] = '\0';
|
|
}
|
|
}
|
|
|
|
if (ssid == NULL || strlen(ssid) == 0) {
|
|
free(ssid);
|
|
free(pass);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"missing ssid\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
const char *p = pass ? pass : "";
|
|
|
|
/* Send HTTP 200 OK FIRST before the Wi-Fi stack switches channels or disconnects */
|
|
char ok[128];
|
|
snprintf(ok, sizeof(ok), "{\"ok\":true,\"ssid\":\"%s\"}\n", ssid);
|
|
send_json(req, 200, ok);
|
|
|
|
/* Defer the actual connection to a background task so the current socket is not interrupted */
|
|
async_wifi_connect_args_t *args = malloc(sizeof(async_wifi_connect_args_t));
|
|
if (args) {
|
|
strncpy(args->ssid, ssid, sizeof(args->ssid) - 1);
|
|
args->ssid[sizeof(args->ssid) - 1] = '\0';
|
|
strncpy(args->pass, p, sizeof(args->pass) - 1);
|
|
args->pass[sizeof(args->pass) - 1] = '\0';
|
|
if (xTaskCreate(async_wifi_connect_task, "wf_conn_async", 4096, args, 5, NULL) != pdPASS) {
|
|
ESP_LOGE(TAG, "Failed to create async_wifi_connect_task, connecting synchronously");
|
|
wifi_manager_connect_new(ssid, p, true);
|
|
free(args);
|
|
}
|
|
} else {
|
|
wifi_manager_connect_new(ssid, p, true);
|
|
}
|
|
|
|
free(ssid);
|
|
free(pass);
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t handle_wifi_forget(httpd_req_t *req)
|
|
{
|
|
if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) {
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
char *form = malloc((size_t)req->content_len + 1);
|
|
if (form == NULL) {
|
|
send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}");
|
|
return ESP_OK;
|
|
}
|
|
int got = httpd_req_recv(req, form, (size_t)req->content_len);
|
|
if (got != req->content_len) {
|
|
free(form);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}");
|
|
return ESP_OK;
|
|
}
|
|
form[got] = '\0';
|
|
|
|
char *ssid = get_form_param(form, "ssid");
|
|
free(form);
|
|
|
|
if (ssid) {
|
|
size_t slen = strlen(ssid);
|
|
while (slen > 0 && (ssid[slen - 1] == ' ' || ssid[slen - 1] == '\r' || ssid[slen - 1] == '\n')) {
|
|
ssid[--slen] = '\0';
|
|
}
|
|
}
|
|
|
|
if (ssid == NULL || strlen(ssid) == 0) {
|
|
free(ssid);
|
|
send_json(req, 400, "{\"ok\":false,\"error\":\"missing ssid\"}");
|
|
return ESP_OK;
|
|
}
|
|
|
|
wifi_manager_delete_profile(ssid);
|
|
char ok[128];
|
|
snprintf(ok, sizeof(ok), "{\"ok\":true,\"ssid\":\"%s\"}\n", ssid);
|
|
send_json(req, 200, ok);
|
|
|
|
free(ssid);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Serial Log endpoints */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static esp_err_t handle_logs(httpd_req_t *req)
|
|
{
|
|
uint32_t offset = 0;
|
|
char query[64] = {0};
|
|
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
|
|
char val[24] = {0};
|
|
if (httpd_query_key_value(query, "offset", val, sizeof(val)) == ESP_OK) {
|
|
offset = (uint32_t)strtoul(val, NULL, 10);
|
|
}
|
|
}
|
|
|
|
size_t chunk_size = 4096;
|
|
char *buf = malloc(chunk_size);
|
|
if (buf == NULL) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
uint32_t next_offset = offset;
|
|
size_t bytes_read = serial_log_read(offset, buf, chunk_size, &next_offset);
|
|
|
|
char off_str[24];
|
|
snprintf(off_str, sizeof(off_str), "%lu", (unsigned long)next_offset);
|
|
|
|
httpd_resp_set_type(req, "text/plain");
|
|
httpd_resp_set_hdr(req, "X-Log-Offset", off_str);
|
|
httpd_resp_set_hdr(req, "Access-Control-Expose-Headers", "X-Log-Offset");
|
|
|
|
esp_err_t ret = httpd_resp_send(req, buf, (ssize_t)bytes_read);
|
|
free(buf);
|
|
return ret;
|
|
}
|
|
|
|
static esp_err_t handle_logs_clear(httpd_req_t *req)
|
|
{
|
|
serial_log_clear();
|
|
send_json(req, 200, "{\"ok\":true}\n");
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* SoftAP + server */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
esp_err_t web_server_start(void)
|
|
{
|
|
if (s_server != NULL) {
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* If Wi-Fi is not yet started, configure AP and start it */
|
|
wifi_mode_t mode;
|
|
if (esp_wifi_get_mode(&mode) != ESP_OK) {
|
|
esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
|
|
if (ap == NULL) {
|
|
ap = esp_netif_create_default_wifi_ap();
|
|
if (ap != NULL) {
|
|
esp_netif_set_hostname(ap, "mtg-companion");
|
|
}
|
|
}
|
|
(void)esp_wifi_set_mode(WIFI_MODE_APSTA);
|
|
wifi_config_t wifi_cfg = {
|
|
.ap = {
|
|
.ssid_len = sizeof(AP_SSID) - 1,
|
|
.max_connection = 4,
|
|
.authmode = (AP_PASS[0] == '\0') ? WIFI_AUTH_OPEN : WIFI_AUTH_WPA2_PSK,
|
|
},
|
|
};
|
|
strncpy((char *)wifi_cfg.ap.ssid, AP_SSID, sizeof(wifi_cfg.ap.ssid) - 1);
|
|
if (AP_PASS[0] != '\0') {
|
|
strncpy((char *)wifi_cfg.ap.password, AP_PASS, sizeof(wifi_cfg.ap.password) - 1);
|
|
}
|
|
(void)esp_wifi_set_config(WIFI_IF_AP, &wifi_cfg);
|
|
(void)esp_wifi_start();
|
|
}
|
|
|
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
|
config.stack_size = 8 * 1024;
|
|
config.max_uri_handlers = 32;
|
|
config.lru_purge_enable = true;
|
|
config.keep_alive_enable = true;
|
|
config.max_open_sockets = 4;
|
|
|
|
esp_err_t ret = httpd_start(&s_server, &config);
|
|
if (ret != ESP_OK) {
|
|
ESP_LOGE(TAG, "httpd_start failed: %s", esp_err_to_name(ret));
|
|
return ret;
|
|
}
|
|
|
|
/* Core Pairing & Card APIs */
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ROOT));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CARDS));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ART));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_UNBIND));
|
|
|
|
/* Wi-Fi Management APIs */
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_STATUS));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_SCAN));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_SAVED));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_CONNECT));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_FORGET));
|
|
|
|
/* Serial Log APIs */
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LOGS));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LOGS_CLEAR));
|
|
|
|
/* Captive Portal OS Probes */
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_APPLE));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_204_1));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_204_2));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_WIN_1));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_WIN_2));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_FF_1));
|
|
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_FF_2));
|
|
|
|
/* Start Captive Portal DNS redirection server */
|
|
dns_server_start();
|
|
|
|
ESP_LOGI(TAG, "Web server ready on all interfaces (Hotspot: 192.168.4.1, Wi-Fi: %s)", wifi_manager_get_ip());
|
|
return ESP_OK;
|
|
}
|
|
|
|
bool web_server_is_running(void)
|
|
{
|
|
return s_server != NULL;
|
|
} |