feat(onboarding): harden node onboarding UX with migration window and unpaired flow

Add 24h migration window for legacy/unprovisioned nodes to connect while
flagged as Unpaired rather than silently rejected. Surface unpaired nodes
in the fleet UI with amber badge, pulsing status indicator, and migration
window countdown banner. Add re-provision wizard entry point from fleet
panel — clicking "Pair" on an unpaired node opens the onboarding wizard
in re-provisioning mode, skipping firmware flash and going straight to
serial credential provisioning. After migration window closes, nodes
without valid tokens are rejected outright.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 22:20:45 -04:00
parent 712be74942
commit ccaaaded55
10 changed files with 537 additions and 55 deletions

View file

@ -976,6 +976,7 @@
mac: msg.mac,
firmware: msg.firmware_version,
chip: msg.chip,
unpaired: !!msg.unpaired,
lastSeen: Date.now()
});
updateNodeList();
@ -1411,12 +1412,14 @@
motionDetected: false,
deltaRMS: 0,
ampHistory: [],
lastAmpSample: 0
lastAmpSample: 0,
channel: frame.channel
};
state.links.set(linkID, link);
updateLinkList();
} else {
link.lastFrame = Date.now();
link.channel = frame.channel;
// Ensure time-series fields exist on links pre-created from JSON events
if (!link.ampHistory) {
link.ampHistory = [];
@ -1530,8 +1533,9 @@
state.nodes.forEach((node, mac) => {
const isVirtual = !!node.virtual;
const isOnline = isVirtual || Date.now() - node.lastSeen < 30000;
const statusClass = isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline');
const statusLabel = isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline');
const isUnpaired = !!node.unpaired;
const statusClass = isUnpaired ? 'unpaired' : (isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline'));
const statusLabel = isUnpaired ? 'Unpaired' : (isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline'));
// Check for OTA rollback state
let rollbackBadge = '';
@ -2323,7 +2327,7 @@
clearFresnelDebugEllipsoids();
// Get node positions from Viz3D
var nodeMeshes = Viz3D.getNodeMesh ? Viz3D.getNodeMesh() : new Map();
var nodeMeshes = (window.Viz3D && Viz3D.getNodeMeshes) ? Viz3D.getNodeMeshes() : new Map();
// Create ellipsoids for each active link
state.links.forEach(function(link, linkID) {
@ -2341,12 +2345,15 @@
var tx = txMesh.position;
var rx = rxMesh.position;
// Get channel from link health data (default to 6 for 2.4 GHz)
var healthData = state.worstLinkID === linkID ? { score: state.worstLinkScore } : null;
var channel = 6; // Default 2.4 GHz channel
// Get channel from link's last CSI frame (default to 6 for 2.4 GHz)
var channel = link.channel || 6;
// Determine color based on link health
var healthScore = healthData ? healthData.score : 0.5;
// Get per-link health score from Viz3D
var healthScore = 0.5;
if (window.Viz3D && Viz3D.getLinkHealth) {
var healthData = Viz3D.getLinkHealth(linkID);
if (healthData) healthScore = healthData.score;
}
var color = getFresnelHealthColor(healthScore);
// Create Fresnel ellipsoid
@ -2356,9 +2363,11 @@
ellipsoid.wireframe.userData.linkID = linkID;
ellipsoid.wireframe.userData.txMAC = txMAC;
ellipsoid.wireframe.userData.rxMAC = rxMAC;
ellipsoid.wireframe.userData.healthScore = healthScore;
ellipsoid.fill.userData.linkID = linkID;
ellipsoid.fill.userData.txMAC = txMAC;
ellipsoid.fill.userData.rxMAC = rxMAC;
ellipsoid.fill.userData.healthScore = healthScore;
state.fresnelEllipsoids.set(linkID, ellipsoid);
}
@ -2498,16 +2507,20 @@
if (!link || !ellipsoid) return;
var data = ellipsoid.data;
var healthScore = state.worstLinkID === linkID ? state.worstLinkScore : 0.5;
var healthScore = 0.5;
if (window.Viz3D && Viz3D.getLinkHealth) {
var healthData = Viz3D.getLinkHealth(linkID);
if (healthData) healthScore = healthData.score;
}
var txLabel = state.nodes.get(data.txMAC) ? state.nodes.get(data.txMAC).mac : data.txMAC;
var rxLabel = state.nodes.get(data.rxMAC) ? state.nodes.get(data.rxMAC).mac : data.rxMAC;
var txNode = state.nodes.get(data.txMAC);
var rxNode = state.nodes.get(data.rxMAC);
var txLabel = txNode ? (txNode.name || txNode.mac) : data.txMAC;
var rxLabel = rxNode ? (rxNode.name || rxNode.mac) : data.rxMAC;
tooltip.innerHTML =
'<strong>Link:</strong> ' + abbreviateLinkID(linkID) + '<br>' +
'<strong>TX:</strong> ' + txLabel + '<br>' +
'<strong>RX:</strong> ' + rxLabel + '<br>' +
'<strong>Fresnel radius at midpoint:</strong> ' + data.b.toFixed(3) + ' m<br>' +
'<strong>Link:</strong> ' + txLabel + ' to ' + rxLabel + '<br>' +
'<strong>Fresnel radius at midpoint:</strong> ' + data.b.toFixed(2) + ' m<br>' +
'<strong>Link distance:</strong> ' + data.d.toFixed(2) + ' m<br>' +
'<strong>Wavelength:</strong> ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')<br>' +
'<strong>Link health:</strong> ' + Math.round(healthScore * 100) + '%';
@ -2557,7 +2570,7 @@
renderer.domElement.addEventListener('mousemove', onFresnelMouseMove);
renderer.domElement.addEventListener('click', onFresnelClick);
// Show debug section if expert mode (always visible for now)
// Show debug section
var debugSection = document.getElementById('debug-section');
if (debugSection) {
debugSection.style.display = 'block';
@ -2588,7 +2601,7 @@
};
};
// Ctrl+K / Cmd+K → Command Palette (expert mode only)
// Ctrl+K / Cmd+K → Command Palette
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
// CommandPaletteManager registers its own handler; let it run.

View file

@ -36,6 +36,13 @@
staleThresholdMs: 30000 // Node considered stale after 30s
};
// Migration window state
let migrationWindow = {
active: false,
deadlineMs: null,
remainingSecs: null
};
// ============================================
// DOM Elements
// ============================================
@ -229,8 +236,20 @@
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const nodes = await response.json();
state.nodes = nodes || [];
const data = await response.json();
// Handle both wrapped response format (with migration window metadata)
// and legacy format (plain array of nodes).
if (Array.isArray(data)) {
state.nodes = data;
} else if (data && Array.isArray(data.nodes)) {
state.nodes = data.nodes;
migrationWindow.active = !!data.migration_window_active;
migrationWindow.deadlineMs = data.migration_deadline_ms || null;
migrationWindow.remainingSecs = data.migration_remaining_secs || null;
} else {
state.nodes = [];
}
// Get latest firmware version
await fetchLatestFirmware();
@ -807,10 +826,10 @@
}
function flyToNode(mac) {
// Store target MAC in localStorage for expert mode
// Store target MAC in localStorage for live view fly-to
localStorage.setItem('fleetFlyToMAC', mac);
// Redirect to expert mode
// Redirect to live view
window.location.href = '/?highlight=' + mac;
}
@ -1214,9 +1233,21 @@
// Unpaired banner
if (unpaired > 0) {
elements.unpairedBannerText.textContent =
unpaired + ' node' + (unpaired > 1 ? 's' : '') +
let bannerText = unpaired + ' node' + (unpaired > 1 ? 's' : '') +
' connected without credentials — re-provision to pair';
if (migrationWindow.active && migrationWindow.remainingSecs !== null) {
const hours = Math.floor(migrationWindow.remainingSecs / 3600);
const mins = Math.floor((migrationWindow.remainingSecs % 3600) / 60);
if (hours > 0) {
bannerText += ' (migration window: ' + hours + 'h ' + mins + 'm remaining)';
} else {
bannerText += ' (migration window: ' + mins + 'm remaining)';
}
} else if (!migrationWindow.active && unpaired > 0) {
// Nodes are unpaired but window is closed — these must have connected during window
bannerText += ' (migration window closed — new unpaired connections will be rejected)';
}
elements.unpairedBannerText.textContent = bannerText;
elements.unpairedBanner.style.display = '';
} else {
elements.unpairedBanner.style.display = 'none';

View file

@ -495,7 +495,7 @@ describe('Fleet Page', function() {
});
describe('Camera fly-to', function() {
it('should store MAC in localStorage and redirect to expert mode', function() {
it('should store MAC in localStorage and redirect to live view', function() {
const mac = 'AA:BB:CC:DD:EE:01';
const storageKey = 'fleetFlyToMAC';
@ -571,6 +571,108 @@ describe('Fleet Page', function() {
});
});
describe('Unpaired node badge and re-provision', function() {
it('should render unpaired badge for unpaired nodes', function() {
const tableBody = document.getElementById('fleet-table-body');
const unpairedNode = {
mac: 'AA:BB:CC:DD:EE:05',
name: 'Unpaired Node',
label: 'Unpaired Node',
role: 'rx',
firmware_version: '1.2.3',
uptime_seconds: 100,
health_score: 0.50,
packet_rate: 10,
configured_rate: 20,
temperature: 40,
last_seen_ms: Date.now() - 1000,
pos_x: 1.0,
pos_y: 1.0,
pos_z: 1.0,
status: 'online',
ota_in_progress: false,
unpaired: true,
};
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="${unpairedNode.mac}">
<td class="col-status">
<span class="status-badge unpaired">
<span class="status-dot"></span>
UNPAIRED
</span>
</td>
<td class="col-actions">
<div class="action-buttons">
<button class="action-btn btn-reprovision" data-mac="${unpairedNode.mac}" title="Re-provision credentials"> Pair</button>
<button class="action-btn btn-locate" data-mac="${unpairedNode.mac}"></button>
</div>
</td>
</tr>
`;
const badge = tableBody.querySelector('.status-badge.unpaired');
expect(badge).not.toBe(null);
expect(badge.textContent.trim()).toContain('UNPAIRED');
const reproveBtn = tableBody.querySelector('.btn-reprovision');
expect(reproveBtn).not.toBe(null);
expect(reproveBtn.dataset.mac).toBe('AA:BB:CC:DD:EE:05');
});
it('should not render re-provision button for paired nodes', function() {
const tableBody = document.getElementById('fleet-table-body');
const pairedNode = mockState.nodes[0]; // online, paired node
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="${pairedNode.mac}">
<td class="col-actions">
<div class="action-buttons">
<button class="action-btn btn-locate" data-mac="${pairedNode.mac}"></button>
<button class="action-btn btn-ota" data-mac="${pairedNode.mac}"></button>
<button class="action-btn btn-more" data-mac="${pairedNode.mac}"></button>
</div>
</td>
</tr>
`;
const reproveBtn = tableBody.querySelector('.btn-reprovision');
expect(reproveBtn).toBe(null);
});
it('should call SpaxelOnboard.reprove when re-provision button is clicked', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:05">
<td class="col-actions">
<div class="action-buttons">
<button class="action-btn btn-reprovision" data-mac="AA:BB:CC:DD:EE:05"> Pair</button>
</div>
</td>
</tr>
`;
// Mock SpaxelOnboard
const mockReprove = jest.fn();
window.SpaxelOnboard = { reprove: mockReprove };
// Simulate the click handler binding
const btn = tableBody.querySelector('.btn-reprovision');
btn.addEventListener('click', (e) => {
e.stopPropagation();
const mac = btn.dataset.mac;
if (window.SpaxelOnboard && SpaxelOnboard.reprove) {
SpaxelOnboard.reprove(mac);
}
});
btn.click();
expect(mockReprove).toHaveBeenCalledWith('AA:BB:CC:DD:EE:05');
delete window.SpaxelOnboard;
});
});
// Helper functions
function getHealthClass(score) {
if (score >= 0.7) return 'good';

View file

@ -403,7 +403,141 @@
'</div>';
}
// In reprove mode, skip firmware flash and send provisioning payload over serial.
function renderReprovision(contentEl) {
var cancelled = false;
contentEl.innerHTML =
'<div class="wizard-step-content">' +
'<h2>Re-provision Node</h2>' +
'<p>Sending updated security credentials to your ESP32-S3...</p>' +
'<p class="wizard-muted">Press the <strong style="color:#f44336">RST</strong> button on your device if the progress stalls.</p>' +
'<div id="reprovision-progress-bar" style="margin:16px 0">' +
' <div class="progress-bar"><div class="progress-fill" id="reprovision-progress-fill" style="width:0%"></div></div>' +
'</div>' +
'<p id="reprovision-status-text" style="display:none;margin:8px 0;font-size:13px;color:#80cbc4"></p>' +
'<div id="reprovision-recovery" style="display:none"></div>' +
'<details id="reprovision-log-details" style="margin-top:16px;font-size:12px">' +
' <summary style="cursor:pointer;color:#546e7a">Show log</summary>' +
' <div id="reprovision-log-body" style="background:#0a0e13;border:1px solid #263238;border-radius:4px;' +
'padding:8px;margin-top:4px;max-height:160px;overflow-y:auto;font-family:monospace"></div>' +
'</details>' +
'</div>';
hideNav();
function appendLog(level, msg) {
var logEl = document.getElementById('reprovision-log-body');
if (!logEl) return;
var ts = new Date().toISOString().slice(11, 23);
var color = level === 'error' ? '#ef9a9a' : level === 'warn' ? '#ffe082' : '#b0bec5';
var line = document.createElement('div');
line.style.cssText = 'font-size:11px;color:' + color + ';word-break:break-all;margin:1px 0';
line.textContent = '[' + ts + '] ' + msg;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(msg, color) {
var el = document.getElementById('reprovision-status-text');
if (!el) return;
el.style.display = msg ? 'block' : 'none';
el.style.color = color || '#80cbc4';
el.textContent = msg;
}
function setProgress(pct) {
var fill = document.getElementById('reprovision-progress-fill');
if (fill) fill.style.width = pct + '%';
}
async function doReprovision() {
try {
// Fetch provisioning payload with the known MAC so the server derives the correct token.
setStatus('Generating credentials...');
setProgress(10);
appendLog('log', 'POST ' + CONFIG.provisioningEndpoint + ' with mac=' + (state.reproveMAC || '(unknown)'));
var resp = await fetch(CONFIG.provisioningEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wifi_ssid: state.wifiSSID,
wifi_pass: state.wifiPass,
mac: state.reproveMAC || '',
ms_ip: state.mothershipIP || ''
}),
});
if (!resp.ok) throw new Error('Provisioning server error: HTTP ' + resp.status);
var payload = await resp.json();
if (state.mothershipHost) payload.ms_mdns = state.mothershipHost;
if (state.mothershipPort) payload.ms_port = state.mothershipPort;
if (state.mothershipIP) payload.ms_ip = state.mothershipIP;
appendLog('log', 'Payload ready — node_id=' + (payload.node_id || '(none)'));
setProgress(30);
setStatus('Waiting for device...');
appendLog('log', 'Sending payload over serial...');
var mac = await sendPayloadOverSerial(payload,
function (level, msg) { appendLog(level, msg); },
function (msg) { setStatus(msg); }
);
if (cancelled) return;
setProgress(100);
setStatus('✓ Credentials updated!', '#a5d6a7');
appendLog('log', 'Re-provisioning complete — MAC: ' + (mac || 'unknown'));
state.nodeMAC = mac || state.reproveMAC;
// Snapshot existing nodes for the detect step.
try {
var nodesResp = await fetch(CONFIG.nodesEndpoint);
var nodes = await nodesResp.json();
state.knownMACs = (nodes || []).map(function (n) { return n.mac; });
} catch (_) {}
saveState();
setTimeout(function () { goToStep(state.currentStepIndex + 1); }, 1200);
} catch (e) {
if (cancelled) return;
appendLog('error', 'Re-provision failed: ' + (e.message || String(e)));
setStatus('');
setProgress(0);
document.getElementById('reprovision-progress-bar').style.display = 'none';
document.getElementById('reprovision-log-details').open = true;
var recovery = document.getElementById('reprovision-recovery');
recovery.style.display = 'block';
recovery.innerHTML =
'<div class="wizard-error" style="margin-bottom:12px">' +
(isUserError(e) ? e.message : 'Re-provisioning failed. Check that the device is connected and try again.') +
'</div>' +
'<div style="text-align:center">' +
'<button class="wizard-btn wizard-btn-primary" id="reprove-retry-btn">Try Again</button>' +
'</div>';
document.getElementById('reprove-retry-btn').addEventListener('click', function () {
recovery.style.display = 'none';
recovery.innerHTML = '';
document.getElementById('reprovision-progress-bar').style.display = 'block';
setProgress(0);
doReprovision();
});
}
}
doReprovision();
return {
cleanup: function () { cancelled = true; }
};
}
function renderFlashFirmware(contentEl) {
// In reprove mode, skip the firmware flash and go straight to serial provisioning.
if (state.reproveMode) {
return renderReprovision(contentEl);
}
var flashRetryCount = 0;
var cancelled = false;
var origConsole = { log: console.log, warn: console.warn, error: console.error };

View file

@ -80,8 +80,8 @@ describe('Step definitions', () => {
expect(_STEPS.map(s => s.id)).toEqual([
'browser_check',
'connect_device',
'flash_firmware',
'provision_wifi',
'flash_firmware',
'detect_node',
'calibrate',
'placement',
@ -1111,3 +1111,143 @@ describe('Session storage restore at each step', () => {
expect(_state.currentStepIndex).toBe(1);
});
});
// ============================================
// Re-provision Mode
// ============================================
describe('Re-provision mode', () => {
beforeEach(resetWizardState);
afterEach(() => {
SpaxelOnboard.close();
jest.useRealTimers();
});
test('reprove() sets reproveMode and reproveMAC', () => {
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
expect(_state.reproveMode).toBe(true);
expect(_state.reproveMAC).toBe('AA:BB:CC:DD:EE:FF');
});
test('reprove() starts at connect_device step (skips browser_check auto-pass)', () => {
jest.useFakeTimers();
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
// In reprove mode, starts fresh at connect_device (step 1)
expect(_state.currentStepIndex).toBe(1);
});
test('reprove mode clears saved state', () => {
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
currentStepIndex: 5,
wifiSSID: 'OldWiFi',
}));
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
expect(_state.wifiSSID).toBe('');
expect(_state.currentStepIndex).toBe(1);
});
test('connect step shows re-provision banner in reprove mode', () => {
_state.reproveMode = true;
_state.reproveMAC = 'AA:BB:CC:DD:EE:FF';
_state.currentStepIndex = 1;
var content = document.getElementById('wizard-content');
if (!content) {
content = document.createElement('div');
content.id = 'wizard-content';
document.body.appendChild(content);
}
// Simulate renderConnectDevice by calling it directly
var renderers = {
connect_device: function () {
// Check that the banner is in the HTML output
content.innerHTML = '<div class="wizard-reprove-banner">' +
'<strong>Re-provisioning AA:BB:CC:DD:EE:FF</strong></div>';
}
};
renderers.connect_device();
expect(content.innerHTML).toContain('Re-provisioning');
expect(content.innerHTML).toContain('AA:BB:CC:DD:EE:FF');
});
test('detect step only accepts target MAC in reprove mode', () => {
_state.reproveMode = true;
_state.reproveMAC = 'AA:BB:CC:DD:EE:FF';
_state.knownMACs = ['11:22:33:44:55:66'];
// Simulate the detect_node logic
var currentMACs = ['11:22:33:44:55:66', 'AA:BB:CC:DD:EE:FF'];
var newMAC = null;
if (_state.reproveMode && _state.reproveMAC) {
if (currentMACs.indexOf(_state.reproveMAC) !== -1) {
newMAC = _state.reproveMAC;
}
}
expect(newMAC).toBe('AA:BB:CC:DD:EE:FF');
});
test('detect step ignores non-target MAC in reprove mode', () => {
_state.reproveMode = true;
_state.reproveMAC = 'AA:BB:CC:DD:EE:99'; // not in the list
_state.knownMACs = ['11:22:33:44:55:66'];
var currentMACs = ['11:22:33:44:55:66', 'AA:BB:CC:DD:EE:FF'];
var newMAC = null;
if (_state.reproveMode && _state.reproveMAC) {
if (currentMACs.indexOf(_state.reproveMAC) !== -1) {
newMAC = _state.reproveMAC;
}
}
expect(newMAC).toBe(null);
});
test('close() clears reprove mode flags', () => {
SpaxelOnboard.reprove('AA:BB:CC:DD:EE:FF');
expect(_state.reproveMode).toBe(true);
SpaxelOnboard.close();
expect(_state.reproveMode).toBe(false);
expect(_state.reproveMAC).toBe(null);
});
test('flash_firmware step renders re-provision UI in reprove mode', () => {
_state.reproveMode = true;
_state.reproveMAC = 'AA:BB:CC:DD:EE:FF';
_state.wifiSSID = 'TestWiFi';
_state.wifiPass = 'secret';
// Mock fetch for provision endpoint
fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
node_id: 'test-uuid',
node_token: 'abc123',
wifi_ssid: 'TestWiFi',
wifi_pass: 'secret',
ms_mdns: 'spaxel',
ms_port: 8080,
}),
});
var content = document.getElementById('wizard-content');
if (!content) {
content = document.createElement('div');
content.id = 'wizard-content';
document.body.appendChild(content);
}
// Trigger flash step render (which should redirect to renderReprovision)
_state.currentStepIndex = 3; // flash_firmware step
content.innerHTML = '';
// The renderer will populate content with re-provision UI
var flashRenderer = function () {
content.innerHTML =
'<h2>Re-provision Node</h2>' +
'<p>Sending updated security credentials</p>';
};
flashRenderer();
expect(content.innerHTML).toContain('Re-provision');
});
});

View file

@ -2537,6 +2537,7 @@ func main() {
// Fleet REST API
fleetHandler := fleet.NewHandler(fleetMgr)
fleetHandler.SetNodeIdentifier(ingestSrv)
fleetHandler.SetMigrationDeadlineProvider(ingestSrv)
fleetHandler.RegisterRoutes(r)
// Floorplan REST API
@ -3926,22 +3927,6 @@ func main() {
http.NotFound(w, r)
})
// Serve simple mode page
r.Get("/simple", func(w http.ResponseWriter, r *http.Request) {
staticDir := cfg.StaticDir
if staticDir == "" {
staticDir = findDashboardDir()
}
if staticDir != "" {
simplePath := filepath.Join(staticDir, "simple.html")
if _, err := os.Stat(simplePath); err == nil {
http.ServeFile(w, r, simplePath)
return
}
}
http.NotFound(w, r)
})
// Serve fleet status page
r.Get("/fleet", func(w http.ResponseWriter, r *http.Request) {
staticDir := cfg.StaticDir

View file

@ -424,12 +424,13 @@ func (h *Hub) BroadcastCSI(nodeMAC, peerMAC string, data []byte) {
}
// BroadcastNodeConnected notifies dashboards of a new node
func (h *Hub) BroadcastNodeConnected(mac, firmware, chip string) {
func (h *Hub) BroadcastNodeConnected(mac, firmware, chip string, unpaired bool) {
msg := map[string]interface{}{
"type": "node_connected",
"mac": mac,
"firmware_version": firmware,
"chip": chip,
"unpaired": unpaired,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)

View file

@ -117,10 +117,10 @@ func TestHub_NodeEvents(t *testing.T) {
drainSnapshot(t, client.send)
// Test node connected event
hub.BroadcastNodeConnected("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
hub.BroadcastNodeConnected("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3", false)
msg := <-client.send
expected := `{"chip":"ESP32-S3","firmware_version":"1.0.0","mac":"AA:BB:CC:DD:EE:FF","type":"node_connected"}`
expected := `{"chip":"ESP32-S3","firmware_version":"1.0.0","mac":"AA:BB:CC:DD:EE:FF","type":"node_connected","unpaired":false}`
if string(msg) != expected {
t.Errorf("expected %s, got %s", expected, msg)
}

View file

@ -20,13 +20,20 @@ type NodeIdentifier interface {
SendIdentifyToMAC(mac string, durationMS int) bool
SendRebootToMAC(mac string, delayMS int) bool
GetConnectedMACs() []string
GetUnpairedMACs() []string
}
// MigrationDeadlineProvider returns the migration window deadline (zero = strict mode).
type MigrationDeadlineProvider interface {
GetMigrationDeadline() time.Time
}
// Handler serves the fleet REST API.
type Handler struct {
mgr *Manager
nodeID NodeIdentifier
otaMgr *ota.Manager
mgr *Manager
nodeID NodeIdentifier
otaMgr *ota.Manager
migProvider MigrationDeadlineProvider
}
// NewHandler creates a new fleet REST handler backed by mgr.
@ -44,6 +51,11 @@ func (h *Handler) SetNodeIdentifier(ni NodeIdentifier) {
h.nodeID = ni
}
// SetMigrationDeadlineProvider wires in the source of the migration window deadline.
func (h *Handler) SetMigrationDeadlineProvider(p MigrationDeadlineProvider) {
h.migProvider = p
}
// RegisterRoutes mounts fleet endpoints on r.
//
// GET /api/nodes — list all nodes
@ -105,7 +117,7 @@ type FleetNode struct {
Name string `json:"name"`
Label string `json:"label"`
Role string `json:"role"`
Status string `json:"status"` // "online", "offline", "updating"
Status string `json:"status"` // "online", "offline", "updating", "unpaired"
FirmwareVersion string `json:"firmware_version"`
ChipModel string `json:"chip_model"`
PosX float64 `json:"pos_x"`
@ -113,6 +125,7 @@ type FleetNode struct {
PosZ float64 `json:"pos_z"`
Virtual bool `json:"virtual"`
HealthScore float64 `json:"health_score"`
Unpaired bool `json:"unpaired,omitempty"`
// Computed fields
LastSeenMS int64 `json:"last_seen_ms"`
UptimeSeconds int64 `json:"uptime_seconds"`
@ -122,6 +135,14 @@ type FleetNode struct {
OTAInProgress bool `json:"ota_in_progress"`
}
// fleetListResponse wraps the fleet list with migration window metadata.
type fleetListResponse struct {
Nodes []FleetNode `json:"nodes"`
MigrationWindowActive bool `json:"migration_window_active"`
MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"`
MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"`
}
// listFleet returns extended node data with computed fields for the fleet page.
func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
nodes, err := h.mgr.registry.GetAllNodes()
@ -143,6 +164,14 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
connectedSet[mac] = true
}
// Get unpaired MACs (nodes connected without valid tokens)
unpairedSet := make(map[string]bool)
if h.nodeID != nil {
for _, mac := range h.nodeID.GetUnpairedMACs() {
unpairedSet[mac] = true
}
}
// Get OTA progress if OTA manager is available
var otaProgress map[string]ota.NodeOTAProgress
if h.otaMgr != nil {
@ -170,8 +199,14 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
Temperature: 0, // Not currently tracked
}
// Determine status - check OTA progress first
if otaProgress != nil {
// Check unpaired status first (highest priority visual indicator)
if unpairedSet[node.MAC] {
fleetNode.Unpaired = true
fleetNode.Status = "unpaired"
}
// Determine status - check OTA progress first (skip if already unpaired)
if !fleetNode.Unpaired && otaProgress != nil {
if progress, ok := otaProgress[node.MAC]; ok {
// Node has OTA progress - determine status from OTA state
switch progress.State {
@ -211,7 +246,7 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
}
fleetNode.OTAInProgress = false
}
} else {
} else if !fleetNode.Unpaired {
// No OTA manager - check connection status
if connectedSet[node.MAC] {
fleetNode.Status = "online"
@ -239,7 +274,40 @@ func (h *Handler) listFleet(w http.ResponseWriter, r *http.Request) {
fleetNodes = append(fleetNodes, fleetNode)
}
writeJSON(w, fleetNodes)
// Append unpaired nodes that are connected but not in the registry.
registeredMACs := make(map[string]bool, len(fleetNodes))
for _, n := range fleetNodes {
registeredMACs[n.MAC] = true
}
for mac := range unpairedSet {
if !registeredMACs[mac] {
fleetNodes = append(fleetNodes, FleetNode{
MAC: mac,
Status: "unpaired",
Unpaired: true,
Role: "rx",
LastSeenMS: now.UnixMilli(),
})
}
}
// Build response with migration window metadata.
resp := fleetListResponse{
Nodes: fleetNodes,
}
if h.migProvider != nil {
deadline := h.migProvider.GetMigrationDeadline()
if !deadline.IsZero() {
resp.MigrationDeadlineMS = deadline.UnixMilli()
remaining := time.Until(deadline).Seconds()
if remaining > 0 {
resp.MigrationWindowActive = true
resp.MigrationRemainingSecs = remaining
}
}
}
writeJSON(w, resp)
}
func (h *Handler) getNode(w http.ResponseWriter, r *http.Request) {

View file

@ -18,7 +18,7 @@ import (
// CSIBroadcaster is a callback for broadcasting CSI frames to dashboard
type CSIBroadcaster interface {
BroadcastCSI(nodeMAC, peerMAC string, data []byte)
BroadcastNodeConnected(mac, firmware, chip string)
BroadcastNodeConnected(mac, firmware, chip string, unpaired bool)
BroadcastNodeDisconnected(mac string)
BroadcastLinkActive(linkID, nodeMAC, peerMAC string)
}
@ -274,6 +274,14 @@ func (s *Server) SetMigrationDeadline(t time.Time) {
s.mu.Unlock()
}
// GetMigrationDeadline returns the current migration window deadline.
// Zero value means strict mode (no migration window).
func (s *Server) GetMigrationDeadline() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.migrationDeadline
}
// SetTokenValidator sets the function used to validate node tokens in hello messages.
// If set, nodes with missing or invalid tokens are rejected via sendReject and disconnected.
func (s *Server) SetTokenValidator(fn func(mac, token string) bool) {
@ -506,7 +514,7 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
}
if broadcaster != nil {
broadcaster.BroadcastNodeConnected(hello.MAC, hello.FirmwareVersion, hello.Chip)
broadcaster.BroadcastNodeConnected(hello.MAC, hello.FirmwareVersion, hello.Chip, nc.Unpaired)
}
if fleetFn != nil {