diff --git a/CLAUDE.md b/CLAUDE.md index bec1f8b..5806c6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,11 @@ Access at http://localhost:{web_port} (configured in config.json) - **admin/** - Admin panel (login + tabbed settings UI) - Theme management: rename display names, upload per-direction background PNGs, create new themes - Display names stored in `config.json` `theme_names` map (folder name -> display name) + - **pixel-editor.js** - In-app Canvas2D pixel editor (Sprites tab "EDIT" button) + - Edits sprites at native 500x333; tools: pencil, eraser, color picker, fill bucket, pan + - Zoom/pan, undo/redo (snapshot-per-stroke), grid overlay, retro palette + native color input + - Exports a 500x333 PNG via `srcCanvas.toBlob` and POSTs to the existing `/api/admin/sprites/{type}` upload endpoint (no new server route) + - Keyboard: B/E/I/F tools, Space=pan, +/-/0 zoom, Ctrl+Z/Y undo/redo, G grid, Esc close - **setup/** - First-run setup wizard (multi-step configuration) diff --git a/admin/admin.css b/admin/admin.css index 44d6cd7..f5d75ff 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -592,6 +592,337 @@ textarea { font-size: 11px; } +/* =================================================================== + Receivers tab — status indicators & selectable scan results + =================================================================== */ +.rx-section-label { + font-family: 'Press Start 2P', monospace; + font-size: 10px; + color: #fcd444; + margin: 22px 0 12px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(92, 148, 252, 0.3); +} + +.rx-summary { + font-family: 'Courier New', monospace; + font-size: 12px; + color: #b4d4ec; +} + +#receiver-status-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.rx-status-item { + display: flex; + align-items: center; + gap: 10px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(92, 148, 252, 0.4); + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + flex-wrap: wrap; +} + +.rx-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 6px currentColor; +} +.rx-dot.green { background: #54fc54; color: #54fc54; } +.rx-dot.yellow { background: #fcd444; color: #fcd444; } +.rx-dot.red { background: #fc5454; color: #fc5454; } +.rx-dot.gray { background: #888; color: #888; box-shadow: none; } + +.rx-ip { + font-family: 'Courier New', monospace; + font-weight: bold; + color: #fcfcfc; + min-width: 110px; +} + +.rx-label { + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} +.rx-green { color: #54fc54; } +.rx-yellow { color: #fcd444; } +.rx-red { color: #fc5454; } +.rx-gray { color: #888; } + +.rx-detail { + color: #b4d4ec; + font-size: 11px; + margin-left: auto; +} + +.rx-scan-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 4px; + cursor: pointer; + border-bottom: 1px solid rgba(92, 148, 252, 0.2); +} + +.rx-scan-item input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #fcd444; + cursor: pointer; +} + +.rx-scan-ip { + font-family: 'Courier New', monospace; + font-weight: bold; + color: #fcfcfc; +} + +/* =================================================================== + Pixel Editor + =================================================================== */ +.pe-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +body.pe-open { overflow: hidden; } + +.pe-modal { + background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); + border: 3px solid #fcd444; + border-radius: 12px; + width: 100%; + max-width: 1100px; + height: 100%; + max-height: 760px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6); + overflow: hidden; +} + +.pe-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.3); + border-bottom: 2px solid #5c94fc; + flex-wrap: wrap; + gap: 8px; +} + +.pe-header .retro-title { + font-size: 13px; + margin: 0; +} + +.pe-header-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.pe-body { + flex: 1; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: 1fr; + min-height: 0; +} + +/* Toolbar (left column) */ +.pe-toolbar { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 10px; + background: rgba(0, 0, 0, 0.25); + border-right: 2px solid #5c94fc; + overflow-y: auto; +} + +.pe-tool-group { + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(92, 148, 252, 0.25); +} + +.pe-tool-group:last-child { border-bottom: none; padding-bottom: 0; } + +.pe-tool, .pe-icon-btn { + width: 44px; + height: 44px; + font-size: 20px; + line-height: 1; + border: 2px solid #5c94fc; + border-radius: 6px; + background: rgba(92, 148, 252, 0.15); + color: #fcfcfc; + cursor: pointer; + transition: all 0.12s; + display: flex; + align-items: center; + justify-content: center; +} + +.pe-tool:hover, .pe-icon-btn:hover:not(:disabled) { + background: rgba(92, 148, 252, 0.4); +} + +.pe-tool.active { + background: rgba(252, 212, 68, 0.3); + border-color: #fcd444; + color: #fcd444; +} + +.pe-icon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.pe-tool-label { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: #b4d4ec; + text-align: center; +} + +.pe-check { + display: flex; + align-items: center; + gap: 6px; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: #b4d4ec; + cursor: pointer; +} + +.pe-check input { width: 16px; height: 16px; accent-color: #fcd444; cursor: pointer; } + +.pe-brush-group { align-items: center; } +.pe-brush-group input[type="range"] { width: 44px; accent-color: #fcd444; } + +/* Canvas area (center column) */ +.pe-canvas-wrap { + position: relative; + min-width: 0; + background: #14202b; + overflow: hidden; +} + +#pe-view { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +/* Sidebar (right column) */ +.pe-sidebar { + width: 150px; + padding: 14px 12px; + background: rgba(0, 0, 0, 0.25); + border-left: 2px solid #5c94fc; + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; +} + +.pe-color-current { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.pe-swatch-large { + width: 100%; + height: 48px; + border: 2px solid #fcfcfc; + border-radius: 6px; + background: #5c94fc; +} + +#pe-color-input { + width: 100%; + height: 32px; + padding: 2px; + border: 2px solid #5c94fc; + border-radius: 6px; + background: rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.pe-palette { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; +} + +.pe-swatch { + aspect-ratio: 1; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 3px; + cursor: pointer; + padding: 0; + transition: transform 0.1s; +} + +.pe-swatch:hover { + transform: scale(1.12); + border-color: #fcd444; +} + +.pe-readout { + margin-top: auto; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: #b4d4ec; + line-height: 2.2; +} + +.pe-readout span { color: #54fc54; } + +/* Sprite card EDIT button uses primary accent */ +.sprite-edit-btn { border-color: #fcd444; color: #fcd444; } +.sprite-edit-btn:hover { background: rgba(252, 212, 68, 0.3); } + +@media (max-width: 768px) { + .pe-modal { max-height: none; border-radius: 0; } + .pe-body { grid-template-columns: auto 1fr; } + .pe-sidebar { + grid-column: 1 / -1; + grid-row: 2; + width: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + border-left: none; + border-top: 2px solid #5c94fc; + } + .pe-body { grid-template-rows: 1fr auto; } + .pe-palette { flex: 1; } + .pe-readout { margin-top: 0; } +} + /* Mobile */ @media (max-width: 768px) { .admin-header { diff --git a/admin/admin.html b/admin/admin.html index aeb4d84..196d28e 100644 --- a/admin/admin.html +++ b/admin/admin.html @@ -7,7 +7,7 @@ - + @@ -76,37 +76,69 @@ @@ -265,6 +297,7 @@ - + + diff --git a/admin/admin.js b/admin/admin.js index 46b640d..c2d10fd 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -78,6 +78,12 @@ themesManagerLoaded = true; loadThemeManager(); } + // Live receiver health polling only while the Receivers tab is open + if (tab.dataset.tab === 'receivers') { + startReceiverStatusPolling(); + } else { + stopReceiverStatusPolling(); + } }); }); @@ -191,11 +197,44 @@ }); html += ''; el.innerHTML = html; + + populateScanInterfaces(ifaces); } catch (e) { el.textContent = 'Error loading network info'; } } + // Build the "Interface / Subnet to Scan" dropdown from the host interfaces. + // Subnets larger than /20 (>4094 hosts) are disabled — the server rejects + // them anyway, and scanning e.g. a docker /16 is impractically slow. + const MAX_SCAN_HOSTS = 4094; + function populateScanInterfaces(ifaces) { + const sel = document.getElementById('scan-interface'); + if (!sel) return; + sel.innerHTML = ''; + + const allOpt = document.createElement('option'); + allOpt.value = ''; + allOpt.textContent = 'All local subnets (slower)'; + sel.appendChild(allOpt); + + let firstScannable = null; + ifaces.forEach(i => { + const prefix = parseInt((i.network.split('/')[1]) || '0', 10); + const hosts = Math.max(0, Math.pow(2, 32 - prefix) - 2); + const opt = document.createElement('option'); + opt.value = i.network; + const tooBig = hosts > MAX_SCAN_HOSTS; + opt.textContent = `${i.name} — ${i.network} (${hosts} hosts)` + (tooBig ? ' — too large' : ''); + opt.disabled = tooBig; + sel.appendChild(opt); + if (!tooBig && firstScannable === null) firstScannable = i.network; + }); + + // Default to the first reasonably-sized interface for a fast scan. + if (firstScannable !== null) sel.value = firstScannable; + } + // ------- Receivers ------- function toggleManualIps(mode) { document.getElementById('manual-ips-group').classList.toggle('hidden', mode === 'AUTO'); @@ -210,7 +249,10 @@ btn.textContent = 'SCANNING...'; btn.disabled = true; try { - const subnet = (document.getElementById('scan-subnet').value || '').trim(); + // Custom subnet text overrides the interface dropdown selection. + const custom = (document.getElementById('scan-subnet').value || '').trim(); + const iface = document.getElementById('scan-interface').value || ''; + const subnet = custom || iface; const fetchOpts = { method: 'POST' }; if (subnet) { fetchOpts.headers = { 'Content-Type': 'application/json' }; @@ -218,16 +260,7 @@ } const res = await fetch('/api/admin/scan-receivers', fetchOpts); const data = await res.json(); - const box = document.getElementById('scan-results'); - const list = document.getElementById('scan-results-list'); - box.classList.remove('hidden'); - if (data.error) { - list.textContent = data.error; - } else if (data.receivers && data.receivers.length) { - list.textContent = data.receivers.join(', '); - } else { - list.textContent = 'No receivers found on network'; - } + renderScanResults(data); } catch (e) { toast('Scan failed', true); } @@ -235,17 +268,77 @@ btn.disabled = false; }); + // Render scan results as a selectable checklist. + function renderScanResults(data) { + const box = document.getElementById('scan-results'); + const list = document.getElementById('scan-results-list'); + const useRow = document.getElementById('scan-use-row'); + box.classList.remove('hidden'); + list.innerHTML = ''; + + if (data.error) { + list.textContent = data.error; + useRow.style.display = 'none'; + return; + } + const found = data.receivers || []; + if (!found.length) { + list.textContent = 'No receivers found on network'; + useRow.style.display = 'none'; + return; + } + + // Pre-check IPs already in the manual list. + const current = new Set(document.getElementById('receiver-ips-input').value + .split('\n').map(s => s.trim()).filter(Boolean)); + + found.forEach(ip => { + const row = document.createElement('label'); + row.className = 'rx-scan-item'; + row.innerHTML = ` + + ${ip}`; + list.appendChild(row); + }); + useRow.style.display = ''; + } + + // "USE SELECTED" -> switch to MANUAL, populate IPs, save & apply. + document.getElementById('use-selected-btn').addEventListener('click', async () => { + const checked = Array.from( + document.querySelectorAll('#scan-results-list input[type="checkbox"]:checked') + ).map(cb => cb.value); + if (!checked.length) { toast('Select at least one receiver', true); return; } + + document.getElementById('receiver-mode').value = 'MANUAL'; + toggleManualIps('MANUAL'); + document.getElementById('receiver-ips-input').value = checked.join('\n'); + await saveAndApplyReceivers(); + }); + document.getElementById('restart-receivers-btn').addEventListener('click', async () => { + const btn = document.getElementById('restart-receivers-btn'); + const orig = btn.textContent; + btn.textContent = 'RESTARTING…'; + btn.disabled = true; try { const res = await fetch('/api/admin/restart-receivers', { method: 'POST' }); - if (res.ok) toast('Receivers restarted'); + if (res.ok) { toast('Receivers restarted'); await testReceivers(); } else toast('Failed to restart', true); } catch (e) { toast('Error restarting receivers', true); } + btn.textContent = orig; + btn.disabled = false; }); - document.getElementById('save-receivers-btn').addEventListener('click', async () => { + document.getElementById('save-receivers-btn').addEventListener('click', saveAndApplyReceivers); + + // Save the receiver selection, reconnect, then re-test — the full + // select -> save -> verify loop in one action. + async function saveAndApplyReceivers() { + const btn = document.getElementById('save-receivers-btn'); + const orig = btn.textContent; const mode = document.getElementById('receiver-mode').value; const port = parseInt(document.getElementById('receiver-port').value) || 30003; const body = { receiver_port: port }; @@ -255,11 +348,122 @@ } else { const ips = document.getElementById('receiver-ips-input').value .split('\n').map(s => s.trim()).filter(Boolean); - body.receivers = ips.length ? ips : 'AUTO'; + if (!ips.length) { toast('Enter at least one receiver IP', true); return; } + body.receivers = ips; } - await saveConfig(body); - }); + btn.textContent = 'APPLYING…'; + btn.disabled = true; + try { + const saved = await saveConfig(body, true); + if (saved) { + // Apply immediately so the active list reflects the new selection. + await fetch('/api/admin/restart-receivers', { method: 'POST' }); + await testReceivers(); + toast('Saved & applied — testing connections'); + } + } catch (e) { + toast('Failed to apply', true); + } + btn.textContent = orig; + btn.disabled = false; + } + + // ------- Receiver status / health ------- + let receiverStatusInterval = null; + + function startReceiverStatusPolling() { + loadReceiverStatus(); + if (!receiverStatusInterval) { + receiverStatusInterval = setInterval(loadReceiverStatus, 4000); + } + } + + function stopReceiverStatusPolling() { + if (receiverStatusInterval) { + clearInterval(receiverStatusInterval); + receiverStatusInterval = null; + } + } + + async function loadReceiverStatus() { + const list = document.getElementById('receiver-status-list'); + try { + const res = await fetch('/api/admin/receiver-status'); + if (!res.ok) { + // 404 here almost always means the server is running an older + // build without this endpoint — tell the user instead of + // leaving a silent "Loading…". + if (res.status === 404) { + list.innerHTML = 'Status endpoint not found — ' + + 'restart the server to load the latest build.'; + } + return; + } + const data = await res.json(); + renderReceiverStatus(data.receivers || [], data.configured); + } catch (e) { + // Only replace the initial placeholder; don't clobber good data on + // a transient poll failure. + if (/Loading/.test(list.textContent)) { + list.innerHTML = 'Could not reach server.'; + } + } + } + + document.getElementById('test-receivers-btn').addEventListener('click', testReceivers); + + async function testReceivers() { + const btn = document.getElementById('test-receivers-btn'); + const orig = btn.textContent; + btn.textContent = 'TESTING…'; + btn.disabled = true; + try { + const res = await fetch('/api/admin/test-receivers', { method: 'POST' }); + if (res.ok) { + const data = await res.json(); + renderReceiverStatus(data.receivers || [], undefined, true); + } else { + toast('Test failed', true); + } + } catch (e) { + toast('Test error', true); + } + btn.textContent = orig; + btn.disabled = false; + } + + function renderReceiverStatus(statuses, configured, tested) { + const list = document.getElementById('receiver-status-list'); + const summary = document.getElementById('receiver-status-summary'); + + if (configured !== undefined) { + const modeLabel = (configured === 'AUTO') ? 'AUTO' : 'MANUAL'; + summary.textContent = `(${statuses.length} • ${modeLabel})`; + } + + if (!statuses.length) { + list.innerHTML = 'No receivers configured. ' + + 'Use AUTO mode or add IPs below, then SAVE & APPLY.'; + return; + } + + list.innerHTML = ''; + statuses.forEach(s => { + const row = document.createElement('div'); + row.className = 'rx-status-item'; + const detail = s.last_msg_age != null + ? `${s.msg_count} msgs · last ${s.last_msg_age}s ago` + : (s.state === 'down' ? (tested ? 'no response on port' : 'not connected') + : 'waiting for data…'); + row.innerHTML = ` + + ${s.ip} + ${s.label} + ${detail}`; + list.appendChild(row); + }); + } // ------- Location ------- document.getElementById('browser-location-btn').addEventListener('click', () => { @@ -522,6 +726,7 @@
${sprite.categories || ''}
${imgHtml}
+
`; + const editBtn = card.querySelector('.sprite-edit-btn'); + editBtn.addEventListener('click', () => { + if (window.PixelEditor) { + window.PixelEditor.open(sprite.type, { onSave: loadSprites }); + } else { + toast('Pixel editor not loaded', true); + } + }); + const fileInput = card.querySelector('.sprite-upload-input'); fileInput.addEventListener('change', async (e) => { const file = e.target.files[0]; @@ -570,7 +784,7 @@ } // ------- Helpers ------- - async function saveConfig(body) { + async function saveConfig(body, silent) { try { const res = await fetch('/api/admin/config', { method: 'PUT', @@ -578,13 +792,16 @@ body: JSON.stringify(body) }); if (res.ok) { - toast('Settings saved'); + if (!silent) toast('Settings saved'); + return true; } else { const data = await res.json(); toast(data.error || 'Save failed', true); + return false; } } catch (e) { toast('Connection error', true); + return false; } } diff --git a/admin/pixel-editor.js b/admin/pixel-editor.js new file mode 100644 index 0000000..8d257d1 --- /dev/null +++ b/admin/pixel-editor.js @@ -0,0 +1,719 @@ +// ADS-Bit In-App Pixel Editor +// A dependency-free Canvas2D sprite editor for the admin Sprites tab. +// Edits sprites at their native 500x333 resolution and exports a PNG that +// is POSTed to the existing /api/admin/sprites/{type} endpoint. +// +// Public API: +// PixelEditor.open(spriteType, { existingUrl, onSave }) +// +// Tools: pencil, eraser, color picker, fill bucket, pan. +// Features: zoom/pan, undo/redo, grid overlay, load existing, export PNG. +(function () { + 'use strict'; + + // ----- Sprite dimensions (fixed by the sprite spec) ----- + const SPRITE_W = 500; + const SPRITE_H = 333; + + // ----- Tunables ----- + const MIN_ZOOM = 0.5; + const MAX_ZOOM = 40; + const HISTORY_LIMIT = 40; // max undo snapshots (~666 KB each) + const GRID_MIN_ZOOM = 6; // only show pixel grid when zoomed in enough + + // ----- Retro palette (SNES-style white/blue/red leaning) ----- + const PALETTE = [ + '#000000', '#1a1a1a', '#3c3c3c', '#5c5c5c', '#9c9c9c', '#fcfcfc', + '#5c94fc', '#3c5cac', '#1a2c6c', '#b4d4ec', '#54b4fc', '#0c84fc', + '#fc5454', '#ac2c2c', '#fc9c9c', '#fcd444', '#fc9838', '#a85400', + '#54fc54', '#2c942c', '#a4e4a4', '#fc54fc', '#9438a8', '#54fcdc', + ]; + + // ----- Module state ----- + let root = null; // overlay element + let els = {}; // cached DOM references + + let srcCanvas = null; // 500x333 source-of-truth canvas + let srcCtx = null; + let viewCanvas = null; // on-screen scaled render target + let viewCtx = null; + + let tool = 'pencil'; + let brushSize = 1; + let color = '#5c94fc'; + let zoom = 1; + let panX = 0, panY = 0; + let showGrid = true; + + let spriteType = null; + let saveCallback = null; + + // Pointer interaction state + let drawing = false; // a draw stroke is in progress + let panning = false; // a pan drag is in progress + let spaceDown = false; // hold space to temporarily pan + let lastDraw = null; // last source pixel during a stroke {x,y} + let panStart = null; // {x,y,panX,panY} at pan start + + // Undo/redo + let history = []; + let historyIndex = -1; + + // =================================================================== + // DOM construction + // =================================================================== + function build() { + if (root) return; + + root = document.createElement('div'); + root.id = 'pe-overlay'; + root.className = 'pe-overlay hidden'; + root.innerHTML = ` +
+
+

