A wireless field sensor you can build yourself. Each SPECTER Node pairs a capacitive proximity antenna with a 3-axis magnetometer on an ESP32, then streams synchronized telemetry into SPECTER. Deploy up to three Nodes with direct-first Wi-Fi and an authenticated two-hop Relay Assist mesh for locations where one Node cannot reach the field computer directly. Total build cost is roughly $15–25 per Node in parts.
The SPECTER Node view is designed for your phone, so you can carry it in the field. This is a live simulation of the DIRECTION FINDER: a top-down compass radar that shows not just how strong the magnetic disturbance is, but which direction it is coming from. A bearing needle swings toward the source and a proximity ring closes in as something gets nearer. Rotate your phone or resize and it flips to landscape automatically.
Simulated for preview. A physical node produces the same live data over your local network.
The node is a small battery- or USB-powered pod. An ESP32 microcontroller reads two sensors a hundred times a second: a capacitive telescopic antenna (its reading rises as a person or object approaches - a proximity and field-change channel) and a QMC5883L magnetometer (a 3-axis reading of the local magnetic field). An onboard LED ring and buzzer give you a standalone alert, while the ESP32 joins your Wi-Fi and connects to the SPECTER desktop app.
SPECTER runs a small server that the Node discovers automatically. The app broadcasts its address, the Node finds it, and telemetry starts flowing at 10 Hz. From there the Node behaves like any other SPECTER sensor: its readings feed the Anomaly Index, threshold crossings create timestamped detection events in the evidence archive, and the live field display renders in the instrument rail.
Relay Assist lets up to three SPECTER Nodes maintain one synchronized sensor network across a larger location. Each Node still tries its normal direct Wi-Fi connection first. If a Node cannot reach the SPECTER computer, it can forward authenticated telemetry and event frames through a nearby Node that does have a working app connection.
| Position | Network role | Maximum route |
|---|---|---|
| Node nearest the field computer or hotspot | Direct Node and available gateway | Node to SPECTER |
| Node beyond direct app range | One-hop relayed Node | Node to gateway to SPECTER |
| Node deeper in the location | Two-hop relayed Node | Node to intermediate Node to gateway to SPECTER |
Gateway availability is advertised over ESP-NOW on the active 2.4 GHz channel. Relayed Nodes select a route using stable hardware identities, not the operator-assigned Node 1, 2, or 3 labels. If a gateway disappears, routes expire quickly and the remaining Nodes look for another valid path.
You can also generate and apply the shared key from SPECTER Settings > Sensors & Alerts > SPECTER Node > Relay Assist while the Nodes are directly connected. Route diagnostics show DIRECT, RELAYED VIA, or 2 HOPS VIA, along with channel, quality, command round-trip time, and dropped-frame estimates.
Assign each Node to the Lorex or IP camera watching the same area. When that Node reports a genuine event, its mapped camera highlights immediately while the Node remains the true trigger in evidence metadata. A separate 0 to 2500 ms capture offset can compensate for RTSP delay so the saved frame can show the Node LEDs. If two or three Nodes report together, all mapped cameras remain highlighted. Organic detections from multiple cameras also remain visible together.
| Component | What it does | Approx. |
|---|---|---|
| Type-C D1 Mini NodeMCU ESP32 ESP-WROOM-32 | Supported compact Wi-Fi microcontroller board | $5–8 |
| QMC5883L magnetometer breakout | 3-axis magnetic field sensor (I2C) | $2–4 |
| WS2812B addressable LED ring (12 px) | Local visual alert | $3–5 |
| Passive piezo buzzer | Local audible alert | $1 |
| Telescopic antenna / brass rod | Capacitive proximity antenna | $1–3 |
| Breadboard + jumper wires | Wiring | $3–5 |
| USB cable / 18650 power (optional) | Power | - |
This guide and the exact pin-position diagram use the Type-C D1 Mini NodeMCU ESP32 ESP-WROOM-32. Other classic ESP32 boards can run the firmware, but their header positions differ. The QMC5883L is the common blue "GY-273 / HMC5883L-style" module - confirm it is the QMC5883L variant at I2C address 0x0D.
The standard Type-C D1 Mini32 build uses two ESP32 pins and one high-value resistor. GPIO18 charges the antenna node through 1MΩ while GPIO4 measures how long that node takes to rise. A nearby hand adds capacitance and increases the measured charge time.
| Component | What it does | Approx. |
|---|---|---|
| 1MΩ resistor | Required charge path from GPIO18 to the GPIO4 antenna sense node | $0.10 |
| Optional 10kΩ resistor | Series protection between the telescopic antenna and sense node | $0.10 |
| 100µF / 35V electrolytic capacitor | Optional bulk power smoothing, positive to 3.3V and negative to GND | on hand |
The 1MΩ resistor belongs between GPIO18 and the sense node. Do not connect it from the antenna node to GND. The optional 10kΩ resistor sits between the antenna and sense node and provides limited input protection without changing the measurement method.
This alternate firmware mode reads the antenna directly on GPIO4 without the 1MΩ timing resistor. It remains available for existing builds, but the Type-C D1 Mini32 resistor diagram below is the standard supported wiring. All sensors run on 3.3V.
| Component pin | ESP32 pin |
|---|---|
| Telescopic antenna wire | GPIO4 (T0, touch) |
| QMC5883L · SDA | GPIO21 |
| QMC5883L · SCL | GPIO22 |
| QMC5883L · VCC / GND | 3.3V / GND |
| LED ring · DIN | GPIO13 |
| LED ring · VCC / GND | 3.3V (or 5V) / GND |
| Buzzer · + | GPIO12 |
| Buzzer · − | GND |
This diagram is for the Type-C D1 Mini NodeMCU ESP32 ESP-WROOM-32. It matches the back pin-label side in the supplied board photo, with USB-C at the bottom. It shows all four header columns, every unused pin, and every SPECTER connection. GPIO18 is the controlled drive pin and GPIO4 is the antenna sense pin. The required 1MΩ resistor creates the measurable charge time.
OPEN THE FULL-SIZE TYPE-C D1 MINI32 PIN DIAGRAM
| Connection | Exact physical destination |
|---|---|
| GPIO18 drive | Right inner header, row 4 from the antenna end, then through required 1MΩ to sense junction |
| GPIO4 sense | Left outer header, row 6, directly to the same sense junction |
| QMC5883L SCL / SDA | GPIO22 left inner row 3 / GPIO21 left inner row 4 |
| LED ring DIN | GPIO13 / TCK, right inner row 9 |
| Passive buzzer + | GPIO12 / TDI, left outer row 5 |
| 3.3V shared power | 3V3, right inner row 8 |
| Shared ground | GND, left inner row 7 |
| Telescopic antenna | To sense junction through optional 10kΩ resistor |
| 100µF electrolytic, optional | Positive to 3V3, negative to GND |
Not every builder uses the same LED ring. Pick your chipset, color order, and count, then map which LED faces front / right / back / left so SPECTER can light the correct side toward a detected source. This generates a config block - paste it at the top of the firmware, replacing the matching lines.
Once your board is wired up, you can install the SPECTER Node firmware right here over USB – no Arduino IDE, no libraries, no partition settings to worry about. Plug the ESP32 into your computer with a USB cable, click below, and pick the serial port. It comes preconfigured for RGBW (SK6812) rings, and you can tune everything from the on-device Settings page afterward.
Rather do it by hand? This is the complete firmware in a single file - the on-device dashboard (live telemetry, the 3D field-vector graph, Wi-Fi setup and settings) is built right into it. No separate data upload, no extra plugins. Just paste it into the Arduino IDE, install the four libraries below, select your ESP32 board, and hit Upload.
After it uploads, the node hosts a Wi-Fi network called SPECTER-NODE-01. Join it from your phone, open 192.168.4.1 in a browser, and you’ll land on the live telemetry page. Tap Settings to connect the node to your home Wi-Fi and tune it.
In the Arduino IDE, open Tools → Manage Libraries and install:
You also need the ESP32 board package (Boards Manager → "esp32" by Espressif). Then select ESP32 Dev Module as your board.
Important - set the partition scheme so wireless updates work. With the ESP32 Dev Module selected, open Tools → Partition Scheme and choose "Minimal SPIFFS (1.9MB APP with OTA / 190KB SPIFFS)" (also shown as "Minimal SPIFFS (Large APPS with OTA)"). This gives the node two app slots so it can flash a new version over Wi-Fi from the Settings page. If you leave the default scheme, the node will still run, but wireless firmware updates will fail with a space error and you will have to re-flash over USB each time.
Already flashed with the default scheme and updates will not install? Just set the partition scheme above and re-upload once over USB. From then on, updates install wirelessly.
No Arduino IDE handy, or a node stuck after a bad update? Use the browser USB flasher to install or recover firmware in a couple of clicks.
This firmware builds on both the 2.x and 3.x ESP32 board packages, and on a plain ESP32 board. If you are building for an ESP32-S2, S3 or C3, use the latest board package.
/*
============================================================================
SPECTER NODE -- MAKER EDITION · ESP32 · firmware v2.16.1-maker
============================================================================
Public build-your-own field-sensor pod for the SPECTER system.
This is the open sensor node: capacitive telescopic antenna (touchRead) +
QMC5883L magnetometer, with an LED ring and buzzer for local alerts. It joins
your Wi-Fi and streams telemetry to the SPECTER desktop app, which shows it
live in the instrument rail and 3D field mesh.
Sensors: capacitive antenna (GPIO4/T0) + QMC5883L magnetometer (I2C 0x0D)
Outputs: addressable LED ring (LED_DATA_PIN) + passive buzzer (GPIO12)
Link: Wi-Fi. The pod is a WebSocket CLIENT; the SPECTER app is the SERVER.
Architecture (dual-core FreeRTOS):
Core 1 hardwareTask -- 100 Hz sensor read + threshold/severity + LED/buzzer
Core 0 loop() -- UDP discovery, WS client to app, local AP + portal,
10 Hz telemetry / 1 Hz net diagnostics / event frames
Discovery: the app broadcasts a UDP beacon on :47845; the node connects to the
app's WebSocket server on :47846. On first boot join the pod's own AP
(SPECTER-NODE-01) and set your Wi-Fi at http://192.168.4.1/.
============================================================================
*/
#include <WiFi.h>
#include <AsyncUDP.h>
#include <WebSocketsClient.h> // Links2004/arduinoWebSockets
#include <WebSocketsServer.h>
#include <WebServer.h>
#include <Preferences.h>
#include <ArduinoJson.h> // v6 or v7
#include <Wire.h>
#include <FastLED.h>
#include <Adafruit_NeoPixel.h> // RGBW strips (SK6812) -- FastLED does NOT support RGBW
#include <HTTPClient.h>
#include <HTTPUpdate.h>
#include <WiFiClientSecure.h>
#include <ESPmDNS.h> // specter-node-<slot>.local for the portal
#include <esp_now.h>
#include <esp_wifi.h>
#include <soc/gpio_struct.h> // direct GPIO register access inside rcMeasureOnce10 (IRAM)
#include <mbedtls/md.h>
#include <stddef.h>
#include <math.h>
// ---- embedded portal pages (index + settings) ----
// portal_pages.h - SPECTER maker-node portal, embedded so there is NO separate
// LittleFS/data upload. One .ino, one Upload button. Served from PROGMEM.
// PAGE_INDEX -> "/" live telemetry + 3D field-vector graph
// PAGE_SETTINGS -> "/settings" Wi-Fi, calibrate, thresholds, firmware info
const char PAGE_INDEX[] PROGMEM = R"HTMLPAGE(
<!DOCTYPE html><html lang=en><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1">
<title>SPECTER NODE</title><style>
:root{--bg-0:#07090b;--bg-1:#0a0d10;--bg-2:#0f1318;--line:#1c2329;--text-0:#c6d4dc;--text-1:#889aa3;--text-2:#586872;--text-dim:#3b4750;--accent:#6ad7a8;--alert:#ff4d4f;--mono:ui-monospace,Menlo,monospace}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html,body{margin:0;background:var(--bg-0);color:var(--text-0);font-family:var(--mono);font-size:14px}body{padding:16px 16px 90px;user-select:none}
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:12px;border-bottom:1px solid var(--line);margin-bottom:16px}
.brand{font-size:12px;font-weight:700;letter-spacing:.22em}.sub{font-size:10px;color:var(--text-dim);letter-spacing:.15em}
.conn{font-size:10px;letter-spacing:.18em;color:var(--text-2);display:inline-flex;gap:6px;align-items:center}.conn .dot{width:8px;height:8px;border-radius:50%;background:var(--text-dim)}.conn.live .dot{background:var(--accent);box-shadow:0 0 6px rgba(106,215,168,.7)}.conn.live{color:var(--accent)}.conn.alert .dot{background:var(--alert);box-shadow:0 0 8px rgba(255,77,79,.8)}.conn.alert{color:var(--alert)}
.viz-wrap{position:relative;background:var(--bg-1);border:1px solid var(--line);border-radius:6px;height:280px;margin-bottom:14px;overflow:hidden}#viz{display:block;width:100%;height:100%}
.viz-tag{position:absolute;top:10px;left:12px;font-size:9px;letter-spacing:.2em;color:var(--text-2);text-transform:uppercase}.viz-amp{position:absolute;top:10px;right:12px;font-size:9px;letter-spacing:.14em;color:var(--accent)}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px}.tile{background:var(--bg-1);border:1px solid var(--line);border-radius:4px;padding:14px}.tile .k{font-size:9px;letter-spacing:.18em;color:var(--text-2);text-transform:uppercase}.tile .v{font-size:26px;margin-top:6px}.tile .u{font-size:10px;color:var(--text-dim)}
.bar{height:3px;background:var(--line);border-radius:2px;overflow:hidden;margin-top:8px}.bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .12s linear}.xyz{font-size:10px;color:var(--text-1);margin-top:6px}
nav{position:fixed;left:0;right:0;bottom:0;display:flex;background:var(--bg-1);border-top:1px solid var(--line)}nav a{flex:1;text-align:center;padding:16px 0;font-size:10px;letter-spacing:.18em;color:var(--text-2);text-decoration:none;text-transform:uppercase}nav a.on{color:var(--accent);border-top:2px solid var(--accent);margin-top:-1px}
button.mini{padding:6px 10px;font-size:11px;min-width:auto}
</style></head><body>
<header><div><div class=brand>SPECTER NODE</div><div class=sub id=fwline>fw · standalone</div></div><div class=conn id=conn><span class=dot></span><span id=conn-t>CONNECTING…</span></div></header>
<div class=viz-wrap><canvas id=viz></canvas><div class=viz-tag>FIELD VECTOR</div><div class=viz-amp id=viz-amp>-</div></div>
<div class=grid>
<div class=tile><div class=k>Proximity</div><div class=v id=prox>-</div><div class=u id=prox-unit>raw capacitance</div><div class=bar><i id=prox-bar></i></div><div class=xyz id=proxdbg style="margin-top:4px;color:var(--accent)">delta - / thr -</div></div>
<div class=tile><div class=k>Mag amplitude</div><div class=v id=mag>-</div><div class=u>LSB</div><div class=xyz id=xyz>x - y - z -</div><div class=xyz id=magdbg style="margin-top:4px;color:var(--accent)">sensor - · delta - / thr -</div></div>
<div class=tile><div class=k>Signal</div><div class=v id=rssi>-</div><div class=u>dBm</div><div class=bar><i id=rssi-bar></i></div></div>
<div class=tile><div class=k>Uptime</div><div class=v id=uptime>-</div><div class=u id=ssid>-</div><div class=xyz id=linkdbg style="margin-top:4px;color:var(--accent)">app -</div></div>
</div>
<nav><a class=on href=/>TELEMETRY</a><a href=/settings>SETTINGS</a></nav>
<script>
var $=function(i){return document.getElementById(i)},ws,alertOn=false,vec={x:0,y:0,z:0},amp=0,target={x:0,y:0,z:0};
function connect(){ws=new WebSocket('ws://'+location.hostname+':81/');ws.onopen=function(){setConn('live')};ws.onclose=function(){setConn('lost');setTimeout(connect,2000)};ws.onmessage=function(e){try{onFrame(JSON.parse(e.data))}catch(x){var d=$('proxdbg');if(d){d.textContent='portal data error: '+(x&&x.message?x.message:'unknown');d.style.color='#ff5a5a'}if(window.console)console.error(x)}}}
function setConn(s){var c=$('conn');c.className='conn'+(s==='live'?' live':s==='alert'?' live alert':'');$('conn-t').textContent=s==='live'?'LIVE':s==='alert'?'DISTURBANCE':'LOST'}
function onFrame(m){if(m.t==='telemetry'){$('prox').textContent=m.prox;var _pmax=(window._proxMax=Math.max(window._proxMax||1,m.prox));$('prox-bar').style.width=Math.max(0,Math.min(100,(m.prox/_pmax*100)))+'%';if($('prox-live'))$('prox-live').textContent=m.prox;if($('prox-delta-live'))$('prox-delta-live').textContent=(m.proxDelta!=null?m.proxDelta:'-');if($('prox-thr-live'))$('prox-thr-live').textContent=(m.proxThr!=null?m.proxThr:'-');if(m.touchPin!=null){var pp=$('prox-pin');if(pp&&!pp._userset)pp.value=m.touchPin;}if(m.rcBuzzerPinsSwapped!=null){var ps=$('pin-swap');if(ps&&!ps._userset)ps.checked=!!m.rcBuzzerPinsSwapped;}if(m.proxMode!=null){var pu=$('prox-unit');if(pu)pu.textContent=(+m.proxMode===1)?('RC charge '+((m.rcCharge||0)/(m.rcSub||100)).toFixed(2)+' us / base '+((m.rcBase||0)/(m.rcSub||100)).toFixed(2)+' us'):'raw capacitance';}if($('therm-field')&&m.proxField!=null){$('therm-field').textContent=m.proxField;var tb=$('therm-bar');if(tb)tb.style.width=Math.max(0,Math.min(100,m.proxField))+'%';}if($('therm-span')&&m.proxSpan!=null)$('therm-span').textContent=m.proxSpan;if($('therm-noise')&&m.proxNoise!=null&&$('therm-noise')._u){$('therm-noise')._u=false;$('therm-noise').value=m.proxNoise;if($('therm-noise-v'))$('therm-noise-v').textContent=m.proxNoise;}if(m.theremin!=null&&$('therm-on')&&!$('therm-on')._userset)$('therm-on').checked=!!m.theremin;if($('proxdbg')){var pd=(+m.proxMode===1&&m.rcOk===false)?(m.rcFault===1?'GPIO4 discharge failed':m.rcFault===2?'GPIO18 to 1M to GPIO4 charge failed':'RC sample failed'):('delta '+(m.proxDelta!=null?m.proxDelta:'-')+' / thr '+(m.proxThr!=null?m.proxThr:'-'));$('proxdbg').textContent=pd;$('proxdbg').style.color=(+m.proxMode===1&&m.rcOk===false)?'#ff5a5a':((m.proxThr!=null&&m.proxDelta>m.proxThr)?'#4ade80':'var(--accent)')}amp=Math.round(Math.sqrt(m.mag.x*m.mag.x+m.mag.y*m.mag.y+m.mag.z*m.mag.z));$('mag').textContent=amp;$('xyz').textContent='x '+m.mag.x+' y '+m.mag.y+' z '+m.mag.z;$('viz-amp').textContent=amp+' LSB';if($('magdbg')){var ok=(m.magOk===false)?'NOT FOUND':(m.magDataOk===false?'NO DATA':('ok '+(m.magChip||'')));var dd=(m.magDelta!=null?m.magDelta:'-');var th=(m.magThr!=null?m.magThr:'-');$('magdbg').textContent='sensor '+ok+' \u00b7 I2C '+(m.i2c||'-')+' \u00b7 delta '+dd+' / thr '+th;$('magdbg').style.color=(m.magOk===false||m.magDataOk===false)?'#ff5a5a':((m.magDelta!=null&&m.magThr!=null&&m.magDelta>m.magThr)?'#4ade80':'var(--accent)')}target={x:m.mag.x,y:m.mag.y,z:m.mag.z}}else if(m.t==='net'){$('rssi').textContent=m.rssi;$('rssi-bar').style.width=Math.max(0,Math.min(100,(m.rssi+100)*1.4))+'%';$('uptime').textContent=m.uptime+'s';$('ssid').textContent=m.ssid||'-';if($('linkdbg')){var lc=m.appConnected?'connected':(m.appStarted?('trying '+((m.appAgeMs||0)/1000).toFixed(0)+'s '+(m.appIp||'')):'searching');var ld='app '+lc+(m.stallCount?(' \u00b7 stalls '+m.stallCount):'');$('linkdbg').textContent=ld;$('linkdbg').style.color=m.appConnected?'#4ade80':(m.appStarted?'#fbbf24':'var(--accent)')}}else if(m.t==='hello'){$('fwline').textContent='fw '+(m.fw||'-')+' \u00b7 standalone'}else if(m.t==='event'){alertOn=true;setConn('alert');clearTimeout(window._a);window._a=setTimeout(function(){alertOn=false;setConn('live')},1600)}}
var cv=$('viz'),cx=cv.getContext('2d'),rot=0,dpr=Math.min(2,window.devicePixelRatio||1);
function fit(){var r=cv.parentElement.getBoundingClientRect();cv.width=r.width*dpr;cv.height=r.height*dpr}window.addEventListener('resize',fit);fit();
function proj(x,y,z,cxp,cyp,s){var c=Math.cos(rot),n=Math.sin(rot),rx=x*c-z*n,rz=x*n+z*c,t=0.55,sy=y*Math.cos(t)-rz*Math.sin(t);return[cxp+rx*s,cyp-sy*s]}
function drawViz(){var w=cv.width,h=cv.height,cxp=w/2,cyp=h/2;cx.clearRect(0,0,w,h);var R=Math.min(w,h)*0.34;vec.x+=(target.x-vec.x)*0.15;vec.y+=(target.y-vec.y)*0.15;vec.z+=(target.z-vec.z)*0.15;var mg=Math.sqrt(vec.x*vec.x+vec.y*vec.y+vec.z*vec.z)||1,nx=vec.x/mg,ny=vec.y/mg,nz=vec.z/mg,hot=alertOn,ac=hot?'#ff4d4f':'#6ad7a8';cx.lineWidth=dpr;
for(var ring=0;ring<3;ring++){cx.strokeStyle=(hot?'rgba(255,77,79,':'rgba(106,215,168,')+(0.11-ring*0.02)+')';cx.beginPath();for(var a=0;a<=64;a++){var tt=a/64*Math.PI*2,px,py,pz;if(ring===0){px=Math.cos(tt);py=0;pz=Math.sin(tt)}else if(ring===1){px=Math.cos(tt);py=Math.sin(tt);pz=0}else{px=0;py=Math.cos(tt);pz=Math.sin(tt)}var p=proj(px,py,pz,cxp,cyp,R);a===0?cx.moveTo(p[0],p[1]):cx.lineTo(p[0],p[1])}cx.stroke()}
cx.strokeStyle='rgba(88,104,114,0.35)';[[1,0,0],[0,1,0],[0,0,1]].forEach(function(v){var p0=proj(-v[0],-v[1],-v[2],cxp,cyp,R),p1=proj(v[0],v[1],v[2],cxp,cyp,R);cx.beginPath();cx.moveTo(p0[0],p0[1]);cx.lineTo(p1[0],p1[1]);cx.stroke()});
var o=proj(0,0,0,cxp,cyp,R),ln=Math.min(1,mg/3000),tp=proj(nx*ln,ny*ln,nz*ln,cxp,cyp,R),g=cx.createLinearGradient(o[0],o[1],tp[0],tp[1]);g.addColorStop(0,'rgba(106,215,168,0.15)');g.addColorStop(1,ac);cx.strokeStyle=g;cx.lineWidth=3*dpr;cx.lineCap='round';cx.beginPath();cx.moveTo(o[0],o[1]);cx.lineTo(tp[0],tp[1]);cx.stroke();
cx.fillStyle=ac;cx.beginPath();cx.arc(tp[0],tp[1],5*dpr+ln*4*dpr,0,Math.PI*2);cx.fill();cx.strokeStyle=ac;cx.globalAlpha=0.4+0.3*Math.sin(Date.now()/240);cx.lineWidth=dpr;cx.beginPath();cx.arc(tp[0],tp[1],(10+ln*10)*dpr,0,Math.PI*2);cx.stroke();cx.globalAlpha=1;rot+=0.008;requestAnimationFrame(drawViz)}
drawViz();connect();
</script></body></html>
)HTMLPAGE";
const char PAGE_SETTINGS[] PROGMEM = R"HTMLPAGE(
<!DOCTYPE html><html lang=en><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1">
<title>SPECTER NODE · Settings</title><style>
:root{--bg-0:#07090b;--bg-1:#0a0d10;--bg-2:#0f1318;--line:#1c2329;--text-0:#c6d4dc;--text-1:#889aa3;--text-2:#586872;--text-dim:#3b4750;--accent:#6ad7a8;--warn:#f0b65e;--alert:#ff4d4f;--mono:ui-monospace,Menlo,monospace}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html,body{margin:0;background:var(--bg-0);color:var(--text-0);font-family:var(--mono);font-size:14px}body{padding:16px 16px 90px;user-select:none}
header{display:flex;justify-content:space-between;align-items:center;padding-bottom:12px;border-bottom:1px solid var(--line);margin-bottom:8px}.brand{font-size:12px;font-weight:700;letter-spacing:.22em}.sub{font-size:10px;color:var(--text-dim);letter-spacing:.15em}
.tabs{position:sticky;top:0;z-index:5;display:flex;gap:6px;background:var(--bg-0);padding:10px 0;border-bottom:1px solid var(--line);margin-bottom:14px}
.tabs button{flex:1;padding:10px 0;font-size:10px;letter-spacing:.16em}
.sect{display:none}.sect.on{display:block}
h4{font-size:10px;letter-spacing:.2em;color:var(--text-2);text-transform:uppercase;margin:22px 0 8px}.card{background:var(--bg-1);border:1px solid var(--line);border-radius:5px;padding:14px;margin-bottom:14px}
button{font-family:var(--mono);background:var(--bg-2);border:1px solid var(--line);color:var(--text-0);padding:11px 12px;border-radius:3px;font-size:11px;letter-spacing:.12em;cursor:pointer}button:active,button.on{border-color:var(--accent);color:var(--accent)}button[disabled]{opacity:.4}
.row{display:flex;gap:10px;flex-wrap:wrap}.row>button{flex:1}label{display:block;font-size:10px;letter-spacing:.1em;color:var(--text-2);margin:12px 0 4px;text-transform:uppercase}
input[type=range]{width:100%;accent-color:var(--accent)}input[type=text],input[type=password],select{width:100%;background:var(--bg-2);border:1px solid var(--line);color:var(--text-0);font-family:var(--mono);font-size:12px;padding:10px;border-radius:3px}.val{color:var(--text-0);font-size:12px}
.bar{height:3px;background:var(--line);border-radius:2px;overflow:hidden;margin-top:8px}.bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .12s linear}
.wifi-list{margin:4px 0;max-height:190px;overflow:auto}.wifi{display:flex;justify-content:space-between;align-items:center;padding:11px 12px;border:1px solid var(--line);border-radius:3px;margin-bottom:6px;cursor:pointer;font-size:12px}.wifi:active,.wifi.sel{border-color:var(--accent)}.wifi .rssi{color:var(--text-2);font-size:10px}.wifi .lock{color:var(--text-dim);font-size:10px;margin-left:6px}
.status{font-size:11px;color:var(--text-1);margin:8px 0;min-height:14px}.ok{color:var(--accent)}.warn{color:var(--warn)}.err{color:var(--alert)}
.xyz{font-size:10px;color:var(--text-1)}
details{border:1px solid var(--line);border-radius:4px;margin-top:12px;background:var(--bg-2)}details summary{cursor:pointer;padding:10px 12px;font-size:10px;letter-spacing:.18em;color:var(--warn);text-transform:uppercase;list-style:none}details summary::-webkit-details-marker{display:none}details summary:before{content:'\25B8 '}details[open] summary:before{content:'\25BE '}details .inner{padding:0 12px 12px}
nav{position:fixed;left:0;right:0;bottom:0;display:flex;background:var(--bg-1);border-top:1px solid var(--line)}nav a{flex:1;text-align:center;padding:16px 0;font-size:10px;letter-spacing:.18em;color:var(--text-2);text-decoration:none;text-transform:uppercase}nav a.on{color:var(--accent);border-top:2px solid var(--accent);margin-top:-1px}
button.mini{padding:6px 10px;font-size:11px;min-width:auto}
</style></head><body>
<header><div><div class=brand>SPECTER NODE</div><div class=sub>SETTINGS</div></div><div class=sub id=fwline>fw -</div></header>
<div class=tabs><button id=tb-connect>CONNECT</button><button id=tb-detect>DETECT</button><button id=tb-leds>LEDS</button><button id=tb-system>SYSTEM</button></div>
<section class=sect id=tab-connect>
<h4>Node identity</h4><div class=card>
<div class=status>Hardware ID: <span class=val id=node-id>-</span></div>
<label>Node name</label><input type=text id=node-name maxlength=31 placeholder="Automatic from slot"><div class=row><button id=node-name-save class=mini>SAVE NODE NAME</button><button id=node-name-auto class=mini>AUTO FROM SLOT</button></div><div class=status id=node-name-status>Automatic names follow the assigned slot.</div>
<label>Assigned node slot</label><select id=node-slot><option value=1>Node 1</option><option value=2>Node 2</option><option value=3>Node 3</option></select>
<div class=status id=node-slot-status>Assign each physical Node a unique slot.</div><div class=row><button id=node-slot-save>SAVE NODE SLOT</button></div></div>
<h4>Wi-Fi</h4><div class=card>
<div class=status id=wifi-cur>Current: -</div>
<div class=row><button id=wifi-scan>SCAN NETWORKS</button></div><div class=wifi-list id=wifi-list></div>
<label>Network (SSID)</label><input type=text id=wifi-ssid placeholder="Select above or type SSID">
<label>Password</label><input type=password id=wifi-pass placeholder="Leave blank for open network">
<div class=status id=wifi-status></div><div class=row><button id=wifi-join>SAVE & CONNECT</button><button id=wifi-forget class=mini>FORGET SAVED WI-FI</button></div></div>
<h4>Reaching SPECTER</h4><div class=card>
<div class=status id=relay-cur>Mode: LAN discovery</div>
<label>Internet relay URL</label><input type=text id=relay-url placeholder="Paste the relay address SPECTER shows (leave blank for LAN)">
<div class=xyz style="margin-top:2px">Connects to ONE SPECTER over the internet through a secure tunnel. Leave blank for local-network discovery. Reboot after saving.</div>
<div class=status id=relay-status></div><div class=row><button id=relay-save>SAVE RELAY</button><button id=relay-clear class=mini>CLEAR</button></div>
<label style="margin-top:16px;display:flex;align-items:center;gap:8px"><input type=checkbox id=ra-on style="width:auto"> Peer routing (Relay Assist)</label>
<div class=xyz style="margin:4px 0 10px">Direct Wi-Fi remains primary. If this Node cannot reach SPECTER, it can forward through one or two nearby Nodes using the same group key. Firmware updates still require direct Wi-Fi.</div>
<label>Relay group key</label><input type=password id=ra-key minlength=12 maxlength=64 placeholder="12 to 64 characters; use the same key on all Nodes">
<div class=status id=ra-route>Route: -</div><div class=status id=ra-status></div>
<div class=row><button id=ra-generate class=mini>GENERATE KEY</button><button id=ra-save>SAVE RELAY ASSIST</button></div></div>
</section>
<section class=sect id=tab-detect>
<div class=card><div class=row><button id=baseline>SET BASELINE</button><button id=identify>IDENTIFY</button></div>
<div class=status>Baseline zeroes BOTH channels to the current ambient. Identify flashes this pod's ring.</div>
<label style="margin-top:10px;display:flex;align-items:center;gap:8px"><input type=checkbox id=lock-on style="width:auto"> <span style="color:var(--warn)">FIELD LOCK</span> — freeze self-tuning for a session</label>
<div class=xyz id=lock-status style="margin-top:4px">While locked the node stops adapting itself: no span auto-fit, no baseline drift-follow, no mag re-zero. Your explicit actions (baseline, thresholds) still apply. Survives reboot.</div></div>
<h4>Magnetometer</h4><div class=card>
<label>Mag sensitivity <span class=val id=thr-mag-v>-</span></label><input type=range id=thr-mag min=0 max=100 step=1>
<div class=xyz style="margin-top:2px">Higher = reacts to smaller/farther field changes. Baseline auto-zeros to ambient, so any NEW field for a direction triggers. Auto-saves as you drag.</div></div>
<h4>Proximity antenna</h4><div class=card>
<div class=xyz style="margin-bottom:8px">Live: <b id=prox-live>-</b> delta <b id=prox-delta-live>-</b> / thr <b id=prox-thr-live>-</b></div>
<label>Sense source <span class=val id=prox-mode-v>-</span></label>
<select id=prox-mode><option value=0>Basic capacitive touch input</option><option value=1>Extended-range resistor sensor (GPIO18 drive to 1M to GPIO4)</option></select>
<div class=xyz id=prox-mode-msg style="margin-top:4px">Basic mode reads the selectable touch GPIO below.</div>
<label style="margin-top:10px">Trigger threshold (lower = fires on a lighter approach) <span class=val id=cadence-thresh-v>-</span></label>
<input type=range id=cadence-thresh min=5 max=400 step=1>
<div class=xyz id=cadence-thresh-hint style="margin-top:2px">Live delta is shown above — set this below your normal touch reading.</div>
<label style="margin-top:10px">Alert duration (seconds) <span class=val id=cadence-alert-v>-</span></label>
<input type=range id=cadence-alert min=0.5 max=30 step=0.5>
<label>Cooldown duration (seconds) <span class=val id=cadence-cooldown-v>-</span></label>
<input type=range id=cadence-cooldown min=0 max=30 step=0.5>
<label>Minimum animation time (seconds) <span class=val id=cadence-minhold-v>-</span></label>
<input type=range id=cadence-minhold min=0 max=10 step=0.5>
<div class=xyz style="margin-top:2px">Any crossing over the threshold plays at least this much animation, even if your hand is already gone. Capped by the alert duration above.</div>
<label>Heartbeat resumes after (seconds) <span class=val id=cadence-hb-v>-</span></label>
<input type=range id=cadence-hb min=0 max=15 step=0.5>
<div class=xyz style="margin-top:2px">How long the ring stays dark after the last detection before the idle heartbeat comes back. Was fixed at 10s.</div>
<div class=row style="margin-top:6px"><button id=cadence-apply class=mini>APPLY</button></div>
<div class=xyz id=cadence-msg style="margin-top:4px">Alert duration is how long the beep/flash/event stays active once triggered (default 6s); cooldown is the quiet window right after (default 6s). Effective trigger threshold: <b id=cadence-eff-v>-</b>.</div>
<label style="margin-top:14px;display:flex;align-items:center;gap:8px"><input type=checkbox id=therm-on style="width:auto"> Theremin response — continuous field</label>
<div class=xyz style="margin:2px 0 8px">Continuous field display below the alert threshold. The optional pitch is separate from alert beeps; turn it off for quiet monitoring — alerts still fire.</div>
<label style="display:flex;align-items:center;gap:8px"><input type=checkbox id=therm-tone style="width:auto"> Continuous pitch below alert (optional)</label>
<div class=xyz style="margin-top:8px">Live field: <b id=therm-field>-</b> / 100 <span style="color:var(--text-2)">(span <b id=therm-span>-</b>)</span></div>
<div class=bar style="margin-top:4px"><i id=therm-bar style="width:0%"></i></div>
<label style="margin-top:8px">Response curve</label><select id=therm-curve><option value=1>Natural (spread across the approach)</option><option value=0>Linear (legacy, bunches near contact)</option></select>
<label style="margin-top:8px">Jitter deadband <span class=val id=therm-noise-v>-</span></label><input type=range id=therm-noise min=0 max=60 step=1><div class=xyz style="margin-top:2px">Raise until the field sits at 0 with no hand near.</div>
<div class=row style="margin-top:8px"><button id=therm-refit class=mini>REFIT RANGE</button><button id=therm-save class=mini>SAVE THEREMIN</button></div>
<div class=xyz id=therm-save-msg style="margin-top:4px">Loading saved node settings...</div>
<div class=xyz style="margin-top:4px">Click REFIT, then within ~1.5s make your closest hand pass at the antenna — that motion becomes full field.</div>
<details><summary>Advanced / troubleshooting</summary><div class=inner>
<label style="margin-top:10px;display:flex;align-items:center;gap:8px"><input type=checkbox id=pin-swap style="width:auto"> Swap RC drive / buzzer pins</label>
<div class=xyz style="margin-top:4px">For miswired nodes only. Normal: RC drive GPIO18, buzzer GPIO12. Swapped: RC drive GPIO12, buzzer GPIO18. Applies immediately.</div>
<div id=touch-pin-controls><label>Antenna GPIO <span class=val id=prox-pin-v>-</span></label>
<select id=prox-pin><option value=4>GPIO4 (T0, default)</option><option value=27>GPIO27 (T7)</option><option value=32>GPIO32 (T9)</option><option value=33>GPIO33 (T8)</option></select>
<div class=row style="margin-top:8px"><button id=prox-pin-save class=mini>SAVE TOUCH PIN</button></div>
<div class=xyz id=prox-pin-msg style="margin-top:4px">Touch mode only. Applies instantly, no reboot. Boot-strap pins are excluded.</div></div>
<label style="margin-top:10px">Timeout margin (x ambient) <span class=val id=rcmargin-mult-v>-</span></label>
<input type=range id=rcmargin-mult min=2 max=40 step=1>
<label>Timeout floor (us) <span class=val id=rcmargin-floor-v>-</span></label>
<input type=range id=rcmargin-floor min=5 max=2000 step=5>
<div class=row style="margin-top:6px"><button id=rcmargin-apply class=mini>APPLY & RECALIBRATE</button></div>
<div class=xyz id=rcmargin-msg style="margin-top:4px">Extended-range mode only. Lower = sensitive further from contact but closer to noise. Learned window: <b id=rcmargin-timeout-v>-</b> us against ambient <b id=rcmargin-ambient-v>-</b> us.</div>
<label style="margin-top:10px">De-glitch (ms, filters instant noise spikes) <span class=val id=cadence-arm-v>-</span></label>
<input type=range id=cadence-arm min=0 max=800 step=10>
<div class=xyz style="margin-top:2px">Raw signal must sustain past threshold this long before arming (default 150ms; real touches easily clear it). Applied with the APPLY button above.</div>
<div class=row style="margin-top:10px"><button id=diag-log-start class=mini>CAPTURE 2 MIN LOG</button><a id=diag-log-download class=mini style="display:none" href="/diag/log/download">DOWNLOAD CSV</a></div>
<div class=xyz id=diag-log-msg style="margin-top:4px">2-minute capture of proximity, RC, magnetometer, alert phase, and Wi-Fi signal for troubleshooting.</div>
</div></details></div>
<h4>Interference</h4><div class=card>
<div class=xyz style="margin-bottom:8px">Walkie-talkie / RF cancel: silences false hits from a keyed two-way radio. Hold CAPTURE and key your radio near the node for ~2s, then release.</div>
<div class=row><button id=rf-cap class=mini>HOLD TO CAPTURE</button><button id=rf-clr class=mini>CLEAR</button></div>
<label style="margin-top:8px;display:flex;align-items:center;gap:8px"><input type=checkbox id=rf-arm style="width:auto"> Suppress matching signals</label>
<div class=xyz id=rf-dbg style="margin-top:4px;color:var(--accent)">no signature captured</div></div>
</section>
<section class=sect id=tab-leds>
<h4>Ring setup</h4><div class=card>
<label>Chipset</label><select id=led-chip><option>WS2812B</option><option>SK6812</option><option>SK6812RGBW</option><option>WS2811</option><option>WS2813</option><option>APA102</option><option>SK9822</option></select>
<label>Color order</label><select id=led-order><option>GRB</option><option>RGB</option><option>BGR</option><option>BRG</option><option>RBG</option><option>GBR</option><option>GRBW</option><option>RGBW</option><option>BGRW</option></select>
<div class=row><div style="flex:1"><label>Data pin</label><input type=text id=led-data></div><div style="flex:1"><label>Count</label><input type=text id=led-count></div></div>
<label>Brightness <span class=val id=led-bright-v>-</span></label><input type=range id=led-bright min=0 max=255 step=1>
<label>Magnetic direction alignment</label>
<div class=xyz style="margin-bottom:8px">A steady lobe lights the "front" of the ring. Tap ROTATE to spin it 45° at a time until the lit side points the way your antenna faces.</div>
<div class=row style="align-items:center"><button id=led-rot class=mini style="flex:1">ROTATE 45°</button><div style="flex:1;text-align:center">angle <b id=led-rot-v>0°</b></div></div>
<div class=row style="align-items:center;margin-top:6px"><button id=led-flip class=mini style="flex:1">FLIP DIRECTION</button><div style="flex:1;text-align:center">spin <b id=led-flip-v>CW</b></div></div>
<div class=row style="margin-top:8px"><div style="flex:1"><label>Front pixel (ring start)</label><input type=text id=led-front></div></div>
<div class=status id=led-status>Brightness, map, count & color order apply live. Chipset and data/clock pin need a reflash.</div>
<div class=status id=led-test-hint>Test: fires a solid color on all <span class=val id=led-count-echo>16</span> pixels for 2s. If only some light up, check Count; wrong color, change Color order.</div>
<div class=row><button id=t-red class=mini style="border-color:#c0392b">RED</button><button id=t-grn class=mini style="border-color:#27ae60">GREEN</button><button id=t-blu class=mini style="border-color:#2980b9">BLUE</button><button id=t-wht class=mini>WHITE</button></div>
<div class=row><button id=led-save>SAVE LED CONFIG</button><button id=led-test>PREVIEW BOOT</button></div></div>
<h4>Effects</h4><div class=card>
<div class=status>Detection themes: a color + animation per detection type. Applies live; higher severity auto-brightens toward white.</div>
<label>Magnetic anomaly</label>
<div class=row><div style="flex:1"><input type=color id=ev-mag-c></div><div style="flex:1"><input type=color id=ev-mag-c2></div><div style="flex:1"><input type=color id=ev-mag-c3></div><div><button id=ev-mag-t class=mini>PREVIEW</button></div></div>
<select id=ev-mag-a style="margin-top:6px"><option value=0>Directional (fade to source)</option><option value=1>Pulse</option><option value=2>Spin</option><option value=3>Blink</option><option value=4>Theatre chase</option><option value=5>Strobe</option><option value=6>Dual comet</option><option value=7>Ripple</option><option value=8>Colour wipe</option><option value=9>Sparkle</option><option value=10>Gradient cycle</option><option value=11>Halves</option><option value=12>Breathe blend</option><option value=13>Radar</option></select>
<label>Proximity</label>
<div class=row><div style="flex:1"><input type=color id=ev-prox-c></div><div style="flex:1"><input type=color id=ev-prox-c2></div><div style="flex:1"><input type=color id=ev-prox-c3></div><div><button id=ev-prox-t class=mini>PREVIEW</button></div></div>
<select id=ev-prox-a style="margin-top:6px"><option value=0>Directional (fade to source)</option><option value=1>Pulse</option><option value=2>Spin</option><option value=3>Blink</option><option value=4>Theatre chase</option><option value=5>Strobe</option><option value=6>Dual comet</option><option value=7>Ripple</option><option value=8>Colour wipe</option><option value=9>Sparkle</option><option value=10>Gradient cycle</option><option value=11>Halves</option><option value=12>Breathe blend</option><option value=13>Radar</option></select>
<div class=xyz style="margin-top:6px">Three colours per channel. Directional, Pulse, Spin and Blink use the first colour only; the rest blend or cycle all three.</div>
<div class=status id=ev-status></div>
<div class=row><button id=ev-save>SAVE THEMES</button></div>
<label style="margin-top:14px">Boot animation</label>
<div class=row><div style="flex:1"><label>Build color</label><input type=color id=boot-a></div><div style="flex:1"><label>Sweep color</label><input type=color id=boot-b></div></div>
<label style="display:flex;align-items:center;gap:8px"><input type=checkbox id=boot-en style="width:auto"> Play boot animation on startup</label>
<div class=xyz>On power-up the ring builds 1→N, sweeps your two colors, then goes dark until a detection.</div>
<div class=row style="margin-top:8px"><button id=boot-save>SAVE BOOT</button><button id=boot-test class=mini>PREVIEW</button></div>
<label style="margin-top:14px;display:flex;align-items:center;gap:8px"><input type=checkbox id=heartbeat-on style="width:auto"> Idle heartbeat light <span style="color:var(--warn)">may reduce battery life</span></label>
<label>Heartbeat color</label><input type=color id=heartbeat-color value="#20e080">
<div class=status id=heartbeat-status>Gently fades while the Node is idle. Alerts and guided effects take priority.</div>
<div class=row><button id=heartbeat-save>SAVE HEARTBEAT</button></div></div>
</section>
<section class=sect id=tab-system>
<h4>Sync nodes</h4><div class=card>
<div class=xyz style="margin-bottom:8px">Copies THIS node's detection tuning, cadence, LED setup, animations and colours to every other SPECTER node on the same Wi-Fi. Each node keeps its own name, slot, Wi-Fi and relay settings.</div>
<div class=row><button id=sync-push>SYNC ALL NODES TO THIS ONE</button></div>
<div class=status id=sync-status>Nodes are found automatically over the local network.</div></div>
<h4>Firmware</h4><div class=card>
<div class=status>Installed: <span class=val id=fwv>-</span></div>
<div class=status>Device: <span class=val id=devn>-</span></div>
<div class=status id=upd-line>Tap below to check specter-imaging.com for a newer build.</div>
<div class=row><button id=upd-check>CHECK FOR UPDATES</button><button id=upd-install disabled>INSTALL UPDATE</button></div>
<div class=status style="font-size:10px;margin-top:6px">Update will not install? Flash over USB at <b>specter-imaging.com/help/flash-node</b></div></div>
</section>
<nav><a href=/>TELEMETRY</a><a class=on href=/settings>SETTINGS</a></nav>
<script>
var $=function(i){return document.getElementById(i)};
var TABS=['connect','detect','leds','system'];
function showTab(t){if(TABS.indexOf(t)<0)t='connect';TABS.forEach(function(n){var s=$('tab-'+n),b=$('tb-'+n);if(s)s.className='sect'+(n===t?' on':'');if(b)b.className=(n===t?'on':'')});try{history.replaceState(null,'','#'+t)}catch(x){}}
TABS.forEach(function(n){var b=$('tb-'+n);if(b)b.onclick=function(){showTab(n)}});
showTab((location.hash||'#connect').slice(1));
var ws,thrMag=1500,thrProx=8;
function connect(){ws=new WebSocket('ws://'+location.hostname+':81/');ws.onclose=function(){setTimeout(connect,2000)};ws.onmessage=function(e){try{onFrame(JSON.parse(e.data))}catch(x){}}}
function send(o){if(ws&&ws.readyState===1)ws.send(JSON.stringify(o))}
function onFrame(m){if(m.rcSub)window._rcSub=m.rcSub;if(m.t==='hello'){$('fwline').textContent='fw '+(m.fw||'-');$('fwv').textContent=m.fw||'-';$('devn').textContent=m.name||m.device||'-';if(m.thresholds){thrMag=(m.thresholds.magSens!=null?m.thresholds.magSens:70);thrProx=m.thresholds.prox;syncThr()}}else if(m.t==='telemetry'){if(m.proxMode!=null)updateProxModeUi(+m.proxMode,m.touchPin,m.rcCharge,m.rcBase,m.rcOk,m.rcFault);var tc=$('therm-on'),tt=$('therm-tone');if(tc&&m.theremin!=null&&!tc._userset)tc.checked=!!m.theremin;if(tt&&m.thermTone!=null&&!tt._userset)tt.checked=!!m.thermTone;if($('therm-field')&&m.proxField!=null)$('therm-field').textContent=m.proxField;if($('therm-span')&&m.proxSpan!=null)$('therm-span').textContent=m.proxSpan;if($('therm-bar')&&m.proxField!=null)$('therm-bar').style.width=Math.max(0,Math.min(100,m.proxField))+'%';if($('therm-noise')&&m.proxNoise!=null&&!$('therm-noise')._userset){$('therm-noise').value=m.proxNoise;if($('therm-noise-v'))$('therm-noise-v').textContent=m.proxNoise;}if(m.lock!=null&&$('lock-on')&&!$('lock-on')._userset)$('lock-on').checked=!!m.lock;if(m.lock!=null&&$('lock-status'))$('lock-status').style.color=m.lock?'var(--warn)':'var(--text-1)';if(m.proxCurve!=null&&$('therm-curve')&&!$('therm-curve')._userset)$('therm-curve').value=String(m.proxCurve);var sm=$('therm-save-msg');if(sm&&!window._thermSaving){sm.textContent='Saved on node: response '+(m.theremin?'ON':'OFF')+', buzzer '+(m.thermTone?'ON':'OFF')+'.';sm.style.color='#4ade80';}}}
function syncThr(){$('thr-mag').value=thrMag;$('thr-mag-v').textContent=thrMag}
$('baseline').onclick=function(){send({t:'cmd',cmd:'rebaseline'})};$('identify').onclick=function(){send({t:'cmd',cmd:'identify'})};
var mt;$('thr-mag').oninput=function(e){thrMag=+e.target.value;$('thr-mag-v').textContent=thrMag;clearTimeout(mt);mt=setTimeout(function(){send({t:'cmd',cmd:'setThreshold',channel:'mag',value:thrMag})},250)};
function rfPost(u){return fetch(u,{method:'POST'}).then(function(r){return r.json()}).catch(function(){return{}})}
function rfStat(){fetch('/rf/status').then(function(r){return r.json()}).then(function(d){var a=$('rf-arm');if(a)a.checked=!!d.active;var db=$('rf-dbg');if(db){if(d.capturing){db.textContent='capturing... key your radio now';db.style.color='#fbbf24'}else if(d.magPeak>0||d.proxPeak>0){db.textContent='signature: mag '+d.magPeak+' / prox '+d.proxPeak+(d.active?' \u00b7 ARMED':' \u00b7 off');db.style.color=d.active?'#4ade80':'var(--text-2)'}else{db.textContent='no signature captured';db.style.color='var(--text-2)'}}}).catch(function(){})}
if($('rf-cap')){var cap=$('rf-cap');var down=function(e){e.preventDefault();rfPost('/rf/capture?action=start').then(rfStat)};var up=function(e){e.preventDefault();rfPost('/rf/capture?action=stop').then(rfStat)};cap.addEventListener('mousedown',down);cap.addEventListener('touchstart',down);cap.addEventListener('mouseup',up);cap.addEventListener('mouseleave',up);cap.addEventListener('touchend',up);}
if($('rf-clr')){$('rf-clr').onclick=function(){rfPost('/rf/clear').then(rfStat)}}
if($('rf-arm')){$('rf-arm').onchange=function(e){rfPost('/rf/arm?on='+(e.target.checked?'1':'0')).then(rfStat)}}
rfStat();setInterval(rfStat,1500);
function proxPost(u){return fetch(u,{method:'POST'}).then(function(r){return r.json()}).catch(function(){return{}})}
function updateProxModeUi(mode,touch,charge,base,ok,fault){var rc=(mode===1),sel=$('prox-mode'),pv=$('prox-pin-v'),pin=$('prox-pin'),save=$('prox-pin-save'),msg=$('prox-mode-msg');if(sel)sel.value=rc?'1':'0';if($('prox-mode-v'))$('prox-mode-v').textContent=rc?'RC / GPIO18 TO GPIO4':'TOUCH';if(pv)pv.textContent=rc?'GPIO18 drive / GPIO4 sense':'GPIO'+(touch||4);if(pin)pin.disabled=rc;if(save)save.disabled=rc;if(msg){var live=((charge||0)/(window._rcSub||100)).toFixed(2),zero=((base||0)/(window._rcSub||100)).toFixed(2),state=(ok===false?(fault===1?' GPIO4 could not discharge.':fault===2?' GPIO4 never charged HIGH. Check GPIO18 to 1M to GPIO4.':' Check RC wiring.'):' Wiring active.');msg.textContent=rc?'GPIO18 drives the antenna node through 1M ohm and GPIO4 measures charge time. Live '+live+' us, baseline '+zero+' us.'+state+' The touch-pin selector is ignored in this mode.':'Basic mode reads the selectable capacitive-touch GPIO below.';}}
if($('pin-swap')){$('pin-swap')._userset=false;$('pin-swap').onchange=function(){var el=this;var wanted=el.checked;el._userset=true;fetch('/hardware/pinswap',{method:'POST',body:new URLSearchParams({enabled:wanted?'1':'0'})}).then(function(r){return r.json()}).then(function(d){el.checked=!!d.pinSwap}).catch(function(){el.checked=!wanted}).finally(function(){el._userset=false})}}
if($('prox-mode')){$('prox-mode').onchange=function(e){var pm=+e.target.value;updateProxModeUi(pm,+($('prox-pin')?$('prox-pin').value:4),0,0);send({t:'cmd',cmd:'setTheremin',proxMode:pm});var sm=$('therm-save-msg');if(sm){sm.textContent='Sense source saved. Waiting for node confirmation...';sm.style.color='#fbbf24';}}}
if($('prox-pin')){$('prox-pin')._userset=false;$('prox-pin').onchange=function(){$('prox-pin')._userset=true;};}
if($('prox-pin-save')){$('prox-pin-save').onclick=function(){var v=$('prox-pin').value;proxPost('/prox/pin?pin='+v).then(function(d){if($('prox-pin-v'))$('prox-pin-v').textContent='GPIO'+(d.pin||v);var m=$('prox-pin-msg');if(m){m.textContent='Saved GPIO'+(d.pin||v)+' \u00b7 recalibrated. Keep your hand away for a moment.';}})}}
if($('diag-log-start')){$('diag-log-start').onclick=function(){var b=$('diag-log-start'),dl=$('diag-log-download'),m=$('diag-log-msg');if(dl)dl.style.display='none';proxPost('/diag/log/start?ms=120000').then(function(){if(b)b.textContent='CAPTURING...';var poll=setInterval(function(){fetch('/diag/log/status').then(function(r){return r.json()}).then(function(d){if(m)m.textContent='Capturing... '+(d.rows||0)+' rows, '+Math.max(0,Math.round((d.msRemaining||0)/1000))+'s left.';if(!d.armed){clearInterval(poll);if(b)b.textContent='CAPTURE 2 MIN LOG';if(dl){dl.style.display='inline';dl.href='/diag/log/download?_='+Date.now();}if(m)m.textContent='Capture complete, '+(d.rows||0)+' rows. Download and send the CSV.';}})},1000)})}}
function refreshRcMargin(){fetch('/prox/rcmargin').then(function(r){return r.json()}).then(function(d){if($('rcmargin-mult'))$('rcmargin-mult').value=d.mult;if($('rcmargin-mult-v'))$('rcmargin-mult-v').textContent=d.mult;if($('rcmargin-floor'))$('rcmargin-floor').value=d.floorUs;if($('rcmargin-floor-v'))$('rcmargin-floor-v').textContent=d.floorUs;if($('rcmargin-timeout-v'))$('rcmargin-timeout-v').textContent=(d.timeoutUs||0);if($('rcmargin-ambient-v'))$('rcmargin-ambient-v').textContent=(d.ambientUs||0)})}
if($('rcmargin-mult'))$('rcmargin-mult').oninput=function(){if($('rcmargin-mult-v'))$('rcmargin-mult-v').textContent=this.value};
if($('rcmargin-floor'))$('rcmargin-floor').oninput=function(){if($('rcmargin-floor-v'))$('rcmargin-floor-v').textContent=this.value};
if($('rcmargin-apply')){$('rcmargin-apply').onclick=function(){var mult=$('rcmargin-mult').value,fl=$('rcmargin-floor').value,m=$('rcmargin-msg');proxPost('/prox/rcmargin?mult='+mult+'&floorUs='+fl).then(function(){if(m)m.textContent='Applied, recalibrating... keep your hand away for a moment.';setTimeout(refreshRcMargin,1200)})}}
refreshRcMargin();
function refreshCadence(){fetch('/prox/cadence').then(function(r){return r.json()}).then(function(d){if($('cadence-minhold')&&d.minHoldMs!=null){$('cadence-minhold').value=(d.minHoldMs/1000);$('cadence-minhold-v').textContent=(d.minHoldMs/1000)}if(d.rcSub)window._rcSub=d.rcSub;var ct=$('cadence-thresh');if(ct&&d.threshMax){ct.max=d.threshMax;ct.min=(d.proxMode===1?5:4)}var hh=$('cadence-thresh-hint');if(hh)hh.textContent=(d.proxMode===1?'RC mode: units are 0.01us of charge time. A full touch typically reads 60-100; rest jitter is under 15.':'Touch mode: units are raw touchRead counts.');if($('cadence-hb')&&d.hbResumeMs!=null){$('cadence-hb').value=(d.hbResumeMs/1000);$('cadence-hb-v').textContent=(d.hbResumeMs/1000)}if($('cadence-alert')){$('cadence-alert').value=(d.alertMs/1000);$('cadence-alert-v').textContent=(d.alertMs/1000)}if($('cadence-cooldown')){$('cadence-cooldown').value=(d.cooldownMs/1000);$('cadence-cooldown-v').textContent=(d.cooldownMs/1000)}if($('cadence-arm')){$('cadence-arm').value=d.armMs;$('cadence-arm-v').textContent=d.armMs}if($('cadence-thresh')){$('cadence-thresh').value=d.threshProx;$('cadence-thresh-v').textContent=d.threshProx}if($('cadence-eff-v'))$('cadence-eff-v').textContent=d.effThreshProx})}
if($('cadence-alert'))$('cadence-alert').oninput=function(){if($('cadence-alert-v'))$('cadence-alert-v').textContent=this.value};
if($('cadence-cooldown'))$('cadence-cooldown').oninput=function(){if($('cadence-cooldown-v'))$('cadence-cooldown-v').textContent=this.value};
if($('cadence-thresh'))$('cadence-thresh').oninput=function(){if($('cadence-thresh-v'))$('cadence-thresh-v').textContent=this.value};
if($('cadence-arm'))$('cadence-arm').oninput=function(){if($('cadence-arm-v'))$('cadence-arm-v').textContent=this.value};
if($('cadence-hb'))$('cadence-hb').oninput=function(){if($('cadence-hb-v'))$('cadence-hb-v').textContent=this.value};
if($('cadence-minhold'))$('cadence-minhold').oninput=function(){if($('cadence-minhold-v'))$('cadence-minhold-v').textContent=this.value};
if($('cadence-apply')){$('cadence-apply').onclick=function(){var a=Math.round($('cadence-alert').value*1000),c=Math.round($('cadence-cooldown').value*1000),g=$('cadence-arm').value,t=$('cadence-thresh').value,hb=Math.round(($('cadence-hb')?$('cadence-hb').value:2)*1000),m=$('cadence-msg');proxPost('/prox/cadence?alertMs='+a+'&cooldownMs='+c+'&armMs='+g+'&thresh='+t+'&hbResumeMs='+hb+'&minHoldMs='+Math.round(($('cadence-minhold')?$('cadence-minhold').value:2)*1000)).then(function(){if(m)m.textContent='Applied.';setTimeout(refreshCadence,600)})}}
refreshCadence();
function saveTherm(){var tc=$('therm-on'),tt=$('therm-tone'),sm=$('therm-save-msg');if(tc)tc._userset=true;if(tt)tt._userset=true;window._thermSaving=true;if(sm){sm.textContent='Saving to node...';sm.style.color='#fbbf24';}send({t:'cmd',cmd:'setTheremin',on:tc?tc.checked:false,tone:tt?tt.checked:true});setTimeout(function(){if(tc)tc._userset=false;if(tt)tt._userset=false;window._thermSaving=false;if(sm)sm.textContent='Waiting for node confirmation...';},700)}
if($('therm-on')){$('therm-on')._userset=false;$('therm-on').onchange=saveTherm}
if($('therm-tone')){$('therm-tone')._userset=false;$('therm-tone').onchange=saveTherm}
if($('therm-save')){$('therm-save').onclick=saveTherm}
if($('therm-refit')){$('therm-refit').onclick=function(){var b=$('therm-refit');if(b)b.textContent='WAVE NOW…';setTimeout(function(){send({t:'cmd',cmd:'setTheremin',on:$('therm-on')?$('therm-on').checked:false,tone:$('therm-tone')?$('therm-tone').checked:true,refit:true});if(b){b.textContent='REFIT ✓';setTimeout(function(){b.textContent='REFIT RANGE'},1400)}},1500)}}
if($('therm-noise')){$('therm-noise')._userset=false;$('therm-noise').oninput=function(e){$('therm-noise')._userset=true;$('therm-noise-v').textContent=e.target.value;clearTimeout(window._tn);window._tn=setTimeout(function(){send({t:'cmd',cmd:'setTheremin',on:$('therm-on')?$('therm-on').checked:false,tone:$('therm-tone')?$('therm-tone').checked:true,noise:+e.target.value});setTimeout(function(){$('therm-noise')._userset=false},500)},250)}}
function loadNodeIdentity(){fetch('/node/identity').then(function(r){return r.json()}).then(function(d){$('node-id').textContent=d.nodeId||'-';$('node-slot').value=String(d.slot||1);if($('node-name'))$('node-name').value=d.customName?(d.name||''):'';if($('pin-swap'))$('pin-swap').checked=!!d.pinSwap;if($('node-name-status'))$('node-name-status').textContent=d.customName?'Custom name: '+(d.name||'-'):'Automatic name: Node '+(d.slot||1)}).catch(function(){})}
$('node-name-save').onclick=function(){var name=$('node-name').value.trim();fetch('/node/name',{method:'POST',body:new URLSearchParams({name:name})}).then(function(r){return r.json()}).then(function(d){$('node-name').value=d.customName?(d.name||''):'';$('node-name-status').className='status ok';$('node-name-status').textContent=d.customName?'Saved custom name: '+d.name:'Automatic name: Node '+d.slot}).catch(function(){$('node-name-status').className='status err';$('node-name-status').textContent='Could not save node name'})};
$('node-name-auto').onclick=function(){fetch('/node/name',{method:'POST',body:new URLSearchParams({name:''})}).then(function(r){return r.json()}).then(function(d){$('node-name').value='';$('node-name-status').className='status ok';$('node-name-status').textContent='Automatic name: '+d.name}).catch(function(){})};
$('node-slot-save').onclick=function(){var slot=$('node-slot').value;fetch('/node/slot',{method:'POST',body:new URLSearchParams({slot:slot})}).then(function(r){return r.json()}).then(function(d){$('node-slot-status').className='status ok';$('node-slot-status').textContent='Saved as Node '+d.slot+' ('+(d.name||('Node '+d.slot))+'). Reboot to reconnect with this slot.';if($('node-name')&&!d.customName)$('node-name').value='';if($('node-name-status')&&!d.customName)$('node-name-status').textContent='Automatic name: '+(d.name||('Node '+d.slot))}).catch(function(){$('node-slot-status').className='status err';$('node-slot-status').textContent='Could not save slot'})};
function loadHeartbeat(){fetch('/heartbeat/status').then(function(r){return r.json()}).then(function(d){$('heartbeat-on').checked=!!d.enabled;$('heartbeat-color').value='#'+(d.color||'20E080')}).catch(function(){})}
$('heartbeat-save').onclick=function(){var body=new URLSearchParams({enabled:$('heartbeat-on').checked?'1':'0',color:$('heartbeat-color').value.replace('#','')});fetch('/heartbeat/save',{method:'POST',body:body}).then(function(r){return r.json()}).then(function(d){$('heartbeat-status').className='status ok';$('heartbeat-status').textContent='Saved. Heartbeat '+(d.enabled?'enabled':'disabled')+'.'}).catch(function(){$('heartbeat-status').className='status err';$('heartbeat-status').textContent='Could not save heartbeat'})};
loadNodeIdentity();loadHeartbeat();
function wifiStatus(){fetch('/wifi/status').then(function(r){return r.json()}).then(function(d){$('wifi-cur').textContent='Current: '+(d.ssid?d.ssid+' ('+(d.rssi||'?')+' dBm, '+(d.ip||'-')+')':'not connected - hosting '+(d.ap||'SPECTER-NODE'))}).catch(function(){})}
$('wifi-scan').onclick=function(){$('wifi-status').textContent='Scanning\u2026';$('wifi-scan').disabled=true;fetch('/wifi/scan').then(function(r){return r.json()}).then(function(nets){var l=$('wifi-list');l.innerHTML='';(nets||[]).forEach(function(n){var el=document.createElement('div');el.className='wifi';el.innerHTML='<span>'+n.ssid+'</span><span class=rssi>'+n.rssi+' dBm<span class=lock>'+(n.enc?'\uD83D\uDD12':'open')+'</span></span>';el.onclick=function(){document.querySelectorAll('.wifi').forEach(function(x){x.classList.remove('sel')});el.classList.add('sel');$('wifi-ssid').value=n.ssid;$('wifi-pass').focus()};l.appendChild(el)});$('wifi-status').textContent=(nets&&nets.length?nets.length+' networks found':'no networks found');$('wifi-scan').disabled=false}).catch(function(){$('wifi-status').textContent='Scan failed';$('wifi-status').className='status err';$('wifi-scan').disabled=false})};
$('wifi-join').onclick=function(){var ssid=$('wifi-ssid').value.trim();if(!ssid){$('wifi-status').textContent='Enter a network name';return}$('wifi-status').className='status';$('wifi-status').textContent='Connecting to '+ssid+'\u2026';$('wifi-join').disabled=true;var body=new URLSearchParams({ssid:ssid,pass:$('wifi-pass').value});fetch('/wifi/save',{method:'POST',body:body}).then(function(r){return r.json()}).then(function(d){if(d.ok){$('wifi-status').className='status ok';$('wifi-status').textContent='Connected \u00b7 IP '+(d.ip||'-')+'. Reachable at that IP on your network.'}else{$('wifi-status').className='status err';$('wifi-status').textContent='Could not connect: '+(d.error||'check password')}$('wifi-join').disabled=false;wifiStatus()}).catch(function(){$('wifi-status').className='status err';$('wifi-status').textContent='Join request failed';$('wifi-join').disabled=false})};
$('wifi-forget').onclick=function(){$('wifi-status').className='status';$('wifi-status').textContent='Clearing saved Wi-Fi...';fetch('/wifi/forget',{method:'POST'}).then(function(r){return r.json()}).then(function(d){$('wifi-ssid').value='';$('wifi-pass').value='';$('wifi-status').className='status ok';$('wifi-status').textContent=d.note||'Saved Wi-Fi cleared.';wifiStatus()}).catch(function(){$('wifi-status').className='status err';$('wifi-status').textContent='Could not clear saved Wi-Fi'})};
function relayStatus(){fetch('/relay/get').then(function(r){return r.json()}).then(function(d){$('relay-url').value=d.url||'';$('relay-cur').textContent='Mode: '+(d.active?'INTERNET RELAY · '+d.url:'LAN discovery')}).catch(function(){})}
$('relay-save').onclick=function(){var u=$('relay-url').value.trim();$('relay-status').className='status';$('relay-status').textContent='Saving…';$('relay-save').disabled=true;var body=new URLSearchParams({url:u});fetch('/relay/save',{method:'POST',body:body}).then(function(r){return r.json()}).then(function(d){$('relay-status').className='status ok';$('relay-status').textContent=(d.note||'Saved.')+' Reboot to apply.';$('relay-save').disabled=false;relayStatus()}).catch(function(){$('relay-status').className='status err';$('relay-status').textContent='Save failed';$('relay-save').disabled=false})};
$('relay-clear').onclick=function(){$('relay-status').className='status';$('relay-status').textContent='Clearing…';fetch('/relay/clear',{method:'POST'}).then(function(r){return r.json()}).then(function(d){$('relay-url').value='';$('relay-status').className='status ok';$('relay-status').textContent=(d.note||'Cleared.');relayStatus()}).catch(function(){$('relay-status').className='status err';$('relay-status').textContent='Clear failed'})};
relayStatus();
function raStatus(){fetch('/relay-assist/status').then(function(r){return r.json()}).then(function(d){$('ra-on').checked=!!d.enabled;var role=(d.role||'idle').toUpperCase();var route='Route: '+role+' · channel '+(d.channel||'-')+' · quality '+(d.quality!=null?d.quality:'-');if(d.parentNodeId)route+=' · via '+d.parentNodeId;if(d.depth)route+=' · '+d.depth+' hop'+(d.depth===1?'':'s');$('ra-route').textContent=route;$('ra-status').className='status '+(d.started?'ok':'');$('ra-status').textContent=d.enabled?(d.configured?(d.started?'Relay Assist ready.':'Saved; radio initialization pending.'):'Group key required.'):'Disabled.'}).catch(function(){$('ra-status').className='status err';$('ra-status').textContent='Could not read Relay Assist status'})}
$('ra-generate').onclick=function(){var a=new Uint8Array(18);crypto.getRandomValues(a);$('ra-key').value=Array.from(a,function(b){return b.toString(16).padStart(2,'0')}).join('');$('ra-on').checked=true};
$('ra-save').onclick=function(){var enabled=$('ra-on').checked,key=$('ra-key').value.trim();if(enabled&&(key.length<12||key.length>64)){$('ra-status').className='status err';$('ra-status').textContent='Group key must be 12 to 64 characters.';return}$('ra-save').disabled=true;$('ra-status').className='status';$('ra-status').textContent='Saving...';fetch('/relay-assist/save',{method:'POST',body:new URLSearchParams({enabled:enabled?'1':'0',key:key})}).then(function(r){if(!r.ok)throw new Error('save');return r.json()}).then(function(){$('ra-key').value='';$('ra-status').className='status ok';$('ra-status').textContent='Saved. Direct Wi-Fi remains primary.';raStatus()}).catch(function(){$('ra-status').className='status err';$('ra-status').textContent='Save failed. Check the group key length.'}).then(function(){$('ra-save').disabled=false})};
raStatus();setInterval(raStatus,3000);
if($('lock-on')){$('lock-on')._userset=false;$('lock-on').onchange=function(){var el=this;el._userset=true;fetch('/lock/set',{method:'POST',body:new URLSearchParams({on:el.checked?'1':'0'})}).then(function(r){return r.json()}).then(function(d){el.checked=!!d.lock;var st=$('lock-status');if(st)st.textContent=d.lock?'LOCKED: the node will not retune itself until you unlock.':'Unlocked: span auto-fit and baseline drift-follow active.'}).catch(function(){el.checked=!el.checked}).finally(function(){el._userset=false})}}
if($('therm-curve')){$('therm-curve')._userset=false;$('therm-curve').onchange=function(){var el=this;el._userset=true;send({t:'cmd',cmd:'setTheremin',curve:+el.value});setTimeout(function(){el._userset=false},600)}}
// ---- LED config (per-node, on-device) ----
function ledLoad(){fetch('/led/status').then(function(r){return r.json()}).then(function(d){
$('led-chip').value=d.chipset||'WS2812B';$('led-order').value=d.order||'GRB';
$('led-data').value=(d.dataPin!=null?d.dataPin:13);$('led-count').value=(d.count!=null?d.count:16);
$('led-bright').value=(d.bright!=null?d.bright:100);$('led-bright-v').textContent=(d.bright!=null?d.bright:100);
var m=d.map||{};$('led-front').value=(m.front!=null?m.front:'');
if($('led-rot-v'))$('led-rot-v').textContent=((m.rot!=null?m.rot:0))+'°';window._magRot=(m.rot!=null?m.rot:0);window._magFlip=(m.flip?1:0);if($('led-flip-v'))$('led-flip-v').textContent=(window._magFlip?'CCW':'CW');
var ev=d.events||{},bt=d.boot||{};
if($('ev-mag-c')){$('ev-mag-c').value='#'+(ev.magColor||'FF2020');$('ev-mag-a').value=(ev.magAnim!=null?ev.magAnim:0);
$('ev-mag-c2').value='#'+(ev.magColorB||'FF8000');$('ev-mag-c3').value='#'+(ev.magColorC||'FFFFFF');
$('ev-prox-c2').value='#'+(ev.proxColorB||'2060FF');$('ev-prox-c3').value='#'+(ev.proxColorC||'FFFFFF');
$('ev-prox-c').value='#'+(ev.proxColor||'20E0FF');$('ev-prox-a').value=(ev.proxAnim!=null?ev.proxAnim:2);
$('boot-a').value='#'+(bt.colorA||'1030FF');$('boot-b').value='#'+(bt.colorB||'10FF80');$('boot-en').checked=(bt.enable!=0);}
var ce=$('led-count-echo');if(ce)ce.textContent=(d.count!=null?d.count:16);
}).catch(function(){})}
$('led-bright').oninput=function(e){$('led-bright-v').textContent=e.target.value};
if($('led-rot')){$('led-rot').onclick=function(){var b=$('led-rot');fetch('/led/config',{method:'POST',body:new URLSearchParams({rotStep:45})}).then(function(r){return r.json()}).then(function(d){var rv=(d&&d.rot!=null)?d.rot:(((window._magRot||0)+45)%360);window._magRot=rv;if($('led-rot-v'))$('led-rot-v').textContent=rv+'\u00b0';if(b){b.textContent='ROTATED \u2192';setTimeout(function(){b.textContent='ROTATE 45\u00b0'},700)}}).catch(function(){})}}
if($('led-flip')){$('led-flip').onclick=function(){var b=$('led-flip');fetch('/led/config',{method:'POST',body:new URLSearchParams({flipToggle:1})}).then(function(r){return r.json()}).then(function(d){var fv=(d&&d.flip!=null)?(d.flip?1:0):(window._magFlip?0:1);window._magFlip=fv;if($('led-flip-v'))$('led-flip-v').textContent=(fv?'CCW':'CW');if(b){b.textContent='FLIPPED';setTimeout(function(){b.textContent='FLIP DIRECTION'},700)}}).catch(function(){})}}
$('led-save').onclick=function(){var b=new URLSearchParams({chipset:$('led-chip').value,order:$('led-order').value,dataPin:$('led-data').value||13,count:$('led-count').value||16,bright:$('led-bright').value,front:$('led-front').value||-1});
fetch('/led/config',{method:'POST',body:b}).then(function(r){return r.json()}).then(function(d){
return fetch('/led/status').then(function(r){return r.json()}).then(function(s){
$('led-status').className='status ok';
$('led-status').textContent='Running: fw '+(s.fw||'?')+' · '+(s.chipset||'?')+' · order '+(s.order||'?')+' · count '+(s.count!=null?s.count:'?');
if(d.rebootNeeded){
if(d.chipSaved===false){
$('led-status').className='status err';
$('led-status').textContent='Could not save chipset to the node. Not rebooting (it would revert). Try again.';
return;
}
$('led-status').className='status';
$('led-status').textContent='Saved '+(d.chipset||$('led-chip').value)+'. Rebooting to bind it now...';
fetch('/reboot',{method:'POST'}).catch(function(){});
var n=9;var iv=setInterval(function(){n--;$('led-status').textContent='Rebooting to bind '+(d.chipset||$('led-chip').value)+'... reconnecting in '+n+'s';if(n<=0){clearInterval(iv);location.reload();}},1000);
}
});
}).catch(function(){$('led-status').className='status err';$('led-status').textContent='Save failed'})};
$('led-test').onclick=function(){ledPreview('boot')};
[['t-red','red'],['t-grn','green'],['t-blu','blue'],['t-wht','white']].forEach(function(pair){var el=$(pair[0]);if(el)el.onclick=function(){ledPreview(pair[1])}});
function echoCount(){var e=$('led-count-echo');if(e)e.textContent=$('led-count').value||'16'}
if($('led-count'))$('led-count').oninput=echoCount;
function hx6(v){return (v||'').replace('#','').toUpperCase()}
function ledPreview(what){fetch('/led/test',{method:'POST',body:new URLSearchParams({what:what})}).catch(function(){})}
function themeSave(cb){var b=new URLSearchParams({magColor:hx6($('ev-mag-c').value),magColorB:hx6($('ev-mag-c2').value),magColorC:hx6($('ev-mag-c3').value),magAnim:$('ev-mag-a').value,proxColor:hx6($('ev-prox-c').value),proxColorB:hx6($('ev-prox-c2').value),proxColorC:hx6($('ev-prox-c3').value),proxAnim:$('ev-prox-a').value});fetch('/led/config',{method:'POST',body:b}).then(function(r){return r.json()}).then(function(){if(cb)cb()}).catch(function(){})}
function bootSave(cb){var b=new URLSearchParams({bootColorA:hx6($('boot-a').value),bootColorB:hx6($('boot-b').value),bootEnable:$('boot-en').checked?1:0});fetch('/led/config',{method:'POST',body:b}).then(function(r){return r.json()}).then(function(){if(cb)cb()}).catch(function(){})}
if($('ev-save')){$('ev-save').onclick=function(){themeSave(function(){$('ev-status').className='status ok';$('ev-status').textContent='Themes saved.'})};}
if($('ev-mag-t')){$('ev-mag-t').onclick=function(){themeSave(function(){ledPreview('mag')})};}
if($('ev-prox-t')){$('ev-prox-t').onclick=function(){themeSave(function(){ledPreview('prox')})};}
if($('boot-save')){$('boot-save').onclick=function(){bootSave(function(){})};}
if($('boot-test')){$('boot-test').onclick=function(){bootSave(function(){ledPreview('boot')})};}
if($('sync-push')){$('sync-push').onclick=function(){
var b=$('sync-push'),st=$('sync-status');
if(!confirm('Copy this node\'s settings to every other node on this network?'))return;
b.disabled=true;st.className='status';st.textContent='Looking for other nodes...';
fetch('/sync/push',{method:'POST'}).then(function(r){return r.json()}).then(function(d){
if(!d.ok){st.className='status err';st.textContent='Could not start: '+(d.error||'unknown');b.disabled=false;return}
var poll=setInterval(function(){fetch('/sync/status').then(function(r){return r.json()}).then(function(s){
st.textContent=s.msg||'Working...';
if(!s.busy){clearInterval(poll);b.disabled=false;st.className='status '+(s.failed||s.total===0?'warn':'ok')}
}).catch(function(){clearInterval(poll);b.disabled=false;st.className='status err';st.textContent='Lost contact with the node'})},1200);
}).catch(function(){st.className='status err';st.textContent='Sync request failed';b.disabled=false})}}
// ---- firmware update ----
function fmtK(v){v=+v||0;return v>=1024?(Math.round(v/1024)+'K'):(''+v)}
function updDiag(d){
// largest contiguous block is what the TLS OTA needs; surface it + any
// persisted failure from the previous (rebooted) flash attempt.
var bits=[];
if(d.maxBlock!=null)bits.push('free block '+fmtK(d.maxBlock));
if(d.lastOtaErr)bits.push('last flash: '+d.lastOtaErr+(d.lastOtaErrStr?' ('+d.lastOtaErrStr+')':'')+(d.lastOtaBlock!=null?' @ block '+fmtK(d.lastOtaBlock):''));
return bits.length?(' · '+bits.join(' · ')):''}
function updRender(d){var a=d&&d.available;$('upd-install').disabled=!a;var diag=updDiag(d||{});
if(d&&d.error){$('upd-line').className='status err';$('upd-line').textContent='Check failed: '+d.error+diag;return}
if(a){$('upd-line').className='status ok';$('upd-line').textContent='Update available: '+d.latest+(d.notes?' - '+d.notes:'')+diag}
else if(d&&d.checked!==false){$('upd-line').className='status';$('upd-line').textContent='Up to date ('+(d.current||'-')+').'+diag}
else if(d&&d.lastOtaErr){$('upd-line').className='status err';$('upd-line').textContent='Previous update failed'+diag}}
$('upd-check').onclick=function(){$('upd-line').className='status';$('upd-line').textContent='Checking…';$('upd-check').disabled=true;fetch('/update/check').then(function(r){return r.json()}).then(function(d){updRender(d)}).catch(function(){$('upd-line').className='status err';$('upd-line').textContent='Could not reach update server'}).then(function(){$('upd-check').disabled=false})};
$('upd-install').onclick=function(){if(!confirm('Install the update? The node will flash and reboot.'))return;$('upd-line').className='status';$('upd-line').textContent='Installing - do not power off…';$('upd-install').disabled=true;fetch('/update/install',{method:'POST'}).then(function(r){return r.json()}).then(function(d){if(d.ok){$('upd-line').textContent='Flashing '+(d.installing||'')+' - node will reboot.'}else{$('upd-line').className='status err';$('upd-line').textContent='Install failed: '+(d.error||'unknown');$('upd-install').disabled=false}}).catch(function(){$('upd-line').className='status err';$('upd-line').textContent='Install request failed';$('upd-install').disabled=false})};
ledLoad();
syncThr();wifiStatus();connect();
fetch('/update/status').then(function(r){return r.json()}).then(function(d){if(d)updRender(d)}).catch(function(){});
</script></body></html>
)HTMLPAGE";
// ---- identity / version ----
#define FW_VERSION "2.16.1-maker"
#define DEVICE_TYPE "specter-node"
static char nodeName[32] = "Node 1";
static bool nodeNameCustom = false;
// ---- pins ----
#define TOUCH_PIN_DEFAULT 4 // T0 (GPIO4) -- capacitive telescopic antenna
// Runtime-selectable touch pin (portal). Touch-capable & free on classic ESP32:
// 4(T0) 2(T2) 15(T3) 14(T6) 27(T7) 33(T8) 32(T9). Avoid 13(LED)/12(buzzer).
volatile int touchPin = TOUCH_PIN_DEFAULT;
#define I2C_SDA 21
#define I2C_SCL 22
#define BUZZER_PIN 12
volatile bool rcBuzzerPinsSwapped = false;
// ---- Two-pin RC antenna sensor (extended-range prox, optional Road B hardware) ----
// GPIO18 drives the antenna node through 1M ohm; GPIO4 measures how long that node
// takes to charge. A nearby hand raises antenna capacitance and lengthens the charge
// time. No oscillator IC, transistor, inductor, or external library is required.
// Wiring: GPIO18 -> 1M -> sense node -> GPIO4; antenna -> optional 10k -> sense node.
#define RC_DRIVE_PIN 18
#define RC_SENSE_PIN 4
// RC measurement resolution: sub-microsecond units per microsecond (WAS 10).
// See the note at rcMeasureOnce10() -- tenths collapsed a real touch into 8
// integer steps, below the trigger floor. Hundredths cost nothing.
#define RC_SUB 100
// Absolute floor for the RC-mode trigger, in RC_SUB units. Below this the
// de-glitch window does all the work; kept only so a threshold of 0 can't
// free-run.
#define RC_THRESH_MIN 5
#define RC_TIMEOUT_MIN_US 10000
#define RC_TIMEOUT_CAL_US 60000
#define RC_TIMEOUT_MAX_US 60000
static inline uint8_t activeBuzzerPin() { return rcBuzzerPinsSwapped ? RC_DRIVE_PIN : BUZZER_PIN; }
static inline uint8_t activeRcDrivePin() { return rcBuzzerPinsSwapped ? BUZZER_PIN : RC_DRIVE_PIN; }
// ===== LED CONFIG =====
// Generate this block with the configurator at
// specter-imaging.com/help/build-node/#leds (Configure your LEDs)
// then paste over these lines. Defaults suit a 12-pixel WS2812B ring.
#define LED_CHIPSET WS2812B
#define LED_COLOR_ORD GRB
#define LED_DATA_PIN 13
// #define LED_CLK_PIN 14 // uncomment for clocked chips (APA102 / SK9822)
#define NUM_LEDS 16
#define LED_BRIGHT 100
// Positional map -- which LED index points which way (-1 = unset).
// Face the antenna FORWARD; SPECTER lights the side toward a source.
#define LED_FRONT 0
#define LED_RIGHT 4
#define LED_BACK 8
#define LED_LEFT 12
// ── Runtime LED config (portal-editable, persisted to Preferences) ──
// NOTE: FastLED templates the CHIPSET + COLOR ORDER at COMPILE time, so those
// (plus DATA pin / clock pin) are saved but only take effect after a REBOOT.
// BRIGHTNESS and the positional MAP apply live. This mirrors policy: LED setup
// is per-node, on-device, on the node's own portal.
struct LedCfg {
char chipset[16]; // "WS2812B","SK6812RGBW" etc (applied on reboot) - MUST fit "SK6812RGBW"+null
char order[5]; // "GRB" etc (applied LIVE via software remap)
int dataPin; // applied on reboot
int clkPin; // -1 = none
int count; // applied LIVE (pixels driven 0..count-1)
int bright; // applied live
int mFront, mRight, mBack, mLeft; // positional map (applied live)
int magRot; // extra rotation of the mag lobe, DEGREES (0/45/90.. via ROTATE button)
int magFlip; // 0 = ring winds CW (default), 1 = CCW. Flips rotation DIRECTION of the lobe.
// Per-event theming (applied live, persisted). Colors are 0xRRGGBB.
// Each channel carries THREE colors. Every animation uses A; the multi-colour
// patterns (4..13) blend or cycle B and C as well. Older single-colour
// patterns (0..3) ignore B and C, so an existing config keeps its exact look.
// anim: 0=solid/directional 1=pulse 2=spin 3=blink 4=chase 5=strobe
// 6=dual comet 7=ripple 8=wipe 9=sparkle 10=gradient 11=halves
// 12=breathe blend 13=radar. Severity brightens via value.
uint32_t magColor; uint32_t magColorB; uint32_t magColorC; int magAnim;
uint32_t proxColor; uint32_t proxColorB; uint32_t proxColorC; int proxAnim;
uint32_t bootColorA; uint32_t bootColorB; // boot sweep colors
int bootEnable; // 1 = play boot animation
};
LedCfg ledCfg = { "SK6812RGBW", "GRB", LED_DATA_PIN, -1, NUM_LEDS, LED_BRIGHT,
LED_FRONT, LED_RIGHT, LED_BACK, LED_LEFT, /*magRot*/ 0, /*magFlip*/ 0,
/*magColor*/ 0xFF2020, /*magColorB*/ 0xFF8000, /*magColorC*/ 0xFFFFFF, /*magAnim*/ 0,
/*proxColor*/ 0x20E0FF, /*proxColorB*/0x2060FF, /*proxColorC*/0xFFFFFF, /*proxAnim*/ 2,
/*bootColorA*/0x1030FF, /*bootColorB*/0x10FF80, // blue+green
/*bootEnable*/1 };
// Stable multi-node identity. Slot is operator-assigned (1..3) and persisted
// separately from sensor/network settings. The hardware ID comes from eFuse MAC.
char stableNodeId[24] = "";
volatile int nodeSlot = 1;
static void applySlotName() { if (!nodeNameCustom) snprintf(nodeName, sizeof(nodeName), "Node %d", nodeSlot); }
// Optional lowest-priority idle heartbeat. Dedicated NVS namespace "heartbeat"
// prevents contention with sensor/network and LED configuration writes.
volatile bool heartbeatEnabled = false;
volatile uint32_t heartbeatColor = 0x20E080;
static void loadIdentityAndHeartbeat() {
uint64_t mac = ESP.getEfuseMac();
snprintf(stableNodeId, sizeof(stableNodeId), "SN-%04X%08X",
(uint16_t)(mac >> 32), (uint32_t)mac);
Preferences np;
np.begin("nodeid", true); nodeSlot = constrain(np.getInt("slot", 1), 1, 3); np.end();
Preferences hp;
hp.begin("heartbeat", true);
heartbeatEnabled = hp.getBool("enabled", false);
heartbeatColor = hp.getUInt("color", 0x20E080);
hp.end();
}
// ── OTA self-update (mirrors full firmware) ──
#define UPDATE_MANIFEST_DEFAULT "https://specter-imaging.com/firmware/node/latest-maker.json"
String updateUrl = UPDATE_MANIFEST_DEFAULT;
String updAvailVer = "";
String updBinUrl = "";
String updNotes = "";
String updError = "";
String lastOtaErr = ""; // persisted reason of the PREVIOUS failed OTA (survives reboot)
String lastOtaErrStr = "";
uint32_t lastOtaErrHeap = 0, lastOtaErrBlk = 0, lastOtaErrAt = 0;
// OTA diagnostics -- surfaced in /update/check so we can see WHY a flash failed.
volatile uint32_t otaFreeHeap = 0; // free heap captured right before the OTA
volatile uint32_t otaMaxBlock = 0; // largest contiguous block (TLS needs this)
volatile int updOtaStage = 0; // 0 idle,1 prepping,2 flashing,3 failed
volatile bool otaActive = false; // when true, hardwareTask idles so FastLED bit-banging
// (which disables IRQs) can't corrupt the WiFi RX mid-OTA download
bool updChecked = false;
// Returns >0 if server version is newer than local (semver-ish, numeric parts).
int versionCompare(const String& local, const String& remote) {
int la=0,lb=0,lc=0, ra=0,rb=0,rc=0;
sscanf(local.c_str(), "%d.%d.%d", &la,&lb,&lc);
sscanf(remote.c_str(), "%d.%d.%d", &ra,&rb,&rc);
if (ra!=la) return ra-la; if (rb!=lb) return rb-lb; return rc-lc;
}
// Fetch the manifest; populate upd* globals. Does NOT flash.
bool checkForUpdate() {
updError = ""; updChecked = true;
if (WiFi.status() != WL_CONNECTED) { updError = "offline"; return false; }
WiFiClientSecure client; client.setInsecure();
HTTPClient http;
if (!http.begin(client, updateUrl)) { updError = "bad-url"; return false; }
http.addHeader("User-Agent", "specter-node/" FW_VERSION);
int code = http.GET();
if (code != 200) { updError = "http-" + String(code); http.end(); return false; }
String body = http.getString(); http.end();
JsonDocument d;
if (deserializeJson(d, body)) { updError = "bad-manifest"; return false; }
updAvailVer = String((const char*)(d["version"] | ""));
// manifest uses "url" for the binary; accept "bin" too for backward-compat
updBinUrl = String((const char*)(d["url"] | ""));
if (updBinUrl.length() == 0) updBinUrl = String((const char*)(d["bin"] | ""));
updNotes = String((const char*)(d["notes"] | ""));
if (updAvailVer.length() == 0 || updBinUrl.length() == 0) { updError = "incomplete"; return false; }
return versionCompare(FW_VERSION, updAvailVer) > 0;
}
// WS server/client globals -- declared here (before performUpdate) so the OTA
// routine can disconnect them to free heap for the TLS handshake. The
// port literal is used directly to avoid depending on a later #define.
WebSocketsServer localWS(81); // LOCAL_WS_PORT
WebSocketsClient appWS;
Preferences prefs; // moved above performUpdate: it persists OTA-failure reason to NVS
static void relayAssistStop();
void performUpdate(const String& url) {
if (WiFi.status() != WL_CONNECTED) { updError = "offline"; return; }
// Park the hardware task FIRST so no FastLED.show() runs during the TLS download
// (its IRQ-disable windows corrupt the WiFi RX and fail the flash mid-stream).
// Give it two task ticks to reach the guard and go quiet before we proceed.
otaActive = true;
relayAssistStop();
delay(120);
// FREE HEAP FOR TLS: the firmware OTA is an HTTPS download, and its handshake
// needs a big contiguous heap block. On a node that has been running a while,
// the WebSocket server + app client + their session buffers fragment/consume
// heap enough that the TLS allocation fails and the flash never starts. Tear
// those down first so the largest possible contiguous block is available.
updOtaStage = 1; // 1 = prepping / freeing heap
// Tear down the WebSocket app-client + local server so their session/RX
// buffers are released, giving the TLS handshake the largest possible
// contiguous heap block. This is the real fix on core 2.0.17 (which does not
// expose WiFiClientSecure buffer sizing) for OTA failing on long-uptime nodes.
appWS.disconnect();
localWS.disconnect();
delay(250); // let the sockets fully release
otaFreeHeap = ESP.getFreeHeap(); // capture AFTER freeing, for diagnostics
otaMaxBlock = ESP.getMaxAllocHeap();
WiFiClientSecure client; client.setInsecure();
client.setTimeout(30); // seconds: tolerate a slow field link mid-stream
// Drive the download through an HTTPClient WE configure, so we can (a) follow
// any HTTP->HTTPS or host redirect the CDN might introduce, and (b) set a long
// read timeout. On core 2.0.17 the bare update(client,url) form uses a short
// internal timeout and does NOT follow redirects, which shows up in the field
// as a silent mid-download flash failure on slower links. This overload fixes
// both without touching the rest of the hardened OTA sequence.
HTTPClient ota;
ota.begin(client, url);
ota.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS);
ota.setTimeout(30000); // ms: whole-request budget
ota.setConnectTimeout(15000); // ms: TLS connect budget
ota.addHeader("User-Agent", "specter-node/" FW_VERSION);
// Do NOT reboot inside httpUpdate: on some units the fast auto-reboot fires
// before the OTA boot-slot flag (otadata) is committed to flash, so the node
// silently boots the OLD image again. Instead let update() return, give the
// flash a beat to settle, then restart deliberately so otadata sticks.
httpUpdate.rebootOnUpdate(false);
updOtaStage = 2; // 2 = flashing
t_httpUpdate_return r = httpUpdate.update(ota, FW_VERSION);
ota.end();
if (r == HTTP_UPDATE_FAILED) {
updError = "flash-" + String(httpUpdate.getLastError());
updOtaStage = 3; // 3 = failed; caller should reboot to
// Persist the failure so it survives the recovery reboot (otherwise the
// reason vanishes and OTA failures are impossible to diagnose in the field).
// We record the httpUpdate error code + string and the heap we had for TLS.
{
prefs.begin("specter", false);
prefs.putString("otaErr", updError);
prefs.putString("otaErrStr", httpUpdate.getLastErrorString());
prefs.putUInt("otaErrHeap", otaFreeHeap);
prefs.putUInt("otaErrBlk", otaMaxBlock);
prefs.putUInt("otaErrAt", (uint32_t)(millis()/1000));
prefs.end();
}
// Recovery: sockets are down and we are in a half-torn-down state. A clean
// restart brings the node back healthy on the OLD image so it can retry.
delay(400); ESP.restart();
return;
}
if (r == HTTP_UPDATE_OK) {
prefs.begin("specter", false); prefs.remove("otaErr"); prefs.remove("otaErrStr"); prefs.end();
delay(500); // let otadata commit before we pull the trigger
ESP.restart();
}
}
// ---- network constants (must match SPECTER app) ----
#define BEACON_PORT 47845
#define APP_WS_PORT 47846
#define LOCAL_WS_PORT 81
#define TELEMETRY_HZ_DEFAULT 10
// ---- hardware globals ----
#define LED_MAX 60 // max pixels the physical buffer supports
CRGB leds[LED_MAX]; // logical RGB the animations write into (index 0..count-1)
CRGB phys[LED_MAX]; // physical buffer FastLED drives (after color-order remap) -- RGB strips
// RGBW support: SK6812-RGBW strips take 4 bytes/pixel (R,G,B,W). FastLED 3.6 only
// pushes 3, which misaligns the stream (symptom: RED shows as alternating colors).
// For RGBW we drive a raw 4-bytes/pixel span as (LED_MAX*4/3 +1) RGB "pixels" and
// pack the bytes ourselves in ledShow().
#define RGBW_RAW_BYTES (LED_MAX*4)
#define RGBW_RAW_CRGB ((RGBW_RAW_BYTES + 2) / 3 + 1) // ceil to whole CRGB + guard
CRGB physRGBW[RGBW_RAW_CRGB]; // (legacy, unused) raw span kept so old refs compile
bool ledIsRGBW = false; // true when chipset == SK6812RGBW
// RGBW is driven by Adafruit NeoPixel (native 4-byte GRBW), NOT FastLED.
Adafruit_NeoPixel* rgbwStrip = nullptr;
uint16_t rgbwNeoFlag = NEO_GRBW + NEO_KHZ800; // BTF-LIGHTING SK6812 is GRBW
// (Re)bind the Adafruit NeoPixel RGBW strip using the current ledCfg.order. Called at
// chipset bind AND whenever the order dropdown changes, so GRBW/RGBW/BGRW apply LIVE.
void rebindRgbw() {
char o[6]; strncpy(o, ledCfg.order, sizeof(o)-1); o[sizeof(o)-1]=0;
for (char* q=o; *q; ++q) *q = toupper(*q);
if (!strcmp(o,"RGBW") || !strcmp(o,"RGB")) rgbwNeoFlag = NEO_RGBW + NEO_KHZ800;
else if (!strcmp(o,"BGRW") || !strcmp(o,"BGR")) rgbwNeoFlag = NEO_BGRW + NEO_KHZ800;
else if (!strcmp(o,"GRBW") || !strcmp(o,"GRB")) rgbwNeoFlag = NEO_GRBW + NEO_KHZ800;
else if (!strcmp(o,"BRGW") || !strcmp(o,"BRG")) rgbwNeoFlag = NEO_BRGW + NEO_KHZ800;
else if (!strcmp(o,"RBGW") || !strcmp(o,"RBG")) rgbwNeoFlag = NEO_RBGW + NEO_KHZ800;
else if (!strcmp(o,"GBRW") || !strcmp(o,"GBR")) rgbwNeoFlag = NEO_GBRW + NEO_KHZ800;
else rgbwNeoFlag = NEO_GRBW + NEO_KHZ800;
if (rgbwStrip) { delete rgbwStrip; rgbwStrip = nullptr; }
rgbwStrip = new Adafruit_NeoPixel(LED_MAX, LED_DATA_PIN, rgbwNeoFlag);
rgbwStrip->begin();
rgbwStrip->show();
}
volatile int16_t magX = 0, magY = 0, magZ = 0;
volatile bool magPresent = false; // set true if QMC5883L answers on I2C
volatile int proxVal = 0;
volatile int magDelta = 0;
// ---- EVENT QUEUE: 4-deep single-producer/single-consumer ring (Core1 -> Core0).
// The old handoff was one slot behind an eventPending flag: two crossings inside
// one 100ms drain window meant the second silently overwrote (or was skipped
// over) the first, and the app had no way to detect the loss. Every event now
// carries a monotonic `seq`; a ring-full drop shows up as a seq gap, so the
// app's CLASS-tier statistics become trustworthy.
#define EVT_RING 4
struct EvtSlot { uint32_t seq; int value; int threshold; char kind[16]; char channel[8]; char severity[8]; };
EvtSlot evtRing[EVT_RING];
volatile uint8_t evtHead = 0, evtTail = 0; // head: Core1 writes, tail: Core0 drains
volatile uint32_t evtSeqCounter = 0; // total events GENERATED (drops leave gaps)
// Legacy mirror of the event being drained -- the Relay Assist payload
// builder (raSendEvent) still reads these.
volatile bool eventPending = false;
volatile int evtValue = 0, evtThreshold = 0;
char evtChannel[8] = "mag", evtSeverity[8] = "low", evtKind[16] = "spike";
// Core1-side enqueue. Ring full -> the event is dropped but seq still advances,
// so the gap is visible downstream.
static void queueEvent(const char* kind, const char* channel, const char* sev, int value, int threshold) {
uint32_t seq = ++evtSeqCounter;
uint8_t h = evtHead, nxt = (uint8_t)((h + 1) % EVT_RING);
if (nxt == evtTail) return; // full
EvtSlot &e = evtRing[h];
e.seq = seq; e.value = value; e.threshold = threshold;
strncpy(e.kind, kind, sizeof(e.kind)-1); e.kind[sizeof(e.kind)-1] = 0;
strncpy(e.channel, channel, sizeof(e.channel)-1); e.channel[sizeof(e.channel)-1] = 0;
strncpy(e.severity, sev, sizeof(e.severity)-1); e.severity[sizeof(e.severity)-1] = 0;
evtHead = nxt;
}
volatile int16_t baseMagX = 0, baseMagY = 0, baseMagZ = 0;
// FLOAT accumulators behind the int16 baseline. The EMA used to be applied
// directly to the int16s:
// baseMagX = (int16_t)(baseMagX + a * (magX - baseMagX));
// which TRUNCATES. At the steady-state rate a = 0.002 the increment only
// changes the stored integer once |magX - baseMagX| >= 500 -- so for every
// realistic residual the baseline literally could not move, and a stale offset
// sat there forever: a phantom vector pointing one fixed direction, riding
// just under threshold and flickering over it (diag11: magDelta held 36-96 for
// the whole 112 s capture, 22 separate alert blips, never once converging
// toward 0). It also worsened with uptime, because ambient drift accumulated
// against a baseline that could never follow it. Accumulate in float, round on
// the way out.
float baseMagXf = 0.0f, baseMagYf = 0.0f, baseMagZf = 0.0f;
static inline void magBaselineSet(int16_t x, int16_t y, int16_t z) {
baseMagXf = x; baseMagYf = y; baseMagZf = z;
baseMagX = x; baseMagY = y; baseMagZ = z;
}
volatile int baseProx = 0;
volatile bool recalibrate = true;
volatile int ledTestReq = 0; // 0 none,1 boot,2 mag,3 prox,4 red,5 green,6 blue,7 white (portal preview)
// Diagnostic actuator self-test: verifies the buzzer + LED ring respond to a
// commanded pulse (used by the field diagnostics sweep). channel 0=prox-style
// ring fade, 1=mag-style directional lobe. Duration in ms, intensity 0..1.
volatile int diagPulseReq = 0; // 0 none, 1 armed
volatile uint32_t diagPulseMs = 0; // how long to hold the actuator test
volatile int diagPulseChan = 0; // 0 = prox ring, 1 = mag lobe
volatile float diagPulseInt = 1.0f; // 0..1 test drive level
volatile int diagPulseBrg = 0; // bearing deg for the mag-lobe test
volatile bool diagPulseMaxBrightness = false; // explicit all-node proximity override
volatile int diagStopReq = 0; // set by a diag "stop" frame to end a pulse early
volatile uint32_t diagQuietUntil = 0; // suppress the node's OWN alert detection until this millis()
volatile bool diagAnimActive = false;
volatile bool diagAnimReleasing = false;
volatile uint32_t diagAnimStart = 0;
volatile uint32_t diagAnimUntil = 0;
volatile uint32_t diagAnimReleaseStart = 0;
// (a commanded diag pulse actuates LEDs/buzzer and physically
// perturbs the sensors; without this the node re-fires off its
// own drive, sputtering on/off at low level).
// ---- Field capture log: bounded ring buffer for on-demand troubleshooting ----
// Started from the portal, runs for a fixed window, then downloadable as CSV.
// Captures exactly the fields needed to correlate a proximity spike against
// Wi-Fi timing, RC fault state, alert phase, and the magnetometer channel.
#define DIAG_LOG_CAP 2400 // 2400 rows @ ~20Hz (decimated from the 100Hz hardware task) covers 2 minutes
struct DiagLogRow {
uint32_t ts; int16_t rcCharge10; int16_t rcBase10; uint8_t rcOk; uint8_t rcFault;
int16_t proxDelta; uint8_t proxAlert; uint8_t satArmed; int16_t magDelta; uint8_t magAlert;
uint8_t alertPhaseSnap; int8_t rssi; uint16_t rcTimeoutUsRaw;
// v2.16.0: proxDelta/magDelta above are now the CALIBRATED values (gain
// applied). Raw pre-calibration values are NOT stored per-row here (2400
// rows x extra int16s overflowed DRAM by 2.5KB) -- the currently-applied
// proxGain/magGain is exposed once via /diag/log/status and telemetry
// instead, and raw = calibrated / gain since gain is constant during a
// capture window.
};
volatile bool diagLogArmed = false;
volatile uint32_t diagLogUntil = 0;
volatile uint16_t diagLogCount = 0;
DiagLogRow diagLogRows[DIAG_LOG_CAP];
// ── LOOP-STALL LOG: core-0 loop() calls server.handleClient()/localWS.loop()/
// appWS.loop()/relayAssistLoop() every iteration with nothing between them and
// nothing else running on this core. If ANY of those four blocks (e.g. the
// WebSocketsClient lib doing a blocking TCP connect() against a dead/unreachable
// saved app IP), telemetry sends and the local portal WS freeze together for the
// whole stall -- which is exactly the "freezes every few seconds" symptom. This
// ring buffer records which call stalled, for how long, and the app-link state
// at that moment, so a real capture shows the cause instead of guesswork.
#define STALL_LOG_CAP 40
#define STALL_MIN_MS 40
struct StallLogRow { uint32_t ts; char label[20]; uint16_t ms; uint8_t appWSStarted; uint8_t isAppConnected; };
StallLogRow stallLogRows[STALL_LOG_CAP];
volatile uint16_t stallLogCount = 0; // total ever recorded (for overflow indication)
volatile uint16_t stallLogHead = 0; // next write index (circular)
#define DIAG_LOG_DEFAULT_MS 120000 // 2 minutes: enough for a 30s settle + approach + touch
volatile int threshProx = 8; // OPERATOR-set floor from the app slider (abs deviation, LSB)
volatile int proxNoiseFloor = 0; // measured rest jitter (max |sample-mean|) during boot settle
volatile int effThreshProx = 40; // ACTUAL prox trigger used = max(threshProx, proxNoiseFloor+margin)
// ── THEREMIN MODE ───────────────────────────────────────────────────────────
// When on, proximity behaves like a theremin: a CONTINUOUS 0..100 "field" that
// rises smoothly as a hand approaches (well before the trigger), driving LED
// brightness and (optionally) a continuously-pitched buzzer tone. The proximity
// event/alert path still fires when the field crosses threshProx, so snapshots
// and the app dashboard keep working.
// FIELD LOCK: freezes every self-tuning behavior for the duration of an
// investigation session -- span auto-fit/shrink/refit, the prox rolling-baseline
// drift-follow, and the mag slow baseline (incl. its post-hit hard re-zero).
// EXPLICIT operator actions (rebaseline cmd, threshold changes) still apply:
// the lock stops the instrument changing itself, not the operator changing it.
// Settable from the portal (/lock/set) and from SPECTER (cmd setFieldLock),
// persisted so a mid-session brownout comes back still locked.
volatile bool fieldLock = false;
// Distance shaping for the 0..100 theremin field: hand capacitance falls off
// steeply with distance, so a linear map bunches all response near contact.
// 1 = natural (sqrt) spreads the field across the approach; 0 = legacy linear.
volatile int proxCurve = 1; // persisted
volatile bool proxTheremin = true; // theremin mode on/off (persisted) -- DEFAULT ON
volatile int proxSpan = 60; // proxDelta that maps to full field (100). Auto-fits: nudges up on a bigger pass, slowly shrinks toward real passes so a fresh/NVS-erased node self-calibrates to its antenna within a few waves.
volatile int proxNoise = 6; // jitter deadband: proxDelta below this = field 0 (rest)
volatile float proxGain = 1.0f; // per-node calibration multiplier, see magGain comment above
volatile int proxDeltaG = 0; // CALIBRATED prox delta (proxGain applied) -- single source of truth; alert/telemetry/diag/relay all read THIS, never recompute abs(baseProx-proxVal) themselves
volatile int proxDeltaRawG = 0; // uncalibrated abs(baseProx-proxVal) -- diagnostic visibility only
volatile int proxPeakSeen = 0; // largest proxDelta seen since last refit (for smart REFIT)
volatile bool proxRefitReq = false; // REFIT pressed -> set span from the peak hand delta just seen
volatile int proxField = 0; // live 0..100 continuous field
volatile bool proxInit = false; // baseline captured yet (shared: loop + net cmd)
// ---- prox sense source ----
// proxMode 0 = touchRead (default, ~15cm, no extra hardware)
// proxMode 1 = two-pin RC charge-time sensor, GPIO18 drive + GPIO4 sense
volatile int proxMode = 0;
// Prox tuning is stored PER SENSE MODE. touchRead counts and RC_SUB units are
// different physical scales (a touch is ~8-40 counts in touch mode, ~80 units
// in RC mode), so one shared "prox" key meant switching modes silently loaded a
// nonsense threshold. Keys: touch -> prox/proxSpan/proxNoise,
// RC -> proxRC/proxSpanRC/proxNoiseRC.
static const char* kThreshKey(int mode) { return mode == 1 ? "proxRC" : "prox"; }
static const char* kSpanKey (int mode) { return mode == 1 ? "proxSpanRC" : "proxSpan"; }
static const char* kNoiseKey (int mode) { return mode == 1 ? "proxNoiseRC": "proxNoise"; }
#define RC_THRESH_DEFAULT 30 // ~0.30us: ~4x the trimmed-mean rest band, ~35% of a full touch
#define RC_SPAN_DEFAULT 80 // a full hand touch measures ~0.8us on a typical antenna
#define RC_NOISE_DEFAULT 12
// Load the tuning triple for `mode`. First run in RC mode migrates the old
// shared tenths-scale value by x10 (that is exactly the RC_SUB change) so an
// existing node keeps its feel instead of resetting to default.
static void loadProxTuningFor(int mode) {
Preferences pp; pp.begin("specter", true);
if (mode == 1) {
int legacy = pp.getInt("prox", 0);
threshProx = pp.getInt("proxRC", legacy > 0 ? constrain(legacy * 10, RC_THRESH_MIN, 400) : RC_THRESH_DEFAULT);
int legacySpan = pp.getInt("proxSpan", 0);
proxSpan = pp.getInt("proxSpanRC", legacySpan > 0 ? legacySpan * 10 : RC_SPAN_DEFAULT);
proxNoise = pp.getInt("proxNoiseRC", RC_NOISE_DEFAULT);
} else {
threshProx = pp.getInt("prox", 8);
proxSpan = pp.getInt("proxSpan", 60);
proxNoise = pp.getInt("proxNoise", 6);
}
pp.end();
}
static void saveProxTuningFor(int mode) {
Preferences pp; pp.begin("specter", false);
pp.putInt(kThreshKey(mode), threshProx);
pp.putInt(kSpanKey(mode), proxSpan);
pp.putInt(kNoiseKey(mode), proxNoise);
pp.end();
}
int recomputeEffThreshProx() {
// RC mode (proxMode==1) already has its raw-noise rejection handled by the
// live-tunable rcMarginMult/rcMarginFloorUs timeout window (see rcRead10()).
// Stacking the boot-calibration noise-floor percentile on top of that here
// double-counts ambient jitter and was inflating the real trigger to ~30,
// well above most approach-only readings (10-24) -- the "have to touch it"
// symptom. RC mode uses a small fixed floor instead; legacy touchRead mode
// (proxMode==0) keeps the original noise-adaptive guard band unchanged.
int v;
if (proxMode == 1) {
// RC mode: the operator's slider IS the trigger. The old fixed floor of
// 10 was in tenths-of-a-us and sat ABOVE a real touch (8) on a typical
// antenna -- the node could see the hand in telemetry and still refuse
// to fire, and no slider position could fix it. Timeout-window margin
// (rcMarginMult/FloorUs), the de-glitch window and the ramp gate already
// reject noise; a second hidden floor here only fights the operator.
// The remaining guard is a hard minimum well under any real touch.
v = threshProx;
if (v < RC_THRESH_MIN) v = RC_THRESH_MIN;
} else {
v = proxNoiseFloor + 8;
if (v < threshProx) v = threshProx;
if (v < 12) v = 12;
}
return v;
}
volatile int rcCharge10 = 0; // live charge time in 0.1 microsecond units
volatile int rcBase10 = 0; // ambient no-hand baseline in same units
float rcBaseEma10 = 0.0f; // robust baseline; never latch one low outlier
volatile bool rcReady = false; // pins configured and baseline initialized
volatile bool rcValid = false; // sense node discharged and charged before timeout
volatile int rcFault = 0; // 0 ok, 1 discharge failed, 2 charge timeout, 3 too few valid samples
volatile uint32_t rcTimeoutUs = RC_TIMEOUT_CAL_US; // learned at boot from this antenna
// Guard the timing-critical measurement window (below) against interrupt/
// scheduler preemption on this core. Only ever engaged when the window is
// short -- see rcMeasureOnce10(). Holding interrupts off for the full 60ms
// boot-calibration window would itself disrupt Wi-Fi/BLE, so that phase
// stays unguarded (it already tolerates more noise via other layers).
#define RC_GUARD_MAX_US 2000
// (RC_SUB / RC_THRESH_MIN are defined near the top with the other RC timing
// constants so they precede every use, including the NVS helpers.)
// RC measurement resolution: sub-microsecond units per microsecond.
// WAS 10 (tenths). At 240 MHz the cycle counter resolves 4.2 ns, and a typical
// antenna rests near 16.7 us (~4000 cycles) while a full hand touch adds only
// ~0.8 us. Quantizing to tenths turned that entire usable range into 8 integer
// steps -- so the trigger floor (10) sat ABOVE a real touch (8), and there was
// nothing to set a threshold *inside*. Hundredths cost nothing (the cycles were
// always there, we were dividing them away) and turn the same touch into ~80
// steps against a ~7-15 step noise band: a real 1-100 working range.
static portMUX_TYPE rcMux = portMUX_INITIALIZER_UNLOCKED;
volatile bool rcSmartCalibrated = false; // true after a valid boot learning window
volatile bool rcSmartCalReq = false; // portal/app rebaseline reruns RC learning
volatile bool rcSaturated = false; // majority charge timeouts after healthy calibration
volatile uint32_t rcSaturatedAt = 0; // sustained saturation eventually becomes a real fault
#define RC_SATURATION_MAX_MS 5000
// Minimum time a prox alert stays in the ALERTING phase once it triggers, even
// if the field drops away immediately. WAS a fixed 350 ms, which is shorter
// than most of the animations' own cycle -- a quick hand pass produced a
// visual stutter rather than a recognisable pattern. Now operator-settable
// ("minHoldMs") and defaulted to a full 2 s so ANY crossing over the threshold
// plays a complete animation. Still clamped to proxAlertMs: the minimum can
// never outlast the configured alert duration.
#define PROX_MIN_ALERT_MS_DEFAULT 2000
volatile int proxMinHoldMs = PROX_MIN_ALERT_MS_DEFAULT;
#define RC_SATURATION_ARM_READS 4
#define RC_SATURATION_CLEAR_READS 10
// Saturation may only ARM if a genuinely elevated CLEAN reading (median rise >
// effThreshProx/2) was seen this recently. A real hand approaching the antenna
// always ramps the analog charge time up through intermediate values before it
// saturates the window; a CPU-stall burst jumps straight from a flat ambient
// baseline to "timeout" with no ramp at all (diag9: ambient delta 0-9 on one
// 10ms tick, full saturation on the next -- physically impossible for a hand).
#define RC_SAT_RAMP_WINDOW_MS 2500
#define RC_QUICK_REBASE_SAMPLES 9
volatile bool rcQuickRebaseArmed = false;
volatile uint8_t rcQuickRebaseCount = 0;
volatile int rcQuickRebaseBuf[RC_QUICK_REBASE_SAMPLES];
volatile int rcLastGood10 = 0; // last confirmed (non-timeout-dominated) charge reading, held during an UNCONFIRMED timeout burst
volatile int rcMarginMult = 5; // learned timeout = ambient charge time x this multiplier (was an unconditional 8x baked in)
volatile int rcMarginFloorUs = 40; // + this many us of absolute headroom (was an unconditional fixed 2000us)
volatile int proxAlertMs = 6000; // live-tunable: how long the PROX alert (beep+flash+event) stays active while triggered, default 6s
volatile int proxCooldownMs = 6000; // live-tunable: quiet window enforced after a PROX alert ends, default 6s
// De-glitch window applied to a CONFIRMED saturated (contact) reading. Short
// because saturation has already passed its own multi-call confirmation.
#define PROX_SAT_ARM_MS 40
volatile int proxArmMs = 150; // live-tunable: raw threshold crossing must sustain this long before arming an alert (de-glitch), default 150ms
volatile bool proxToneOn = false; // also play a continuous pitch on the buzzer in theremin mode
volatile bool magToneOn = false; // optional buzzer chirp on a mag hit (default OFF; mag shows on LEDs)
// MASTER AUDIBLE-ALERT SWITCH. Gates every buzzer output on the node: alert
// beeps, mag chirps, theremin pitch, boot/diag tones. The per-feature toggles
// (magToneOn, proxToneOn) still work underneath it -- this is the one control
// that silences a pod outright, so the app can mute a whole rig from one
// button during a take without the operator re-tuning anything.
// Persisted ("audible"); default ON so nothing changes for existing nodes.
volatile bool audibleAlerts = true;
// Every buzzer call in the firmware goes through these two wrappers. Using
// tone()/noTone() directly would bypass the mute -- if you add a new sound,
// use these.
static inline void buzz(unsigned int freq, unsigned long durMs) {
if (!audibleAlerts) return;
tone(activeBuzzerPin(), freq, durMs);
}
static inline void buzzStop() { noTone(activeBuzzerPin()); }
volatile bool proxRecalibrate = false;// re-zero PROX baseline only (mag baseline untouched) -- used after prox cooldown
volatile int threshMag = 60; // DERIVED vector-delta trigger (LSB) -- computed from magSens
volatile int magSens = 70; // 0..100 sensitivity slider (100 = most sensitive)
// ---- Per-node calibration gain (v2.16.0) ----
// Hand-wired antennas/wiring differ node to node, so the SAME physical
// stimulus can produce a different raw delta on each node. A shared (LINKed)
// app-side threshold only means the same thing on every node if the raw
// signal is normalized FIRST. proxGain/magGain are per-node multipliers
// applied to the raw delta the instant it's computed -- before ANY threshold
// comparison, span learning, or telemetry -- so everything downstream
// (alerts, theremin field, diag CSV, telemetry) already operates in
// calibrated units. Persisted in their own "nodecal" NVS namespace. Default
// 1.0 = uncalibrated/factory, byte-identical to pre-2.16.0 behavior.
volatile float magGain = 1.0f;
volatile int magDeltaRawG = 0; // uncalibrated vector delta -- diagnostic visibility only, never compared against a threshold
// ---- Walkie-talkie / RF interference cancel (on-device) ----
// A keyed two-way radio induces a repeatable spike on the antenna + mag. We let the
// operator CAPTURE that signature (peak magDelta + proxDelta), persist it, then
// suppress any event whose peaks fall within tolerance of the captured signature --
// so the node stays quiet (no buzzer/LED) when someone keys a radio nearby.
volatile bool rfActive = false; // suppression on/off
volatile int rfMagPeak = 0; // captured mag signature (LSB delta)
volatile int rfProxPeak = 0; // captured prox signature (delta)
// ── Field alert cadence + auto-baseline (v2.9.0) ────────────────────────────
// Field-friendly behavior so the operator never has to hand-tune per location.
// MAG and PROX are DECOUPLED:
// BOOT BASELINE after power-on we wait BASELINE_SETTLE_MS for the sensors to
// stabilize, then snap a clean ambient baseline. No alerts fire
// during this window.
// MAG (always on) the magnetometer ALWAYS detects and drives the LEDs whenever
// a disturbance is present. It has NO cooldown and is never
// silenced by the prox cadence -- mag hits always show on the ring.
// PROX ALERT/COOLDOWN the proximity channel keeps the runaway-guard cadence:
// on a prox trigger it beeps + fades the ring for up to
// ALERT_MAX_MS, then goes quiet for COOLDOWN_MS (no prox beep,
// no prox LEDs, no prox events) and re-baselines PROX ONLY to the
// current ambient. The mag baseline is left untouched. This stops
// the constant prox beeping while keeping mag detection fully live.
#define BASELINE_SETTLE_MS 2600 // post-boot settle before first baseline (avg window)
// ALERT_MAX_MS/COOLDOWN_MS retired as fixed macros; live-tunable proxAlertMs/proxCooldownMs (default 6000/6000) now govern this, see globals above.
#define MAG_REFRACTORY_MS 1500 // after a MAG event, hold off re-firing this long so a field
// hovering near threshold cannot sputter mag alerts on/off
// Alert lifecycle: 0 = idle/armed, 1 = alerting, 2 = cooling down (quiet).
volatile int alertPhase = 0;
volatile uint32_t alertPhaseStart = 0; // millis() the current phase began
uint32_t proxRawSince = 0; // millis() the current raw prox_delta>effThreshProx run began, 0 if not currently crossing
volatile bool bootBaselineDone = false; // false until the settle window elapses
volatile uint32_t magRefractoryUntil = 0; // mag alert is held off until this millis()
volatile uint32_t heartbeatSuppressUntil = 0; // no idle heartbeat until this ms
// How long the ring stays dark after the LAST event before the idle heartbeat
// resumes. WAS a hard-coded 10s, which stacked on top of the 6s alert: a single
// touch left the node visibly dead for ~16s. Now operator-settable and short by
// default -- the alert animation itself already owns the ring while it runs.
volatile int heartbeatResumeMs = 2000; // persisted ("hbResumeMs")
#define HEARTBEAT_SUPPRESS_MS heartbeatResumeMs
volatile uint32_t bootBaselineAt = 0; // millis() target for the boot baseline
// Boot-baseline averaging: instead of snapping ONE (possibly noisy) sample at
// the settle point, we accumulate readings across the whole settle window and
// use their mean. A stable mean = a stable auto-threshold, so the node stops
// false-triggering right after power-on.
volatile double blMagXsum = 0, blMagYsum = 0, blMagZsum = 0, blProxSum = 0;
volatile uint32_t blSamples = 0;
// RC boot learning uses valid raw charge-time samples, not the already-scaled
// proximity value. It learns each board/antenna combination independently.
volatile uint64_t blRcSum10 = 0;
volatile uint32_t blRcSamples = 0;
volatile int blRcMin10 = 0, blRcMax10 = 0;
#define RC_CAL_SAMPLE_CAP 63
volatile int blRcValues10[RC_CAL_SAMPLE_CAP] = {0};
volatile uint8_t blRcValueCount = 0;
float rfTolerance = 0.35f; // +/- 35% band
volatile bool rfCapturing = false; // true while HOLD-capturing a signature
volatile int rfCapMag = 0, rfCapProx = 0; // running peak during capture
uint32_t rfSuppressUntil = 0; // debounce window so one key-up = one suppression
volatile int telemetryHz = TELEMETRY_HZ_DEFAULT;
#define QMC5883L_ADDR 0x0D // older QMC5883L (chip id reg 0x0D == 0xFF)
#define QMC5883P_ADDR 0x2C // newer QMC5883P / "HP5883" (chip id reg 0x00 == 0x80)
#define HMC5883L_ADDR 0x1E // original HMC5883L found on older/replacement GY-271 boards
uint8_t magAddr = QMC5883L_ADDR; // active address, chosen at init
uint8_t magType = 0; // 0 none, 1 = QMC5883L, 2 = QMC5883P, 3 = HMC5883L
uint8_t i2cFound[16] = {0};
volatile uint8_t i2cFoundCount = 0;
volatile bool magDataValid = false; // identified device has delivered a nonzero XYZ sample
uint16_t magZeroStreak = 0;
uint32_t magLastInitMs = 0;
// QMC5883L bring-up. Registers per datasheet:
// 0x0B SET/RESET period -> 0x01 (recommended, required for correct operation)
// 0x0A control 2 -> 0x80 soft reset, then 0x00 (no interrupts; DRDY pin unused)
// 0x09 control 1 -> mode=continuous, ODR=200Hz, RNG=8G, OSR=512 = 0x1D
// NOTE: DRDY is NOT wired -- we poll the data registers over I2C, so only
// VCC / GND / SDA / SCL are needed. The extra DRDY pad on the breakout is optional.
// Bring up the magnetometer. Two very different chips ship on GY-271/GY-273 boards:
// - QMC5883L @ 0x0D (chip-id register 0x0D reads 0xFF)
// - QMC5883P @ 0x2C (a.k.a. "HP5883"; chip-id register 0x00 reads 0x80,
// DIFFERENT register map: mode 0x0A, config 0x0B, data 0x01..0x06)
// We probe 0x2C (the newer/"fake" part most boards now use) first, then 0x0D.
static bool i2cReg(uint8_t addr, uint8_t reg, uint8_t& out) {
Wire.beginTransmission(addr); Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false;
Wire.requestFrom((uint16_t)addr, (uint8_t)1);
if (!Wire.available()) return false;
out = Wire.read(); return true;
}
static void scanI2CBus() {
uint8_t n = 0;
for (uint8_t addr = 1; addr < 127 && n < sizeof(i2cFound); ++addr) {
Wire.beginTransmission(addr);
if (Wire.endTransmission() == 0) i2cFound[n++] = addr;
}
for (uint8_t i = n; i < sizeof(i2cFound); ++i) i2cFound[i] = 0;
i2cFoundCount = n;
}
void initSensors() {
// --- Capacitive touch sensitivity tuning ---
// A long telescopic antenna adds a big fixed capacitance that pulls touchRead()'s
// baseline way down and saturates it (tiny range left for a hand). Widening the
// measurement window recovers dynamic range so an APPROACHING hand shows up.
// touchSetCycles(measure, sleep): bigger 'measure' = more charge/discharge cycles
// counted per read = larger, more sensitive values. Default is ~0x1000; we push it up.
// NOTE: touchSetCycles() only exists on the ORIGINAL ESP32 (not S2/S3/C3) AND
// only on the classic v2.x Arduino-ESP32 core. The v3.x core dropped this global,
// and the newer chips use a different touch peripheral -- calling it there fails to
// compile ("not declared in this scope"). Guard on chip + core version; it's just
// a sensitivity tweak, so a no-op elsewhere is fine (touchRead still works).
#if defined(CONFIG_IDF_TARGET_ESP32) && (!defined(ESP_ARDUINO_VERSION) || ESP_ARDUINO_VERSION < ESP_ARDUINO_VERSION_VAL(3,0,0))
touchSetCycles(0x6000, 0x1000);
#endif
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(100000); // conservative speed for breadboards and longer jumper wires
magType = 0; magPresent = false; magDataValid = false; magZeroStreak = 0;
magLastInitMs = millis();
scanI2CBus();
// --- Try QMC5883P @ 0x2C ---
uint8_t id = 0;
if (i2cReg(QMC5883P_ADDR, 0x00, id) && id == 0x80) {
magAddr = QMC5883P_ADDR; magType = 2;
// QMC5883P requires two undocumented vendor setup writes used by the
// reference driver before continuous mode will produce XYZ samples.
Wire.beginTransmission(magAddr); Wire.write(0x0D); Wire.write(0x40); if (Wire.endTransmission() != 0) return; delay(10);
Wire.beginTransmission(magAddr); Wire.write(0x29); Wire.write(0x06); if (Wire.endTransmission() != 0) return; delay(10);
// CTL1 0xCF = continuous, 200Hz, +/-2G. CTL2 must be 0x00.
Wire.beginTransmission(magAddr); Wire.write(0x0A); Wire.write(0xCF); if (Wire.endTransmission() != 0) return; delay(10);
Wire.beginTransmission(magAddr); Wire.write(0x0B); Wire.write(0x00); if (Wire.endTransmission() != 0) return; delay(10);
magPresent = true;
return;
}
// --- Fall back to QMC5883L @ 0x0D ---
Wire.beginTransmission(QMC5883L_ADDR); Wire.write(0x0A); Wire.write(0x80); Wire.endTransmission(); // soft reset
delay(10);
Wire.beginTransmission(QMC5883L_ADDR); Wire.write(0x0B); Wire.write(0x01); Wire.endTransmission();
Wire.beginTransmission(QMC5883L_ADDR); Wire.write(0x0A); Wire.write(0x00); Wire.endTransmission();
Wire.beginTransmission(QMC5883L_ADDR); Wire.write(0x09); Wire.write(0x1D); Wire.endTransmission();
uint8_t l = 0;
if (i2cReg(QMC5883L_ADDR, 0x0D, l) && l == 0xFF) {
magAddr = QMC5883L_ADDR; magType = 1; magPresent = true;
return;
}
// --- Original HMC5883L @ 0x1E ---
// Older and some replacement GY-271 boards use this entirely different chip.
// ID registers 0x0A..0x0C normally read "H43". Accept an ACK even on clones,
// then configure 8-sample average, 15Hz, normal measurement, continuous mode.
Wire.beginTransmission(HMC5883L_ADDR);
if (Wire.endTransmission() == 0) {
magAddr = HMC5883L_ADDR; magType = 3;
Wire.beginTransmission(magAddr); Wire.write(0x00); Wire.write(0x70); if (Wire.endTransmission() != 0) { magType = 0; return; }
Wire.beginTransmission(magAddr); Wire.write(0x01); Wire.write(0x20); if (Wire.endTransmission() != 0) { magType = 0; return; }
Wire.beginTransmission(magAddr); Wire.write(0x02); Wire.write(0x00); if (Wire.endTransmission() != 0) { magType = 0; return; }
delay(10);
magPresent = true;
}
}
void readQMC5883L() {
if (!magPresent) { magDataValid = false; return; }
// Data output starts at 0x00 on the 5883L, 0x01 on the 5883P. Both are
// 6 bytes, X/Y/Z as little-endian int16.
uint8_t dataReg = (magType == 2) ? 0x01 : (magType == 3 ? 0x03 : 0x00);
Wire.beginTransmission(magAddr); Wire.write(dataReg);
if (Wire.endTransmission(false) != 0) { magDataValid = false; return; }
if (Wire.requestFrom((uint16_t)magAddr, (uint8_t)6) != 6) { magDataValid = false; return; }
uint8_t b0=Wire.read(), b1=Wire.read(), b2=Wire.read(), b3=Wire.read(), b4=Wire.read(), b5=Wire.read();
int16_t nx, ny, nz;
if (magType == 3) {
// HMC5883L is big-endian and orders output X, Z, Y.
nx = (int16_t)((b0 << 8) | b1);
nz = (int16_t)((b2 << 8) | b3);
ny = (int16_t)((b4 << 8) | b5);
} else {
nx = (int16_t)(b0 | (b1 << 8));
ny = (int16_t)(b2 | (b3 << 8));
nz = (int16_t)(b4 | (b5 << 8));
}
if (nx == 0 && ny == 0 && nz == 0) {
magDataValid = false;
if (magZeroStreak < 65535) magZeroStreak++;
// An identified magnetometer should never report the Earth's field as
// exact 0/0/0. Retry its full setup after a sustained all-zero stream.
if (magZeroStreak >= 25 && millis() - magLastInitMs > 1500) initSensors();
return;
}
magX = nx; magY = ny; magZ = nz;
magZeroStreak = 0; magDataValid = true;
}
// ---- LED animation engine ----
static inline CRGB hex2crgb(uint32_t h){ return CRGB((h>>16)&0xFF,(h>>8)&0xFF,h&0xFF); }
// Apply the portal-configured COLOR ORDER + COUNT in software, then push to FastLED.
// leds[] holds logical R,G,B; we reorder each pixel's bytes into phys[] per ledCfg.order.
// Pixels beyond ledCfg.count are forced dark so a shorter ring never shows stale data.
void ledShow() {
int n = constrain(ledCfg.count, 0, LED_MAX);
const char* o = ledCfg.order; // e.g. "GRB","RGB","BGR"...
if (ledIsRGBW) {
// RGBW driven by Adafruit NeoPixel (native GRBW). We hand it plain R,G,B + a
// computed White; NeoPixel applies the byte order (set at bind from ledCfg.order).
if (!rgbwStrip) return;
uint8_t br = ledCfg.bright;
for (int i = 0; i < LED_MAX; i++) {
uint8_t r=0,g=0,b=0;
if (i < n) { r=leds[i].r; g=leds[i].g; b=leds[i].b; }
// scale by brightness (NeoPixel has no global setBrightness that plays nice here)
r=(uint8_t)((r*br)/255); g=(uint8_t)((g*br)/255); b=(uint8_t)((b*br)/255);
uint8_t w = (uint8_t)min((int)r, min((int)g, (int)b)); // pull common white out
uint8_t cr=(r>=w)?r-w:0, cg=(g>=w)?g-w:0, cb=(b>=w)?b-w:0;
rgbwStrip->setPixelColor(i, cr, cg, cb, w);
}
rgbwStrip->show();
return;
}
for (int i = 0; i < LED_MAX; i++) {
if (i >= n) { phys[i] = CRGB::Black; continue; }
uint8_t r = leds[i].r, g = leds[i].g, b = leds[i].b;
uint8_t out[3] = { r, g, b }; // default RGB
// map logical channel -> physical slot by the order string
for (int k = 0; k < 3 && o[k]; k++) {
char ch = o[k];
out[k] = (ch=='R'||ch=='r') ? r : (ch=='G'||ch=='g') ? g : b;
}
phys[i] = CRGB(out[0], out[1], out[2]);
}
FastLED.show();
}
// Boot animation: build 1..N, then a blue/green circular sweep, then dark.
void playBootAnimation() {
int n = constrain(ledCfg.count, 1, LED_MAX);
CRGB a = hex2crgb(ledCfg.bootColorA); // blue
CRGB b = hex2crgb(ledCfg.bootColorB); // green
FastLED.setBrightness(ledCfg.bright);
// 1) build-up 1 -> n, each pixel eases in blue
fill_solid(leds, LED_MAX, CRGB::Black);
for (int i = 0; i < n; i++) {
leds[i] = a;
// gentle trailing dim so it reads as a growing arc, not a hard fill
if (i > 0) leds[i-1].nscale8(180);
ledShow();
buzz(600 + i * 45, 18);
delay(38);
}
delay(90);
// 2) two smooth blue->green circular sweeps
for (int lap = 0; lap < 2; lap++) {
for (int step = 0; step < n; step++) {
for (int i = 0; i < n; i++) {
// blend across the ring so the seam is a moving blue/green gradient
uint8_t mix = sin8((i * 255 / n) + step * (256 / n));
leds[i] = blend(a, b, mix);
}
ledShow();
delay(22);
}
}
buzz(1400, 60);
// 3) fade to dark -- idle until first detection
for (int f = 255; f >= 0; f -= 15) {
FastLED.setBrightness(scale8(ledCfg.bright, f));
ledShow();
delay(16);
}
fill_solid(leds, LED_MAX, CRGB::Black);
FastLED.setBrightness(ledCfg.bright);
ledShow();
}
// Render one detection event with its themed color + animation.
// sevScale 0..255 lets severity push brightness/intensity.
#define LED_ANIM_MAX 13 // highest valid anim id (0..13)
// Three-colour event animation renderer.
// cA is the primary colour (every pattern uses it); cB/cC are used by the
// multi-colour patterns 4..13. Patterns 0..3 deliberately ignore cB/cC so an
// existing single-colour config renders EXACTLY as it did before.
// sevScale (0..255) is the severity brightness; patterns that don't fold it in
// themselves get it applied at the end.
void renderEventLeds(uint32_t cAHex, uint32_t cBHex, uint32_t cCHex,
int anim, uint8_t sevScale, uint32_t nowMs) {
int n = constrain(ledCfg.count, 1, LED_MAX);
CRGB c = hex2crgb(cAHex);
CRGB cB = hex2crgb(cBHex);
CRGB cC = hex2crgb(cCHex);
bool scaleAtEnd = (anim >= 4); // 0..3 keep their original internal handling
switch (anim) {
case 1: { // pulse -- whole ring breathes
uint8_t b = scale8(sin8(nowMs / 4), 200) + 55;
b = scale8(b, sevScale);
fill_solid(leds, LED_MAX, CRGB::Black);
for (int i = 0; i < n; i++) leds[i] = c;
fadeToBlackBy(leds, LED_MAX, 255 - b);
break;
}
case 2: { // spin -- a comet chases around the ring
fill_solid(leds, LED_MAX, CRGB::Black);
int head = (nowMs / 45) % n;
for (int t = 0; t < 5; t++) {
int idx = (head - t + n) % n;
CRGB cc = c; cc.nscale8(255 - t * 55);
leds[idx] = cc;
}
break;
}
case 3: { // blink -- hard on/off
bool on = (nowMs / 120) & 1;
fill_solid(leds, LED_MAX, on ? c : CRGB::Black);
break;
}
case 4: { // theatre chase -- every 3rd pixel, groups cycle A/B/C
fill_solid(leds, LED_MAX, CRGB::Black);
int step = (nowMs / 90) % 3;
for (int i = 0; i < n; i++) {
if (((i + step) % 3) != 0) continue;
int grp = ((i / 3) % 3);
leds[i] = (grp == 0) ? c : (grp == 1) ? cB : cC;
}
break;
}
case 5: { // strobe -- double flash then a dark gap, alternating A / B
uint32_t t = nowMs % 900;
bool on = (t < 60) || (t >= 140 && t < 200);
bool second = (t >= 140);
fill_solid(leds, LED_MAX, CRGB::Black);
if (on) for (int i = 0; i < n; i++) leds[i] = second ? cB : c;
break;
}
case 6: { // dual comet -- two heads 180 deg apart, A and B, C at the meet
fill_solid(leds, LED_MAX, CRGB::Black);
int head = (nowMs / 40) % n;
int head2 = (head + n / 2) % n;
for (int t = 0; t < 6; t++) {
uint8_t f = 255 - t * 42;
CRGB a1 = c; a1.nscale8(f);
CRGB a2 = cB; a2.nscale8(f);
int i1 = (head - t + n) % n, i2 = (head2 - t + n) % n;
leds[i1] += a1;
if (i2 == i1) leds[i2] = cC; else leds[i2] += a2;
}
break;
}
case 7: { // ripple -- expands outward from the mapped FRONT pixel
fill_solid(leds, LED_MAX, CRGB::Black);
int front = constrain(ledCfg.mFront, 0, n - 1);
int half = n / 2 + 1;
int r = (nowMs / 70) % half;
for (int d = 0; d <= half; d++) {
int age = r - d;
if (age < 0 || age > 3) continue;
CRGB cc = blend(c, cB, (uint8_t)constrain(d * 255 / (half ? half : 1), 0, 255));
cc.nscale8(255 - age * 60);
leds[(front + d) % n] += cc;
leds[(front - d + n) % n] += cc;
}
break;
}
case 8: { // colour wipe -- fills the ring A, then B, then C
uint32_t period = (uint32_t)n * 55;
uint32_t phase = (nowMs / (period ? period : 1)) % 3;
int filled = (int)(((nowMs % (period ? period : 1)) * n) / (period ? period : 1));
CRGB fillC = (phase == 0) ? c : (phase == 1) ? cB : cC;
CRGB prevC = (phase == 0) ? cC : (phase == 1) ? c : cB;
for (int i = 0; i < n; i++) leds[i] = (i <= filled) ? fillC : prevC;
for (int i = n; i < LED_MAX; i++) leds[i] = CRGB::Black;
break;
}
case 9: { // sparkle -- random twinkles in A/B/C over black
fadeToBlackBy(leds, LED_MAX, 60);
for (int k = 0; k < 2; k++) {
int i = random8(n);
uint8_t pick = random8(3);
leds[i] = (pick == 0) ? c : (pick == 1) ? cB : cC;
}
for (int i = n; i < LED_MAX; i++) leds[i] = CRGB::Black;
break;
}
case 10: { // gradient cycle -- smooth A->B->C->A rotating around the ring
uint8_t off = (uint8_t)((nowMs / 12) & 0xFF);
for (int i = 0; i < n; i++) {
uint8_t pos = (uint8_t)(((uint16_t)i * 255) / (n ? n : 1) + off);
CRGB cc;
if (pos < 85) cc = blend(c, cB, (uint8_t)(pos * 3));
else if (pos < 170) cc = blend(cB, cC, (uint8_t)((pos - 85) * 3));
else cc = blend(cC, c, (uint8_t)((pos - 170) * 3));
leds[i] = cc;
}
for (int i = n; i < LED_MAX; i++) leds[i] = CRGB::Black;
break;
}
case 11: { // halves -- ring split A/B, swapping, C flashes on the swap
uint32_t slot = nowMs / 400;
bool swap = (slot & 1);
bool flash = ((nowMs % 400) < 70);
int half = n / 2;
for (int i = 0; i < n; i++) {
if (flash) { leds[i] = cC; continue; }
bool first = (i < half);
leds[i] = (first != swap) ? c : cB;
}
for (int i = n; i < LED_MAX; i++) leds[i] = CRGB::Black;
break;
}
case 12: { // breathe blend -- whole ring breathes while cycling A->B->C
uint8_t b = scale8(sin8(nowMs / 5), 205) + 50;
uint8_t pos = (uint8_t)((nowMs / 9) & 0xFF);
CRGB cc;
if (pos < 85) cc = blend(c, cB, (uint8_t)(pos * 3));
else if (pos < 170) cc = blend(cB, cC, (uint8_t)((pos - 85) * 3));
else cc = blend(cC, c, (uint8_t)((pos - 170) * 3));
cc.nscale8(b);
fill_solid(leds, LED_MAX, CRGB::Black);
for (int i = 0; i < n; i++) leds[i] = cc;
break;
}
case 13: { // radar -- bright head, long fading tail blending A->B, C tip
fill_solid(leds, LED_MAX, CRGB::Black);
int head = (nowMs / 55) % n;
int tail = (n > 10) ? (n / 2) : 5;
for (int t = 0; t < tail; t++) {
int idx = (head - t + n) % n;
CRGB cc = blend(c, cB, (uint8_t)((t * 255) / (tail ? tail : 1)));
cc.nscale8((uint8_t)(255 - (t * 240) / (tail ? tail : 1)));
leds[idx] = cc;
}
leds[head] = cC;
break;
}
default: { // 0 solid
fill_solid(leds, LED_MAX, c);
break;
}
}
if (scaleAtEnd && sevScale < 255)
for (int i = 0; i < LED_MAX; i++) leds[i].nscale8(sevScale);
// severity tint toward white on the brightest events
if (sevScale > 210 && anim != 1)
for (int i = 0; i < LED_MAX; i++) leds[i] += CRGB(30, 30, 30);
}
// Clear the logical buffer to blank (idle). ledShow() masks to count.
static inline void ledIdle() { fill_solid(leds, LED_MAX, CRGB::Black); }
static inline void renderHeartbeat(uint32_t nowMs) {
if (!heartbeatEnabled) { ledIdle(); return; }
if (nowMs < heartbeatSuppressUntil) { ledIdle(); return; }
// Gentle ~2.8 second breathing cycle, deliberately capped below alert level.
float phase = (sinf((float)nowMs * 0.002244f - 1.5707963f) + 1.0f) * 0.5f;
float eased = phase * phase * (3.0f - 2.0f * phase);
uint8_t level = (uint8_t)(10.0f + eased * 82.0f); // subtle 4%..36%
CRGB c = hex2crgb(heartbeatColor);
fill_solid(leds, LED_MAX, c);
fadeToBlackBy(leds, LED_MAX, 255 - level);
}
// PROXIMITY fade: the whole ring fades UP toward 100% as the reading approaches
// (and passes) the proximity threshold. approach 0.0 = at threshold edge,
// 1.0 = fully triggered / very close. Uses the configured prox color.
void renderProxFade(uint32_t colorHex, float approach) {
approach = approach < 0 ? 0 : (approach > 1 ? 1 : approach);
CRGB c = hex2crgb(colorHex);
uint8_t b = (uint8_t)(approach * 255.0f + 0.5f); // 0..255 fade-up
fill_solid(leds, LED_MAX, c);
fadeToBlackBy(leds, LED_MAX, 255 - b);
}
// DIRECTIONAL MAG: fade up the color on the side of the ring nearest the source.
// dirX/dirY = magnetometer delta vector (X right, Y forward). We build a heading,
// then light each pixel by how well its angular position matches that heading,
// so the arc glows brightest toward the disturbance and falls off around the ring.
// strength 0..1 sets peak brightness (severity/approach). Falls back to a full
// pulse if the positional map is unset or the vector is too weak to have a heading.
void renderMagDirectional(uint32_t colorHex, int dirX, int dirY, float strength) {
strength = strength < 0 ? 0 : (strength > 1 ? 1 : strength);
CRGB c = hex2crgb(colorHex);
int n = constrain(ledCfg.count, 1, LED_MAX);
ledIdle();
float mag = sqrtf((float)dirX * dirX + (float)dirY * dirY);
bool haveMap = (ledCfg.mFront >= 0);
if (mag < 1.0f || !haveMap) {
// no usable heading -> even fade-up on the whole ring
uint8_t b = (uint8_t)(strength * 255.0f + 0.5f);
fill_solid(leds, LED_MAX, c);
fadeToBlackBy(leds, LED_MAX, 255 - b);
return;
}
// Source heading in radians. atan2(x, y): 0 = FRONT, +pi/2 = RIGHT (X+),
// matching the FRONT/RIGHT/BACK/LEFT map (antenna faces forward).
float head = atan2f((float)dirX, (float)dirY);
// magRot lets the operator visually spin the whole lobe in 45-deg steps
// (ROTATE button) to line the LEDs up with the physical antenna orientation.
head -= (float)ledCfg.magRot * 0.0174532925f; // deg -> rad
// FRONT pixel index anchors angle 0; each pixel steps 2pi/n around the ring.
int frontIdx = constrain(ledCfg.mFront, 0, n - 1);
uint8_t peak = (uint8_t)(strength * 255.0f + 0.5f);
for (int i = 0; i < n; i++) {
// this pixel's angle around the ring, relative to FRONT
int step = (i - frontIdx + n) % n;
// magFlip reverses which way the ring is physically wound (CW vs CCW),
// so the lit lobe sweeps the SAME direction as the source bearing.
if (ledCfg.magFlip) step = (n - step) % n;
float ang = ((float)step / (float)n) * 6.2831853f;
// angular distance to the source heading (wrapped to [0,pi])
float d = ang - head;
while (d > 3.14159265f) d -= 6.2831853f;
while (d < -3.14159265f) d += 6.2831853f;
d = fabsf(d);
// cosine lobe: full at the source, dark on the far side
float lobe = 0.5f + 0.5f * cosf(d); // 1.0 toward source -> 0.0 opposite
lobe = lobe * lobe; // tighten the beam
uint8_t b = (uint8_t)(lobe * peak + 0.5f);
CRGB pc = c; pc.nscale8(b);
leds[i] = pc;
}
}
// ---- Core 1: sensor read + threshold logic + LED/buzzer ----
// ============ TWO-PIN RC ANTENNA PROX ============
// Measure antenna capacitance as charge time. GPIO18 alternately discharges and
// charges GPIO4 through the external 1M resistor. Seven readings are median-filtered
// so Wi-Fi and LED switching spikes do not move the proximity channel.
// Cached at rcBegin(): ESP.getCpuFreqMHz() is flash-resident and must not be
// called from inside the IRAM measurement routine.
static uint32_t rcCpuMhz = 240;
// Direct-register GPIO for the timed charge window. All three candidate pins
// (RC_SENSE_PIN 4, RC_DRIVE_PIN 18, BUZZER_PIN 12) are < 32, so the low
// out_w1ts/out_w1tc/in registers cover them. Using registers (instead of
// digitalWrite/digitalRead) guarantees no flash-resident HAL call can sneak
// into the IRAM poll loop, and shaves ~50 cycles per poll.
static inline __attribute__((always_inline)) void rcDriveWrite(uint8_t pin, int level) {
if (level) GPIO.out_w1ts = ((uint32_t)1 << pin);
else GPIO.out_w1tc = ((uint32_t)1 << pin);
}
static inline __attribute__((always_inline)) int rcSenseLevel() {
return (int)((GPIO.in >> RC_SENSE_PIN) & 0x1);
}
void rcBegin() {
rcCpuMhz = (uint32_t)ESP.getCpuFreqMHz();
pinMode(activeRcDrivePin(), OUTPUT);
digitalWrite(activeRcDrivePin(), LOW);
// Force GPIO4 back from the touch peripheral to ordinary digital GPIO.
pinMode(RC_SENSE_PIN, OUTPUT);
digitalWrite(RC_SENSE_PIN, LOW);
delayMicroseconds(50);
pinMode(RC_SENSE_PIN, INPUT);
rcReady = true;
rcValid = false;
rcFault = 0;
rcBase10 = 0; rcBaseEma10 = 0.0f; rcLastGood10 = 0;
// Start wide enough to characterize a large loop or a high-capacitance build.
// The boot learning window tightens this automatically after settling.
rcTimeoutUs = RC_TIMEOUT_CAL_US;
rcSmartCalibrated = false; rcSaturated = false; rcSaturatedAt = 0;
}
// Return codes:
// >0 charge time in 0.1us units
// -1 discharge fail (sense stuck HIGH before the window: wiring/short)
// -2 GENUINE charge timeout: the pin was still LOW at the timing bound.
// The RC node truly never charged inside the learned window -- real
// over-capacitance (hand on/near the antenna). Only this code may feed
// the saturation surrogate.
// -3 STALL: the pin was HIGH at the bound but we noticed late. The analog
// crossing happened at an unknown earlier time while the CPU was frozen
// mid-poll. The charge itself was normal; the SAMPLE is unusable. It
// must be discarded -- never mapped to the saturation surrogate.
//
// Why stalls happen at all: interrupts on this core are already masked for the
// window (portENTER_CRITICAL below), but a critical section CANNOT guard
// against flash-cache suspension. Whenever the OTHER core commits an NVS /
// LittleFS / OTA write, the SPI flash cache is disabled and any code that is
// not in IRAM freezes on BOTH cores until the flash op completes (multiple
// milliseconds -- far past a ~150us tuned window). The old build ran this
// routine from flash and lumped "HIGH but late" together with "never charged",
// so every such stall was misclassified as a saturated hand reading. diag9
// signature: charge pinned at EXACTLY rcBase10 + satDelta (the surrogate, a
// manufactured constant -- not a measurement) for seconds at a time, with
// ambient flat at delta 0-9 immediately before and after.
//
// Defense in depth, in order:
// 1. IRAM_ATTR + register GPIO: the poll loop keeps executing through a
// flash-cache stall, so the crossing is timestamped on time and the
// sample is simply VALID. This removes the false spikes at the source.
// 2. -3 classification: any residual late-notice case is discarded instead
// of being mistaken for saturation.
// 3. Ramp gate in rcRead10: saturation can only arm off -2 runs that were
// preceded by a genuinely elevated clean reading (a hand ramps; a stall
// burst does not).
IRAM_ATTR int rcMeasureOnce10() {
const uint32_t mhz = rcCpuMhz;
const uint32_t maxCycles = mhz * rcTimeoutUs;
const uint8_t drivePin = activeRcDrivePin();
// Discharge the sense node directly through GPIO4. Discharging only through
// the 1M resistor can leave long antennas above the digital LOW threshold.
// (pinMode is flash-resident but runs BEFORE the timed window, where a
// stall costs nothing.)
rcDriveWrite(drivePin, 0);
pinMode(RC_SENSE_PIN, OUTPUT);
rcDriveWrite(RC_SENSE_PIN, 0);
delayMicroseconds(20);
if (rcSenseLevel() != 0) { pinMode(RC_SENSE_PIN, INPUT); return -1; }
pinMode(RC_SENSE_PIN, INPUT);
// Charge through the drive pin -> 1M and time GPIO4 crossing HIGH. The
// interrupt mask still removes same-core ISR jitter from the measurement
// (see RC_GUARD_MAX_US); IRAM removes cross-core flash-cache stalls.
bool guarded = (rcTimeoutUs <= RC_GUARD_MAX_US);
if (guarded) portENTER_CRITICAL(&rcMux);
uint32_t start = ESP.getCycleCount();
rcDriveWrite(drivePin, 1);
while (rcSenseLevel() == 0 && (uint32_t)(ESP.getCycleCount() - start) < maxCycles) {}
uint32_t cycles = (uint32_t)(ESP.getCycleCount() - start);
bool charged = (rcSenseLevel() != 0);
rcDriveWrite(drivePin, 0);
if (guarded) portEXIT_CRITICAL(&rcMux);
if (cycles >= maxCycles) return charged ? -3 : -2;
if (!charged) return -2; // loop exited without HIGH inside the bound
// Tenths of a microsecond preserve useful resolution with small antenna capacitance.
int measured = (int)((cycles * (uint32_t)RC_SUB + mhz / 2UL) / mhz);
return measured > 0 ? measured : 1;
}
int rcRead10() {
int samples[7];
int n = 0, dischargeFails = 0, chargeFails = 0;
// Once a healthy ambient baseline exists, a hand can add enough capacitance
// to exceed even the learned timing window. That is a saturated proximity
// reading, not an immediate missing-wire fault. Map timeout samples to a
// bounded raw surrogate so they participate in the median without exploding
// proxSpan or making the reported delta jump to hundreds of thousands.
int thresholdSnapshot = effThreshProx;
int spanSnapshot = proxSpan;
int satDelta = thresholdSnapshot + (4 * RC_SUB / 10); // +0.4us over the trigger
if (spanSnapshot > satDelta) satDelta = spanSnapshot;
satDelta = constrain(satDelta, (4 * RC_SUB / 10), (40 * RC_SUB / 10));
int baseSnapshot10 = rcBase10;
if (baseSnapshot10 < 1) baseSnapshot10 = 1;
int saturatedSample10 = baseSnapshot10 + satDelta;
int stallFails = 0;
for (int i = 0; i < 7; ++i) {
int v = rcMeasureOnce10();
if (v > 0) samples[n++] = v;
else if (v == -1) dischargeFails++;
else if (v == -3) stallFails++; // CPU-stall-invalidated: discard outright.
// NEVER a chargeFail, NEVER the surrogate.
else {
chargeFails++;
if (rcSmartCalibrated) samples[n++] = saturatedSample10;
}
delayMicroseconds(20);
}
bool majorityTimeout = rcSmartCalibrated && chargeFails >= 4 && dischargeFails == 0;
static uint8_t consecutiveTimeoutReads = 0;
static uint8_t consecutiveCleanReads = 0;
if (majorityTimeout) {
if (consecutiveTimeoutReads < 250) consecutiveTimeoutReads++;
consecutiveCleanReads = 0;
} else {
consecutiveCleanReads++;
if (consecutiveCleanReads >= RC_SATURATION_CLEAR_READS) consecutiveTimeoutReads = 0;
}
// A single call, one Wi-Fi-timing stall can look identical to a hand near
// the antenna. Require RC_SATURATION_ARM_READS consecutive calls before we
// trust it as a real close-range reading, and require the sensor to come
// back clean for RC_SATURATION_CLEAR_READS calls before it can arm again.
//
// RAMP GATE: additionally require that a genuinely elevated CLEAN reading
// was measured within RC_SAT_RAMP_WINDOW_MS. A hand physically approaching
// the antenna raises the analog charge time continuously, so at 100 Hz the
// approach ALWAYS produces intermediate elevated medians before the window
// saturates. A stall burst starts from a flat ambient baseline and jumps
// straight to timeout -- no ramp -- so it can never arm.
static uint32_t lastElevatedCleanMs = 0;
bool rampSeen = (lastElevatedCleanMs != 0) &&
((uint32_t)(millis() - lastElevatedCleanMs) <= RC_SAT_RAMP_WINDOW_MS);
bool majoritySaturated = (consecutiveTimeoutReads >= RC_SATURATION_ARM_READS) && rampSeen;
if (majoritySaturated) {
if (!rcSaturatedAt) rcSaturatedAt = millis();
rcSaturated = true;
// A real hand/contact is represented long enough to produce one complete
// alert. A wire that remains open becomes a fault and cannot retrigger forever.
if (millis() - rcSaturatedAt > RC_SATURATION_MAX_MS) {
rcValid = false; rcFault = 2; consecutiveTimeoutReads = 0; return 0;
}
// CONFIRMED: this is the 2nd+ consecutive majority-timeout call, so the
// elevated surrogate is trusted to represent a real close-range reading.
rcValid = true; rcFault = 0; rcLastGood10 = saturatedSample10;
return saturatedSample10;
} else {
rcSaturated = false; rcSaturatedAt = 0;
}
if (majorityTimeout) {
// UNCONFIRMED single-call timeout burst: this is exactly the case a lone
// Wi-Fi-timing stall or transient RF glitch produces. Hold the last
// confirmed-good value unchanged rather than letting the surrogate move
// proxVal/proxDelta at all. No fault is reported either - the wiring is
// fine, we simply are not yet confident this reading is real.
rcValid = true; rcFault = 0;
return rcLastGood10 > 0 ? rcLastGood10 : baseSnapshot10;
}
if (n < 5) {
// Stall-dominated call: too few usable samples, but the shortfall came
// from discarded -3 stalls, not from wiring. Hold the last good value;
// no fault, no surrogate -- the sensor is fine, the CPU was busy.
if (stallFails > 0 && dischargeFails == 0 && chargeFails == 0) {
rcValid = true; rcFault = 0;
return rcLastGood10 > 0 ? rcLastGood10 : baseSnapshot10;
}
rcValid = false;
rcFault = dischargeFails > chargeFails ? 1 : (chargeFails > 0 ? 2 : 3);
return 0;
}
for (int a = 0; a < n; ++a) for (int b = a + 1; b < n; ++b) {
if (samples[b] < samples[a]) { int t = samples[a]; samples[a] = samples[b]; samples[b] = t; }
}
rcValid = true; rcFault = 0;
// TRIMMED MEAN over the sorted samples: drop the extreme low and high (the
// median's outlier immunity is what we want to keep) and average the rest.
// A bare median of 7 discards six perfectly good samples; averaging the
// middle five cuts the per-read noise by roughly sqrt(5) with no extra
// measurement time. At RC_SUB=100 that is the difference between a +/-15
// and a +/-7 rest band under an ~80-count touch.
long acc = 0; int cnt = 0;
for (int k = 1; k < n - 1; ++k) { acc += samples[k]; cnt++; }
int robust = cnt > 0 ? (int)((acc + cnt / 2) / cnt) : samples[n / 2];
rcLastGood10 = robust;
// Feed the ramp gate: a clean median meaningfully above baseline means a
// real analog rise is in progress, so saturation is allowed to arm soon.
if (rcBase10 > 0 && (robust - rcBase10) > (effThreshProx / 2)) {
lastElevatedCleanMs = millis();
}
return robust;
}
void hardwareTask(void* pv) {
initSensors();
// Load persisted LED settings. count + color order now apply LIVE (software remap);
// only chipset + data/clock pin are compile-time for FastLED (need a reflash).
prefs.begin("specter", true);
ledCfg.count = constrain(prefs.getInt("ledCount", ledCfg.count), 1, LED_MAX);
{ String ord = prefs.getString("ledOrd", ledCfg.order); strncpy(ledCfg.order, ord.c_str(), sizeof(ledCfg.order)-1); ledCfg.order[sizeof(ledCfg.order)-1]=0; }
// CHIPSET lives in its OWN namespace "ledcfg" so the hardware task's frequent
// "specter" writes (proxSpan/proxNoise/hz) can never clobber it. Migrate a value
// that an older build left under "specter" if the dedicated key isn't set yet.
{
Preferences cp; cp.begin("ledcfg", true);
String chp = cp.getString("chip", "");
cp.end();
if (chp.length() == 0) { // not in dedicated ns yet
String legacy = prefs.getString("ledChip", ""); // shared ns (read handle already open)
if (legacy.length()) chp = legacy; // migrate old value
}
if (chp.length()) { strncpy(ledCfg.chipset, chp.c_str(), sizeof(ledCfg.chipset)-1); ledCfg.chipset[sizeof(ledCfg.chipset)-1]=0; }
// else: keep the compile-time default (now SK6812RGBW)
}
Serial.printf("[LED] boot chipset = '%s'\n", ledCfg.chipset);
ledCfg.bright = prefs.getInt("ledBright", ledCfg.bright);
ledCfg.mFront = prefs.getInt("ledFront", ledCfg.mFront);
ledCfg.mRight = prefs.getInt("ledRight", ledCfg.mRight);
ledCfg.mBack = prefs.getInt("ledBack", ledCfg.mBack);
ledCfg.mLeft = prefs.getInt("ledLeft", ledCfg.mLeft);
ledCfg.magRot = prefs.getInt("ledMagRot", ledCfg.magRot);
ledCfg.magFlip = prefs.getInt("ledMagFlip", ledCfg.magFlip);
lastOtaErr = prefs.getString("otaErr", "");
lastOtaErrStr = prefs.getString("otaErrStr", "");
lastOtaErrHeap= prefs.getUInt("otaErrHeap", 0);
lastOtaErrBlk = prefs.getUInt("otaErrBlk", 0);
lastOtaErrAt = prefs.getUInt("otaErrAt", 0);
ledCfg.magColor = prefs.getUInt("ledMagC", ledCfg.magColor);
ledCfg.magColorB = prefs.getUInt("ledMagC2", ledCfg.magColorB);
ledCfg.magColorC = prefs.getUInt("ledMagC3", ledCfg.magColorC);
ledCfg.magAnim = prefs.getInt("ledMagA", ledCfg.magAnim);
ledCfg.proxColor = prefs.getUInt("ledPrxC", ledCfg.proxColor);
ledCfg.proxColorB = prefs.getUInt("ledPrxC2", ledCfg.proxColorB);
ledCfg.proxColorC = prefs.getUInt("ledPrxC3", ledCfg.proxColorC);
ledCfg.proxAnim = prefs.getInt("ledPrxA", ledCfg.proxAnim);
ledCfg.bootColorA = prefs.getUInt("ledBootA", ledCfg.bootColorA);
ledCfg.bootColorB = prefs.getUInt("ledBootB", ledCfg.bootColorB);
ledCfg.bootEnable = prefs.getInt("ledBoot", ledCfg.bootEnable);
prefs.end();
// Keep the physical pin-role setting in its own namespace so unrelated
// sensor settings cannot overwrite it during portal updates.
Preferences hwPrefs;
hwPrefs.begin("nodehw", true);
rcBuzzerPinsSwapped = hwPrefs.getBool("pinSwap", false);
hwPrefs.end();
// Per-node calibration gain -- OWN namespace (never the frequently-written
// "specter" namespace) so it can't be clobbered by unrelated portal saves.
{
Preferences calPrefs;
calPrefs.begin("nodecal", true);
proxGain = calPrefs.getFloat("proxGain", 1.0f);
magGain = calPrefs.getFloat("magGain", 1.0f);
calPrefs.end();
// Guard against a corrupt/out-of-range persisted value bricking sensitivity.
if (!(proxGain > 0.05f && proxGain < 20.0f)) proxGain = 1.0f;
if (!(magGain > 0.05f && magGain < 20.0f)) magGain = 1.0f;
}
// Bind the FastLED driver to the chipset the operator picked in the portal.
// FastLED templates the chipset at compile time, so we can't pass a string --
// instead we branch across the supported types and add the matching driver.
// COLOR ORDER stays RGB here; we remap to the configured order in software (ledShow).
{
String cs = String(ledCfg.chipset); cs.toUpperCase();
ledIsRGBW = (cs == "SK6812RGBW" || cs == "SK6812RGB" || cs == "SK6812W" || cs == "RGBW");
Serial.printf("[LED] bind chipset='%s' rgbw=%d\n", cs.c_str(), ledIsRGBW ? 1 : 0);
if (ledIsRGBW) {
// RGBW (SK6812) is driven by Adafruit NeoPixel -- FastLED can't do RGBW.
rebindRgbw(); // builds the strip using the current ledCfg.order flag
}
else if (cs == "SK6812") FastLED.addLeds<SK6812, LED_DATA_PIN, RGB>(phys, LED_MAX);
else if (cs == "WS2811") FastLED.addLeds<WS2811, LED_DATA_PIN, RGB>(phys, LED_MAX);
else if (cs == "WS2812") FastLED.addLeds<WS2812, LED_DATA_PIN, RGB>(phys, LED_MAX);
else if (cs == "WS2813") FastLED.addLeds<WS2813, LED_DATA_PIN, RGB>(phys, LED_MAX);
else if (cs == "NEOPIXEL") FastLED.addLeds<NEOPIXEL, LED_DATA_PIN>(phys, LED_MAX);
#ifdef LED_CLK_PIN
else if (cs == "APA102" || cs == "DOTSTAR") FastLED.addLeds<APA102, LED_DATA_PIN, LED_CLK_PIN, RGB>(phys, LED_MAX);
else if (cs == "SK9822") FastLED.addLeds<SK9822, LED_DATA_PIN, LED_CLK_PIN, RGB>(phys, LED_MAX);
#endif
else FastLED.addLeds<WS2812B, LED_DATA_PIN, RGB>(phys, LED_MAX); // default
}
FastLED.setBrightness(ledCfg.bright);
if (ledCfg.bootEnable) playBootAnimation(); // build 1..N, blue/green sweep, dark
float proxAvg = 0; uint32_t proxSettleStart = 0; // proxInit is now file-scope
// Arm the boot auto-baseline: wait for the sensors to settle after power-on,
// THEN snap a clean ambient baseline. Until then, hold alerts off.
bootBaselineAt = millis() + BASELINE_SETTLE_MS;
bootBaselineDone = false;
blMagXsum = blMagYsum = blMagZsum = blProxSum = 0; blSamples = 0;
blRcSum10 = 0; blRcSamples = 0; blRcMin10 = 0; blRcMax10 = 0; blRcValueCount = 0;
// The averaged boot window is the calibration. Do not let the legacy
// one-shot flag overwrite its learned mean on the closing sample.
recalibrate = false;
alertPhase = 0; alertPhaseStart = 0;
for (;;) {
// OTA GUARD: during a firmware download we must NOT drive the WS2812 strip.
// FastLED.show() disables interrupts for the whole bit-bang (~0.5ms/16px);
// repeated back-to-back on core 1 while core 0 streams a 1.1MB TLS download
// starves/corrupts the WiFi RX path and the flash fails mid-stream. Park the
// task quietly (no LED writes, no sensor churn) until the OTA finishes/reboots.
if (otaActive) { vTaskDelay(pdMS_TO_TICKS(50)); continue; }
if (ledTestReq) {
int req = ledTestReq; ledTestReq = 0;
if (req == 8) { ledIdle(); FastLED.setBrightness(ledCfg.bright); ledShow(); } // live refresh after config save
else if (req == 1) playBootAnimation();
else if (req >= 4 && req <= 7) {
// Solid color test: fill every driven pixel so you can verify
// wiring, count, and color order. Red=4 Green=5 Blue=6 White=7.
CRGB tc = (req==4)?CRGB(255,0,0):(req==5)?CRGB(0,255,0):(req==6)?CRGB(0,0,255):CRGB(255,255,255);
fill_solid(leds, LED_MAX, tc);
FastLED.setBrightness(ledCfg.bright); ledShow();
delay(2000);
ledIdle(); ledShow();
} else {
// ~1.8s preview that mirrors live behavior.
uint32_t t0 = millis();
while (millis() - t0 < 1800) {
uint32_t e = millis() - t0;
if (req == 2) {
// MAG preview: hold a STEADY lobe pointing FRONT (heading 0). This is
// the alignment target for the ROTATE button -- magRot spins this lobe
// in 45-deg steps so the operator can line the lit side up with the
// physical antenna. (Solid look; themed anims just play the theme.)
if (ledCfg.magAnim == 0) {
renderMagDirectional(ledCfg.magColor, 0, 1000, 1.0f); // dirX=0,dirY+ = FRONT
} else renderEventLeds(ledCfg.magColor, ledCfg.magColorB, ledCfg.magColorC, ledCfg.magAnim, 255, millis());
} else {
// PROX preview: if solid, fade the whole ring UP then hold; else the theme.
if (ledCfg.proxAnim == 0) {
float a = e < 1200 ? (float)e / 1200.0f : 1.0f; // ramp to 100%
renderProxFade(ledCfg.proxColor, a);
} else renderEventLeds(ledCfg.proxColor, ledCfg.proxColorB, ledCfg.proxColorC, ledCfg.proxAnim, 255, millis());
}
FastLED.setBrightness(ledCfg.bright); ledShow();
delay(16);
}
ledIdle(); ledShow();
}
}
// ---- Diagnostic actuator pulse (commanded self-test) ----------------
// Nonblocking actuator animation. The old implementation held this
// hardware task inside a while/delay loop for up to 30 seconds, which
// stopped RC/magnetometer reads and made the app sensor panel freeze.
// Advance one animation frame per normal hardwareTask iteration instead.
uint32_t animNow = millis();
if (diagPulseReq) {
diagPulseReq = 0;
diagStopReq = 0;
if (!diagAnimActive) diagAnimStart = animNow;
// A fresh streamed drive frame preempts a release fade and keeps
// the current hit alive without restarting its attack envelope.
diagAnimReleasing = false;
diagAnimActive = true;
diagAnimUntil = animNow + max((uint32_t)50, (uint32_t)diagPulseMs);
diagQuietUntil = diagAnimUntil + 400;
}
if (diagStopReq && diagAnimActive) {
diagStopReq = 0;
diagAnimReleasing = true;
diagAnimReleaseStart = animNow;
diagQuietUntil = animNow + 1100;
}
if (diagAnimActive) {
const uint32_t ATTACK_MS = 350;
const uint32_t RELEASE_MS = (diagPulseChan == 1) ? 300 : 700;
float env = 0.0f;
if (!diagAnimReleasing && (int32_t)(animNow - diagAnimUntil) >= 0) {
diagAnimReleasing = true;
diagAnimReleaseStart = animNow;
diagQuietUntil = animNow + RELEASE_MS + 400;
}
if (diagAnimReleasing) {
uint32_t re = animNow - diagAnimReleaseStart;
if (re >= RELEASE_MS) {
diagAnimActive = false;
noTone(activeBuzzerPin());
ledIdle(); ledShow();
heartbeatSuppressUntil = animNow + HEARTBEAT_SUPPRESS_MS;
} else {
float k = 1.0f - (float)re / (float)RELEASE_MS;
env = k * k;
}
} else {
uint32_t age = animNow - diagAnimStart;
env = age < ATTACK_MS ? (float)age / (float)ATTACK_MS : 1.0f;
float t = age * 0.001f;
env *= 0.82f + 0.18f * sinf(t * 8.8f);
env *= 1.0f + 0.06f * sinf(t * 31.4f);
}
if (diagAnimActive) {
if (env < 0.0f) env = 0.0f;
if (env > 1.0f) env = 1.0f;
float lvl = diagPulseInt; if (lvl < 0.05f) lvl = 0.05f;
float drive = lvl * env;
float rad = (diagPulseBrg - 90) * 0.01745329f;
int lx = (int)(cosf(rad) * 1000.0f), ly = (int)(-sinf(rad) * 1000.0f);
if (diagPulseChan == 1) {
// Magnetic drives keep their original directional response.
renderMagDirectional(ledCfg.magColor, lx, ly, 0.35f + drive * 0.65f);
buzz(1500 + (int)(drive * 400), 30);
} else {
// Proximity drives must use the exact configured organic
// proximity event animation, not a plain fade.
if (ledCfg.proxAnim == 0) renderProxFade(ledCfg.proxColor, 0.15f + drive * 1.05f);
else renderEventLeds(ledCfg.proxColor, ledCfg.proxColorB, ledCfg.proxColorC, ledCfg.proxAnim, (uint8_t)constrain((int)(drive * 255.0f), 0, 255), millis());
buzz(500 + (int)(drive * drive * 1500.0f), 30);
}
FastLED.setBrightness(diagPulseMaxBrightness ? 255 : ledCfg.bright); ledShow();
}
}
// A requested rebaseline reruns the complete RC learning window on the
// hardware core. This safely adapts after an antenna or resistor change.
if (rcSmartCalReq) {
rcSmartCalReq = false;
rcReady = false; rcBase10 = 0; rcBaseEma10 = 0.0f; rcLastGood10 = 0; rcValid = false; rcFault = 0;
rcTimeoutUs = RC_TIMEOUT_CAL_US; rcSmartCalibrated = false; rcSaturated = false; rcSaturatedAt = 0;
bootBaselineAt = millis() + BASELINE_SETTLE_MS; bootBaselineDone = false;
blMagXsum = blMagYsum = blMagZsum = blProxSum = 0; blSamples = 0;
blRcSum10 = 0; blRcSamples = 0; blRcMin10 = 0; blRcMax10 = 0; blRcValueCount = 0;
proxNoiseFloor = 0; proxInit = false; recalibrate = false; alertPhase = 0;
}
// Proximity source: two-pin RC charge time on GPIO18/GPIO4 if enabled,
// otherwise the ESP32's built-in touchRead path. Both feed the same rising
// proxVal convention used by baseline, threshold, field, alert, and Theremin logic.
if (proxMode == 1) {
if (!rcReady) rcBegin();
rcCharge10 = rcRead10();
if (rcValid) {
if (!bootBaselineDone) {
// The raw samples below calibrate the antenna. Do not derive a
// temporary proximity signal from a moving minimum during boot.
proxVal = 0;
} else {
if (rcBaseEma10 <= 0.0f) rcBaseEma10 = (float)(rcBase10 > 0 ? rcBase10 : rcCharge10);
int signedRise = rcCharge10 - (int)(rcBaseEma10 + 0.5f);
int restBand = max(6, max(proxNoise * 2, effThreshProx / 3));
// A lower sample can only mean ambient recovery or an outlier,
// never an approaching hand. Follow it gently, not instantly.
// Small positive rest drift follows much more slowly; a real
// approach remains frozen against the baseline and stays visible.
//
// BUT: "gently" must still mean REJECTING implausible collapses,
// not just following them slowly. A real ambient baseline never
// halves in one 70ms sample -- that is a measurement glitch (a
// spurious early edge on the sense line), not the antenna's true
// resting capacitance. Blending those glitch samples in, even at
// a tiny alpha, accumulates over hundreds of samples into a
// runaway downward drift that eventually reads as a permanent
// false alert against a perfectly flat true signal. Reject any
// sample that collapses below half the current baseline outright
// -- it never touches the EMA, no matter how small its alpha
// would otherwise be.
bool implausibleCollapse = (rcCharge10 < (int)(rcBaseEma10 * 0.5f));
float alpha = 0.0f;
if (!implausibleCollapse && !fieldLock) { // locked: baseline frozen
if (signedRise < 0) alpha = (abs(signedRise) <= restBand * 4) ? 0.02f : 0.005f;
else if (signedRise <= restBand) alpha = 0.002f;
}
if (alpha > 0.0f) rcBaseEma10 += alpha * ((float)rcCharge10 - rcBaseEma10);
rcBase10 = (int)(rcBaseEma10 + 0.5f);
int rise = rcCharge10 - rcBase10;
proxVal = rise > 0 ? rise : 0;
}
} else {
proxVal = 0; // missing/shorted RC wiring must never create a false alert
}
// In RC mode baseProx is zero and proxVal rises with added capacitance.
if (!proxInit) { baseProx = 0; proxAvg = 0; proxInit = true; }
} else {
// Median-of-9 read: touchRead() is noisy (jitters tens of counts). A median
// rejects the spikes far better than an average, so the baseline stays stable
// and a real hand-approach stands out cleanly.
int sm[9];
for (int k = 0; k < 9; k++) { sm[k] = touchRead(touchPin); delayMicroseconds(200); }
for (int a = 0; a < 9; a++) for (int b = a+1; b < 9; b++) if (sm[b] < sm[a]) { int t=sm[a]; sm[a]=sm[b]; sm[b]=t; }
proxVal = sm[4]; // median
}
readQMC5883L();
// BOOT AUTO-BASELINE: once the settle window has elapsed, take one clean
// ambient baseline. This runs after the sensors have had time to stabilize
// post power-on, so we don't lock onto noisy startup values.
if (!bootBaselineDone) {
// Accumulate settle-window samples for a robust mean baseline.
blMagXsum += magX; blMagYsum += magY; blMagZsum += magZ;
blProxSum += proxVal; blSamples++;
if (proxMode == 1 && rcValid && rcCharge10 > 0) {
blRcSum10 += (uint32_t)rcCharge10; blRcSamples++;
if (blRcMin10 == 0 || rcCharge10 < blRcMin10) blRcMin10 = rcCharge10;
if (rcCharge10 > blRcMax10) blRcMax10 = rcCharge10;
if (blRcValueCount < RC_CAL_SAMPLE_CAP) blRcValues10[blRcValueCount++] = rcCharge10;
}
// Track the noise floor: how far the resting prox reading swings around
// its running mean while nobody is near it. This becomes the minimum
// trigger threshold so idle jitter can never fire a false proximity alert.
if (proxMode != 1 && blSamples > 3) {
int runMean = (int)(blProxSum / blSamples);
int dev = abs(proxVal - runMean);
if (dev > proxNoiseFloor) proxNoiseFloor = dev;
}
if ((int32_t)(millis() - bootBaselineAt) >= 0) {
// Window closed: adopt the AVERAGE as the clean ambient baseline.
if (blSamples > 0) {
magBaselineSet((int16_t)(blMagXsum / blSamples),
(int16_t)(blMagYsum / blSamples),
(int16_t)(blMagZsum / blSamples));
baseProx = (proxMode == 1) ? 0 : (int)(blProxSum / blSamples);
proxAvg = baseProx; proxInit = true; proxSettleStart = millis();
if (proxMode == 1) {
if (blRcValueCount >= 3) {
int sorted[RC_CAL_SAMPLE_CAP];
int n = blRcValueCount;
for (int i=0; i<n; ++i) sorted[i] = blRcValues10[i];
for (int a=1; a<n; ++a) { int v=sorted[a], b=a-1; while (b>=0 && sorted[b]>v) { sorted[b+1]=sorted[b]; --b; } sorted[b+1]=v; }
int ambient10 = sorted[n/2];
int low10 = sorted[n/10];
int high10 = sorted[(n*9)/10];
int spread10 = max(ambient10-low10, high10-ambient10);
rcBase10 = ambient10; rcBaseEma10 = (float)ambient10;
// RC noise uses the robust 10th..90th percentile spread.
// One impossible low sample can no longer force the baseline
// to 1.2 us or inflate the effective threshold to ~170.
proxNoiseFloor = max(0, spread10);
// rcMarginMult ambient time constants plus rcMarginFloorUs of
// absolute jitter headroom (both live-tunable from the portal,
// default 5x + 40us). A hand approaching (not touching) only
// shifts the RC charge time a little; if the timeout window is
// thousands of times ambient (the old fixed 2ms/10ms floors),
// that shift never reads as anything but flat baseline, then
// touch alone crosses the huge window and it looks like an
// on/off switch instead of a graduated approach signal. Keeping
// the window close to actual ambient lets the in-between reads
// through as real proportional numbers.
int marginMult = rcMarginMult; if (marginMult < 2) marginMult = 2; if (marginMult > 40) marginMult = 40;
int marginFloorUs = rcMarginFloorUs; if (marginFloorUs < 5) marginFloorUs = 5; if (marginFloorUs > 2000) marginFloorUs = 2000;
uint64_t target10 = (uint64_t)ambient10 * (uint64_t)marginMult + (uint64_t)max((int64_t)marginFloorUs * RC_SUB, (int64_t)(spread10 * 8));
uint64_t min10 = (uint64_t)ambient10 + (30ULL * RC_SUB); // never below ambient + 30us: keeps a hard-open-wire read fast
uint64_t max10 = (uint64_t)RC_TIMEOUT_MAX_US * (uint64_t)RC_SUB;
if (target10 < min10) target10 = min10;
if (target10 > max10) target10 = max10;
rcTimeoutUs = (uint32_t)((target10 + (RC_SUB - 1)) / RC_SUB);
rcSmartCalibrated = true;
} else {
// No trustworthy antenna sample: return to the short
// diagnostic timeout so a real open wire fails quickly.
rcTimeoutUs = RC_TIMEOUT_MIN_US;
rcSmartCalibrated = false;
}
}
// Guard band: the real trigger sits at least (noiseFloor + 60%)
// above rest, and never below the operator's slider value. This
// is what stops the constant low-level proximity chatter.
effThreshProx = recomputeEffThreshProx();
} else {
recalibrate = true; // no samples (shouldn't happen) -> single-shot fallback
}
bootBaselineDone = true;
}
}
if (recalibrate) {
magBaselineSet(magX, magY, magZ);
if (proxMode == 1) {
// A single instantaneous rcCharge10 sample can be a transient glitch
// (RF, power draw, etc). Collect a short burst instead of snapping to
// whichever value happened to land on this exact cycle.
rcQuickRebaseArmed = true; rcQuickRebaseCount = 0;
baseProx = 0; proxAvg = 0;
} else {
baseProx = proxVal; proxAvg = proxVal;
}
proxInit = true; proxSettleStart = millis();
recalibrate = false;
}
// PROX-ONLY re-zero (after a prox cooldown): snap prox baseline to current
// ambient without disturbing the magnetometer baseline, so mag stays live.
if (proxRecalibrate) {
if (proxMode == 1) {
rcQuickRebaseArmed = true; rcQuickRebaseCount = 0;
baseProx = 0; proxAvg = 0;
} else {
baseProx = proxVal; proxAvg = proxVal;
}
proxInit = true; proxSettleStart = millis();
proxRecalibrate = false;
}
// Robust quick re-baseline in progress: accumulate a handful of ALREADY
// majority-filtered rcRead10() results across consecutive cycles, then
// snap to their median. One glitched cycle can no longer poison 6+
// seconds of readings until the next cooldown.
if (rcQuickRebaseArmed && proxMode == 1) {
if (rcValid && rcQuickRebaseCount < RC_QUICK_REBASE_SAMPLES) {
rcQuickRebaseBuf[rcQuickRebaseCount++] = rcCharge10;
}
if (rcQuickRebaseCount >= RC_QUICK_REBASE_SAMPLES) {
int tmp[RC_QUICK_REBASE_SAMPLES];
for (int i = 0; i < RC_QUICK_REBASE_SAMPLES; ++i) tmp[i] = rcQuickRebaseBuf[i];
for (int a = 1; a < RC_QUICK_REBASE_SAMPLES; ++a) { int v = tmp[a], b = a - 1; while (b >= 0 && tmp[b] > v) { tmp[b+1] = tmp[b]; --b; } tmp[b+1] = v; }
rcBase10 = tmp[RC_QUICK_REBASE_SAMPLES / 2];
rcBaseEma10 = (float)rcBase10;
rcQuickRebaseArmed = false;
}
}
// SLOW baseline tracker (drifts with temperature/humidity). We freeze it while
// a touch is active so the baseline doesn't creep toward the touched value.
int dX = magX - baseMagX, dY = magY - baseMagY, dZ = magZ - baseMagZ;
magDeltaRawG = (int)sqrtf((float)dX * dX + (float)dY * dY + (float)dZ * dZ);
magDelta = (int)lroundf((float)magDeltaRawG * magGain); // calibration applied HERE, before threshMag compare below
// SLOW dynamic mag baseline: self-zeros to the ambient field so even a small NEW
// field stands out. It drifts gently (alpha 0.002 ~= tens of seconds) toward the
// current reading, but FREEZES while a mag hit is active so a held magnet is not
// absorbed into the baseline. Fast-settle for the first 3s after boot/recal.
{
static uint32_t magSettleStart = 0;
static bool magSettleInit = false;
static uint32_t magHitStart = 0; // when the current sustained hit began
if (!magSettleInit) { magSettleStart = millis(); magSettleInit = true; }
if (recalibrate) { magSettleStart = millis(); magHitStart = 0; }
bool magHitActive = (magDelta > threshMag);
// STUCK-ALERT AUTO-RECALIBRATION: if a hit stays active longer than
// MAG_HOLD_MS (a magnet parked next to the antenna, or the ambient
// field genuinely shifted), snap the baseline to the CURRENT field so
// the node stops screaming forever. Removing the magnet then produces
// a brief opposite blip that re-settles in a couple seconds instead of
// latching on until a manual reset.
// STALE-BASELINE WATCHDOG. The tracking below only runs while NO hit
// is active, and the hard re-zero needs MAG_HOLD_MS of CONTINUOUS
// hit. A residual parked near threshold satisfies neither: it
// flickers, so magHitStart keeps resetting and the re-zero never
// fires, while the flickers themselves keep pausing the tracking.
// diag11 sat in exactly that state. If the field has not agreed
// with the baseline (delta under half threshold) for MAG_STALE_MS,
// force a re-zero regardless of hit state.
const uint32_t MAG_STALE_MS = 25000;
static uint32_t magAgreeAt = 0;
if (magAgreeAt == 0 || recalibrate) magAgreeAt = millis();
if (magDelta < (threshMag / 2)) magAgreeAt = millis();
if (!fieldLock && (millis() - magAgreeAt > MAG_STALE_MS)) {
magBaselineSet(magX, magY, magZ);
magAgreeAt = millis();
magHitStart = 0;
magSettleStart = millis();
}
const uint32_t MAG_HOLD_MS = 6000;
if (magHitActive && !fieldLock) { // locked: no post-hit hard re-zero
if (magHitStart == 0) magHitStart = millis();
if (millis() - magHitStart > MAG_HOLD_MS) {
magBaselineSet(magX, magY, magZ); // hard re-zero
magHitStart = 0;
magSettleStart = millis(); // fast-settle after
}
} else {
magHitStart = 0;
}
// Normal slow baseline tracking while NOT in a hit (fast-settle 3s after boot/recal).
float a = (millis() - magSettleStart < 3000) ? 0.05f : 0.002f;
if (!magHitActive && !fieldLock) { // locked: mag baseline frozen too
baseMagXf += a * ((float)magX - baseMagXf);
baseMagYf += a * ((float)magY - baseMagYf);
baseMagZf += a * ((float)magZ - baseMagZf);
baseMagX = (int16_t)lroundf(baseMagXf);
baseMagY = (int16_t)lroundf(baseMagYf);
baseMagZ = (int16_t)lroundf(baseMagZf);
}
}
// Map the 0..100 sensitivity slider to an LSB trigger threshold. 100 = most
// sensitive (tiny delta fires); 0 = least. Exponential curve so the sensitive end
// is fine-grained: thr = 12 .. ~900 LSB across the slider.
{
float t = (100 - constrain(magSens, 0, 100)) / 100.0f; // 0 at max sens
threshMag = (int)(12.0f + t * t * 900.0f);
}
// Proximity as ABSOLUTE deviation from a rolling baseline -- absolute-deviation proximity behavior.
// A hand nearing the antenna changes its capacitance, moving touchRead() away from
// baseline in EITHER direction (rises or falls depending on core/board). We react to
// the magnitude of the change, so approach -- not just contact -- triggers.
proxDeltaRawG = abs(baseProx - proxVal);
proxDeltaG = (int)lroundf((float)proxDeltaRawG * proxGain); // calibration applied HERE, before effThreshProx compare below
int proxDelta = proxDeltaG; // local alias -- rest of this tick (span learning, theremin, alert, diag row) already reads `proxDelta`
// Use the guard-banded threshold: never fire below the measured rest
// jitter, so idle capacitance drift can't sputter false proximity alerts.
bool proxAlert = (proxDelta > effThreshProx);
bool magAlert = (magDelta > threshMag);
// DE-GLITCH: a real hand approach/touch takes many multiples of the
// ~70ms loop cadence to happen; a single-cycle instantaneous jump to a
// fixed ceiling value (RF/ESD coupling snapping the sense pin) does not.
// Require the raw crossing to sustain for proxArmMs before it can arm
// an alert. This does not affect the raw prox_delta/prox_alert values
// reported to the app or the diagnostic CSV -- only the arming gate below.
uint32_t nowMsRaw = millis();
if (proxAlert) {
if (proxRawSince == 0) proxRawSince = nowMsRaw;
} else {
proxRawSince = 0;
}
// A CONFIRMED saturation (rcSaturated) is already the most heavily
// vetted reading the sensor produces: 4+ consecutive majority-timeout
// calls, stall samples excluded, plus the ramp gate. Making it ALSO wait
// out the full de-glitch window meant a firm touch that saturated and
// released inside ~150 ms produced prox_alert=1 in telemetry and no
// alert at all -- exactly diag11's touch, which held for two 71 ms diag
// rows and never reached alertPhase 1. Give a confirmed saturation a
// shorter window; everything else keeps the full proxArmMs.
uint32_t armWindow = (uint32_t)proxArmMs;
if (rcSaturated && armWindow > PROX_SAT_ARM_MS) armWindow = PROX_SAT_ARM_MS;
bool proxSustained = proxAlert && proxRawSince != 0 && (nowMsRaw - proxRawSince >= armWindow);
// THEREMIN/SPAN-LEARN DE-GLITCH. diag12 (all three nodes "flickering blue"
// with zero alerts logged): rc_charge shows isolated single-TICK spikes to
// 60-180 units, flat baseline immediately before and after, RSSI stable --
// never crossing the alert threshold, so nothing faults and nothing is in
// the alert de-glitch's view. The theremin field and the span auto-fit
// below are both driven from raw proxDelta EVERY tick with nothing between
// the sensor and the LEDs (unlike the alert path, which already waits out
// proxArmMs). One glitched 10ms sample was punching straight through as a
// visible flash in ledCfg.proxColor (blue-cyan by default) -- and, worse,
// silently training proxSpan on the glitch (effDelta > proxSpan learns from
// ANY non-saturated reading, and these never saturate).
// A real approach ramps across many consecutive ticks; an RF/EMI coupling
// spike does not. Median-of-3 passes a genuine ramp with ~20ms of lag
// (imperceptible) and fully rejects an isolated one-tick spike (median of
// low,HIGH,low is low). Deliberately narrow: only the THEREMIN DISPLAY and
// SPAN LEARNING read this value. proxDelta itself, and everything derived
// from it above this point -- proxAlert, the alert de-glitch, diagnostics,
// telemetry -- is untouched, so alert behavior does not change at all.
static int proxDeltaHist[3] = {0, 0, 0};
static uint8_t proxDeltaHistPos = 0;
proxDeltaHist[proxDeltaHistPos] = proxDelta;
proxDeltaHistPos = (proxDeltaHistPos + 1) % 3;
int pdSorted[3] = { proxDeltaHist[0], proxDeltaHist[1], proxDeltaHist[2] };
for (int a = 0; a < 3; a++) for (int b = a + 1; b < 3; b++)
if (pdSorted[b] < pdSorted[a]) { int t = pdSorted[a]; pdSorted[a] = pdSorted[b]; pdSorted[b] = t; }
int proxDeltaField = pdSorted[1]; // median of the last 3 ticks
// THEREMIN FIELD: continuous 0..100 from proxDeltaField, scaled by proxSpan.
// proxNoise is a jitter deadband so an idle antenna truly rests at field 0
// (the raw touch value floats +/- a few counts even with no hand near it).
// effDelta is how far past the noise floor we are; only THAT drives the field.
int effDelta = proxDeltaField - proxNoise;
if (effDelta < 0) effDelta = 0;
// remember the strongest reach this session so REFIT can size the span to
// an ACTUAL hand pass rather than a guess. Never learn from a saturated
// read: its value is the manufactured surrogate (base + satDelta), not a
// measurement -- letting it in is how span self-fitted to false spikes
// in diag9 (span converged to exactly satDelta's cap).
if (effDelta > proxPeakSeen && !rcSaturated && !fieldLock) proxPeakSeen = effDelta;
// REFIT: size the span to the biggest hand delta we've just seen (min 12),
// so the closest your hand got == full field. Then reset the peak tracker.
if (proxRefitReq && fieldLock) proxRefitReq = false; // locked: ignore refit
if (proxRefitReq) {
proxSpan = (proxPeakSeen > 12) ? proxPeakSeen : 12;
proxPeakSeen = 0;
proxRefitReq = false;
prefs.begin("specter", false); prefs.putInt(kSpanKey(proxMode), proxSpan); prefs.end();
}
// Auto-nudge span up if a hand ever pushes past it (self-fitting reach).
// Surrogate (saturated) values are synthetic -- never fit span to them.
if (effDelta > proxSpan && !rcSaturated && !fieldLock) proxSpan = (int)(proxSpan + 0.25f * (effDelta - proxSpan)) + 1;
// Auto-SHRINK: remember the best pass over a rolling window and let span decay
// toward it when idle. Without this, span only ever grew, so a fresh node (or
// one whose saved span was wiped by a USB flash) stayed stuck wide and felt
// dead. Now it self-tightens to the actual antenna within a handful of waves.
{
static int spanBestPass = 0; // strongest effDelta this window
static uint32_t spanWinAt = 0; // window start
if (effDelta > spanBestPass) spanBestPass = effDelta;
uint32_t nowMs = millis();
if (spanWinAt == 0) spanWinAt = nowMs;
// every 20s, if we saw a decent pass, ease span down toward it (never below
// a floor so idle jitter can't collapse it). Only shrink, never below 40.
if (nowMs - spanWinAt > 20000) {
if (spanBestPass > 40 && spanBestPass < proxSpan && !fieldLock)
proxSpan = (int)(proxSpan + 0.5f * (spanBestPass - proxSpan)); // halve the gap toward the real pass
if (proxSpan < 40) proxSpan = 40;
spanBestPass = 0; spanWinAt = nowMs;
}
}
{
float ratio = (proxSpan > 0) ? ((float)effDelta / (float)proxSpan) : 0.0f;
if (ratio > 1.0f) ratio = 1.0f;
if (proxCurve == 1) ratio = sqrtf(ratio); // natural: spread response across the approach
float f = 100.0f * ratio;
static float fieldEma = 0.0f;
fieldEma = fieldEma * 0.65f + (f > 100 ? 100 : f) * 0.35f;
if (fieldEma < 0.4f) fieldEma = 0.0f; // snap to true rest so idle = 0
proxField = (int)(fieldEma + 0.5f);
}
// --- Walkie/RF cancel ---
// While capturing, track the PEAK deltas so a key-up defines the signature.
if (rfCapturing) {
if (magDelta > rfCapMag) rfCapMag = magDelta;
if (proxDelta > rfCapProx) rfCapProx = proxDelta;
}
// If suppression is armed and this spike matches the captured RF signature,
// treat it as interference: kill the alert this cycle (no buzzer/LED/event).
bool rfSuppress = false;
if (rfActive && (rfMagPeak > 0 || rfProxPeak > 0) && (proxAlert || magAlert)) {
bool mOk = (rfMagPeak <= 0) || (fabsf((float)(magDelta - rfMagPeak)) <= rfMagPeak * rfTolerance);
bool pOk = (rfProxPeak <= 0) || (fabsf((float)(proxDelta - rfProxPeak)) <= rfProxPeak * rfTolerance);
if (mOk && pOk) { rfSuppress = true; rfSuppressUntil = millis() + 400; }
}
if (rfSuppress) { ledIdle(); FastLED.setBrightness(ledCfg.bright); ledShow(); vTaskDelay(pdMS_TO_TICKS(10)); continue; }
// Freeze baseline during an active touch so it can't drift onto the touched level.
// Baseline: settle FAST for the first ~2s (so boot noise doesn't poison it),
// then hold SLOWLY so a lingering hand doesn't get absorbed into the baseline.
// STUCK-ALERT AUTO-RECALIBRATION (mirrors mag): if a prox hit stays active
// longer than PROX_HOLD_MS, snap the baseline to the current reading so a
// parked hand / permanent capacitance shift stops latching the alarm on.
{
static uint32_t proxHitStart = 0;
const uint32_t PROX_HOLD_MS = 6000;
if (proxAlert) {
if (proxHitStart == 0) proxHitStart = millis();
if (millis() - proxHitStart > PROX_HOLD_MS) {
if (proxMode == 1) {
rcQuickRebaseArmed = true; rcQuickRebaseCount = 0; // absorb held capacitance via robust burst, not one sample
proxAvg = 0;
} else {
proxAvg = (float)proxVal; // touch mode hard re-zero
}
proxHitStart = 0;
proxSettleStart = millis(); // fast-settle after
}
} else {
proxHitStart = 0;
}
}
if (proxMode == 1) {
// rcBase10 already tracks ambient capacitance; a second rolling baseline
// here would double-subtract and create an alert when a held hand leaves.
proxAvg = 0; baseProx = 0;
} else {
if (proxInit && !proxAlert) {
float a = (millis() - proxSettleStart < 2000) ? 0.05f : 0.001f;
proxAvg = proxAvg * (1.0f - a) + (float)proxVal * a;
}
baseProx = (int)proxAvg;
}
// ── ALERT STATE MACHINE: fire (max 2s) -> cooldown (6s, quiet + rebaseline) ──
// Raw trigger this cycle (may be gated below). Held off until the boot
// baseline is captured so startup noise never fires an alert.
// ============================================================
// DECOUPLED DETECTION: mag and prox are handled INDEPENDENTLY.
// MAG = always live. Whenever a mag disturbance is present it
// detects + drives the LEDs directionally + fires events,
// with NO cooldown. The mag channel never goes silent-dark
// just because prox recently alerted.
// PROX = keeps the alert -> cooldown cadence (the thing that was
// beeping constantly). alertPhase now governs PROX ONLY:
// alert for up to ALERT_MAX_MS, then COOLDOWN_MS of quiet
// (no prox beep/flash, no prox events), then re-baseline.
// LED priority: an active MAG hit always paints; otherwise an active
// PROX alert paints; otherwise theremin/idle below.
// ============================================================
uint32_t nowMs = millis();
// A commanded diag pulse (operator command) actuates the node; while
// diagQuietUntil is in the future we must NOT treat the resulting sensor
// wobble as a real detection, or the node sputters alerts on/off.
bool diagQuiet = (nowMs < diagQuietUntil);
bool magLive = bootBaselineDone && magAlert && !diagQuiet;
bool proxLive = bootBaselineDone && proxSustained && !diagQuiet;
// ---------- PROX cadence state machine (prox only) ----------
bool proxPainting = false; // did the prox cadence paint the ring this cycle?
if (alertPhase == 2) {
// PROX COOLDOWN: prox stays quiet (no prox beep, no prox event, no
// prox LEDs). Mag is unaffected and handled separately below.
if (nowMs - alertPhaseStart >= (uint32_t)proxCooldownMs) {
proxRecalibrate = true; // re-zero prox baseline to current ambient
alertPhase = 0;
alertPhaseStart = 0;
}
} else if (alertPhase == 1) {
// PROX ALERTING: beep + ring fade + event, up to ALERT_MAX_MS, then cooldown.
// HYSTERESIS: once we're alerting, keep alerting until the field drops
// BELOW a lower release level (75% of the trigger). Without this, a real
// touch whose delta hovers right at effThreshProx flickers proxLive off
// for a single cycle and cancels the alert the instant it began -- the
// "beeps once then stops" symptom. proxHeld stays true through the dips.
int proxReleaseThr = (effThreshProx * 3) / 4;
uint32_t alertAge = nowMs - alertPhaseStart;
// A crossing arms on one cycle and paints/beeps on the next. Hold the
// alert briefly even if the raw RC value dips, otherwise a 0..24 flicker
// can disappear before any audible or event output is produced. The
// minimum hold never exceeds the operator's configured alert duration.
int alertMsSnap = proxAlertMs;
uint32_t minHoldMs = (uint32_t)((alertMsSnap < proxMinHoldMs) ? alertMsSnap : proxMinHoldMs);
bool proxHeld = bootBaselineDone && !diagQuiet
&& (alertAge < minHoldMs || proxDelta > proxReleaseThr);
bool expired = (alertAge >= (uint32_t)proxAlertMs);
if (expired || !proxHeld) {
noTone(activeBuzzerPin());
alertPhase = 2;
alertPhaseStart = nowMs;
} else {
const char* sev = "low";
if (proxDelta > effThreshProx + 40) sev = "high";
else if (proxDelta > effThreshProx + 18) sev = "med";
// Rate-gate to the old effective cadence (one per ~100ms drain)
// instead of relying on the single-slot side effect. A mag event
// arriving in the same window now queues alongside instead of
// being lost.
static uint32_t lastProxEvtMs = 0;
if (nowMs - lastProxEvtMs >= 100) {
lastProxEvtMs = nowMs;
queueEvent("proximity", "prox", sev, proxDelta, effThreshProx);
heartbeatSuppressUntil = nowMs + HEARTBEAT_SUPPRESS_MS;
}
int pitch = constrain(map(proxDelta, effThreshProx, effThreshProx + 60, 400, 2000), 400, 2000);
buzz(pitch, 40);
if (ledCfg.proxAnim == 0) {
float approach = (float)(proxDelta - effThreshProx) / 40.0f;
approach = 0.15f + approach * 1.15f;
renderProxFade(ledCfg.proxColor, approach);
} else {
uint8_t sevScale = 150;
if (!strcmp(sev, "high")) sevScale = 255;
else if (!strcmp(sev, "med")) sevScale = 205;
renderEventLeds(ledCfg.proxColor, ledCfg.proxColorB, ledCfg.proxColorC, ledCfg.proxAnim, sevScale, nowMs);
}
proxPainting = true;
}
} else {
// PROX ARMED: a fresh prox trigger enters ALERTING on this same cycle.
if (proxLive) { alertPhase = 1; alertPhaseStart = nowMs; }
}
// ---------- MAG: ALWAYS-ON detection + LEDs (no cooldown) ----------
// Mag paints with priority over prox. Two separate concerns:
// (a) LED DIRECTION display: paints EVERY cycle while a field is present,
// so a held magnet continuously shows which side it's on (this is the
// core behavior -- do NOT gate it behind the refractory).
// (b) EVENT + buzzer: rate-limited by MAG_REFRACTORY_MS so a field parked
// near threshMag fires ONE clean event instead of sputtering on/off.
// v2.11.2 wrongly wrapped BOTH under the refractory, which made a held
// magnet flash the LEDs for one frame then go dark for 1.5s ("mag doesn't
// show at all"). Split back apart here.
bool magPainting = false;
bool magInRefractory = (nowMs < magRefractoryUntil);
if (magLive) {
const char* sev = "low";
if (magDelta > threshMag * 3) sev = "high";
else if (magDelta > threshMag * 2) sev = "med";
uint8_t sevScale = 150;
if (!strcmp(sev, "high")) sevScale = 255;
else if (!strcmp(sev, "med")) sevScale = 205;
// (a) DIRECTIONAL LEDS -- every cycle while the field is live.
if (ledCfg.magAnim == 0) {
float mstr = (float)(magDelta - threshMag) / (float)(threshMag * 2 + 1);
mstr = 0.35f + mstr; if (mstr > 1) mstr = 1; if (mstr < 0.35f) mstr = 0.35f;
renderMagDirectional(ledCfg.magColor, dX, dY, mstr);
} else {
renderEventLeds(ledCfg.magColor, ledCfg.magColorB, ledCfg.magColorC, ledCfg.magAnim, sevScale, nowMs);
}
magPainting = true;
// (b) EVENT + buzzer -- only once per refractory window.
if (!magInRefractory) {
magRefractoryUntil = nowMs + MAG_REFRACTORY_MS;
// MAG_REFRACTORY_MS already rate-limits this channel; the ring
// guarantees it is not lost when a prox event shares the window.
queueEvent("mag_anomaly", "mag", sev, magDelta, threshMag);
heartbeatSuppressUntil = nowMs + HEARTBEAT_SUPPRESS_MS;
if (magToneOn) { buzz(1600, 30); }
}
}
// THEREMIN OVERLAY: continuous prox glow + pitch below the alert threshold,
// so the node plays like a theremin. Runs only when NOTHING above painted
// (no mag hit, no prox alert) and prox isn't in its cooldown window.
// A driven trigger/pattern/diag pulse from the paired app (see the diagAnimActive
// block above) is its OWN paint source, same priority as a real mag/prox
// hit -- it must block the idle heartbeat exactly like magPainting/
// proxPainting do, or the heartbeat repaints over/right after the pulse's
// own frame every cycle (the "mag light obscured by heartbeat" bug).
bool diagPainting = diagAnimActive;
if (proxTheremin && bootBaselineDone && !magPainting && !proxPainting && !diagPainting && alertPhase == 0) {
float fld = proxField / 100.0f; // 0..1
if (fld > 0.02f) {
renderProxFade(ledCfg.proxColor, 0.08f + fld * 1.05f);
if (proxToneOn) {
int hz = 180 + (int)(fld * fld * 2020.0f);
buzz(hz, 30);
}
} else {
if (proxToneOn) noTone(activeBuzzerPin());
if (!magPainting && !proxPainting) renderHeartbeat(nowMs);
}
} else if (!magPainting && !proxPainting && !diagPainting && alertPhase != 1) {
// Nothing painting and not mid prox-alert: render optional heartbeat.
renderHeartbeat(nowMs);
}
if (diagLogArmed) {
static uint8_t diagLogDecim = 0;
if (nowMs >= diagLogUntil) {
diagLogArmed = false;
} else if (diagLogCount < DIAG_LOG_CAP && (++diagLogDecim % 5) == 0) { // hardwareTask runs ~100Hz; keep this ~20Hz
DiagLogRow &r = diagLogRows[diagLogCount++];
// Snapshot volatiles once: Arduino's min() is a macro that would
// otherwise read them twice and could latch two different samples.
int chSnap = rcCharge10, bsSnap = rcBase10;
if (chSnap > 32767) chSnap = 32767;
if (bsSnap > 32767) bsSnap = 32767;
r.ts = nowMs; r.rcCharge10 = (int16_t)chSnap; r.rcBase10 = (int16_t)bsSnap;
r.rcOk = rcValid ? 1 : 0; r.rcFault = (uint8_t)rcFault;
r.proxDelta = (int16_t)proxDelta; r.proxAlert = proxAlert ? 1 : 0;
r.satArmed = rcSaturated ? 1 : 0; r.magDelta = (int16_t)magDelta;
r.magAlert = magAlert ? 1 : 0; r.alertPhaseSnap = (uint8_t)alertPhase;
r.rssi = (int8_t)constrain((WiFi.status()==WL_CONNECTED)?WiFi.RSSI():0, -128, 127);
r.rcTimeoutUsRaw = (uint16_t)constrain(rcTimeoutUs, 0, 65535); // already whole microseconds, no rescale
}
}
FastLED.setBrightness(ledCfg.bright);
ledShow();
vTaskDelay(pdMS_TO_TICKS(10)); // 100 Hz
}
}
// ---- Core 0: network ----
WebServer server(80);
// (localWS/appWS declared earlier, above performUpdate, so OTA can tear them down)
AsyncUDP udp;
bool isAppConnected = false, appWSStarted = false;
static void recordStall(const char* label, uint32_t ms) {
if (ms < STALL_MIN_MS) return;
StallLogRow &r = stallLogRows[stallLogHead];
r.ts = millis(); strncpy(r.label, label, sizeof(r.label) - 1); r.label[sizeof(r.label) - 1] = 0;
r.ms = (uint16_t)constrain(ms, 0, 65535);
r.appWSStarted = appWSStarted ? 1 : 0; r.isAppConnected = isAppConnected ? 1 : 0;
stallLogHead = (stallLogHead + 1) % STALL_LOG_CAP;
if (stallLogCount < 65535) stallLogCount++;
}
uint32_t appStartAt = 0; // millis() when we last began an app WS connect (0 = idle)
uint32_t appLinkOkAt = 0; // millis() of last CONNECTED / inbound frame
#define APP_LINK_TIMEOUT_MS 15000 // started-but-never-connected -> tear down + rediscover
#define APP_LINK_RELINK_MS 30000 // connected-then-down-this-long -> reopen discovery (app moved / IP changed)
String targetSSID = "", targetPASS = "", targetAppIP = "", relayUrl = "";
uint32_t bootMs = 0, seqNum = 0, lastTelemetry = 0, lastNet = 0;
// A portal session owns the radio channel while it is active. This protects both
// SoftAP users and browsers that opened the portal through the station IP.
uint32_t portalLastActiveAt = 0;
#define PORTAL_RADIO_LEASE_MS 120000
// ---- RELAY ASSIST (ESP-NOW, opt-in, direct Wi-Fi remains primary) ---------
// ESP-NOW v1 payloads are limited to 250 bytes. The packed frame below is 244
// bytes and carries compact sensor payloads or a minified command. Receive and
// send callbacks only copy/update bounded state; all routing, JSON, WebSocket,
// NVS, and command work runs from loop().
#define RA_MAGIC 0x5352
#define RA_VERSION 1
#define RA_PAYLOAD_MAX 180
#define RA_MAX_HOPS 2
#define RA_ROUTE_STALE_MS 2800
#define RA_GATEWAY_BEACON_MS 500
#define RA_ROUTE_BEACON_MS 700
#define RA_HELLO_MS 10000
#define RA_RETRY_MS 140
#define RA_MAX_RETRIES 3
#define RA_FLAG_ACK_REQ 0x01
enum RelayType : uint8_t {
RA_GATEWAY = 1, RA_ROUTE = 2, RA_HELLO = 3, RA_TELEMETRY = 4,
RA_NET = 5, RA_EVENT = 6, RA_COMMAND = 7, RA_APP_ACK = 8,
RA_LINK_ACK = 9, RA_PONG = 10
};
struct __attribute__((packed)) RelayPacket {
uint16_t magic;
uint8_t version, type, flags, ttl, hops;
uint32_t groupId, seq;
uint64_t originId, finalId, senderId, nextHopId, viaId;
uint8_t payloadLen;
uint8_t payload[RA_PAYLOAD_MAX];
uint8_t auth[8];
};
static_assert(sizeof(RelayPacket) <= 250, "Relay Assist frame exceeds ESP-NOW v1 limit");
struct __attribute__((packed)) RaRoutePayload { uint64_t gatewayId; uint8_t depth, channel; };
struct __attribute__((packed)) RaHelloPayload {
char fw[20], name[32]; uint8_t slot, hz;
int16_t magSens, magThreshold, proxThreshold;
};
struct __attribute__((packed)) RaTelemetryPayload {
uint32_t ts, sampleSeq;
int32_t prox, proxBase, proxDelta, proxThreshold, proxThresholdSet;
int32_t proxNoiseFloor, proxField, proxSpan, proxNoise;
int32_t rcCharge, rcBase, magDelta, magThreshold;
int16_t magX, magY, magZ, magSens, touchPin;
uint8_t slot, heartbeatOn, theremin, tone, proxMode, rcOk, rcFault;
uint8_t magOk, magDataOk, magType, hz;
};
struct __attribute__((packed)) RaNetPayload {
uint32_t ts, uptime, heap; int16_t rssi;
uint8_t clients, channel, routeDepth, quality;
};
struct __attribute__((packed)) RaEventPayload {
uint32_t ts; int32_t value, threshold; uint8_t channel, severity;
};
struct __attribute__((packed)) RaAppAckPayload { int32_t id; uint8_t ok; };
struct __attribute__((packed)) RaPongPayload { uint32_t ts; };
struct __attribute__((packed)) RaLinkAckPayload { uint64_t originId; uint32_t seq; };
static_assert(sizeof(RaHelloPayload) <= RA_PAYLOAD_MAX, "Relay hello payload too large");
static_assert(sizeof(RaTelemetryPayload) <= RA_PAYLOAD_MAX, "Relay telemetry payload too large");
static_assert(sizeof(RaNetPayload) <= RA_PAYLOAD_MAX, "Relay net payload too large");
static_assert(sizeof(RaEventPayload) <= RA_PAYLOAD_MAX, "Relay event payload too large");
// Explicit declarations prevent the Arduino .ino preprocessor from emitting
// invalid prototypes before the packed RelayPacket types above.
static void raSign(RelayPacket& p);
static bool raVerify(const RelayPacket& p);
static RelayPacket raPacket(uint8_t type, uint64_t finalId, uint64_t nextHopId, bool critical);
static void raTrackCritical(const RelayPacket& packet);
static bool raTransmit(RelayPacket packet, bool track);
static void raAckLink(const RelayPacket& packet);
static bool raPop(RelayPacket& packet);
static void raEnvelopeBase(JsonDocument& doc, const RelayPacket& packet);
static void raForwardToApp(const RelayPacket& packet);
static void raAcceptRoute(const RelayPacket& packet, const RaRoutePayload& candidate);
static void raHandleCommandPacket(const RelayPacket& packet);
static void raProcessPacket(RelayPacket packet);
struct RaInboxItem { RelayPacket packet; };
static RaInboxItem raInbox[8];
static volatile uint8_t raInboxHead = 0, raInboxTail = 0;
static portMUX_TYPE raInboxMux = portMUX_INITIALIZER_UNLOCKED;
struct RaSeen { uint64_t originId; uint32_t seq, at; };
static RaSeen raSeen[24] = {};
static uint8_t raSeenPos = 0;
struct RaPending { bool active; RelayPacket packet; uint32_t nextAt; uint8_t attempts; };
static RaPending raPending[4] = {};
struct RaRouteState {
bool valid; uint64_t parentId, gatewayId; uint8_t depth, channel; uint32_t lastAt;
};
static RaRouteState raRoute = {};
static bool relayAssistEnabled = false, relayAssistStarted = false;
static char relayGroupKey[65] = {0};
static uint32_t relayGroupId = 0, relayTxSeq = 1;
static uint64_t relayLocalId = 0;
static uint32_t relayLastGateway = 0, relayLastRoute = 0, relayLastHello = 0;
static uint32_t relayLastScanStep = 0, relayScanRestUntil = 0;
static uint8_t relayScanChannel = 1, relayScanHomeChannel = 1;
#define RA_SCAN_STEP_MS 180
#define RA_SCAN_REST_MS 30000
static uint32_t relayAuthDrops = 0, relayDuplicateDrops = 0, relayMetricWindowAt = 0;
static volatile uint32_t relayRetryCount = 0;
static const uint8_t RA_BROADCAST_MAC[6] = {0xff,0xff,0xff,0xff,0xff,0xff};
void handleCommand(JsonDocument& doc, int src, uint8_t localNum);
static void raIdToString(uint64_t id, char* out, size_t n) {
snprintf(out, n, "SN-%04X%08X", (uint16_t)(id >> 32), (uint32_t)id);
}
static uint64_t raStringToId(const char* value) {
if (!value) return 0;
unsigned int hi = 0, lo = 0;
if (sscanf(value, "SN-%4X%8X", &hi, &lo) != 2) return 0;
return ((uint64_t)(hi & 0xffff) << 32) | (uint32_t)lo;
}
static uint8_t raQuality() {
uint32_t now = millis();
if (!relayMetricWindowAt) relayMetricWindowAt = now;
if (now - relayMetricWindowAt >= 30000) { relayMetricWindowAt = now; relayAuthDrops = 0; relayRetryCount = 0; }
uint32_t penalty = min((uint32_t)90, relayAuthDrops * 2 + relayRetryCount * 3);
return (uint8_t)(100 - penalty);
}
static void raDigest(const uint8_t* data, size_t len, uint8_t out[32]) {
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(info, (const unsigned char*)relayGroupKey, strlen(relayGroupKey), data, len, out);
}
static uint32_t raGroupFromKey(const char* key) {
if (!key || strlen(key) < 12) return 0;
uint8_t out[32];
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md(info, (const unsigned char*)key, strlen(key), out);
uint32_t id = 0; memcpy(&id, out, sizeof(id)); return id;
}
static void raSign(RelayPacket& p) {
memset(p.auth, 0, sizeof(p.auth));
uint8_t digest[32]; raDigest((const uint8_t*)&p, offsetof(RelayPacket, auth), digest);
memcpy(p.auth, digest, sizeof(p.auth));
}
static bool raVerify(const RelayPacket& p) {
if (p.magic != RA_MAGIC || p.version != RA_VERSION || p.groupId != relayGroupId || p.payloadLen > RA_PAYLOAD_MAX) return false;
RelayPacket copy = p; uint8_t wanted[8]; memcpy(wanted, p.auth, 8);
memset(copy.auth, 0, 8); uint8_t digest[32];
raDigest((const uint8_t*)©, offsetof(RelayPacket, auth), digest);
uint8_t diff = 0; for (int i=0;i<8;i++) diff |= wanted[i] ^ digest[i];
return diff == 0;
}
static bool raWasSeen(uint64_t originId, uint32_t seq) {
uint32_t now = millis();
for (const RaSeen& item : raSeen) if (item.originId == originId && item.seq == seq && now - item.at < 8000) return true;
raSeen[raSeenPos] = { originId, seq, now }; raSeenPos = (raSeenPos + 1) % 24; return false;
}
static RelayPacket raPacket(uint8_t type, uint64_t finalId, uint64_t nextHopId, bool critical) {
RelayPacket p = {};
p.magic = RA_MAGIC; p.version = RA_VERSION; p.type = type;
p.flags = critical ? RA_FLAG_ACK_REQ : 0; p.ttl = RA_MAX_HOPS; p.hops = 1;
p.groupId = relayGroupId; p.seq = relayTxSeq++;
p.originId = relayLocalId; p.finalId = finalId; p.senderId = relayLocalId; p.nextHopId = nextHopId;
return p;
}
static void raTrackCritical(const RelayPacket& packet) {
if (!(packet.flags & RA_FLAG_ACK_REQ)) return;
for (RaPending& pending : raPending) {
if (!pending.active) { pending.active = true; pending.packet = packet; pending.attempts = 1; pending.nextAt = millis() + RA_RETRY_MS; return; }
}
}
static bool raTransmit(RelayPacket packet, bool track = false) {
if (!relayAssistStarted || relayGroupId == 0) return false;
packet.senderId = relayLocalId; packet.groupId = relayGroupId; raSign(packet);
esp_err_t result = esp_now_send(RA_BROADCAST_MAC, (const uint8_t*)&packet, sizeof(packet));
if (result == ESP_OK && track) raTrackCritical(packet);
return result == ESP_OK;
}
static void raAckLink(const RelayPacket& packet) {
RelayPacket ack = raPacket(RA_LINK_ACK, packet.originId, 0, false);
RaLinkAckPayload payload = { packet.originId, packet.seq };
ack.payloadLen = sizeof(payload); memcpy(ack.payload, &payload, sizeof(payload)); raTransmit(ack);
}
static void raClearPending(uint64_t originId, uint32_t seq) {
for (RaPending& pending : raPending)
if (pending.active && pending.packet.originId == originId && pending.packet.seq == seq) pending.active = false;
}
static void raQueueCallback(const uint8_t*, const uint8_t* data, int len) {
if (len != (int)sizeof(RelayPacket)) return;
portENTER_CRITICAL(&raInboxMux);
uint8_t next = (raInboxHead + 1) % 8;
if (next != raInboxTail) { memcpy(&raInbox[raInboxHead].packet, data, sizeof(RelayPacket)); raInboxHead = next; }
portEXIT_CRITICAL(&raInboxMux);
}
static void raSendCallback(const uint8_t*, esp_now_send_status_t status) {
if (status != ESP_NOW_SEND_SUCCESS) relayRetryCount++;
}
static bool raPop(RelayPacket& packet) {
bool ok = false;
portENTER_CRITICAL(&raInboxMux);
if (raInboxTail != raInboxHead) { packet = raInbox[raInboxTail].packet; raInboxTail = (raInboxTail + 1) % 8; ok = true; }
portEXIT_CRITICAL(&raInboxMux); return ok;
}
static void relayAssistStop() {
if (relayAssistStarted) { esp_now_unregister_recv_cb(); esp_now_unregister_send_cb(); esp_now_deinit(); }
relayAssistStarted = false; raRoute = {}; for (RaPending& p : raPending) p.active = false;
}
static void relayAssistStart() {
relayAssistStop();
if (!relayAssistEnabled || strlen(relayGroupKey) < 12) return;
relayGroupId = raGroupFromKey(relayGroupKey); if (!relayGroupId) return;
if (esp_now_init() != ESP_OK) return;
esp_now_register_recv_cb(raQueueCallback); esp_now_register_send_cb(raSendCallback);
esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, RA_BROADCAST_MAC, 6);
peer.channel = 0; peer.encrypt = false; peer.ifidx = WIFI_IF_STA;
if (esp_now_add_peer(&peer) != ESP_OK) { esp_now_deinit(); return; }
relayAssistStarted = true;
}
static void relayAssistLoad() {
Preferences rp; rp.begin("relayassist", true);
relayAssistEnabled = rp.getBool("enabled", false);
String key = rp.getString("key", ""); rp.end();
key.toCharArray(relayGroupKey, sizeof(relayGroupKey)); relayGroupId = raGroupFromKey(relayGroupKey);
}
static bool relayAssistSave(bool enabled, const String& key) {
if (enabled && (key.length() < 12 || key.length() > 64)) return false;
Preferences rp; if (!rp.begin("relayassist", false)) return false;
rp.putBool("enabled", enabled); rp.putString("key", enabled ? key : String("")); rp.end();
relayAssistEnabled = enabled;
(enabled ? key : String("")).toCharArray(relayGroupKey, sizeof(relayGroupKey));
relayGroupId = raGroupFromKey(relayGroupKey); relayAssistStart(); return true;
}
static void raSendRouteAdvert(uint8_t type, uint64_t gatewayId, uint8_t depth, uint8_t channel) {
RelayPacket p = raPacket(type, 0, 0, false); RaRoutePayload payload = { gatewayId, depth, channel };
p.payloadLen = sizeof(payload); memcpy(p.payload, &payload, sizeof(payload)); raTransmit(p);
}
static void raSendHello() {
if (!raRoute.valid || isAppConnected) return;
RelayPacket p = raPacket(RA_HELLO, 0, raRoute.parentId, true); RaHelloPayload h = {};
strncpy(h.fw, FW_VERSION, sizeof(h.fw)-1); strncpy(h.name, nodeName, sizeof(h.name)-1);
h.slot = nodeSlot; h.hz = telemetryHz; h.magSens = magSens; h.magThreshold = threshMag; h.proxThreshold = threshProx;
p.payloadLen = sizeof(h); memcpy(p.payload, &h, sizeof(h)); raTransmit(p, true); relayLastHello = millis();
}
static void raSendTelemetry(uint32_t now) {
if (!raRoute.valid || isAppConnected) return;
RelayPacket p = raPacket(RA_TELEMETRY, 0, raRoute.parentId, false); RaTelemetryPayload t = {};
t.ts=now; t.sampleSeq=seqNum; t.prox=proxVal; t.proxBase=baseProx; t.proxDelta=proxDeltaG; // v2.16.0: use the calibrated global, not a raw recompute -- a relayed node's alerts must reflect its calibration too
t.proxThreshold=effThreshProx; t.proxThresholdSet=threshProx; t.proxNoiseFloor=proxNoiseFloor; t.proxField=proxField;
t.proxSpan=proxSpan; t.proxNoise=proxNoise; t.rcCharge=rcCharge10; t.rcBase=rcBase10; t.magDelta=magDelta; t.magThreshold=threshMag;
t.magX=magX; t.magY=magY; t.magZ=magZ; t.magSens=magSens; t.touchPin=touchPin; t.slot=nodeSlot;
t.heartbeatOn=heartbeatEnabled; t.theremin=proxTheremin; t.tone=proxToneOn; t.proxMode=proxMode; t.rcOk=rcValid; t.rcFault=rcFault;
t.magOk=magPresent; t.magDataOk=magDataValid; t.magType=magType; t.hz=telemetryHz;
p.payloadLen=sizeof(t); memcpy(p.payload,&t,sizeof(t)); raTransmit(p);
}
static void raSendNet(uint32_t now, uint8_t clients) {
if (!raRoute.valid || isAppConnected) return;
RelayPacket p=raPacket(RA_NET,0,raRoute.parentId,false); RaNetPayload n={};
n.ts=now; n.uptime=(now-bootMs)/1000; n.heap=ESP.getFreeHeap(); n.rssi=WiFi.status()==WL_CONNECTED?WiFi.RSSI():0;
n.clients=clients; n.channel=WiFi.channel(); n.routeDepth=raRoute.depth; n.quality=raQuality();
p.payloadLen=sizeof(n); memcpy(p.payload,&n,sizeof(n)); raTransmit(p);
}
static uint8_t raSeverityCode(const char* value) { return !strcmp(value,"high")?2:!strcmp(value,"med")?1:0; }
static const char* raSeverityName(uint8_t value) { return value>=2?"high":value==1?"med":"low"; }
static void raSendEvent(uint32_t now) {
if (!raRoute.valid || isAppConnected) return;
RelayPacket p=raPacket(RA_EVENT,0,raRoute.parentId,true); RaEventPayload e={};
e.ts=now; e.value=evtValue; e.threshold=evtThreshold; e.channel=!strcmp(evtChannel,"mag")?1:0; e.severity=raSeverityCode(evtSeverity);
p.payloadLen=sizeof(e); memcpy(p.payload,&e,sizeof(e)); raTransmit(p,true);
}
static void raSendAppAck(long id, bool ok) {
if (!raRoute.valid) return;
RelayPacket p=raPacket(RA_APP_ACK,0,raRoute.parentId,true); RaAppAckPayload a={(int32_t)id,(uint8_t)(ok?1:0)};
p.payloadLen=sizeof(a); memcpy(p.payload,&a,sizeof(a)); raTransmit(p,true);
}
static void raSendPong() {
if (!raRoute.valid) return;
RelayPacket p=raPacket(RA_PONG,0,raRoute.parentId,false); RaPongPayload a={millis()};
p.payloadLen=sizeof(a); memcpy(p.payload,&a,sizeof(a)); raTransmit(p);
}
static void raEnvelopeBase(JsonDocument& doc, const RelayPacket& packet) {
char origin[24], gateway[24], via[24]; raIdToString(packet.originId,origin,sizeof(origin)); raIdToString(relayLocalId,gateway,sizeof(gateway));
doc["t"]="relay"; doc["v"]=1; doc["originNodeId"]=origin; doc["relaySeq"]=packet.seq; doc["hopCount"]=packet.hops;
JsonArray path=doc["routePath"].to<JsonArray>(); path.add(origin);
if (packet.hops>1 && packet.viaId && packet.viaId!=packet.originId) { raIdToString(packet.viaId,via,sizeof(via)); path.add(via); }
path.add(gateway);
JsonObject link=doc["link"].to<JsonObject>(); link["channel"]=WiFi.channel(); link["quality"]=raQuality();
}
static void raForwardToApp(const RelayPacket& packet) {
if (!isAppConnected) return;
JsonDocument doc; raEnvelopeBase(doc,packet); JsonObject payload=doc["payload"].to<JsonObject>();
char origin[24]; raIdToString(packet.originId,origin,sizeof(origin));
if (packet.type==RA_HELLO && packet.payloadLen==sizeof(RaHelloPayload)) {
RaHelloPayload h; memcpy(&h,packet.payload,sizeof(h)); payload["t"]="hello"; payload["device"]="specter-node";
payload["deviceId"]=origin; payload["nodeId"]=origin; payload["slot"]=h.slot; payload["fw"]=h.fw; payload["name"]=h.name; payload["hz"]=h.hz; payload["mode"]="relayed";
JsonArray caps=payload["caps"].to<JsonArray>(); caps.add("prox"); caps.add("mag"); caps.add("net"); caps.add("relay");
JsonObject th=payload["thresholds"].to<JsonObject>(); th["magSens"]=h.magSens; th["mag"]=h.magThreshold; th["prox"]=h.proxThreshold;
} else if (packet.type==RA_TELEMETRY && packet.payloadLen==sizeof(RaTelemetryPayload)) {
RaTelemetryPayload t; memcpy(&t,packet.payload,sizeof(t)); payload["t"]="telemetry"; payload["ts"]=t.ts; payload["seq"]=t.sampleSeq; payload["nodeId"]=origin; payload["slot"]=t.slot;
payload["heartbeatOn"]=(bool)t.heartbeatOn; payload["prox"]=t.prox; payload["proxBase"]=t.proxBase; payload["proxDelta"]=t.proxDelta; payload["proxThr"]=t.proxThreshold; payload["proxThrSet"]=t.proxThresholdSet; payload["proxNoiseFloor"]=t.proxNoiseFloor; payload["proxField"]=t.proxField; payload["theremin"]=(bool)t.theremin; payload["thermTone"]=(bool)t.tone; payload["proxSpan"]=t.proxSpan; payload["proxNoise"]=t.proxNoise; payload["proxMode"]=t.proxMode; payload["rcCharge"]=t.rcCharge; payload["rcBase"]=t.rcBase; payload["rcOk"]=(bool)t.rcOk; payload["rcFault"]=t.rcFault; payload["rcDrivePin"]=activeRcDrivePin(); payload["rcSensePin"]=RC_SENSE_PIN; payload["rcBuzzerPinsSwapped"]=rcBuzzerPinsSwapped;
JsonObject mag=payload["mag"].to<JsonObject>(); mag["x"]=t.magX; mag["y"]=t.magY; mag["z"]=t.magZ; payload["magDelta"]=t.magDelta; payload["magThr"]=t.magThreshold; payload["magSens"]=t.magSens; payload["magOk"]=(bool)t.magOk; payload["magDataOk"]=(bool)t.magDataOk; payload["magChip"]=t.magType==3?"HMC5883L":t.magType==2?"QMC5883P":t.magType==1?"QMC5883L":"none"; payload["hz"]=t.hz;
} else if (packet.type==RA_NET && packet.payloadLen==sizeof(RaNetPayload)) {
RaNetPayload n; memcpy(&n,packet.payload,sizeof(n)); payload["t"]="net"; payload["ts"]=n.ts; payload["rssi"]=n.rssi; payload["uptime"]=n.uptime; payload["clients"]=n.clients; payload["heap"]=n.heap; payload["ssid"]="RELAY ASSIST"; payload["magOk"]=true;
} else if (packet.type==RA_EVENT && packet.payloadLen==sizeof(RaEventPayload)) {
RaEventPayload e; memcpy(&e,packet.payload,sizeof(e)); payload["t"]="event"; payload["ts"]=e.ts; payload["kind"]=e.channel?"mag_anomaly":"proximity"; payload["channel"]=e.channel?"mag":"prox"; payload["value"]=e.value; payload["threshold"]=e.threshold; payload["severity"]=raSeverityName(e.severity);
} else if (packet.type==RA_APP_ACK && packet.payloadLen==sizeof(RaAppAckPayload)) {
RaAppAckPayload a; memcpy(&a,packet.payload,sizeof(a)); payload["t"]="ack"; payload["id"]=a.id; payload["ok"]=(bool)a.ok;
} else if (packet.type==RA_PONG && packet.payloadLen==sizeof(RaPongPayload)) {
RaPongPayload a; memcpy(&a,packet.payload,sizeof(a)); payload["t"]="pong"; payload["ts"]=a.ts;
} else return;
String out; serializeJson(doc,out); appWS.sendTXT(out);
}
static void raAcceptRoute(const RelayPacket& packet, const RaRoutePayload& candidate) {
if (isAppConnected || candidate.depth >= RA_MAX_HOPS || packet.senderId == relayLocalId) return;
uint8_t depth = candidate.depth + 1; uint32_t now=millis();
bool expired=!raRoute.valid || now-raRoute.lastAt>RA_ROUTE_STALE_MS;
bool better=expired || depth<raRoute.depth || (depth==raRoute.depth && candidate.gatewayId<raRoute.gatewayId);
bool same=raRoute.valid && raRoute.parentId==packet.senderId && raRoute.gatewayId==candidate.gatewayId;
if (better || same) {
bool changed=!raRoute.valid || raRoute.parentId!=packet.senderId || raRoute.gatewayId!=candidate.gatewayId || raRoute.depth!=depth;
raRoute={true,packet.senderId,candidate.gatewayId,depth,candidate.channel,now};
if (changed) { relayLastHello=0; raSendHello(); }
}
}
static void raHandleCommandPacket(const RelayPacket& packet) {
if (packet.finalId != relayLocalId || packet.payloadLen==0 || packet.payloadLen>RA_PAYLOAD_MAX) return;
JsonDocument doc; if (deserializeJson(doc,packet.payload,packet.payloadLen)) return;
handleCommand(doc,2,0);
}
static void raProcessPacket(RelayPacket packet) {
if (!raVerify(packet) || packet.originId==relayLocalId) { if (packet.originId!=relayLocalId) relayAuthDrops++; return; }
if (packet.type==RA_LINK_ACK && packet.payloadLen==sizeof(RaLinkAckPayload)) {
RaLinkAckPayload ack; memcpy(&ack,packet.payload,sizeof(ack)); raClearPending(ack.originId,ack.seq); return;
}
bool addressed=(packet.nextHopId==0 || packet.nextHopId==relayLocalId || packet.finalId==relayLocalId);
if (!addressed) return;
if (packet.flags&RA_FLAG_ACK_REQ) raAckLink(packet); // ack retries even if duplicate
if (raWasSeen(packet.originId,packet.seq)) { relayDuplicateDrops++; return; }
if ((packet.type==RA_GATEWAY || packet.type==RA_ROUTE) && packet.payloadLen==sizeof(RaRoutePayload)) {
RaRoutePayload route; memcpy(&route,packet.payload,sizeof(route)); raAcceptRoute(packet,route); return;
}
if (packet.type==RA_COMMAND) {
if (packet.finalId==relayLocalId) { raHandleCommandPacket(packet); return; }
if (packet.ttl>0) { packet.ttl--; packet.hops++; packet.senderId=relayLocalId; packet.nextHopId=0; if (!packet.viaId) packet.viaId=relayLocalId; raTransmit(packet,true); }
return;
}
if (packet.finalId==0 && packet.nextHopId==relayLocalId) {
if (isAppConnected) raForwardToApp(packet);
else if (raRoute.valid && packet.ttl>0) {
packet.ttl--; packet.hops++; packet.senderId=relayLocalId; packet.nextHopId=raRoute.parentId; packet.viaId=relayLocalId; raTransmit(packet,(packet.flags&RA_FLAG_ACK_REQ)!=0);
}
}
}
static bool raGatewaySendCommand(JsonDocument& envelope) {
if (!relayAssistStarted || !isAppConnected) return false;
uint64_t target=raStringToId(envelope["targetNodeId"] | ""); if (!target) return false;
String command; serializeJson(envelope["payload"],command); if (!command.length() || command.length()>RA_PAYLOAD_MAX) return false;
RelayPacket p=raPacket(RA_COMMAND,target,0,true); p.payloadLen=command.length(); memcpy(p.payload,command.c_str(),p.payloadLen); return raTransmit(p,true);
}
static bool portalRadioProtected(uint32_t now) {
if (WiFi.softAPgetStationNum() > 0) return true;
if (localWS.connectedClients(false) > 0) return true;
return portalLastActiveAt != 0 && now - portalLastActiveAt < PORTAL_RADIO_LEASE_MS;
}
static void relayAssistLoop() {
if (!relayAssistStarted) return;
uint32_t now=millis(); RelayPacket packet; int budget=8; while (budget-- && raPop(packet)) raProcessPacket(packet);
for (RaPending& pending : raPending) if (pending.active && (int32_t)(now-pending.nextAt)>=0) {
if (pending.attempts>=RA_MAX_RETRIES) { pending.active=false; relayRetryCount++; }
else { pending.attempts++; pending.nextAt=now+RA_RETRY_MS*pending.attempts; RelayPacket retry=pending.packet; raTransmit(retry,false); }
}
if (isAppConnected) {
raRoute={};
if (now-relayLastGateway>=RA_GATEWAY_BEACON_MS) { relayLastGateway=now; raSendRouteAdvert(RA_GATEWAY,relayLocalId,0,WiFi.channel()); }
} else {
if (raRoute.valid && now-raRoute.lastAt>RA_ROUTE_STALE_MS) { raRoute={}; relayLastHello=0; }
if (raRoute.valid && raRoute.depth<RA_MAX_HOPS && now-relayLastRoute>=RA_ROUTE_BEACON_MS) { relayLastRoute=now; raSendRouteAdvert(RA_ROUTE,raRoute.gatewayId,raRoute.depth,raRoute.channel); }
if (raRoute.valid && now-relayLastHello>=RA_HELLO_MS) raSendHello();
// Nodes that cannot associate with infrastructure Wi-Fi scan for a relay
// only while the local portal is genuinely idle. A browser on either the
// SoftAP or station IP owns a two-minute channel lease. Each unattended
// 1..13 scan is followed by a 30-second return to the portal home channel;
// never hop forever, which leaves clients associated but unable to reach HTTP.
bool portalProtected = portalRadioProtected(now);
if (portalProtected && WiFi.status()!=WL_CONNECTED && WiFi.channel()!=relayScanHomeChannel) {
esp_wifi_set_channel(relayScanHomeChannel, WIFI_SECOND_CHAN_NONE);
relayScanChannel=1; relayScanRestUntil=now+RA_SCAN_REST_MS;
}
if (!raRoute.valid && WiFi.status()!=WL_CONNECTED && !portalProtected
&& now-bootMs>20000 && (int32_t)(now-relayScanRestUntil)>=0
&& now-relayLastScanStep>=RA_SCAN_STEP_MS) {
relayLastScanStep=now;
if (relayScanChannel > 13) {
esp_wifi_set_channel(relayScanHomeChannel, WIFI_SECOND_CHAN_NONE);
relayScanChannel=1; relayScanRestUntil=now+RA_SCAN_REST_MS;
} else {
if (relayScanChannel==1) {
uint8_t home=WiFi.channel();
if (home>=1 && home<=13) relayScanHomeChannel=home;
}
esp_wifi_set_channel(relayScanChannel, WIFI_SECOND_CHAN_NONE);
relayScanChannel++;
}
}
}
}
void sendHello() {
JsonDocument doc;
doc["t"] = "hello"; doc["device"] = DEVICE_TYPE; doc["deviceId"] = stableNodeId; doc["nodeId"] = stableNodeId; doc["slot"] = nodeSlot; doc["fw"] = FW_VERSION;
doc["name"] = nodeName;
JsonArray caps = doc["caps"].to<JsonArray>();
caps.add("prox"); caps.add("mag"); caps.add("net"); caps.add("relay");
doc["hz"] = telemetryHz; doc["mode"] = "linked"; doc["lock"] = fieldLock; doc["audible"] = audibleAlerts;
JsonObject relay = doc["relayAssist"].to<JsonObject>();
relay["enabled"] = relayAssistEnabled; relay["groupId"] = relayGroupId;
relay["role"] = isAppConnected ? "gateway" : (raRoute.valid ? "relayed" : "idle"); relay["depth"] = raRoute.depth;
JsonObject th = doc["thresholds"].to<JsonObject>();
th["magSens"] = magSens; th["mag"] = threshMag; th["prox"] = threshProx;
String out; serializeJson(doc, out); appWS.sendTXT(out);
}
void persistThresholds() {
prefs.begin("specter", false);
prefs.putInt("magSens", magSens); prefs.putInt(kThreshKey(proxMode), threshProx);
prefs.end();
}
// src: 0 = app WS (node->app link), 1 = local portal WS (browser).
// ackNum only meaningful for src==1 (the local server needs the client num).
void sendAck(int src, long id, bool ok, uint8_t localNum) {
if (id < 0) return; // only _sendAcked frames carry an id
JsonDocument a; a["t"] = "ack"; a["id"] = id; a["ok"] = ok;
String out; serializeJson(a, out);
if (src == 0) { if (isAppConnected) appWS.sendTXT(out); }
else if (src == 1) { localWS.sendTXT(localNum, out); }
else if (src == 2) { raSendAppAck(id, ok); }
}
void handleCommand(JsonDocument& doc, int src = 0, uint8_t localNum = 0) {
if (doc["t"] != "cmd") return;
const char* cmd = doc["cmd"] | "";
// Every output command (trigger/pattern/inject-as-diag) carries an `id`
// that the app is waiting to see echoed in an ack within 500ms; without it the
// app assumes the hardware didn't fire and drops to the synthetic fallback.
long ackId = doc["id"] | -1;
if (!strcmp(cmd, "ping")) {
if (src == 2) raSendPong();
else { JsonDocument p; p["t"] = "pong"; p["ts"] = millis(); String out; serializeJson(p, out); if (isAppConnected) appWS.sendTXT(out); }
} else if (!strcmp(cmd, "setRelayAssist")) {
bool enabled = doc["enabled"] | false; String key = doc["key"] | "";
// A direct app command can safely change the key. A relayed command is
// refused before any NVS/radio mutation because changing the active key
// mid-route would cut off its own acknowledgement and strand the Node.
bool ok = (src != 2) ? relayAssistSave(enabled, key) : false;
sendAck(src, ackId, ok, localNum);
} else if (!strcmp(cmd, "setThreshold")) {
const char* ch = doc["channel"] | ""; int val = doc["value"] | 0;
if (!strcmp(ch, "mag")) magSens = constrain(val, 0, 100); // mag slider is 0..100 sensitivity
if (!strcmp(ch, "prox")) {
threshProx = val;
effThreshProx = recomputeEffThreshProx(); // re-apply the guard band around the operator's new value
}
persistThresholds();
} else if (!strcmp(cmd, "setAudible")) {
// App-driven master mute. Applies immediately and silences any tone
// already sounding, so a button press during a take is instant.
if (doc["on"].is<bool>()) {
audibleAlerts = (bool)doc["on"];
if (!audibleAlerts) buzzStop();
prefs.begin("specter", false); prefs.putInt("audible", audibleAlerts ? 1 : 0); prefs.end();
}
sendAck(src, ackId, true, localNum);
} else if (!strcmp(cmd, "setFieldLock")) {
// SPECTER locks/unlocks all nodes when a session starts/ends.
if (doc["on"].is<bool>()) {
fieldLock = (bool)doc["on"];
prefs.begin("specter", false); prefs.putInt("fieldLock", fieldLock ? 1 : 0); prefs.end();
}
sendAck(src, ackId, true, localNum);
} else if (!strcmp(cmd, "setTheremin")) {
// Only change fields explicitly present in the command. Portal controls
// such as Sense source, noise, and refit must never silently turn the
// Theremin response off just because their frame omitted `on`.
if (doc["on"].is<bool>()) proxTheremin = (bool)doc["on"];
if (doc["tone"].is<bool>()) proxToneOn = (bool)doc["tone"];
// REFIT: the portal sends refit:true. Arm the hardware loop to size the
// span from the strongest hand delta it just measured (NOT a fixed 20).
if (doc["refit"] | false) { proxRefitReq = true; proxPeakSeen = 0; }
if (doc["noise"].is<int>()) {
proxNoise = constrain((int)doc["noise"], 0, 200);
prefs.begin("specter", false); prefs.putInt(kNoiseKey(proxMode), proxNoise); prefs.end();
}
if (doc["curve"].is<int>()) {
proxCurve = constrain((int)doc["curve"], 0, 1);
prefs.begin("specter", false); prefs.putInt("proxCurve", proxCurve); prefs.end();
}
if (doc["rcMarginX"].is<int>() || doc["rcMarginFloorUs"].is<int>()) {
if (doc["rcMarginX"].is<int>()) rcMarginMult = constrain((int)doc["rcMarginX"], 2, 40);
if (doc["rcMarginFloorUs"].is<int>()) rcMarginFloorUs = constrain((int)doc["rcMarginFloorUs"], 5, 2000);
prefs.begin("specter", false);
prefs.putInt("rcMarginX", rcMarginMult); prefs.putInt("rcMarginFloorUs", rcMarginFloorUs);
prefs.end();
if (proxMode == 1) rcSmartCalReq = true; // re-learn the timeout window against the new margin immediately
}
if (doc["alertMs"].is<int>() || doc["cooldownMs"].is<int>() || doc["armMs"].is<int>()) {
if (doc["alertMs"].is<int>()) proxAlertMs = constrain((int)doc["alertMs"], 200, 30000);
if (doc["cooldownMs"].is<int>()) proxCooldownMs = constrain((int)doc["cooldownMs"], 0, 30000);
if (doc["armMs"].is<int>()) proxArmMs = constrain((int)doc["armMs"], 0, 2000);
prefs.begin("specter", false);
prefs.putInt("alertMs", proxAlertMs); prefs.putInt("cooldownMs", proxCooldownMs); prefs.putInt("armMs", proxArmMs);
prefs.end();
}
// Direct span set still allowed (advanced), but no longer used by REFIT.
if (doc["span"].is<int>()) proxSpan = constrain((int)doc["span"], 8, 4000);
if (doc["pinSwap"].is<bool>()) {
rcBuzzerPinsSwapped = (bool)doc["pinSwap"];
Preferences hwPrefs; hwPrefs.begin("nodehw", false); hwPrefs.putBool("pinSwap", rcBuzzerPinsSwapped); hwPrefs.end();
pinMode(activeBuzzerPin(), OUTPUT); noTone(activeBuzzerPin());
if (proxMode == 1) { digitalWrite(activeRcDrivePin(), LOW); pinMode(activeRcDrivePin(), OUTPUT); rcSmartCalReq = true; }
}
// Prox sense source: 0 = touchRead on selected pin, 1 = GPIO18/1M/GPIO4 RC charge-time sensor.
if (doc["proxMode"].is<int>()) {
int pm = constrain((int)doc["proxMode"], 0, 1);
if (pm != proxMode) {
// Basic touch mode needs GPIO18 high-impedance; leaving it LOW would
// bias GPIO4 through the external 1M resistor on a wired Road B node.
if (pm == 0) { digitalWrite(activeRcDrivePin(), LOW); pinMode(activeRcDrivePin(), INPUT); }
saveProxTuningFor(proxMode); // keep the mode we're leaving
proxMode = pm; rcReady = false; rcBase10 = 0; rcValid = false; proxInit = false; recalibrate = true;
loadProxTuningFor(proxMode); // adopt the mode we're entering (different unit scale)
effThreshProx = recomputeEffThreshProx();
if (pm == 1) rcSmartCalReq = true;
}
prefs.begin("specter", false); prefs.putInt("proxMode", proxMode); prefs.end();
}
prefs.begin("specter", false);
prefs.putInt("therm", proxTheremin ? 1 : 0);
prefs.putInt("thermTone", proxToneOn ? 1 : 0);
prefs.putInt(kSpanKey(proxMode), proxSpan);
prefs.end();
if (!proxTheremin) noTone(activeBuzzerPin());
} else if (!strcmp(cmd, "setRate")) {
telemetryHz = constrain((int)(doc["hz"] | TELEMETRY_HZ_DEFAULT), 1, 30);
prefs.begin("specter", false); prefs.putInt("hz", telemetryHz); prefs.end();
} else if (!strcmp(cmd, "rebaseline")) {
recalibrate = true;
if (proxMode == 1) rcSmartCalReq = true;
sendAck(src, ackId, true, localNum);
} else if (!strcmp(cmd, "setgain")) {
// Per-node calibration (v2.16.0): normalizes a hand-wired antenna/mag's
// raw response so a shared (LINKed) app-side threshold behaves the same
// across differently-built nodes. Applied to proxDelta/magDelta BEFORE
// any threshold comparison, span learning, or telemetry -- see the
// gain-apply sites in hardwareTask() above.
bool changed = false;
if (!doc["proxGain"].isNull()) {
float g = doc["proxGain"] | 1.0f;
if (g > 0.05f && g < 20.0f) { proxGain = g; changed = true; }
}
if (!doc["magGain"].isNull()) {
float g = doc["magGain"] | 1.0f;
if (g > 0.05f && g < 20.0f) { magGain = g; changed = true; }
}
if (changed) {
Preferences calPrefs;
calPrefs.begin("nodecal", false);
calPrefs.putFloat("proxGain", proxGain);
calPrefs.putFloat("magGain", magGain);
calPrefs.end();
}
sendAck(src, ackId, changed, localNum);
} else if (!strcmp(cmd, "resetGain")) {
proxGain = 1.0f; magGain = 1.0f;
Preferences calPrefs;
calPrefs.begin("nodecal", false);
calPrefs.putFloat("proxGain", 1.0f);
calPrefs.putFloat("magGain", 1.0f);
calPrefs.end();
sendAck(src, ackId, true, localNum);
} else if (!strcmp(cmd, "identify")) {
for (int k = 0; k < 3; k++) {
fill_solid(leds, LED_MAX, CRGB::White); ledShow(); delay(120);
fill_solid(leds, LED_MAX, CRGB::Black); ledShow(); delay(120);
}
} else if (!strcmp(cmd, "diag")) {
// Field diagnostics: actuator self-test. Confirms the buzzer + LED ring
// are driven correctly by pulsing them for the requested window. The
// hardware task performs the pulse so it shares the same render/tone
// path as a live reading (accurate end-to-end check).
const char* mode = doc["mode"] | "pulse";
diagPulseChan = (!strcmp(doc["ch"] | "prox", "mag")) ? 1 : 0;
diagPulseInt = constrain((float)(doc["level"] | 1.0), 0.0f, 1.0f);
diagPulseBrg = (int)(doc["brg"] | 0);
diagPulseMaxBrightness = (bool)(doc["maxBrightness"] | false);
if (!strcmp(mode, "stop")) { diagStopReq = 1; diagPulseReq = 0; }
else { diagPulseMs = constrain((uint32_t)(doc["ms"] | 600), (uint32_t)50, (uint32_t)8000); diagStopReq = 0; diagPulseReq = 1; }
sendAck(src, ackId, true, localNum);
} else if (!strcmp(cmd, "trigger")) {
// Remote fire from the operator (PROTOCOL: hold=true energizes until a
// matching hold=false releases it). We implement hold as a long pulse that
// a hold=false frame stops early, so the "hold button" and momentary blips
// both work. intensity maps to drive level: prox ring for a strong hit,
// mag lobe for a soft one.
bool holdKey = doc["hold"].is<bool>() ? (bool)doc["hold"] : true;
if (!holdKey) {
// Release: stop any active hold/pulse now.
diagStopReq = 1; diagPulseReq = 0;
} else {
float inten = constrain((float)(doc["intensity"] | 1.0), 0.05f, 1.0f);
const char* triggerCh = doc["ch"] | "";
diagPulseChan = !strcmp(triggerCh, "mag") ? 1 : (!strcmp(triggerCh, "prox") ? 0 : ((inten >= 0.66f) ? 0 : 1));
diagPulseInt = inten;
diagPulseBrg = (int)(doc["bearingDeg"] | 0);
// Long hold window (30s safety cap) -- the app releases it with hold:false,
// or it self-ends so a dropped release frame can never latch outputs on.
diagPulseMs = 30000;
diagStopReq = 0; diagPulseReq = 1;
}
sendAck(src, ackId, true, localNum);
} else if (!strcmp(cmd, "pattern")) {
// Timed animated sequence. We run it as a long diag pulse on the prox ring;
// the hardware task's renderEventLeds honors the configured proxAnim, so a
// themed pattern actually animates for the requested duration.
float inten = constrain((float)(doc["intensity"] | 1.0), 0.05f, 1.0f);
uint32_t dur = constrain((uint32_t)(doc["durationMs"] | 7000), (uint32_t)200, (uint32_t)20000);
diagPulseChan = 0;
diagPulseInt = inten;
diagPulseBrg = 0;
diagPulseMs = dur;
diagStopReq = 0; diagPulseReq = 1;
sendAck(src, ackId, true, localNum);
} else {
// Unknown command: still ack (ok:false) so the app resolves fast instead of
// waiting out the 500ms timeout.
if (ackId >= 0) sendAck(src, ackId, false, localNum);
}
}
void appWsEvent(WStype_t type, uint8_t* payload, size_t length) {
if (type == WStype_CONNECTED) { isAppConnected = true; appLinkOkAt = millis(); sendHello(); }
else if (type == WStype_DISCONNECTED) {
// Only mark the link down. Do NOT clear appWSStarted/appStartAt here:
// WebSocketsClient fires transient DISCONNECTED events BETWEEN its own
// auto-reconnect attempts (setReconnectInterval), and on a higher-latency
// link (e.g. a phone hotspot) the handshake needs several seconds. If we
// released appWSStarted on each blip, the next 2s discovery beacon would
// call connectToApp() -> appWS.begin() again and re-init the client
// mid-handshake, throwing away the attempt that was about to succeed --
// an endless start/almost-connect/restart churn that never links.
// Let the library's own reconnect finish the job; the APP_LINK watchdog
// in loop() is the ONE place that deliberately tears down + reopens
// discovery, and only after APP_LINK_TIMEOUT_MS with no CONNECTED.
isAppConnected = false;
}
else if (type == WStype_TEXT) {
appLinkOkAt = millis();
JsonDocument doc;
if (!deserializeJson(doc, payload, length)) {
if (doc["t"] == "relayCmd") raGatewaySendCommand(doc);
else handleCommand(doc, 0, 0);
}
}
}
void sendHelloTo(uint8_t num) {
// Same identity/thresholds the app gets, so the standalone portal can prefill.
JsonDocument doc;
doc["t"] = "hello"; doc["device"] = DEVICE_TYPE; doc["deviceId"] = stableNodeId; doc["nodeId"] = stableNodeId; doc["slot"] = nodeSlot; doc["fw"] = FW_VERSION;
doc["name"] = nodeName;
JsonArray caps = doc["caps"].to<JsonArray>();
caps.add("prox"); caps.add("mag"); caps.add("net"); caps.add("relay");
doc["hz"] = telemetryHz; doc["mode"] = "standalone"; doc["lock"] = fieldLock; doc["audible"] = audibleAlerts;
JsonObject relay = doc["relayAssist"].to<JsonObject>(); relay["enabled"] = relayAssistEnabled; relay["groupId"] = relayGroupId; relay["depth"] = raRoute.depth;
JsonObject th = doc["thresholds"].to<JsonObject>();
th["magSens"] = magSens; th["mag"] = threshMag; th["prox"] = threshProx;
String out; serializeJson(doc, out); localWS.sendTXT(num, out);
}
void localWsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (type == WStype_CONNECTED) { portalLastActiveAt=millis(); sendHelloTo(num); }
else if (type == WStype_DISCONNECTED) { portalLastActiveAt=millis(); }
else if (type == WStype_TEXT) { portalLastActiveAt=millis(); JsonDocument doc; if (!deserializeJson(doc, payload, length)) handleCommand(doc, 1, num); }
}
void connectToApp(const String& ip, int port) {
if (appWSStarted) return;
appWS.begin(ip, port, "/"); appWS.onEvent(appWsEvent);
appWS.setReconnectInterval(3000); appWSStarted = true; appStartAt = millis();
}
// ---- INTERNET RELAY MODE -------------------------------------------------
// When a relay URL is saved (via the portal), the node reaches ONE SPECTER
// instance over the public internet through a Cloudflare tunnel, instead of
// LAN beacon discovery. Cloudflare terminates TLS at the edge, so the node
// speaks wss:// on 443; the tunnel forwards plaintext ws to the app's WS
// server (:47846) unchanged. Accepts "wss://host", "host", or "host:port".
// This is purely additive: an empty relayUrl leaves LAN discovery untouched.
void connectToRelay(const String& url) {
if (appWSStarted) return;
if (url.length() == 0) return;
// Parse: strip scheme, split host[:port][/path]
String u = url;
if (u.startsWith("wss://")) u = u.substring(6);
else if (u.startsWith("https://")) u = u.substring(8);
else if (u.startsWith("ws://")) u = u.substring(5);
else if (u.startsWith("http://")) u = u.substring(7);
String path = "/";
int slash = u.indexOf('/');
if (slash >= 0) { path = u.substring(slash); u = u.substring(0, slash); }
int port = 443;
int colon = u.indexOf(':');
if (colon >= 0) { port = u.substring(colon + 1).toInt(); u = u.substring(0, colon); if (port <= 0) port = 443; }
// TLS handshake needs contiguous heap; the node's HTTPS OTA proves this
// hardware can do it, but guard anyway so a low-heap moment fails soft.
if (ESP.getFreeHeap() < 20000) return;
appWS.beginSSL(u.c_str(), port, path.c_str());
appWS.onEvent(appWsEvent);
appWS.setReconnectInterval(5000);
appWSStarted = true; appStartAt = millis();
}
// ─────────────────────────────────────────────────────────────────────────────
// NODE SETTINGS SYNC -- push this node's tuning to every other node on the LAN.
//
// Scope is deliberately "everything that makes two nodes BEHAVE the same",
// and deliberately NOT identity or connectivity. Slot, node name, Wi-Fi
// credentials, relay URL and the Relay Assist group key are all excluded: a
// sync that renamed every node "Node 1" or moved them onto one SSID would be
// unrecoverable without a USB cable. Field lock is excluded too -- it is
// session state the app drives, not configuration.
//
// Discovery uses the mDNS service every node already advertises
// (specter-node-<slot>._http._tcp). No new protocol, no pairing step.
//
// SECURITY NOTE, stated plainly: /sync/apply is unauthenticated, exactly like
// every other endpoint on this portal (/led/config, /wifi/save, /prox/cadence
// are all open too). Anyone already on your LAN could call it. That matches the
// device's existing posture rather than adding a lock to one door in an
// unlocked house -- but if the portal ever gets auth, this must be covered by it.
// ─────────────────────────────────────────────────────────────────────────────
#define SYNC_MAX_PEERS 12
volatile bool syncBusy = false;
volatile int syncTotal = 0, syncOkCount = 0, syncFailCount = 0, syncRebootCount = 0;
char syncMsg[192] = "";
uint32_t syncFinishedAt = 0;
// Build the shareable settings bundle. Used by /sync/export (inspection) and
// as the body pushed to every peer.
static void buildSyncBundle(JsonDocument& d) {
d["v"] = 1;
d["from"] = stableNodeId;
d["fw"] = FW_VERSION;
JsonObject sen = d["sensor"].to<JsonObject>();
sen["magSens"] = magSens;
sen["proxMode"] = proxMode; // applied FIRST on the peer: it selects the unit scale
sen["threshProx"] = threshProx; // in the units of proxMode above
sen["proxSpan"] = proxSpan;
sen["proxNoise"] = proxNoise;
sen["proxCurve"] = proxCurve;
sen["rcMarginX"] = rcMarginMult;
sen["rcMarginFloorUs"] = rcMarginFloorUs;
sen["theremin"] = proxTheremin;
sen["thermTone"] = proxToneOn;
JsonObject cad = d["cadence"].to<JsonObject>();
cad["alertMs"] = proxAlertMs; cad["cooldownMs"] = proxCooldownMs; cad["armMs"] = proxArmMs;
cad["hbResumeMs"] = heartbeatResumeMs; cad["minHoldMs"] = proxMinHoldMs;
cad["hz"] = telemetryHz;
JsonObject led = d["led"].to<JsonObject>();
led["chipset"] = ledCfg.chipset; led["order"] = ledCfg.order;
led["dataPin"] = ledCfg.dataPin; led["count"] = ledCfg.count; led["bright"] = ledCfg.bright;
led["front"] = ledCfg.mFront; led["rot"] = ledCfg.magRot; led["flip"] = ledCfg.magFlip;
// The rest of the positional map: without right/back/left the directional
// mag lobe points differently on a synced node than on the source.
led["right"] = ledCfg.mRight; led["back"] = ledCfg.mBack; led["left"] = ledCfg.mLeft;
led["clkPin"] = ledCfg.clkPin;
led["magColor"] = ledCfg.magColor; led["magColorB"] = ledCfg.magColorB; led["magColorC"] = ledCfg.magColorC;
led["magAnim"] = ledCfg.magAnim;
led["proxColor"] = ledCfg.proxColor; led["proxColorB"] = ledCfg.proxColorB; led["proxColorC"] = ledCfg.proxColorC;
led["proxAnim"] = ledCfg.proxAnim;
led["bootColorA"] = ledCfg.bootColorA; led["bootColorB"] = ledCfg.bootColorB; led["bootEnable"] = ledCfg.bootEnable;
JsonObject hb = d["heartbeat"].to<JsonObject>();
hb["enabled"] = heartbeatEnabled; hb["color"] = heartbeatColor;
// NOT synced: audibleAlerts. Like fieldLock it is a live control the app
// drives across every node at once, not per-node configuration.
// NOT synced: the walkie-talkie / RF cancel signature (rfMag/rfProx/rfOn).
// It is a MEASUREMENT taken through one node's own antenna and coupling, so
// the peak amplitudes only describe that node. Copying them to a pod with
// different coupling either over-suppresses real hits or does nothing.
// Capture it per node.
}
// Apply a received bundle. Returns true; sets rebootNeeded when a field that
// only binds at boot (LED chipset / data pin) actually changed.
static bool applySyncBundle(JsonDocument& d, bool& rebootNeeded) {
rebootNeeded = false;
if ((int)(d["v"] | 0) != 1) return false;
JsonObject sen = d["sensor"];
if (!sen.isNull()) {
if (sen["magSens"].is<int>()) { magSens = constrain((int)sen["magSens"], 0, 100); }
// proxMode FIRST: it decides which NVS keys (and unit scale) the
// threshold/span/noise values below belong to.
if (sen["proxMode"].is<int>()) {
int pm = constrain((int)sen["proxMode"], 0, 1);
if (pm != proxMode) {
saveProxTuningFor(proxMode);
if (pm == 0) { digitalWrite(activeRcDrivePin(), LOW); pinMode(activeRcDrivePin(), INPUT); }
proxMode = pm; rcReady = false; rcBase10 = 0; rcValid = false; proxInit = false; recalibrate = true;
if (pm == 1) rcSmartCalReq = true;
}
}
if (sen["threshProx"].is<int>()) threshProx = constrain((int)sen["threshProx"], 1, 400);
if (sen["proxSpan"].is<int>()) proxSpan = constrain((int)sen["proxSpan"], 1, 4000);
if (sen["proxNoise"].is<int>()) proxNoise = constrain((int)sen["proxNoise"], 0, 200);
if (sen["proxCurve"].is<int>()) proxCurve = constrain((int)sen["proxCurve"], 0, 1);
if (sen["rcMarginX"].is<int>()) rcMarginMult = constrain((int)sen["rcMarginX"], 2, 40);
if (sen["rcMarginFloorUs"].is<int>()) rcMarginFloorUs = constrain((int)sen["rcMarginFloorUs"], 5, 2000);
if (sen["theremin"].is<bool>()) proxTheremin = (bool)sen["theremin"];
if (sen["thermTone"].is<bool>()) proxToneOn = (bool)sen["thermTone"];
saveProxTuningFor(proxMode);
effThreshProx = recomputeEffThreshProx();
}
JsonObject cad = d["cadence"];
if (!cad.isNull()) {
if (cad["alertMs"].is<int>()) proxAlertMs = constrain((int)cad["alertMs"], 200, 30000);
if (cad["cooldownMs"].is<int>()) proxCooldownMs = constrain((int)cad["cooldownMs"], 0, 30000);
if (cad["armMs"].is<int>()) proxArmMs = constrain((int)cad["armMs"], 0, 2000);
if (cad["hbResumeMs"].is<int>()) heartbeatResumeMs = constrain((int)cad["hbResumeMs"], 0, 15000);
if (cad["minHoldMs"].is<int>()) proxMinHoldMs = constrain((int)cad["minHoldMs"], 0, 10000);
if (cad["hz"].is<int>()) telemetryHz = constrain((int)cad["hz"], 1, 30);
}
JsonObject led = d["led"];
bool chipChanged = false;
if (!led.isNull()) {
if (led["chipset"].is<const char*>()) {
const char* cs = led["chipset"];
if (cs && strncmp(cs, ledCfg.chipset, sizeof(ledCfg.chipset)) != 0) {
strncpy(ledCfg.chipset, cs, sizeof(ledCfg.chipset) - 1);
ledCfg.chipset[sizeof(ledCfg.chipset) - 1] = 0; chipChanged = true;
}
}
if (led["order"].is<const char*>()) {
const char* od = led["order"];
if (od) { strncpy(ledCfg.order, od, sizeof(ledCfg.order) - 1); ledCfg.order[sizeof(ledCfg.order) - 1] = 0; }
}
if (led["dataPin"].is<int>() && (int)led["dataPin"] != ledCfg.dataPin) { ledCfg.dataPin = (int)led["dataPin"]; chipChanged = true; }
if (led["count"].is<int>()) ledCfg.count = constrain((int)led["count"], 1, LED_MAX);
if (led["bright"].is<int>()) ledCfg.bright = constrain((int)led["bright"], 0, 255);
if (led["front"].is<int>()) ledCfg.mFront = (int)led["front"];
if (led["right"].is<int>()) ledCfg.mRight = (int)led["right"];
if (led["back"].is<int>()) ledCfg.mBack = (int)led["back"];
if (led["left"].is<int>()) ledCfg.mLeft = (int)led["left"];
if (led["clkPin"].is<int>() && (int)led["clkPin"] != ledCfg.clkPin) { ledCfg.clkPin = (int)led["clkPin"]; chipChanged = true; }
if (led["rot"].is<int>()) ledCfg.magRot = (int)led["rot"];
if (led["flip"].is<int>()) ledCfg.magFlip = (int)led["flip"] ? 1 : 0;
if (led["magColor"].is<uint32_t>()) ledCfg.magColor = (uint32_t)led["magColor"];
if (led["magColorB"].is<uint32_t>()) ledCfg.magColorB = (uint32_t)led["magColorB"];
if (led["magColorC"].is<uint32_t>()) ledCfg.magColorC = (uint32_t)led["magColorC"];
if (led["magAnim"].is<int>()) ledCfg.magAnim = constrain((int)led["magAnim"], 0, LED_ANIM_MAX);
if (led["proxColor"].is<uint32_t>()) ledCfg.proxColor = (uint32_t)led["proxColor"];
if (led["proxColorB"].is<uint32_t>()) ledCfg.proxColorB = (uint32_t)led["proxColorB"];
if (led["proxColorC"].is<uint32_t>()) ledCfg.proxColorC = (uint32_t)led["proxColorC"];
if (led["proxAnim"].is<int>()) ledCfg.proxAnim = constrain((int)led["proxAnim"], 0, LED_ANIM_MAX);
if (led["bootColorA"].is<uint32_t>()) ledCfg.bootColorA = (uint32_t)led["bootColorA"];
if (led["bootColorB"].is<uint32_t>()) ledCfg.bootColorB = (uint32_t)led["bootColorB"];
if (led["bootEnable"].is<int>()) ledCfg.bootEnable = (int)led["bootEnable"] ? 1 : 0;
}
JsonObject hb = d["heartbeat"];
if (!hb.isNull()) {
Preferences hp; hp.begin("heartbeat", false);
if (hb["enabled"].is<bool>()) { heartbeatEnabled = (bool)hb["enabled"]; hp.putBool("enabled", heartbeatEnabled); }
if (hb["color"].is<uint32_t>()) { heartbeatColor = (uint32_t)hb["color"]; hp.putUInt("color", heartbeatColor); }
hp.end();
}
// Persist everything in one NVS session.
prefs.begin("specter", false);
prefs.putInt("magSens", magSens);
prefs.putInt("proxMode", proxMode);
prefs.putInt("proxCurve", proxCurve);
prefs.putInt("rcMarginX", rcMarginMult); prefs.putInt("rcMarginFloorUs", rcMarginFloorUs);
prefs.putInt("therm", proxTheremin ? 1 : 0); prefs.putInt("thermTone", proxToneOn ? 1 : 0);
prefs.putInt("alertMs", proxAlertMs); prefs.putInt("cooldownMs", proxCooldownMs);
prefs.putInt("armMs", proxArmMs); prefs.putInt("hbResumeMs", heartbeatResumeMs);
prefs.putInt("minHoldMs", proxMinHoldMs); prefs.putInt("hz", telemetryHz);
prefs.putString("ledOrd", ledCfg.order);
prefs.putInt("ledData", ledCfg.dataPin); prefs.putInt("ledCount", ledCfg.count);
prefs.putInt("ledBright", ledCfg.bright); prefs.putInt("ledFront", ledCfg.mFront);
prefs.putInt("ledMagRot", ledCfg.magRot); prefs.putInt("ledMagFlip", ledCfg.magFlip);
prefs.putInt("ledRight", ledCfg.mRight); prefs.putInt("ledBack", ledCfg.mBack);
prefs.putInt("ledLeft", ledCfg.mLeft); prefs.putInt("ledClk", ledCfg.clkPin);
prefs.putUInt("ledMagC", ledCfg.magColor); prefs.putUInt("ledMagC2", ledCfg.magColorB); prefs.putUInt("ledMagC3", ledCfg.magColorC);
prefs.putInt("ledMagA", ledCfg.magAnim);
prefs.putUInt("ledPrxC", ledCfg.proxColor); prefs.putUInt("ledPrxC2", ledCfg.proxColorB); prefs.putUInt("ledPrxC3", ledCfg.proxColorC);
prefs.putInt("ledPrxA", ledCfg.proxAnim);
prefs.putUInt("ledBootA", ledCfg.bootColorA); prefs.putUInt("ledBootB", ledCfg.bootColorB);
prefs.putInt("ledBoot", ledCfg.bootEnable);
prefs.end();
// Chipset has its OWN namespace ("ledcfg"/"chip") so the hardware task's
// frequent "specter" writes can never clobber it -- mirror that here.
{ Preferences cp; cp.begin("ledcfg", false); cp.putString("chip", ledCfg.chipset); cp.end(); }
saveProxTuningFor(proxMode);
FastLED.setBrightness(ledCfg.bright);
ledTestReq = 8; // re-render with the new theme
// ── RE-ZERO AFTER ADOPTING SOMEONE ELSE'S THRESHOLDS ────────────────────
// This is mandatory, not a nicety. A threshold is only meaningful relative
// to the baseline it was tuned against. The source node's numbers were
// chosen for ITS rest state; the receiver still holds a baseline learned
// under its own previous settings, and (per the v2.13.1 investigation) a
// mag baseline in particular can be carrying a stale residual of 60-90 LSB
// that its OLD, looser threshold happened to sit above. Drop a tighter
// threshold on top of that residual and the node alerts continuously the
// moment the sync lands -- which is exactly what nodes 2 and 3 did while
// the source node, which never applies a bundle to itself, stayed quiet.
//
// recalibrate re-zeroes BOTH channels against current ambient. In RC mode
// also re-learn the timeout window, because rcMarginX/rcMarginFloorUs just
// changed and the existing window was learned against the old ones.
recalibrate = true;
if (proxMode == 1) rcSmartCalReq = true;
rebootNeeded = chipChanged; // chipset/data pin only bind at boot
return true;
}
// Background push: discover peers over mDNS and POST the bundle to each.
// Runs off the web handler because mDNS query + N HTTP round-trips take
// seconds and would otherwise stall loop() (and with it the app link).
static void syncPushTask(void*) {
String body;
{ JsonDocument d; buildSyncBundle(d); serializeJson(d, body); }
IPAddress mine = WiFi.localIP();
IPAddress peers[SYNC_MAX_PEERS];
int peerCount = 0;
// Primary discovery: browse the _http._tcp services every node advertises.
int found = MDNS.queryService("http", "tcp");
for (int i = 0; i < found && peerCount < SYNC_MAX_PEERS; ++i) {
String host = MDNS.hostname(i);
IPAddress ip = MDNS.IP(i);
if (!host.startsWith("specter-node")) continue; // only our own kind
if (ip == mine) continue; // never push to self
bool dup = false;
for (int k = 0; k < peerCount; ++k) if (peers[k] == ip) dup = true;
if (!dup) peers[peerCount++] = ip;
}
// Fallback: some mDNS responders answer host queries but not service
// browses. Slots are 1..3 and each node registers specter-node-<slot>, so
// resolve those directly for anything the browse missed.
for (int slot = 1; slot <= 3 && peerCount < SYNC_MAX_PEERS; ++slot) {
if (slot == nodeSlot) continue;
char hn[24]; snprintf(hn, sizeof(hn), "specter-node-%d", slot);
IPAddress ip = MDNS.queryHost(hn, 1200);
if (ip == IPAddress((uint32_t)0) || ip == mine) continue;
bool dup = false;
for (int k = 0; k < peerCount; ++k) if (peers[k] == ip) dup = true;
if (!dup) peers[peerCount++] = ip;
}
int total = 0, okC = 0, failC = 0, rebootC = 0;
for (int i = 0; i < peerCount; ++i) {
IPAddress ip = peers[i];
total++;
WiFiClient client; HTTPClient http;
http.setConnectTimeout(4000); http.setTimeout(6000);
String url = String("http://") + ip.toString() + "/sync/apply";
if (!http.begin(client, url)) { failC++; continue; }
http.addHeader("Content-Type", "application/json");
int code = http.POST(body);
if (code == 200) {
okC++;
String resp = http.getString();
if (resp.indexOf("\"rebootNeeded\":true") >= 0) rebootC++;
} else failC++;
http.end();
vTaskDelay(pdMS_TO_TICKS(50));
}
syncTotal = total; syncOkCount = okC; syncFailCount = failC; syncRebootCount = rebootC;
if (total == 0)
snprintf(syncMsg, sizeof(syncMsg), "No other nodes found on this network. Peers must be on the same Wi-Fi and powered on.");
else
snprintf(syncMsg, sizeof(syncMsg),
"Synced %d of %d node%s. They are re-zeroing to the new thresholds - keep clear for ~10s.%s",
okC, total, total == 1 ? "" : "s",
rebootC ? " Some need a reboot to bind a new LED chipset/pin." : "");
syncFinishedAt = millis();
syncBusy = false;
vTaskDelete(NULL);
}
void setupNetwork() {
prefs.begin("specter", true);
targetSSID = prefs.getString("ssid", "");
targetPASS = prefs.getString("pass", "");
targetAppIP = prefs.getString("appip", "");
relayUrl = prefs.getString("relayurl", "");
magSens = prefs.getInt("magSens", 70);
proxTheremin= prefs.getInt("therm", 1) == 1; // default ON when unset
proxToneOn = prefs.getInt("thermTone", 0) == 1;
proxCurve = prefs.getInt("proxCurve", 1);
fieldLock = prefs.getInt("fieldLock", 0) == 1;
proxMode = prefs.getInt("proxMode", 0);
// threshProx / proxSpan / proxNoise are per-mode (different unit scales);
// loadProxTuningFor opens its own handle, so read proxMode first.
prefs.end(); loadProxTuningFor(proxMode); prefs.begin("specter", true);
rcMarginMult = prefs.getInt("rcMarginX", 5);
rcMarginFloorUs = prefs.getInt("rcMarginFloorUs", 40);
proxAlertMs = prefs.getInt("alertMs", 6000);
proxCooldownMs = prefs.getInt("cooldownMs", 6000);
proxArmMs = prefs.getInt("armMs", 150);
heartbeatResumeMs = prefs.getInt("hbResumeMs", 2000);
audibleAlerts = prefs.getInt("audible", 1) == 1;
proxMinHoldMs = prefs.getInt("minHoldMs", PROX_MIN_ALERT_MS_DEFAULT);
touchPin = prefs.getInt("touchPin", TOUCH_PIN_DEFAULT);
rfMagPeak = prefs.getInt("rfMag", 0);
rfProxPeak = prefs.getInt("rfProx", 0);
rfActive = prefs.getInt("rfOn", 0) == 1;
telemetryHz = prefs.getInt("hz", TELEMETRY_HZ_DEFAULT);
String savedNodeName = prefs.getString("name", "");
savedNodeName.trim();
if (savedNodeName.length() > 0 && savedNodeName != "SPECTER Node A" && savedNodeName != "SPECTER Node B" && savedNodeName != "SPECTER Node C" && savedNodeName != "node-a" && savedNodeName != "node-b" && savedNodeName != "node-c") {
savedNodeName.toCharArray(nodeName, sizeof(nodeName));
nodeNameCustom = true;
} else {
nodeNameCustom = false;
applySlotName();
}
prefs.end();
relayLocalId = ESP.getEfuseMac(); relayAssistLoad();
WiFi.mode(WIFI_AP_STA);
WiFi.setSleep(false); // keep portal WebSocket/HTTP responsive during idle periods
WiFi.softAP("SPECTER-NODE-01");
relayScanHomeChannel = WiFi.channel();
if (relayScanHomeChannel < 1 || relayScanHomeChannel > 13) relayScanHomeChannel = 1;
relayAssistStart();
WiFi.persistent(true); // keep station config across reboots/brownouts
// Manual reconnect policy below protects hotspot clients from AP+STA channel
// changes. ESP32 auto-reconnect can silently move the AP channel and strand
// a laptop attached to 192.168.4.1 while the hotspot remains visible.
WiFi.setAutoReconnect(false);
if (targetSSID != "") {
WiFi.begin(targetSSID.c_str(), targetPASS.c_str());
int t = 0; while (WiFi.status() != WL_CONNECTED && t < 20) { delay(500); t++; }
// If it didn't come up in 10s (slow router after a power blip), don't give up --
// the background retry in the network loop + auto-reconnect keep working on it.
}
if (WiFi.status() == WL_CONNECTED) {
// mDNS: reach the portal at specter-node-<slot>.local instead of
// hunting DHCP tables for the IP.
char mdnsHost[24]; snprintf(mdnsHost, sizeof(mdnsHost), "specter-node-%d", nodeSlot);
if (MDNS.begin(mdnsHost)) MDNS.addService("http", "tcp", 80);
// RELAY MODE takes priority: if a relay URL is saved, connect over the
// internet tunnel and skip LAN beacon discovery entirely.
if (relayUrl.length() > 0) {
connectToRelay(relayUrl);
} else {
// LAN discovery (unchanged): listen for the app's UDP beacon and/or
// connect to a saved app IP.
if (udp.listen(BEACON_PORT)) {
udp.onPacket([](AsyncUDPPacket packet) {
if (isAppConnected || appWSStarted) return;
JsonDocument doc;
if (deserializeJson(doc, packet.data(), packet.length())) return;
if (doc["svc"] == "specter-app")
connectToApp(doc["ip"].as<String>(), doc["ws"] | APP_WS_PORT);
});
}
if (targetAppIP != "") connectToApp(targetAppIP, APP_WS_PORT);
}
}
// ---- Standalone portal (embedded, no LittleFS) ----
// "/" live telemetry + 3D field-vector graph
// "/settings" Wi-Fi, calibrate, thresholds, firmware info
server.on("/", []() { server.send_P(200, "text/html", PAGE_INDEX); });
server.on("/settings", []() { server.send_P(200, "text/html", PAGE_SETTINGS); });
// ---- Wi-Fi management API (used by the settings page) ----
server.on("/wifi/status", []() {
JsonDocument d;
bool up = (WiFi.status() == WL_CONNECTED);
d["connected"] = up;
d["ssid"] = up ? WiFi.SSID() : String("");
d["rssi"] = up ? WiFi.RSSI() : 0;
d["ip"] = up ? WiFi.localIP().toString() : String("");
d["ap"] = WiFi.softAPSSID();
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/wifi/scan", []() {
int n = WiFi.scanNetworks();
JsonDocument d; JsonArray arr = d.to<JsonArray>();
for (int i = 0; i < n && i < 40; i++) {
String ss = WiFi.SSID(i);
if (ss.length() == 0) continue;
bool seen = false;
for (JsonObject o : arr) { if (o["ssid"] == ss) { seen = true; break; } }
if (seen) continue;
JsonObject o = arr.add<JsonObject>();
o["ssid"] = ss; o["rssi"] = WiFi.RSSI(i);
o["enc"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
if (arr.size() >= 24) break;
}
WiFi.scanDelete();
String out; serializeJson(d, out); server.send(200, "application/json", out);
});
server.on("/wifi/save", HTTP_POST, []() {
String ssid = server.hasArg("ssid") ? server.arg("ssid") : "";
String pass = server.hasArg("pass") ? server.arg("pass") : "";
ssid.trim();
if (ssid.length() == 0) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"no ssid\"}"); return; }
// PERSIST FIRST: save the credentials the moment they're submitted, so a
// slow/failed first connect (or a reboot mid-handshake) can never lose them.
targetSSID = ssid; targetPASS = pass;
prefs.begin("specter", false);
prefs.putString("ssid", ssid); prefs.putString("pass", pass);
prefs.end();
WiFi.mode(WIFI_AP_STA); // keep AP up so the portal stays reachable
WiFi.persistent(true); // let the SDK keep the config across resets
WiFi.setSleep(false);
WiFi.setAutoReconnect(false); // portal-safe manual retry policy
WiFi.begin(ssid.c_str(), pass.c_str());
int t = 0;
while (WiFi.status() != WL_CONNECTED && t < 24) { delay(500); t++; }
JsonDocument d;
if (WiFi.status() == WL_CONNECTED) {
d["ok"] = true; d["ip"] = WiFi.localIP().toString();
{ // (re)announce mDNS on the newly joined network
char mdnsHost[24]; snprintf(mdnsHost, sizeof(mdnsHost), "specter-node-%d", nodeSlot);
MDNS.end(); if (MDNS.begin(mdnsHost)) MDNS.addService("http", "tcp", 80);
d["mdns"] = String(mdnsHost) + ".local"; }
} else {
// Saved anyway -- the boot loader + background retry will keep trying.
d["ok"] = false; d["saved"] = true;
d["error"] = "saved, but not connected yet (weak signal / wrong password?) -- it will keep retrying";
}
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/wifi/forget", HTTP_POST, []() {
targetSSID = ""; targetPASS = ""; targetAppIP = "";
prefs.begin("specter", false);
prefs.remove("ssid"); prefs.remove("pass"); prefs.remove("appip");
prefs.end();
WiFi.setAutoReconnect(false);
WiFi.disconnect(false, true); // stop STA retries, keep SoftAP running
JsonDocument d; d["ok"] = true; d["ap"] = WiFi.softAPSSID();
d["note"] = "Saved Wi-Fi cleared. Standalone hotspot will remain active.";
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
// ---- Multi-node identity + idle heartbeat -------------------------------
server.on("/node/identity", []() {
JsonDocument d; d["nodeId"] = stableNodeId; d["slot"] = nodeSlot; d["name"] = nodeName; d["customName"] = nodeNameCustom; d["pinSwap"] = rcBuzzerPinsSwapped;
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
// ---- settings sync ----
server.on("/sync/export", HTTP_GET, []() {
JsonDocument d; buildSyncBundle(d);
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
// Receive a bundle from another node and adopt it.
server.on("/sync/apply", HTTP_POST, []() {
String body = server.hasArg("plain") ? server.arg("plain") : String("");
JsonDocument d;
if (deserializeJson(d, body)) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"bad-json\"}"); return; }
bool rebootNeeded = false;
if (!applySyncBundle(d, rebootNeeded)) { server.send(400, "application/json", "{\"ok\":false,\"error\":\"bad-version\"}"); return; }
JsonDocument r; r["ok"] = true; r["rebootNeeded"] = rebootNeeded; r["node"] = nodeName; r["slot"] = nodeSlot; r["recalibrating"] = true;
String o; serializeJson(r, o); server.send(200, "application/json", o);
});
// Push this node's settings to every peer found on the LAN.
server.on("/sync/push", HTTP_POST, []() {
if (syncBusy) { server.send(200, "application/json", "{\"ok\":true,\"busy\":true}"); return; }
if (WiFi.status() != WL_CONNECTED) {
server.send(409, "application/json", "{\"ok\":false,\"error\":\"not-on-a-network\"}"); return;
}
syncBusy = true; syncTotal = syncOkCount = syncFailCount = syncRebootCount = 0;
snprintf(syncMsg, sizeof(syncMsg), "Looking for other nodes...");
// Own task: mDNS discovery plus N HTTP round-trips take seconds and
// must not block loop() (and with it the app link and portal).
xTaskCreatePinnedToCore(syncPushTask, "SyncPush", 8192, NULL, 1, NULL, 0);
server.send(200, "application/json", "{\"ok\":true,\"started\":true}");
});
server.on("/sync/status", HTTP_GET, []() {
JsonDocument d;
d["busy"] = (bool)syncBusy; d["total"] = syncTotal; d["ok"] = syncOkCount;
d["failed"] = syncFailCount; d["rebootNeeded"] = syncRebootCount; d["msg"] = syncMsg;
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/audible/set", HTTP_POST, []() {
if (server.hasArg("on")) {
audibleAlerts = (server.arg("on") == "1" || server.arg("on") == "true");
if (!audibleAlerts) buzzStop();
prefs.begin("specter", false); prefs.putInt("audible", audibleAlerts ? 1 : 0); prefs.end();
}
server.send(200, "application/json",
String("{\"ok\":true,\"audible\":") + (audibleAlerts ? "true" : "false") + "}");
});
server.on("/lock/set", HTTP_POST, []() {
if (server.hasArg("on")) {
fieldLock = (server.arg("on") == "1" || server.arg("on") == "true");
prefs.begin("specter", false); prefs.putInt("fieldLock", fieldLock ? 1 : 0); prefs.end();
}
server.send(200, "application/json",
String("{\"ok\":true,\"lock\":") + (fieldLock ? "true" : "false") + "}");
});
server.on("/hardware/pinswap", HTTP_POST, []() {
bool enabled = false;
if (server.hasArg("enabled")) enabled = server.arg("enabled") == "1" || server.arg("enabled") == "true";
rcBuzzerPinsSwapped = enabled;
Preferences hwPrefs; hwPrefs.begin("nodehw", false); hwPrefs.putBool("pinSwap", rcBuzzerPinsSwapped); hwPrefs.end();
pinMode(activeBuzzerPin(), OUTPUT); noTone(activeBuzzerPin());
if (proxMode == 1) { digitalWrite(activeRcDrivePin(), LOW); pinMode(activeRcDrivePin(), OUTPUT); rcSmartCalReq = true; }
JsonDocument d; d["ok"] = true; d["pinSwap"] = rcBuzzerPinsSwapped; d["rcDrivePin"] = activeRcDrivePin(); d["buzzerPin"] = activeBuzzerPin();
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/node/name", HTTP_POST, []() {
String name = server.hasArg("name") ? server.arg("name") : "";
name.trim();
if (name.length() > 31) name = name.substring(0, 31);
Preferences np; np.begin("nodeid", false);
if (name.length() == 0) { np.remove("name"); nodeNameCustom = false; applySlotName(); }
else { name.toCharArray(nodeName, sizeof(nodeName)); nodeNameCustom = true; np.putString("name", nodeName); }
np.end();
JsonDocument d; d["ok"] = true; d["name"] = nodeName; d["customName"] = nodeNameCustom;
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/node/slot", HTTP_POST, []() {
int slot = constrain(server.hasArg("slot") ? server.arg("slot").toInt() : nodeSlot, 1, 3);
Preferences np; np.begin("nodeid", false); np.putInt("slot", slot); np.end();
nodeSlot = slot;
applySlotName();
JsonDocument d; d["ok"] = true; d["nodeId"] = stableNodeId; d["slot"] = nodeSlot; d["name"] = nodeName; d["customName"] = nodeNameCustom;
d["note"] = "Saved. Reconnects to SPECTER as this slot immediately after reboot.";
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/heartbeat/status", []() {
JsonDocument d; d["enabled"] = heartbeatEnabled;
char hx[8]; snprintf(hx, sizeof(hx), "%06X", heartbeatColor); d["color"] = hx;
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/heartbeat/save", HTTP_POST, []() {
bool enabled = server.hasArg("enabled") ? server.arg("enabled").toInt() != 0 : heartbeatEnabled;
uint32_t color = heartbeatColor;
if (server.hasArg("color")) color = strtoul(server.arg("color").c_str(), nullptr, 16) & 0xFFFFFF;
Preferences hp; hp.begin("heartbeat", false); hp.putBool("enabled", enabled); hp.putUInt("color", color); hp.end();
heartbeatEnabled = enabled; heartbeatColor = color; ledTestReq = 8;
JsonDocument d; d["ok"] = true; d["enabled"] = heartbeatEnabled;
char hx[8]; snprintf(hx, sizeof(hx), "%06X", heartbeatColor); d["color"] = hx;
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
// ---- RELAY ASSIST (ESP-NOW peer routing) --------------------------------
server.on("/relay-assist/status", []() {
JsonDocument d; d["enabled"] = relayAssistEnabled; d["configured"] = strlen(relayGroupKey) >= 12; d["groupId"] = relayGroupId;
d["started"] = relayAssistStarted; d["channel"] = WiFi.channel(); d["quality"] = raQuality();
d["role"] = isAppConnected ? "gateway" : (raRoute.valid ? "relayed" : "idle"); d["depth"] = raRoute.depth;
if (raRoute.valid) { char parent[24], gateway[24]; raIdToString(raRoute.parentId,parent,sizeof(parent)); raIdToString(raRoute.gatewayId,gateway,sizeof(gateway)); d["parentNodeId"] = parent; d["gatewayNodeId"] = gateway; }
String out; serializeJson(d,out); server.send(200,"application/json",out);
});
server.on("/relay-assist/save", HTTP_POST, []() {
bool enabled = server.hasArg("enabled") && (server.arg("enabled") == "1" || server.arg("enabled") == "true");
String key = server.hasArg("key") ? server.arg("key") : ""; key.trim();
if (enabled && (key.length() < 12 || key.length() > 64)) { server.send(400,"application/json","{\"ok\":false,\"error\":\"key-length\"}"); return; }
bool ok = relayAssistSave(enabled,key); server.send(ok?200:500,"application/json",ok?"{\"ok\":true}":"{\"ok\":false}");
});
// ---- INTERNET RELAY URL (Cloudflare tunnel to one SPECTER instance) -----
// Saved to its OWN habit-safe NVS key "relayurl". Empty = LAN discovery
// (default, unchanged). Set = node connects wss:// over the internet on boot.
server.on("/relay/get", []() {
prefs.begin("specter", true);
String u = prefs.getString("relayurl", "");
prefs.end();
JsonDocument d; d["url"] = u; d["active"] = (u.length() > 0);
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/relay/save", HTTP_POST, []() {
String u = server.hasArg("url") ? server.arg("url") : "";
u.trim();
prefs.begin("specter", false);
prefs.putString("relayurl", u);
prefs.end();
relayUrl = u;
JsonDocument d; d["ok"] = true; d["url"] = u;
d["note"] = "Saved. Reboot the node to connect over the internet relay.";
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/relay/clear", HTTP_POST, []() {
prefs.begin("specter", false);
prefs.putString("relayurl", "");
prefs.remove("appip"); // also drop any stale saved app IP from older firmware
prefs.end();
relayUrl = "";
JsonDocument d; d["ok"] = true;
d["note"] = "Cleared. Reboot to return to LAN discovery.";
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
// ---- LED config API (per-node, on-device) ----
server.on("/led/status", []() {
JsonDocument d;
d["chipset"] = ledCfg.chipset; d["order"] = ledCfg.order;
d["dataPin"] = ledCfg.dataPin; d["clkPin"] = ledCfg.clkPin;
d["count"] = ledCfg.count; d["bright"] = ledCfg.bright;
JsonObject m = d["map"].to<JsonObject>();
m["front"] = ledCfg.mFront; m["right"] = ledCfg.mRight;
m["back"] = ledCfg.mBack; m["left"] = ledCfg.mLeft;
m["rot"] = ledCfg.magRot;
m["flip"] = ledCfg.magFlip;
d["fw"] = FW_VERSION; d["compiledCount"] = LED_MAX;
// per-event theming (live) -- colors as 0xRRGGBB, anim 0=solid 1=pulse 2=spin 3=blink
char hx[8];
JsonObject ev = d["events"].to<JsonObject>();
snprintf(hx,sizeof(hx),"%06X",ledCfg.magColor); ev["magColor"]=hx; ev["magAnim"]=ledCfg.magAnim;
snprintf(hx,sizeof(hx),"%06X",ledCfg.magColorB); ev["magColorB"]=hx;
snprintf(hx,sizeof(hx),"%06X",ledCfg.magColorC); ev["magColorC"]=hx;
snprintf(hx,sizeof(hx),"%06X",ledCfg.proxColor); ev["proxColor"]=hx; ev["proxAnim"]=ledCfg.proxAnim;
snprintf(hx,sizeof(hx),"%06X",ledCfg.proxColorB);ev["proxColorB"]=hx;
snprintf(hx,sizeof(hx),"%06X",ledCfg.proxColorC);ev["proxColorC"]=hx;
ev["animMax"]=LED_ANIM_MAX;
JsonObject bt = d["boot"].to<JsonObject>();
snprintf(hx,sizeof(hx),"%06X",ledCfg.bootColorA); bt["colorA"]=hx;
snprintf(hx,sizeof(hx),"%06X",ledCfg.bootColorB); bt["colorB"]=hx;
bt["enable"]=ledCfg.bootEnable;
// brightness + map + events + boot apply live; the rest need a reboot.
d["maxCount"] = LED_MAX;
d["liveFields"] = "bright,map,events,boot,count,order"; d["rebootFields"] = "chipset,dataPin,clkPin";
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/led/config", HTTP_POST, []() {
prefs.begin("specter", false);
if (server.hasArg("bright")) { ledCfg.bright = constrain(server.arg("bright").toInt(),0,255); prefs.putInt("ledBright", ledCfg.bright); }
if (server.hasArg("front")) { ledCfg.mFront = server.arg("front").toInt(); prefs.putInt("ledFront", ledCfg.mFront); }
if (server.hasArg("right")) { ledCfg.mRight = server.arg("right").toInt(); prefs.putInt("ledRight", ledCfg.mRight); }
if (server.hasArg("back")) { ledCfg.mBack = server.arg("back").toInt(); prefs.putInt("ledBack", ledCfg.mBack); }
if (server.hasArg("left")) { ledCfg.mLeft = server.arg("left").toInt(); prefs.putInt("ledLeft", ledCfg.mLeft); }
bool rotChanged=false;
if (server.hasArg("rot")) { ledCfg.magRot = ((server.arg("rot").toInt() % 360) + 360) % 360; prefs.putInt("ledMagRot", ledCfg.magRot); rotChanged=true; }
if (server.hasArg("rotStep")) { ledCfg.magRot = (ledCfg.magRot + server.arg("rotStep").toInt()) % 360; if (ledCfg.magRot < 0) ledCfg.magRot += 360; prefs.putInt("ledMagRot", ledCfg.magRot); rotChanged=true; }
if (server.hasArg("flip")) { ledCfg.magFlip = server.arg("flip").toInt()?1:0; prefs.putInt("ledMagFlip", ledCfg.magFlip); rotChanged=true; }
if (server.hasArg("flipToggle")){ ledCfg.magFlip = ledCfg.magFlip?0:1; prefs.putInt("ledMagFlip", ledCfg.magFlip); rotChanged=true; }
// chipset + data/clock pin bind at compile time in FastLED -> reflash needed.
// count + order apply LIVE (count = pixels driven; order remapped in software).
bool reboot=false;
bool chipVerified = true;
if (server.hasArg("chipset")){
strncpy(ledCfg.chipset, server.arg("chipset").c_str(), sizeof(ledCfg.chipset)-1); ledCfg.chipset[sizeof(ledCfg.chipset)-1]=0;
// Persist the chipset in its OWN namespace "ledcfg" -- completely isolated
// from the shared "specter" namespace that the hardware task writes to.
// That cross-task contention is what silently dropped the write before
// (survived a reboot as WS2812B despite "verified"). Own namespace = no
// contention. Write + commit + verify from a FRESH read handle (real flash).
{
Preferences cp;
cp.begin("ledcfg", false);
cp.putString("chip", ledCfg.chipset);
cp.end(); // commit
}
{
Preferences vp;
vp.begin("ledcfg", true); // fresh RO -> reads flash, not cache
String rb = vp.getString("chip", "");
vp.end();
chipVerified = rb.equals(ledCfg.chipset);
Serial.printf("[LED] save chipset='%s' flash-readback='%s' verified=%d\n",
ledCfg.chipset, rb.c_str(), chipVerified ? 1 : 0);
}
reboot = true;
}
if (server.hasArg("order")) { strncpy(ledCfg.order, server.arg("order").c_str(), sizeof(ledCfg.order)-1); ledCfg.order[sizeof(ledCfg.order)-1]=0; prefs.putString("ledOrd", ledCfg.order); if (ledIsRGBW) rebindRgbw(); } // live: RGB software-remap, RGBW rebinds NeoPixel
if (server.hasArg("dataPin")){ ledCfg.dataPin = server.arg("dataPin").toInt(); prefs.putInt("ledData", ledCfg.dataPin); reboot=true; }
if (server.hasArg("clkPin")) { ledCfg.clkPin = server.arg("clkPin").toInt(); prefs.putInt("ledClk", ledCfg.clkPin); reboot=true; }
if (server.hasArg("count")) { ledCfg.count = constrain(server.arg("count").toInt(),1,LED_MAX); prefs.putInt("ledCount", ledCfg.count); } // live (drives 0..count-1)
// per-event theming + boot (live, no reboot). Colors accepted as hex "RRGGBB" or "#RRGGBB".
auto parseHex = [](String v)->uint32_t{ if(v.startsWith("#")) v=v.substring(1); return (uint32_t) strtoul(v.c_str(), nullptr, 16); };
if (server.hasArg("magColor")) { ledCfg.magColor = parseHex(server.arg("magColor")); prefs.putUInt("ledMagC", ledCfg.magColor); }
if (server.hasArg("magColorB")){ ledCfg.magColorB= parseHex(server.arg("magColorB")); prefs.putUInt("ledMagC2", ledCfg.magColorB); }
if (server.hasArg("magColorC")){ ledCfg.magColorC= parseHex(server.arg("magColorC")); prefs.putUInt("ledMagC3", ledCfg.magColorC); }
if (server.hasArg("magAnim")) { ledCfg.magAnim = constrain(server.arg("magAnim").toInt(),0,LED_ANIM_MAX); prefs.putInt("ledMagA", ledCfg.magAnim); }
if (server.hasArg("proxColor")){ ledCfg.proxColor= parseHex(server.arg("proxColor")); prefs.putUInt("ledPrxC", ledCfg.proxColor); }
if (server.hasArg("proxColorB")){ledCfg.proxColorB=parseHex(server.arg("proxColorB")); prefs.putUInt("ledPrxC2", ledCfg.proxColorB); }
if (server.hasArg("proxColorC")){ledCfg.proxColorC=parseHex(server.arg("proxColorC")); prefs.putUInt("ledPrxC3", ledCfg.proxColorC); }
if (server.hasArg("proxAnim")) { ledCfg.proxAnim = constrain(server.arg("proxAnim").toInt(),0,LED_ANIM_MAX); prefs.putInt("ledPrxA", ledCfg.proxAnim); }
if (server.hasArg("bootColorA")){ ledCfg.bootColorA=parseHex(server.arg("bootColorA")); prefs.putUInt("ledBootA", ledCfg.bootColorA); }
if (server.hasArg("bootColorB")){ ledCfg.bootColorB=parseHex(server.arg("bootColorB")); prefs.putUInt("ledBootB", ledCfg.bootColorB); }
if (server.hasArg("bootEnable")){ ledCfg.bootEnable=server.arg("bootEnable").toInt()?1:0; prefs.putInt("ledBoot", ledCfg.bootEnable); }
prefs.end();
FastLED.setBrightness(ledCfg.bright);
ledTestReq = rotChanged ? 2 : 8; // rot: show the mag lobe at the new angle; else just re-render idle
JsonDocument d; d["ok"]=true; d["rebootNeeded"]=reboot; d["chipSaved"]=chipVerified; d["chipset"]=ledCfg.chipset; d["rot"]=ledCfg.magRot; d["flip"]=ledCfg.magFlip;
String o; serializeJson(d,o); server.send(200, "application/json", o);
});
// Preview a theme/boot animation live on the ring (portal PREVIEW buttons).
server.on("/led/test", HTTP_POST, []() {
String what = server.hasArg("what") ? server.arg("what") : "boot";
ledTestReq = 0;
if (what == "boot") ledTestReq = 1;
else if (what == "mag") ledTestReq = 2;
else if (what == "prox") ledTestReq = 3;
else if (what == "red") ledTestReq = 4;
else if (what == "green") ledTestReq = 5;
else if (what == "blue") ledTestReq = 6;
else if (what == "white") ledTestReq = 7;
server.send(200, "application/json", "{\"ok\":true}");
});
// ---- Walkie-talkie / RF interference cancel API ----
// GET /rf/status -> current signature + armed state
// POST /rf/capture?action=start|stop -> capture a signature (key the radio during 'start')
// POST /rf/arm?on=1|0 -> enable/disable suppression
// POST /rf/clear -> forget the signature
server.on("/rf/status", []() {
String j = "{";
j += "\"active\":" + String(rfActive ? "true":"false");
j += ",\"capturing\":" + String(rfCapturing ? "true":"false");
j += ",\"magPeak\":" + String(rfMagPeak);
j += ",\"proxPeak\":" + String(rfProxPeak);
j += "}";
server.send(200, "application/json", j);
});
server.on("/rf/capture", HTTP_POST, []() {
String a = server.hasArg("action") ? server.arg("action") : "";
if (a == "start") {
rfCapMag = 0; rfCapProx = 0; rfCapturing = true;
server.send(200, "application/json", "{\"ok\":true,\"capturing\":true}");
} else { // stop -> commit the captured peaks as the signature
rfCapturing = false;
rfMagPeak = rfCapMag; rfProxPeak = rfCapProx;
rfActive = (rfMagPeak > 0 || rfProxPeak > 0); // auto-arm once we have a signature
prefs.begin("node", false);
prefs.putInt("rfMag", rfMagPeak); prefs.putInt("rfProx", rfProxPeak);
prefs.putInt("rfOn", rfActive ? 1 : 0);
prefs.end();
String j = "{\"ok\":true,\"magPeak\":" + String(rfMagPeak) + ",\"proxPeak\":" + String(rfProxPeak) + ",\"active\":" + String(rfActive?"true":"false") + "}";
server.send(200, "application/json", j);
}
});
server.on("/rf/arm", HTTP_POST, []() {
rfActive = server.hasArg("on") && server.arg("on") == "1";
prefs.begin("node", false); prefs.putInt("rfOn", rfActive ? 1 : 0); prefs.end();
server.send(200, "application/json", String("{\"ok\":true,\"active\":") + (rfActive?"true":"false") + "}");
});
server.on("/rf/clear", HTTP_POST, []() {
rfMagPeak = 0; rfProxPeak = 0; rfActive = false; rfCapturing = false;
prefs.begin("node", false); prefs.putInt("rfMag",0); prefs.putInt("rfProx",0); prefs.putInt("rfOn",0); prefs.end();
server.send(200, "application/json", "{\"ok\":true}");
});
// Change which GPIO the antenna is read from (live, no reflash). Forces a recal.
server.on("/prox/pin", HTTP_POST, []() {
if (server.hasArg("pin")) {
int np = server.arg("pin").toInt();
// allow only touch-capable, non-conflicting pins
const int ok[] = {4,27,32,33}; // safe touch pins only
bool valid = false; for (int i=0;i<4;i++) if (ok[i]==np) valid=true;
if (valid) {
touchPin = np;
prefs.begin("node", false); prefs.putInt("touchPin", np); prefs.end();
recalibrate = true; // rebaseline on the new pin
}
}
server.send(200, "application/json", String("{\"ok\":true,\"pin\":") + touchPin + "}");
});
server.on("/prox/recal", HTTP_POST, []() {
recalibrate = true;
if (proxMode == 1) rcSmartCalReq = true;
server.send(200, "application/json", "{\"ok\":true}");
});
// Live-tunable RC timeout margin: how far above the learned ambient charge
// time the antenna must rise before a reading counts as a real close-range
// saturation event, instead of the old fixed 2ms/10ms margin that made
// approach invisible and only touch ever crossed the window.
server.on("/prox/rcmargin", HTTP_POST, []() {
if (server.hasArg("mult")) rcMarginMult = constrain(server.arg("mult").toInt(), 2, 40);
if (server.hasArg("floorUs")) rcMarginFloorUs = constrain(server.arg("floorUs").toInt(), 5, 2000);
prefs.begin("specter", false);
prefs.putInt("rcMarginX", rcMarginMult); prefs.putInt("rcMarginFloorUs", rcMarginFloorUs);
prefs.end();
if (proxMode == 1) rcSmartCalReq = true;
server.send(200, "application/json", String("{\"ok\":true,\"mult\":") + rcMarginMult + ",\"floorUs\":" + rcMarginFloorUs + "}");
});
server.on("/prox/rcmargin", HTTP_GET, []() {
JsonDocument d;
d["mult"] = rcMarginMult; d["floorUs"] = rcMarginFloorUs; d["timeoutUs"] = rcTimeoutUs;
d["ambientUs"] = rcBase10 / (double)RC_SUB; d["calibrated"] = rcSmartCalibrated;
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
// Live-tunable alert/cooldown duration, and the current effective trigger
// threshold, for the portal Antenna/proximity panel.
server.on("/prox/cadence", HTTP_POST, []() {
if (server.hasArg("alertMs")) proxAlertMs = constrain(server.arg("alertMs").toInt(), 200, 30000);
if (server.hasArg("cooldownMs")) proxCooldownMs = constrain(server.arg("cooldownMs").toInt(), 0, 30000);
if (server.hasArg("armMs")) proxArmMs = constrain(server.arg("armMs").toInt(), 0, 2000);
if (server.hasArg("hbResumeMs")) heartbeatResumeMs = constrain((int)server.arg("hbResumeMs").toInt(), 0, 15000);
if (server.hasArg("minHoldMs")) proxMinHoldMs = constrain((int)server.arg("minHoldMs").toInt(), 0, 10000);
prefs.begin("specter", false);
prefs.putInt("alertMs", proxAlertMs); prefs.putInt("cooldownMs", proxCooldownMs); prefs.putInt("armMs", proxArmMs);
prefs.putInt("hbResumeMs", heartbeatResumeMs); prefs.putInt("minHoldMs", proxMinHoldMs);
if (server.hasArg("thresh")) {
threshProx = constrain((int)server.arg("thresh").toInt(), 1, 400);
prefs.putInt(kThreshKey(proxMode), threshProx);
effThreshProx = recomputeEffThreshProx();
}
prefs.end();
server.send(200, "application/json", String("{\"ok\":true,\"alertMs\":") + proxAlertMs + ",\"cooldownMs\":" + proxCooldownMs + ",\"armMs\":" + proxArmMs + ",\"hbResumeMs\":" + heartbeatResumeMs + ",\"minHoldMs\":" + proxMinHoldMs + ",\"threshProx\":" + threshProx + "}");
});
server.on("/prox/cadence", HTTP_GET, []() {
JsonDocument d;
d["alertMs"] = proxAlertMs; d["cooldownMs"] = proxCooldownMs; d["armMs"] = proxArmMs; d["effThreshProx"] = effThreshProx; d["threshProx"] = threshProx;
d["hbResumeMs"] = heartbeatResumeMs; d["minHoldMs"] = proxMinHoldMs; d["proxMode"] = proxMode; d["rcSub"] = RC_SUB; d["threshMax"] = (proxMode == 1 ? 400 : 100);
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
// ---- Field capture log: start / status / CSV download ----
server.on("/diag/log/start", HTTP_POST, []() {
uint32_t ms = server.hasArg("ms") ? (uint32_t)server.arg("ms").toInt() : DIAG_LOG_DEFAULT_MS;
ms = constrain(ms, 5000, DIAG_LOG_DEFAULT_MS);
diagLogCount = 0; diagLogUntil = millis() + ms; diagLogArmed = true;
server.send(200, "application/json", String("{\"ok\":true,\"ms\":") + ms + "}");
});
server.on("/diag/stalls", []() {
JsonDocument d; JsonArray arr = d["stalls"].to<JsonArray>();
uint32_t now = millis();
uint16_t n = stallLogCount < STALL_LOG_CAP ? stallLogCount : STALL_LOG_CAP;
// Oldest-first: if the buffer has wrapped, start at stallLogHead; else start at 0.
uint16_t start = (stallLogCount >= STALL_LOG_CAP) ? stallLogHead : 0;
for (uint16_t i = 0; i < n; i++) {
StallLogRow &r = stallLogRows[(start + i) % STALL_LOG_CAP];
JsonObject o = arr.add<JsonObject>();
o["ageMs"] = now - r.ts; o["label"] = r.label; o["ms"] = r.ms;
o["appStarted"] = r.appWSStarted; o["appConnected"] = r.isAppConnected;
}
d["totalEver"] = stallLogCount;
d["appStarted"] = appWSStarted; d["appConnected"] = isAppConnected;
d["appIp"] = targetAppIP; d["appAgeMs"] = appStartAt ? (now - appStartAt) : 0;
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/diag/log/status", []() {
JsonDocument d;
d["armed"] = diagLogArmed; d["rows"] = diagLogCount; d["cap"] = DIAG_LOG_CAP;
d["msRemaining"] = diagLogArmed ? (int32_t)(diagLogUntil - millis()) : 0;
d["proxGain"] = proxGain; d["magGain"] = magGain; // v2.16.0: currently-applied per-node calibration
String o; serializeJson(d, o); server.send(200, "application/json", o);
});
server.on("/diag/log/download", []() {
// Snapshot the count so a row cannot be half-written while streaming.
uint16_t n = diagLogCount;
String csv;
csv.reserve((size_t)n * 72 + 128);
csv += "ts_ms,rc_charge_us_x100,rc_base_us_x100,rc_ok,rc_fault,prox_delta,prox_alert,sat_armed,mag_delta,mag_alert,alert_phase,rssi_dbm,rc_timeout_us\n";
for (uint16_t i = 0; i < n; ++i) {
DiagLogRow &r = diagLogRows[i];
csv += r.ts; csv += ',';
csv += r.rcCharge10; csv += ',';
csv += r.rcBase10; csv += ',';
csv += r.rcOk; csv += ',';
csv += r.rcFault; csv += ',';
csv += r.proxDelta; csv += ',';
csv += r.proxAlert; csv += ',';
csv += r.satArmed; csv += ',';
csv += r.magDelta; csv += ',';
csv += r.magAlert; csv += ',';
csv += r.alertPhaseSnap; csv += ',';
csv += r.rssi; csv += ',';
csv += r.rcTimeoutUsRaw; csv += '\n';
}
server.sendHeader("Content-Disposition", "attachment; filename=specter-node-diag.csv");
server.send(200, "text/csv", csv);
});
// Restart the node so a chipset / data-pin change binds cleanly at boot.
server.on("/reboot", HTTP_POST, []() {
server.send(200, "application/json", "{\"ok\":true,\"rebooting\":true}");
delay(300); // let the HTTP response fully flush to the browser
// NVS was already committed by whichever handler opened it (e.g. /led/config
// closes its own handle). Do NOT call a bare prefs.end() here: with no handle
// open it is a no-op at best, and it muddied the begin/end balance. Just give
// the flash a beat and restart cleanly so any pending NVS page is flushed.
delay(50);
ESP.restart();
});
// ---- OTA self-update API (portal-driven) ----
server.on("/update/status", []() {
JsonDocument d;
d["current"]=FW_VERSION; d["channel"]=updateUrl; d["checked"]=updChecked;
d["latest"]=updAvailVer; d["notes"]=updNotes; d["error"]=updError;
d["available"]=(updChecked && updAvailVer.length() && versionCompare(FW_VERSION, updAvailVer)>0);
// Last FAILED OTA (persisted across the recovery reboot) so field
// failures are diagnosable: reason, driver string, and the heap we had.
d["lastOtaErr"]=lastOtaErr; d["lastOtaErrStr"]=lastOtaErrStr;
d["lastOtaHeap"]=lastOtaErrHeap; d["lastOtaBlock"]=lastOtaErrBlk;
d["maxBlock"]=(uint32_t)ESP.getMaxAllocHeap();
d["freeHeap"]=(uint32_t)ESP.getFreeHeap();
String o; serializeJson(d,o); server.send(200, "application/json", o);
});
server.on("/update/check", []() {
bool avail = checkForUpdate();
JsonDocument d;
d["current"]=FW_VERSION; d["latest"]=updAvailVer; d["notes"]=updNotes;
d["available"]=avail; d["error"]=updError;
d["freeHeap"]=(uint32_t)ESP.getFreeHeap();
d["maxBlock"]=(uint32_t)ESP.getMaxAllocHeap();
d["otaFreeHeap"]=otaFreeHeap; d["otaMaxBlock"]=otaMaxBlock; d["otaStage"]=updOtaStage;
String o; serializeJson(d,o); server.send(200, "application/json", o);
});
server.on("/update/install", HTTP_GET, []() {
// Browser-friendly: same logic as POST so typing the URL works
if (updBinUrl.length()==0) checkForUpdate();
if (updBinUrl.length()==0) { server.send(409, "application/json", "{\"ok\":false,\"error\":\"" + (updError.length()?updError:String("no-update")) + "\"}"); return; }
server.send(200, "application/json", "{\"ok\":true,\"installing\":\"" + updAvailVer + "\"}");
delay(150);
performUpdate(updBinUrl);
});
server.on("/update/install", HTTP_POST, []() {
if (updBinUrl.length()==0) checkForUpdate();
if (updBinUrl.length()==0) { server.send(409, "application/json", "{\"ok\":false,\"error\":\"" + (updError.length()?updError:String("no-update")) + "\"}"); return; }
server.send(200, "application/json", "{\"ok\":true,\"installing\":\"" + updAvailVer + "\"}");
delay(150);
performUpdate(updBinUrl); // reboots on success
});
server.begin();
localWS.begin(); localWS.onEvent(localWsEvent);
// Remove vanished browser sockets quickly so they cannot consume all five
// WebSocket slots or stall broadcast writes indefinitely.
localWS.enableHeartbeat(15000, 3000, 2);
}
void loadProximityPrefsEarly() {
Preferences early; early.begin("specter", true);
proxMode = early.getInt("proxMode", 0);
touchPin = early.getInt("touchPin", TOUCH_PIN_DEFAULT);
// early boot: mode first, then the matching per-mode tuning
{ int em = early.getInt("proxMode", 0);
int legacyT = early.getInt("prox", 0), legacyS = early.getInt("proxSpan", 0);
if (em == 1) {
threshProx = early.getInt("proxRC", legacyT > 0 ? constrain(legacyT * 10, RC_THRESH_MIN, 400) : RC_THRESH_DEFAULT);
proxSpan = early.getInt("proxSpanRC", legacyS > 0 ? legacyS * 10 : RC_SPAN_DEFAULT);
proxNoise = early.getInt("proxNoiseRC", RC_NOISE_DEFAULT);
} else {
threshProx = legacyT > 0 ? legacyT : 8;
proxSpan = legacyS > 0 ? legacyS : 60;
proxNoise = early.getInt("proxNoise", 6);
} }
rcMarginMult = early.getInt("rcMarginX", 5);
rcMarginFloorUs = early.getInt("rcMarginFloorUs", 40);
proxAlertMs = early.getInt("alertMs", 6000);
proxCooldownMs = early.getInt("cooldownMs", 6000);
proxArmMs = early.getInt("armMs", 150);
heartbeatResumeMs = early.getInt("hbResumeMs", 2000);
audibleAlerts = early.getInt("audible", 1) == 1;
proxMinHoldMs = early.getInt("minHoldMs", PROX_MIN_ALERT_MS_DEFAULT);
proxTheremin = early.getInt("therm", 1) == 1;
proxToneOn = early.getInt("thermTone", 0) == 1;
proxCurve = early.getInt("proxCurve", 1);
fieldLock = early.getInt("fieldLock", 0) == 1;
early.end();
}
void setup() {
Serial.begin(115200);
bootMs = millis();
loadIdentityAndHeartbeat();
loadProximityPrefsEarly();
xTaskCreatePinnedToCore(hardwareTask, "HW_Task", 4096, NULL, 1, NULL, 1);
setupNetwork();
}
void loop() {
uint32_t _t0 = millis(); server.handleClient(); recordStall("handleClient", millis() - _t0);
_t0 = millis(); localWS.loop(); recordStall("localWS.loop", millis() - _t0);
_t0 = millis(); appWS.loop(); recordStall("appWS.loop", millis() - _t0);
_t0 = millis(); relayAssistLoop(); recordStall("relayAssist", millis() - _t0);
uint32_t now = millis();
uint32_t period = 1000 / (telemetryHz > 0 ? telemetryHz : TELEMETRY_HZ_DEFAULT);
if (now - lastTelemetry >= period) {
lastTelemetry = now; seqNum++;
JsonDocument doc;
doc["t"] = "telemetry"; doc["ts"] = now; doc["seq"] = seqNum;
doc["nodeId"] = stableNodeId; doc["slot"] = nodeSlot;
doc["heartbeatOn"] = heartbeatEnabled;
doc["prox"] = proxVal; doc["proxBase"] = baseProx; doc["touchPin"] = touchPin; doc["proxDelta"] = proxDeltaG; doc["proxDeltaRaw"] = proxDeltaRawG; doc["proxGain"] = proxGain; doc["proxThr"] = effThreshProx; doc["proxThrSet"] = threshProx; doc["proxNoiseFloor"] = proxNoiseFloor; doc["proxField"] = proxField; doc["theremin"] = proxTheremin; doc["thermTone"] = proxToneOn; doc["proxSpan"] = proxSpan; doc["proxNoise"] = proxNoise; doc["proxMode"] = proxMode; doc["rcCharge"] = rcCharge10; doc["rcBase"] = rcBase10; doc["rcOk"] = rcValid; doc["rcFault"] = rcFault; doc["rcDrivePin"] = activeRcDrivePin(); doc["rcBuzzerPinsSwapped"] = rcBuzzerPinsSwapped; doc["rcSensePin"] = RC_SENSE_PIN; doc["rcTimeoutUs"] = rcTimeoutUs; doc["rcMarginX"] = rcMarginMult; doc["rcMarginFloorUs"] = rcMarginFloorUs; doc["lock"] = fieldLock; doc["proxCurve"] = proxCurve; doc["rcSub"] = RC_SUB; doc["hbResumeMs"] = heartbeatResumeMs; doc["minHoldMs"] = proxMinHoldMs; doc["audible"] = audibleAlerts; doc["threshMax"] = (proxMode == 1 ? 400 : 100);
JsonObject mag = doc["mag"].to<JsonObject>();
mag["x"] = magX; mag["y"] = magY; mag["z"] = magZ;
doc["magDelta"] = magDelta; // live vector delta from baseline (LSB), CALIBRATED (magGain applied)
doc["magDeltaRaw"] = magDeltaRawG; // uncalibrated, diagnostic visibility only
doc["magGain"] = magGain; // currently-applied per-node calibration multiplier
doc["magThr"] = threshMag; // trigger threshold (LSB, derived)
doc["magSens"] = magSens; // 0..100 sensitivity slider value
doc["magOk"] = magPresent;
doc["magDataOk"] = magDataValid; // false = identified device has not delivered nonzero XYZ data
doc["magChip"] = (magType==3)?"HMC5883L":(magType==2)?"QMC5883P":(magType==1)?"QMC5883L":"none";
char i2cList[80] = {0}; size_t i2cPos = 0;
uint8_t i2cN = i2cFoundCount;
for (uint8_t i=0; i<i2cN && i<16 && i2cPos+6<sizeof(i2cList); ++i)
i2cPos += snprintf(i2cList+i2cPos, sizeof(i2cList)-i2cPos, "%s0x%02X", i?" ":"", i2cFound[i]);
doc["i2c"] = i2cN ? i2cList : "none";
doc["hz"] = telemetryHz;
String out; serializeJson(doc, out);
if (isAppConnected) appWS.sendTXT(out);
else raSendTelemetry(now);
localWS.broadcastTXT(out);
while (evtTail != evtHead) { // drain EVERY pending event this tick
EvtSlot &q = evtRing[evtTail];
// Mirror into the legacy globals so the Relay Assist payload builder
// (raSendEvent) keeps working untouched.
strncpy(evtKind, q.kind, sizeof(evtKind)-1); evtKind[sizeof(evtKind)-1] = 0;
strncpy(evtChannel, q.channel, sizeof(evtChannel)-1); evtChannel[sizeof(evtChannel)-1] = 0;
strncpy(evtSeverity, q.severity, sizeof(evtSeverity)-1); evtSeverity[sizeof(evtSeverity)-1] = 0;
evtValue = q.value; evtThreshold = q.threshold;
JsonDocument e;
e["t"] = "event"; e["ts"] = now; e["seq"] = q.seq;
e["kind"] = evtKind; e["channel"] = evtChannel;
e["value"] = evtValue; e["threshold"] = evtThreshold; e["severity"] = evtSeverity;
String eo; serializeJson(e, eo);
if (isAppConnected) appWS.sendTXT(eo);
else raSendEvent(now);
localWS.broadcastTXT(eo);
evtTail = (uint8_t)((evtTail + 1) % EVT_RING);
}
}
// Wi-Fi reconnect watchdog: if we have saved creds but aren't on the network
// (e.g. rebooted while the router was still coming up after a power blip),
// re-issue begin() every ~15s so the node rejoins on its own -- no manual reboot.
static uint32_t lastWifiTry = 0;
// AP+STA radios share one channel. A station join/retry can move the SoftAP
// channel and drop a laptop using the local portal. Never attempt a station
// reconnect while a hotspot client is attached. When unattended, retry once
// per minute so field-network recovery remains automatic without thrashing.
uint8_t apClientsNow = WiFi.softAPgetStationNum();
const uint32_t wifiRetryMs = raRoute.valid ? 300000 : 60000;
if (targetSSID.length() > 0 && WiFi.status() != WL_CONNECTED
&& apClientsNow == 0 && now - lastWifiTry >= wifiRetryMs) {
lastWifiTry = now;
WiFi.begin(targetSSID.c_str(), targetPASS.c_str());
}
// App-link watchdog: if we began an app WS connect but never reached
// CONNECTED within APP_LINK_TIMEOUT_MS, the target (often a STALE saved
// appip from a previous config) is unreachable. Tear the client down and
// release the started flag so the UDP beacon path can rediscover the app.
// Relay mode is exempt (it has no beacon to fall back to).
// Fire ONLY when we began a connect and have NEVER reached CONNECTED
// (appLinkOkAt == 0). If we connected at least once, a later drop is a
// transient the library will reconnect on its own -- don't tear it down.
if (appWSStarted && !isAppConnected && appLinkOkAt == 0 && relayUrl.length() == 0
&& appStartAt != 0 && now - appStartAt >= APP_LINK_TIMEOUT_MS) {
appWS.disconnect();
appWSStarted = false;
appStartAt = 0;
// Drop the stale saved IP so we stop hammering it and honor fresh beacons.
if (targetAppIP.length() > 0) {
targetAppIP = "";
prefs.begin("specter", false); prefs.remove("appip"); prefs.end();
}
}
// Long-drop recovery: we DID connect once, but the link has now been down
// for much longer than the reconnect interval (app quit, or the hotspot
// handed the app a new IP so the library keeps retrying a dead address).
// Reopen LAN discovery so a fresh beacon can point us at the new IP. Only
// in LAN mode, and only after a generous grace so normal blips self-heal.
if (appWSStarted && !isAppConnected && appLinkOkAt != 0 && relayUrl.length() == 0
&& now - appLinkOkAt >= APP_LINK_RELINK_MS) {
appWS.disconnect();
appWSStarted = false;
appStartAt = 0;
appLinkOkAt = 0; // require a fresh CONNECTED before this branch can fire again
}
if (now - lastNet >= 1000) {
lastNet = now;
JsonDocument n;
n["t"] = "net"; n["ts"] = now; n["rssi"] = WiFi.RSSI();
n["ip"] = WiFi.localIP().toString(); n["uptime"] = (now - bootMs) / 1000;
n["ssid"] = WiFi.SSID(); n["clients"] = apClientsNow;
n["heap"] = ESP.getFreeHeap(); n["magOk"] = magPresent;
n["appStarted"] = appWSStarted; n["appConnected"] = isAppConnected;
n["appIp"] = targetAppIP; n["appAgeMs"] = appStartAt ? (now - appStartAt) : 0;
n["stallCount"] = stallLogCount;
String no; serializeJson(n, no);
if (isAppConnected) appWS.sendTXT(no);
else raSendNet(now, apClientsNow);
localWS.broadcastTXT(no);
}
}
If you've used a REM pod or a K2 (K-II) EMF meter, the SPECTER Node covers similar ground with a capacitive proximity antenna in the spirit of a REM pod's field-disruption trigger, and a magnetometer reading in the spirit of a K2 meter's ambient field readout, but wireless, timestamped, and logged straight into an evidence archive instead of just a light and a tone.
| REM pod | K2 / K-II meter | SPECTER Node | |
|---|---|---|---|
| Typical cost | $80-160 retail | $50-90 retail | $15-25 in parts, free guide |
| Output | Light + tone | 5-LED bar | Timestamped data, logged and archived |
| Direction | No | No | Bearing needle points toward the source |
| Wireless | No | No | Yes, streams over Wi-Fi to SPECTER |
Read the full write-up: REM Pod vs K2 Meter vs a Free DIY EMF Sensor →