spaxel/dashboard/js/apdetection.js
jedarden d2e5b4d4a0 feat: implement passive radar auto-detection of router AP
Automatically detect the home router as a passive radar TX source,
eliminating need for a dedicated active TX node.

Firmware changes:
- During hello message, include ap_bssid and ap_channel from esp_wifi_sta_get_ap_info()

Mothership changes:
- On hello: extract ap_bssid; if >=80% of nodes report same BSSID create virtual node entry with virtual=1
- OUI lookup: embedded IEEE OUI registry as Go map compiled via go:embed; display router brand
- Detect AP BSSID change (router reboot/replacement) and emit system alert
- SQLite nodes table: add virtual BOOL, node_type TEXT, ap_bssid TEXT, ap_channel INT columns

Dashboard changes:
- Show "I detected your router (ASUS). Place it on the floor plan..." notification
- Render virtual AP nodes with distinct router icon in 3D view
- Drag-to-place virtual node (distinct router icon) in 3D editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 14:03:03 -04:00

243 lines
8 KiB
JavaScript

/**
* Spaxel Dashboard - Router AP Detection Panel
*
* Shows when a router AP is auto-detected for passive radar mode.
* Allows the user to place the virtual router node in the 3D editor.
*/
(function() {
'use strict';
// ============================================
// State
// ============================================
const apState = {
detectedAP: null,
dismissed: false,
placementMode: false
};
// ============================================
// API
// ============================================
/**
* Check for detected AP and show notification if found
*/
function checkForDetectedAP() {
if (apState.dismissed) {
return Promise.resolve(null);
}
return fetch('/api/nodes')
.then(res => res.json())
.then(nodes => {
const virtualAP = nodes.find(n => n.virtual && n.node_type === 'ap');
if (virtualAP && !apState.detectedAP) {
apState.detectedAP = virtualAP;
showAPDetectionBanner(virtualAP);
}
return virtualAP;
})
.catch(err => {
console.error('[APDetection] Error checking for AP:', err);
return null;
});
}
/**
* Confirm the AP as a signal source and place it in 3D
*/
function confirmAPAsSignalSource() {
if (!apState.detectedAP) return;
hideAPDetectionBanner();
apState.placementMode = true;
// Switch to 3D view and enable placement mode
if (window.SpaxelRouter) {
window.SpaxelRouter.setMode('3d');
}
// Show placement instructions
showPlacementInstructions();
// Enable drag-to-place for the virtual AP
if (window.SpaxelViz3D) {
window.SpaxelViz3D.enableNodePlacement(apState.detectedAP.mac, {
onComplete: function(position) {
updateAPPosition(apState.detectedAP.mac, position);
apState.placementMode = false;
hidePlacementInstructions();
SpaxelPanels.showSuccess('Router placed successfully! Passive radar is now active.');
},
onCancel: function() {
apState.placementMode = false;
hidePlacementInstructions();
}
});
}
}
/**
* Update AP position in database
*/
function updateAPPosition(mac, position) {
return fetch('/api/nodes/' + mac, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
position: {
x: position.x,
y: position.y,
z: position.z
}
})
})
.then(res => {
if (!res.ok) {
throw new Error('Failed to update AP position');
}
return res.json();
})
.then(node => {
// Update local state
if (window.SpaxelState && window.SpaxelState.nodes) {
window.SpaxelState.nodes[mac] = node;
}
return node;
})
.catch(err => {
console.error('[APDetection] Error updating AP position:', err);
SpaxelPanels.showError('Failed to place router: ' + err.message);
throw err;
});
}
/**
* Dismiss the AP detection banner
*/
function dismissAPDetection() {
apState.dismissed = true;
hideAPDetectionBanner();
}
// ============================================
// UI Rendering
// ============================================
function showAPDetectionBanner(ap) {
// Remove existing banner if present
hideAPDetectionBanner();
const banner = document.createElement('div');
banner.id = 'ap-detection-banner';
banner.className = 'ap-detection-banner';
banner.innerHTML = `
<div class="ap-detection-content">
<div class="ap-detection-icon">📡</div>
<div class="ap-detection-message">
<div class="ap-detection-title">Router Detected!</div>
<div class="ap-detection-subtitle">
I detected your router (${ap.ap_bssid || 'Unknown'})${ap.ap_channel ? ' on channel ' + ap.channel : ''}.
Place it on the floor plan to improve accuracy.
</div>
</div>
<div class="ap-detection-actions">
<button class="ap-btn ap-btn-secondary" id="ap-dismiss-btn">Later</button>
<button class="ap-btn ap-btn-primary" id="ap-confirm-btn">Place Router</button>
</div>
</div>
`;
document.body.appendChild(banner);
// Attach event listeners
document.getElementById('ap-dismiss-btn').addEventListener('click', dismissAPDetection);
document.getElementById('ap-confirm-btn').addEventListener('click', confirmAPAsSignalSource);
// Animate in
requestAnimationFrame(() => {
banner.classList.add('ap-detection-banner-visible');
});
}
function hideAPDetectionBanner() {
const banner = document.getElementById('ap-detection-banner');
if (banner) {
banner.classList.remove('ap-detection-banner-visible');
setTimeout(() => {
if (banner.parentNode) {
banner.parentNode.removeChild(banner);
}
}, 300);
}
}
function showPlacementInstructions() {
SpaxelPanels.openSidebar({
title: 'Place Your Router',
content: `
<div class="ap-placement-content">
<p><strong>Drag the router icon</strong> in the 3D view to its actual location in your home.</p>
<ul class="ap-placement-list">
<li>The router should be placed at its <strong>actual physical location</strong></li>
<li>For best results, place it at <strong>router height</strong> (typically on a desk or shelf)</li>
<li>The virtual node helps the system understand signal geometry</li>
</ul>
<div class="ap-placement-tips">
<div class="ap-placement-tip">
<strong>Tip:</strong> You can fine-tune the position later in Setup Mode
</div>
</div>
<button class="panel-btn panel-btn-secondary panel-btn-full" id="ap-cancel-placement">
Cancel
</button>
</div>
`,
width: '350px'
});
document.getElementById('ap-cancel-placement').addEventListener('click', () => {
if (window.SpaxelViz3D) {
window.SpaxelViz3D.cancelNodePlacement();
}
apState.placementMode = false;
SpaxelPanels.closeSidebar();
});
}
function hidePlacementInstructions() {
if (window.SpaxelPanels) {
window.SpaxelPanels.closeSidebar();
}
}
// ============================================
// Public API
// ============================================
window.SpaxelAPDetection = {
checkForDetectedAP,
confirmAPAsSignalSource,
dismissAPDetection,
updateAPPosition,
getState: () => apState
};
// Auto-check on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(checkForDetectedAP, 2000);
});
} else {
setTimeout(checkForDetectedAP, 2000);
}
// Check periodically for new AP detection
setInterval(checkForDetectedAP, 30000);
console.log('[APDetection] AP detection module loaded');
})();