PIXEL EDITOR

+
+ + + + +
+
+
+
+
+ + + + + +
+
+ + +
+
+ + + +
+
+ +
+
+ SIZE + + 1 +
+
+
+ +
+
+
+
+ +
+
+
+
POS --, --
+
ZOOM 100%
+
SIZE 500×333
+
+
+
+
+ `; + document.body.appendChild(root); + + // Cache references + els.title = root.querySelector('#pe-title'); + els.wrap = root.querySelector('#pe-canvas-wrap'); + els.palette = root.querySelector('#pe-palette'); + els.currentSwatch = root.querySelector('#pe-current-swatch'); + els.colorInput = root.querySelector('#pe-color-input'); + els.pos = root.querySelector('#pe-pos'); + els.zoomVal = root.querySelector('#pe-zoom-val'); + els.brush = root.querySelector('#pe-brush'); + els.brushVal = root.querySelector('#pe-brush-val'); + els.grid = root.querySelector('#pe-grid'); + els.undo = root.querySelector('#pe-undo'); + els.redo = root.querySelector('#pe-redo'); + + viewCanvas = root.querySelector('#pe-view'); + viewCtx = viewCanvas.getContext('2d'); + + // Source-of-truth canvas (offscreen) + srcCanvas = document.createElement('canvas'); + srcCanvas.width = SPRITE_W; + srcCanvas.height = SPRITE_H; + srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); + + buildPalette(); + wireEvents(); + } + + function buildPalette() { + els.palette.innerHTML = ''; + PALETTE.forEach(c => { + const sw = document.createElement('button'); + sw.className = 'pe-swatch'; + sw.style.background = c; + sw.title = c; + sw.addEventListener('click', () => setColor(c)); + els.palette.appendChild(sw); + }); + } + + // =================================================================== + // Event wiring + // =================================================================== + function wireEvents() { + // Tool buttons + root.querySelectorAll('.pe-tool').forEach(btn => { + btn.addEventListener('click', () => setTool(btn.dataset.tool)); + }); + + // Header actions + root.querySelector('#pe-close').addEventListener('click', () => { + if (confirmDiscard()) close(); + }); + root.querySelector('#pe-save').addEventListener('click', save); + root.querySelector('#pe-load').addEventListener('click', () => loadExisting(true)); + root.querySelector('#pe-clear').addEventListener('click', clearCanvas); + + // Zoom controls + root.querySelector('#pe-zoom-in').addEventListener('click', () => zoomAt(1.25, centerPoint())); + root.querySelector('#pe-zoom-out').addEventListener('click', () => zoomAt(0.8, centerPoint())); + root.querySelector('#pe-zoom-fit').addEventListener('click', fitToScreen); + + // Undo/redo + els.undo.addEventListener('click', undo); + els.redo.addEventListener('click', redo); + + // Grid + brush + els.grid.addEventListener('change', () => { showGrid = els.grid.checked; render(); }); + els.brush.addEventListener('input', () => { + brushSize = parseInt(els.brush.value, 10); + els.brushVal.textContent = brushSize; + }); + + // Color input + els.colorInput.addEventListener('input', () => setColor(els.colorInput.value)); + + // Canvas pointer events + viewCanvas.addEventListener('pointerdown', onPointerDown); + viewCanvas.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + viewCanvas.addEventListener('pointerleave', () => { els.pos.textContent = '--, --'; }); + viewCanvas.addEventListener('wheel', onWheel, { passive: false }); + viewCanvas.addEventListener('contextmenu', e => e.preventDefault()); + + // Keyboard shortcuts (only active while editor is open) + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + + // Keep the view sized to its container + window.addEventListener('resize', () => { if (isOpen()) { resizeView(); render(); } }); + } + + // =================================================================== + // Public open/close + // =================================================================== + function open(type, opts) { + opts = opts || {}; + build(); + spriteType = type; + saveCallback = opts.onSave || null; + els.title.textContent = 'PIXEL EDITOR — ' + type; + + // Reset state + zoom = 1; panX = 0; panY = 0; + history = []; historyIndex = -1; + setTool('pencil'); + srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); + + root.classList.remove('hidden'); + document.body.classList.add('pe-open'); + + // Load existing sprite (if any), then size + draw. + loadExisting(false, () => { + resizeView(); + fitToScreen(); + pushHistory(); // baseline snapshot + updateHistoryButtons(); + }); + } + + function isOpen() { return root && !root.classList.contains('hidden'); } + + function close() { + root.classList.add('hidden'); + document.body.classList.remove('pe-open'); + spriteType = null; + saveCallback = null; + } + + function confirmDiscard() { + // Only warn if there is edit history beyond the baseline. + if (historyIndex > 0) { + return window.confirm('Discard unsaved changes and close the editor?'); + } + return true; + } + + // =================================================================== + // Loading / clearing + // =================================================================== + function loadExisting(recordHistory, done) { + const url = `/images/${spriteType}.png?v=${Date.now()}`; + const img = new Image(); + img.onload = () => { + srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); + // Draw centered/stretched into the 500x333 buffer. + srcCtx.imageSmoothingEnabled = false; + srcCtx.drawImage(img, 0, 0, SPRITE_W, SPRITE_H); + if (recordHistory) { pushHistory(); toast('Loaded current sprite'); } + render(); + if (done) done(); + }; + img.onerror = () => { + // No existing sprite — start from a blank transparent canvas. + if (recordHistory) { pushHistory(); toast('No existing sprite — blank canvas', true); } + render(); + if (done) done(); + }; + img.src = url; + } + + function clearCanvas() { + srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); + pushHistory(); + render(); + } + + // =================================================================== + // Tool / color selection + // =================================================================== + function setTool(t) { + tool = t; + root.querySelectorAll('.pe-tool').forEach(b => { + b.classList.toggle('active', b.dataset.tool === t); + }); + updateCursor(); + } + + function setColor(c) { + color = c; + els.currentSwatch.style.background = c; + if (els.colorInput.value.toLowerCase() !== c.toLowerCase()) { + els.colorInput.value = c; + } + } + + function updateCursor() { + let cur = 'crosshair'; + if (tool === 'pan' || spaceDown) cur = panning ? 'grabbing' : 'grab'; + else if (tool === 'picker') cur = 'cell'; + viewCanvas.style.cursor = cur; + } + + // =================================================================== + // Coordinate mapping + // =================================================================== + function eventToSource(e) { + const rect = viewCanvas.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + return { + x: Math.floor((cx - panX) / zoom), + y: Math.floor((cy - panY) / zoom), + cx, cy, + }; + } + + function centerPoint() { + return { cx: viewCanvas.width / 2, cy: viewCanvas.height / 2 }; + } + + // =================================================================== + // Pointer handlers + // =================================================================== + function onPointerDown(e) { + e.preventDefault(); + viewCanvas.setPointerCapture(e.pointerId); + const p = eventToSource(e); + + // Pan: pan tool, space held, or middle mouse. + if (tool === 'pan' || spaceDown || e.button === 1) { + panning = true; + panStart = { cx: e.clientX, cy: e.clientY, panX, panY }; + updateCursor(); + return; + } + + if (tool === 'picker') { + pickColor(p.x, p.y); + return; + } + + if (tool === 'fill') { + floodFill(p.x, p.y); + pushHistory(); + render(); + return; + } + + // Pencil / eraser stroke begins. + drawing = true; + lastDraw = null; + applyBrush(p.x, p.y); + lastDraw = { x: p.x, y: p.y }; + render(); + } + + function onPointerMove(e) { + const p = eventToSource(e); + if (p.x >= 0 && p.x < SPRITE_W && p.y >= 0 && p.y < SPRITE_H) { + els.pos.textContent = `${p.x}, ${p.y}`; + } else { + els.pos.textContent = '--, --'; + } + + if (panning && panStart) { + panX = panStart.panX + (e.clientX - panStart.cx); + panY = panStart.panY + (e.clientY - panStart.cy); + render(); + return; + } + + if (drawing && (tool === 'pencil' || tool === 'eraser')) { + // Interpolate from the last point so fast strokes have no gaps. + if (lastDraw) lineApply(lastDraw.x, lastDraw.y, p.x, p.y); + else applyBrush(p.x, p.y); + lastDraw = { x: p.x, y: p.y }; + render(); + } + } + + function onPointerUp() { + if (drawing) { + drawing = false; + lastDraw = null; + pushHistory(); // one undo step per completed stroke + } + if (panning) { + panning = false; + panStart = null; + updateCursor(); + } + } + + function onWheel(e) { + e.preventDefault(); + const rect = viewCanvas.getBoundingClientRect(); + const focus = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; + zoomAt(e.deltaY < 0 ? 1.15 : 0.87, focus); + } + + // =================================================================== + // Drawing primitives (operate on the 500x333 source canvas) + // =================================================================== + function applyBrush(x, y) { + const half = Math.floor(brushSize / 2); + const x0 = x - half, y0 = y - half; + if (tool === 'eraser') { + srcCtx.clearRect(x0, y0, brushSize, brushSize); + } else { + srcCtx.fillStyle = color; + srcCtx.fillRect(x0, y0, brushSize, brushSize); + } + } + + // Bresenham line between two source pixels, stamping the brush along it. + function lineApply(x0, y0, x1, y1) { + const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + while (true) { + applyBrush(x0, y0); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + } + + function pickColor(x, y) { + if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return; + const d = srcCtx.getImageData(x, y, 1, 1).data; + if (d[3] === 0) { toast('Picked transparent pixel', true); return; } + setColor(rgbToHex(d[0], d[1], d[2])); + setTool('pencil'); + } + + // 4-neighbour flood fill with exact-color matching. + function floodFill(sx, sy) { + if (sx < 0 || sx >= SPRITE_W || sy < 0 || sy >= SPRITE_H) return; + const img = srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H); + const data = img.data; + const idx = (sy * SPRITE_W + sx) * 4; + const target = [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]]; + const fill = hexToRgb(color); + const fillA = 255; + + // No-op if the target already equals the fill color. + if (target[0] === fill.r && target[1] === fill.g && + target[2] === fill.b && target[3] === fillA) return; + + const stack = [sy * SPRITE_W + sx]; + while (stack.length) { + const pos = stack.pop(); + const o = pos * 4; + if (data[o] !== target[0] || data[o + 1] !== target[1] || + data[o + 2] !== target[2] || data[o + 3] !== target[3]) continue; + data[o] = fill.r; data[o + 1] = fill.g; data[o + 2] = fill.b; data[o + 3] = fillA; + const px = pos % SPRITE_W, py = (pos - px) / SPRITE_W; + if (px > 0) stack.push(pos - 1); + if (px < SPRITE_W - 1) stack.push(pos + 1); + if (py > 0) stack.push(pos - SPRITE_W); + if (py < SPRITE_H - 1) stack.push(pos + SPRITE_W); + } + srcCtx.putImageData(img, 0, 0); + } + + // =================================================================== + // Zoom / pan + // =================================================================== + function zoomAt(factor, focus) { + const newZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM); + if (newZoom === zoom) return; + // Keep the source pixel under the focus point stationary. + const sxBefore = (focus.cx - panX) / zoom; + const syBefore = (focus.cy - panY) / zoom; + zoom = newZoom; + panX = focus.cx - sxBefore * zoom; + panY = focus.cy - syBefore * zoom; + render(); + } + + function fitToScreen() { + const w = viewCanvas.width, h = viewCanvas.height; + const margin = 24; + zoom = clamp(Math.min((w - margin) / SPRITE_W, (h - margin) / SPRITE_H), MIN_ZOOM, MAX_ZOOM); + panX = (w - SPRITE_W * zoom) / 2; + panY = (h - SPRITE_H * zoom) / 2; + render(); + } + + function resizeView() { + const rect = els.wrap.getBoundingClientRect(); + viewCanvas.width = Math.max(100, Math.floor(rect.width)); + viewCanvas.height = Math.max(100, Math.floor(rect.height)); + } + + // =================================================================== + // Rendering + // =================================================================== + function render() { + const w = viewCanvas.width, h = viewCanvas.height; + viewCtx.clearRect(0, 0, w, h); + + const dw = SPRITE_W * zoom, dh = SPRITE_H * zoom; + + // Transparency checkerboard behind the artboard. + drawCheckerboard(panX, panY, dw, dh); + + // The sprite itself, nearest-neighbour scaled. + viewCtx.imageSmoothingEnabled = false; + viewCtx.drawImage(srcCanvas, panX, panY, dw, dh); + + // Artboard border. + viewCtx.strokeStyle = '#5c94fc'; + viewCtx.lineWidth = 1; + viewCtx.strokeRect(panX + 0.5, panY + 0.5, dw, dh); + + if (showGrid && zoom >= GRID_MIN_ZOOM) drawGrid(dw, dh); + + els.zoomVal.textContent = Math.round(zoom * 100) + '%'; + } + + function drawCheckerboard(ox, oy, dw, dh) { + const tile = 8; + // Clip to artboard so the checker stays inside the sprite bounds. + viewCtx.save(); + viewCtx.beginPath(); + viewCtx.rect(ox, oy, dw, dh); + viewCtx.clip(); + const startX = Math.floor(ox / tile) * tile; + const startY = Math.floor(oy / tile) * tile; + for (let y = startY; y < oy + dh; y += tile) { + for (let x = startX; x < ox + dw; x += tile) { + const odd = ((x / tile) + (y / tile)) % 2 !== 0; + viewCtx.fillStyle = odd ? '#2c2c2c' : '#3c3c3c'; + viewCtx.fillRect(x, y, tile, tile); + } + } + viewCtx.restore(); + } + + function drawGrid(dw, dh) { + viewCtx.strokeStyle = 'rgba(255,255,255,0.18)'; + viewCtx.lineWidth = 1; + viewCtx.beginPath(); + for (let x = 0; x <= SPRITE_W; x++) { + const px = Math.round(panX + x * zoom) + 0.5; + viewCtx.moveTo(px, panY); + viewCtx.lineTo(px, panY + dh); + } + for (let y = 0; y <= SPRITE_H; y++) { + const py = Math.round(panY + y * zoom) + 0.5; + viewCtx.moveTo(panX, py); + viewCtx.lineTo(panX + dw, py); + } + viewCtx.stroke(); + } + + // =================================================================== + // Undo / redo + // =================================================================== + function pushHistory() { + // Drop any redo tail. + if (historyIndex < history.length - 1) { + history = history.slice(0, historyIndex + 1); + } + history.push(srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)); + // Cap memory use. + if (history.length > HISTORY_LIMIT) history.shift(); + historyIndex = history.length - 1; + updateHistoryButtons(); + } + + function undo() { + if (historyIndex <= 0) return; + historyIndex--; + srcCtx.putImageData(history[historyIndex], 0, 0); + render(); + updateHistoryButtons(); + } + + function redo() { + if (historyIndex >= history.length - 1) return; + historyIndex++; + srcCtx.putImageData(history[historyIndex], 0, 0); + render(); + updateHistoryButtons(); + } + + function updateHistoryButtons() { + els.undo.disabled = historyIndex <= 0; + els.redo.disabled = historyIndex >= history.length - 1; + } + + // =================================================================== + // Save / export + // =================================================================== + function save() { + if (!spriteType) return; + const saveBtn = root.querySelector('#pe-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'SAVING…'; + srcCanvas.toBlob(async (blob) => { + if (!blob) { + toast('Export failed', true); + saveBtn.disabled = false; saveBtn.textContent = 'SAVE'; + return; + } + const fd = new FormData(); + fd.append('file', blob, `${spriteType}.png`); + try { + const res = await fetch(`/api/admin/sprites/${spriteType}`, { + method: 'POST', body: fd, + }); + if (res.ok) { + toast(`${spriteType} sprite saved`); + // Treat the saved state as the new baseline. + history = [srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)]; + historyIndex = 0; + updateHistoryButtons(); + if (saveCallback) saveCallback(); + } else { + const err = await res.json().catch(() => ({})); + toast(err.error || 'Save failed', true); + } + } catch (e) { + toast('Connection error', true); + } + saveBtn.disabled = false; saveBtn.textContent = 'SAVE'; + }, 'image/png'); + } + + // =================================================================== + // Keyboard shortcuts + // =================================================================== + function onKeyDown(e) { + if (!isOpen()) return; + // Don't hijack typing in inputs. + if (e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && e.target.type !== 'range') { + if (e.key === 'Escape') e.target.blur(); + return; + } + + const ctrl = e.ctrlKey || e.metaKey; + if (ctrl && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } + if (ctrl && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { + e.preventDefault(); redo(); return; + } + + switch (e.key) { + case 'Escape': if (confirmDiscard()) close(); break; + case 'b': case 'B': setTool('pencil'); break; + case 'e': case 'E': setTool('eraser'); break; + case 'i': case 'I': setTool('picker'); break; + case 'f': case 'F': setTool('fill'); break; + case 'g': case 'G': els.grid.checked = !els.grid.checked; showGrid = els.grid.checked; render(); break; + case '+': case '=': zoomAt(1.25, centerPoint()); break; + case '-': case '_': zoomAt(0.8, centerPoint()); break; + case '0': fitToScreen(); break; + case ' ': + if (!spaceDown) { spaceDown = true; updateCursor(); } + e.preventDefault(); + break; + } + } + + function onKeyUp(e) { + if (!isOpen()) return; + if (e.key === ' ') { spaceDown = false; updateCursor(); } + } + + // =================================================================== + // Helpers + // =================================================================== + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + + function hexToRgb(hex) { + hex = hex.replace('#', ''); + if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); + const n = parseInt(hex, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + } + + function rgbToHex(r, g, b) { + return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); + } + + // Reuse the admin toast if present, else fall back to a console message. + function toast(msg, isError) { + const el = document.getElementById('toast'); + if (!el) { return; } + el.textContent = msg; + el.classList.remove('hidden', 'error'); + if (isError) el.classList.add('error'); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.add('hidden'), 3000); + } + + // Expose public API + window.PixelEditor = { open, close }; +})(); diff --git a/server.py b/server.py index 267695d..8b6562c 100755 --- a/server.py +++ b/server.py @@ -170,6 +170,69 @@ receivers: List[str] = [] receiver_tasks: List[asyncio.Task] = [] emitter_categories: Dict[str, str] = {} # ICAO hex -> ADS-B emitter category (A1, A3, A7, etc.) +# Live per-receiver connection health, keyed by IP. Each entry: +# {"state": "connecting"|"connected"|"down", "last_msg": ts|None, +# "msg_count": int, "connected_since": ts|None, "error": str} +receiver_status: Dict[str, dict] = {} + +# A receiver is considered "receiving" if it produced a message this recently. +RECEIVER_DATA_FRESH_SECONDS = 30 + + +def reset_receiver_status(ips): + """Replace the tracked receiver set, marking each as connecting.""" + global receiver_status + receiver_status = { + ip: {"state": "connecting", "last_msg": None, + "msg_count": 0, "connected_since": None, "error": ""} + for ip in ips + } + + +def describe_receiver(ip, reachable=None): + """Build a display-friendly status object for one receiver. + + state -> (label, color): + down/missing -> DOWN (red) + connecting -> CONNECTING (gray) + connected + data -> RECEIVING (green) + connected, no data -> NO DATA (yellow) (socket up; maybe no aircraft in range) + reachable: optional active port-probe result that overrides a stale state. + """ + st = receiver_status.get(ip) + now = time.time() + if st is None: + state, last_age, msg_count = "down", None, 0 + else: + state = st["state"] + last_age = (now - st["last_msg"]) if st["last_msg"] else None + msg_count = st["msg_count"] + + # An active probe result is authoritative about reachability. + if reachable is False: + state = "down" + elif reachable is True and state == "down": + state = "connected" + + if state == "down": + label, color = "DOWN", "red" + elif state == "connecting": + label, color = "CONNECTING", "gray" + elif last_age is not None and last_age <= RECEIVER_DATA_FRESH_SECONDS: + label, color = "RECEIVING", "green" + else: + label, color = "NO DATA", "yellow" + + return { + "ip": ip, + "state": state, + "label": label, + "color": color, + "msg_count": msg_count, + "last_msg_age": round(last_age, 1) if last_age is not None else None, + "reachable": reachable, + } + @dataclass class SBSMessage: @@ -246,10 +309,17 @@ async def connect_to_receiver(host: str, port: int = 30003): """Connect to an SBS receiver and process messages""" reconnect_delay = config['tuning']['receiver_reconnect_seconds'] print(f"Connecting to receiver at {host}:{port}...") + # Ensure a status entry exists even if this task started outside a reset. + receiver_status.setdefault(host, {"state": "connecting", "last_msg": None, + "msg_count": 0, "connected_since": None, "error": ""}) while True: try: + receiver_status[host]["state"] = "connecting" reader, writer = await asyncio.open_connection(host, port) print(f"Connected to {host}:{port}") + receiver_status[host]["state"] = "connected" + receiver_status[host]["connected_since"] = time.time() + receiver_status[host]["error"] = "" while True: line = await reader.readline() @@ -259,6 +329,8 @@ async def connect_to_receiver(host: str, port: int = 30003): line = line.decode('utf-8', errors='ignore') msg = parse_sbs_message(line) if msg and msg.icao: + receiver_status[host]["last_msg"] = time.time() + receiver_status[host]["msg_count"] += 1 if msg.icao not in flights: flights[msg.icao] = { 'icao': msg.icao, @@ -295,10 +367,14 @@ async def connect_to_receiver(host: str, port: int = 30003): flight['ground'] = msg.ground flight['last_seen'] = time.time() + # readline returned empty -> peer closed the connection. + receiver_status[host]["state"] = "down" writer.close() await writer.wait_closed() except Exception as e: print(f"Receiver {host}:{port} error: {e}") + receiver_status[host]["state"] = "down" + receiver_status[host]["error"] = str(e) await asyncio.sleep(reconnect_delay) @@ -992,10 +1068,15 @@ async def restart_receiver_connections(): """Cancel existing receiver tasks and start new ones.""" global receivers, receiver_tasks - # Cancel old tasks - for task in receiver_tasks: - task.cancel() + # Cancel old tasks and let them unwind cleanly. Awaiting the cancelled + # tasks here (with return_exceptions) absorbs their CancelledError so it + # can never propagate into the main event loop. + old_tasks = list(receiver_tasks) receiver_tasks.clear() + for task in old_tasks: + task.cancel() + if old_tasks: + await asyncio.gather(*old_tasks, return_exceptions=True) # Determine receivers receiver_config = config["receivers"] @@ -1015,6 +1096,9 @@ async def restart_receiver_connections(): print(f"Error determining receivers: {e}") receivers = [] + # Reset health tracking to the new receiver set. + reset_receiver_status(receivers) + # Start new tasks for r in receivers: task = asyncio.create_task(connect_to_receiver(r, receiver_port)) @@ -1030,6 +1114,28 @@ async def handle_admin_restart_receivers(request): return web.json_response({"ok": True, "receivers": receivers}) +@require_auth +async def handle_admin_receiver_status(request): + """GET /api/admin/receiver-status - Live health of configured receivers.""" + statuses = [describe_receiver(ip) for ip in receivers] + return web.json_response({ + "receivers": statuses, + "configured": config["receivers"], # "AUTO" or a list of IPs + "count": len(receivers), + }) + + +@require_auth +async def handle_admin_test_receivers(request): + """POST /api/admin/test-receivers - Actively probe each configured receiver + and report reachability combined with live data-flow state.""" + port = config["receiver_port"] + probes = await asyncio.gather(*[test_port(ip, port) for ip in receivers]) + statuses = [describe_receiver(ip, reachable=reachable) + for ip, reachable in zip(receivers, probes)] + return web.json_response({"receivers": statuses, "count": len(receivers)}) + + # --------------------------------------------------------------------------- # Static file serving with setup redirect # --------------------------------------------------------------------------- @@ -1103,6 +1209,8 @@ async def start_http_server(): app.router.add_get('/api/admin/network-info', handle_admin_network_info) app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers) app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers) + app.router.add_get('/api/admin/receiver-status', handle_admin_receiver_status) + app.router.add_post('/api/admin/test-receivers', handle_admin_test_receivers) app.router.add_get('/api/admin/sprites', handle_admin_sprites) app.router.add_post('/api/admin/sprites/{type}', handle_admin_sprite_upload) @@ -1148,6 +1256,8 @@ async def main(): else: print(f"Using {len(receivers)} receiver(s)") + reset_receiver_status(receivers) + # Start all tasks tasks = [ start_http_server(), @@ -1156,12 +1266,15 @@ async def main(): poll_aircraft_json(), ] - # Connect to all receivers + # Connect to all receivers. These run as independent background tasks and + # are deliberately NOT awaited in the gather below: restarting receivers + # cancels them, and a cancelled task inside the main gather would tear down + # the whole server. They still run as long as the event loop is alive. for receiver in receivers: task = asyncio.ensure_future(connect_to_receiver(receiver, receiver_port)) receiver_tasks.append(task) - tasks.append(task) + # Only the core services keep the server alive. await asyncio.gather(*tasks)