9 Commits

Author SHA1 Message Date
root b271c15267 Add Docker Compose and Podman support for containerized deployment
Adds Dockerfile, docker-compose.yml, and .dockerignore to enable
running ADS-Bit in a container with host networking for LAN receiver
auto-scanning. Bind mounts persist config, sprites, and backgrounds
across container recreation. Updates README with usage instructions.

Closes #4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 13:52:57 -07:00
root 3a3d46119a Add download buttons for sprites and theme backgrounds
Allow users to download existing sprite and background assets from the
admin panel, useful for editing externally and re-uploading or backing
up customizations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:47:20 -07:00
root 0b16218ce9 Add host network info display and custom subnet scan to Receivers tab
Shows the server's network interfaces (IP, subnet) in the admin Receivers
tab so users know which networks are being scanned. Adds an optional subnet
input field to scan arbitrary networks (e.g. VLANs the server can route to
but isn't directly attached to), with validation and a /20 size cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:06:31 -07:00
root 248c33ed80 Add admin theme management: rename, upload, and create themes
Adds a THEMES tab to the admin panel with per-theme cards showing
4 directional previews, editable display names, per-direction PNG
upload, and new theme creation. Display names are stored in
config.json theme_names map rather than renaming filesystem folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 10:50:40 -07:00
root 933fa8f257 Add balloon, glider, and UAV sprites with admin sprite management
New dedicated pixel art sprites for ADS-B emitter categories B2
(lighter-than-air → balloon), B1 (glider/sailplane → glider), and
B6 (UAV → uav), replacing the previous fallback to smallProp. Adds
an admin Sprites tab for previewing and uploading replacement PNGs
for all 9 aircraft sprite types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:44:46 -07:00
root 883ef20ced Add ADS-B emitter category support via dump1090 JSON API
Poll dump1090's aircraft.json endpoint to get real transponder-reported
aircraft categories (A1=Light, A3=Large, A5=Heavy, A7=Rotorcraft, etc.)
instead of relying solely on heuristics. The SBS protocol on port 30003
doesn't include this field, but the JSON API on port 80 does. Categories
are polled every 5 seconds and merged into flight data. Client uses
emitter category when available, falling back to heuristic classification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:12:08 -07:00
root ed4397b21b Fix aircraft categorization misclassifying Cessnas as helicopters
N-prefix callsigns (US general aviation) were incorrectly matching the
helicopter detection rules due to 'N' in the helicopter callsign list
and a broken .some() condition. Speed/altitude heuristics were also too
broad, and zero-value speed data (not yet received) triggered false
matches. Types are now re-evaluated as better data arrives instead of
being locked to the first guess.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:00:15 -07:00
root da188ea290 Rename project references from Pixel View to ADS-Bit
Rename pixel-view.js to ads-bit.js, rename PixelADSB class to ADSBit,
and update all documentation and code comments to use ADS-Bit branding
consistently throughout the project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 08:54:59 -07:00
root 42a50037ab Add admin panel, authentication, and first-run setup wizard for v1.0
Implements credentialed admin panel and multi-step setup wizard so new
users can configure location, receivers, and preferences without editing
JSON files. Adds bcrypt password auth with session cookies, setup lockout
after first run, and expanded config schema with backward compatibility.

New files: admin/{html,css,js}, setup/{html,css,js}
Modified: server.py (auth, setup, admin APIs), index.html (dynamic title),
pixel-view.js (site/display config), requirements.txt (bcrypt),
config.json.example (full schema)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 08:35:07 -07:00
8 changed files with 36 additions and 1454 deletions
-5
View File
@@ -48,11 +48,6 @@ Access at http://localhost:{web_port} (configured in config.json)
- **admin/** - Admin panel (login + tabbed settings UI) - **admin/** - Admin panel (login + tabbed settings UI)
- Theme management: rename display names, upload per-direction background PNGs, create new themes - 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) - 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) - **setup/** - First-run setup wizard (multi-step configuration)
+2 -2
View File
@@ -102,8 +102,8 @@ ADS-Bit uses a first-run setup wizard to configure your installation. You can al
"receiver_port": 30003, "receiver_port": 30003,
"location": { "location": {
"name": "My Location", "name": "My Location",
"lat": 0.0, "lat": 36.2788,
"lon": 0.0 "lon": -115.2283
}, },
"web_port": 2001, "web_port": 2001,
"theme": "desert" "theme": "desert"
-331
View File
@@ -592,337 +592,6 @@ textarea {
font-size: 11px; 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 */ /* Mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.admin-header { .admin-header {
+11 -44
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/admin/admin.css?v=20260609b"> <link rel="stylesheet" href="/admin/admin.css">
</head> </head>
<body> <body>
<!-- Login Screen --> <!-- Login Screen -->
@@ -76,69 +76,37 @@
<!-- Receivers Tab --> <!-- Receivers Tab -->
<section id="tab-receivers" class="tab-pane hidden"> <section id="tab-receivers" class="tab-pane hidden">
<h2>Receiver Configuration</h2> <h2>Receiver Configuration</h2>
<div id="network-info-box" class="info-box">
<!-- Live status of the receivers the app is currently using --> <h3>Host Network</h3>
<div id="receiver-status-box" class="info-box"> <div id="network-info">Loading...</div>
<h3>Active Receivers <span id="receiver-status-summary" class="rx-summary"></span></h3>
<div id="receiver-status-list">Loading...</div>
<div class="button-row">
<button id="test-receivers-btn" class="btn">TEST CONNECTIONS</button>
<button id="restart-receivers-btn" class="btn">RESTART CONNECTIONS</button>
</div> </div>
<p class="hint">🟢 receiving data &nbsp; 🟡 connected, no data yet &nbsp; 🔴 unreachable</p>
</div>
<!-- Step 1: choose how receivers are selected -->
<div class="rx-section-label">1. Select Receivers</div>
<div class="form-group"> <div class="form-group">
<label>Discovery Mode</label> <label>Discovery Mode</label>
<select id="receiver-mode"> <select id="receiver-mode">
<option value="AUTO">AUTO (use all receivers found on the network)</option> <option value="AUTO">AUTO (scan network)</option>
<option value="MANUAL">MANUAL (use only the IPs I specify)</option> <option value="MANUAL">MANUAL (specify IPs)</option>
</select> </select>
</div> </div>
<div id="manual-ips-group" class="form-group hidden"> <div id="manual-ips-group" class="form-group hidden">
<label>Receiver IPs (one per line)</label> <label>Receiver IPs (one per line)</label>
<textarea id="receiver-ips-input" rows="4" placeholder="192.168.1.100&#10;192.168.1.101"></textarea> <textarea id="receiver-ips-input" rows="4" placeholder="192.168.1.100&#10;192.168.1.101"></textarea>
<span class="hint">The app connects to every IP listed here.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Receiver Port</label> <label>Receiver Port</label>
<input type="number" id="receiver-port" value="30003" min="1" max="65535"> <input type="number" id="receiver-port" value="30003" min="1" max="65535">
</div> </div>
<!-- Step 2: save + apply -->
<div class="rx-section-label">2. Save &amp; Apply</div>
<div class="button-row">
<button id="save-receivers-btn" class="btn btn-primary">SAVE &amp; APPLY</button>
</div>
<p class="hint">Saves your selection, reconnects immediately, then re-tests the receivers below.</p>
<!-- Find receivers on the network -->
<div class="rx-section-label">Find Receivers on Network</div>
<div class="form-group"> <div class="form-group">
<label>Interface / Subnet to Scan</label> <label>Custom Subnet (optional)</label>
<select id="scan-interface"></select> <input type="text" id="scan-subnet" placeholder="e.g. 10.0.0.0/24 (leave empty to scan all local subnets)">
<span class="hint">Pick a network interface to scan, or scan all local subnets.</span>
</div>
<div class="form-group">
<label>Custom Subnet (optional override)</label>
<input type="text" id="scan-subnet" placeholder="e.g. 10.0.0.0/24 — overrides the selection above">
</div> </div>
<div class="button-row"> <div class="button-row">
<button id="scan-receivers-btn" class="btn">SCAN NOW</button> <button id="scan-receivers-btn" class="btn">SCAN NOW</button>
<button id="restart-receivers-btn" class="btn">RESTART CONNECTIONS</button>
<button id="save-receivers-btn" class="btn btn-primary">SAVE</button>
</div> </div>
<div id="scan-results" class="info-box hidden"> <div id="scan-results" class="info-box hidden">
<h3>Scan Results</h3> <h3>Scan Results</h3>
<div id="scan-results-list"></div> <div id="scan-results-list"></div>
<div class="button-row" id="scan-use-row" style="display:none">
<button id="use-selected-btn" class="btn btn-primary">USE SELECTED RECEIVERS</button>
</div>
</div>
<div id="network-info-box" class="info-box">
<h3>Host Network</h3>
<div id="network-info">Loading...</div>
</div> </div>
</section> </section>
@@ -297,7 +265,6 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
</div> </div>
<script src="/admin/pixel-editor.js?v=20260609b"></script> <script src="/admin/admin.js"></script>
<script src="/admin/admin.js?v=20260609b"></script>
</body> </body>
</html> </html>
+17 -234
View File
@@ -78,12 +78,6 @@
themesManagerLoaded = true; themesManagerLoaded = true;
loadThemeManager(); loadThemeManager();
} }
// Live receiver health polling only while the Receivers tab is open
if (tab.dataset.tab === 'receivers') {
startReceiverStatusPolling();
} else {
stopReceiverStatusPolling();
}
}); });
}); });
@@ -197,44 +191,11 @@
}); });
html += '</table>'; html += '</table>';
el.innerHTML = html; el.innerHTML = html;
populateScanInterfaces(ifaces);
} catch (e) { } catch (e) {
el.textContent = 'Error loading network info'; 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 ------- // ------- Receivers -------
function toggleManualIps(mode) { function toggleManualIps(mode) {
document.getElementById('manual-ips-group').classList.toggle('hidden', mode === 'AUTO'); document.getElementById('manual-ips-group').classList.toggle('hidden', mode === 'AUTO');
@@ -249,10 +210,7 @@
btn.textContent = 'SCANNING...'; btn.textContent = 'SCANNING...';
btn.disabled = true; btn.disabled = true;
try { try {
// Custom subnet text overrides the interface dropdown selection. const subnet = (document.getElementById('scan-subnet').value || '').trim();
const custom = (document.getElementById('scan-subnet').value || '').trim();
const iface = document.getElementById('scan-interface').value || '';
const subnet = custom || iface;
const fetchOpts = { method: 'POST' }; const fetchOpts = { method: 'POST' };
if (subnet) { if (subnet) {
fetchOpts.headers = { 'Content-Type': 'application/json' }; fetchOpts.headers = { 'Content-Type': 'application/json' };
@@ -260,7 +218,16 @@
} }
const res = await fetch('/api/admin/scan-receivers', fetchOpts); const res = await fetch('/api/admin/scan-receivers', fetchOpts);
const data = await res.json(); const data = await res.json();
renderScanResults(data); 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';
}
} catch (e) { } catch (e) {
toast('Scan failed', true); toast('Scan failed', true);
} }
@@ -268,77 +235,17 @@
btn.disabled = false; 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 = `
<input type="checkbox" value="${ip}" ${current.has(ip) ? 'checked' : ''}>
<span class="rx-scan-ip">${ip}</span>`;
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 () => { 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 { try {
const res = await fetch('/api/admin/restart-receivers', { method: 'POST' }); const res = await fetch('/api/admin/restart-receivers', { method: 'POST' });
if (res.ok) { toast('Receivers restarted'); await testReceivers(); } if (res.ok) toast('Receivers restarted');
else toast('Failed to restart', true); else toast('Failed to restart', true);
} catch (e) { } catch (e) {
toast('Error restarting receivers', true); toast('Error restarting receivers', true);
} }
btn.textContent = orig;
btn.disabled = false;
}); });
document.getElementById('save-receivers-btn').addEventListener('click', saveAndApplyReceivers); document.getElementById('save-receivers-btn').addEventListener('click', async () => {
// 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 mode = document.getElementById('receiver-mode').value;
const port = parseInt(document.getElementById('receiver-port').value) || 30003; const port = parseInt(document.getElementById('receiver-port').value) || 30003;
const body = { receiver_port: port }; const body = { receiver_port: port };
@@ -348,122 +255,11 @@
} else { } else {
const ips = document.getElementById('receiver-ips-input').value const ips = document.getElementById('receiver-ips-input').value
.split('\n').map(s => s.trim()).filter(Boolean); .split('\n').map(s => s.trim()).filter(Boolean);
if (!ips.length) { toast('Enter at least one receiver IP', true); return; } body.receivers = ips.length ? ips : 'AUTO';
body.receivers = ips;
} }
btn.textContent = 'APPLYING…'; await saveConfig(body);
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 = '<span class="no-sprite">Status endpoint not found — ' +
'restart the server to load the latest build.</span>';
}
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 = '<span class="no-sprite">Could not reach server.</span>';
}
}
}
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 = '<span class="no-sprite">No receivers configured. ' +
'Use AUTO mode or add IPs below, then SAVE &amp; APPLY.</span>';
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 = `
<span class="rx-dot ${s.color}"></span>
<span class="rx-ip">${s.ip}</span>
<span class="rx-label rx-${s.color}">${s.label}</span>
<span class="rx-detail">${detail}</span>`;
list.appendChild(row);
}); });
}
// ------- Location ------- // ------- Location -------
document.getElementById('browser-location-btn').addEventListener('click', () => { document.getElementById('browser-location-btn').addEventListener('click', () => {
@@ -726,7 +522,6 @@
<div class="sprite-categories">${sprite.categories || ''}</div> <div class="sprite-categories">${sprite.categories || ''}</div>
<div class="sprite-preview">${imgHtml}</div> <div class="sprite-preview">${imgHtml}</div>
<div class="sprite-btn-row"> <div class="sprite-btn-row">
<button class="sprite-upload-label sprite-edit-btn" data-type="${sprite.type}">EDIT</button>
<label class="sprite-upload-label"> <label class="sprite-upload-label">
UPLOAD UPLOAD
<input type="file" class="sprite-upload-input" accept=".png,image/png" data-type="${sprite.type}"> <input type="file" class="sprite-upload-input" accept=".png,image/png" data-type="${sprite.type}">
@@ -735,15 +530,6 @@
</div> </div>
`; `;
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'); const fileInput = card.querySelector('.sprite-upload-input');
fileInput.addEventListener('change', async (e) => { fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
@@ -784,7 +570,7 @@
} }
// ------- Helpers ------- // ------- Helpers -------
async function saveConfig(body, silent) { async function saveConfig(body) {
try { try {
const res = await fetch('/api/admin/config', { const res = await fetch('/api/admin/config', {
method: 'PUT', method: 'PUT',
@@ -792,16 +578,13 @@
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (res.ok) { if (res.ok) {
if (!silent) toast('Settings saved'); toast('Settings saved');
return true;
} else { } else {
const data = await res.json(); const data = await res.json();
toast(data.error || 'Save failed', true); toast(data.error || 'Save failed', true);
return false;
} }
} catch (e) { } catch (e) {
toast('Connection error', true); toast('Connection error', true);
return false;
} }
} }
-719
View File
@@ -1,719 +0,0 @@
// 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 = `
<div class="pe-modal">
<div class="pe-header">
<h2 class="retro-title" id="pe-title">PIXEL EDITOR</h2>
<div class="pe-header-actions">
<button class="btn btn-small" id="pe-load">LOAD CURRENT</button>
<button class="btn btn-small" id="pe-clear">CLEAR</button>
<button class="btn btn-small btn-primary" id="pe-save">SAVE</button>
<button class="btn btn-small btn-danger" id="pe-close">CLOSE</button>
</div>
</div>
<div class="pe-body">
<div class="pe-toolbar">
<div class="pe-tool-group">
<button class="pe-tool active" data-tool="pencil" title="Pencil (B)">✏</button>
<button class="pe-tool" data-tool="eraser" title="Eraser (E)">⌫</button>
<button class="pe-tool" data-tool="picker" title="Color picker (I)">⊙</button>
<button class="pe-tool" data-tool="fill" title="Fill bucket (F)">▣</button>
<button class="pe-tool" data-tool="pan" title="Pan (hold Space)">✥</button>
</div>
<div class="pe-tool-group">
<button class="pe-icon-btn" id="pe-undo" title="Undo (Ctrl+Z)">↶</button>
<button class="pe-icon-btn" id="pe-redo" title="Redo (Ctrl+Y)">↷</button>
</div>
<div class="pe-tool-group">
<button class="pe-icon-btn" id="pe-zoom-out" title="Zoom out (-)"></button>
<button class="pe-icon-btn" id="pe-zoom-in" title="Zoom in (+)">+</button>
<button class="pe-icon-btn" id="pe-zoom-fit" title="Fit to screen (0)">⤢</button>
</div>
<div class="pe-tool-group pe-grid-toggle">
<label class="pe-check"><input type="checkbox" id="pe-grid" checked> GRID</label>
</div>
<div class="pe-tool-group pe-brush-group">
<span class="pe-tool-label">SIZE</span>
<input type="range" id="pe-brush" min="1" max="8" value="1">
<span id="pe-brush-val" class="pe-tool-label">1</span>
</div>
</div>
<div class="pe-canvas-wrap" id="pe-canvas-wrap">
<canvas id="pe-view"></canvas>
</div>
<div class="pe-sidebar">
<div class="pe-color-current">
<div class="pe-swatch-large" id="pe-current-swatch"></div>
<input type="color" id="pe-color-input" value="#5c94fc">
</div>
<div class="pe-palette" id="pe-palette"></div>
<div class="pe-readout">
<div>POS <span id="pe-pos">--, --</span></div>
<div>ZOOM <span id="pe-zoom-val">100%</span></div>
<div>SIZE 500×333</div>
</div>
</div>
</div>
</div>
`;
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 };
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+5 -118
View File
@@ -170,69 +170,6 @@ receivers: List[str] = []
receiver_tasks: List[asyncio.Task] = [] receiver_tasks: List[asyncio.Task] = []
emitter_categories: Dict[str, str] = {} # ICAO hex -> ADS-B emitter category (A1, A3, A7, etc.) 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 @dataclass
class SBSMessage: class SBSMessage:
@@ -309,17 +246,10 @@ async def connect_to_receiver(host: str, port: int = 30003):
"""Connect to an SBS receiver and process messages""" """Connect to an SBS receiver and process messages"""
reconnect_delay = config['tuning']['receiver_reconnect_seconds'] reconnect_delay = config['tuning']['receiver_reconnect_seconds']
print(f"Connecting to receiver at {host}:{port}...") 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: while True:
try: try:
receiver_status[host]["state"] = "connecting"
reader, writer = await asyncio.open_connection(host, port) reader, writer = await asyncio.open_connection(host, port)
print(f"Connected to {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: while True:
line = await reader.readline() line = await reader.readline()
@@ -329,8 +259,6 @@ async def connect_to_receiver(host: str, port: int = 30003):
line = line.decode('utf-8', errors='ignore') line = line.decode('utf-8', errors='ignore')
msg = parse_sbs_message(line) msg = parse_sbs_message(line)
if msg and msg.icao: if msg and msg.icao:
receiver_status[host]["last_msg"] = time.time()
receiver_status[host]["msg_count"] += 1
if msg.icao not in flights: if msg.icao not in flights:
flights[msg.icao] = { flights[msg.icao] = {
'icao': msg.icao, 'icao': msg.icao,
@@ -367,14 +295,10 @@ async def connect_to_receiver(host: str, port: int = 30003):
flight['ground'] = msg.ground flight['ground'] = msg.ground
flight['last_seen'] = time.time() flight['last_seen'] = time.time()
# readline returned empty -> peer closed the connection.
receiver_status[host]["state"] = "down"
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
except Exception as e: except Exception as e:
print(f"Receiver {host}:{port} error: {e}") print(f"Receiver {host}:{port} error: {e}")
receiver_status[host]["state"] = "down"
receiver_status[host]["error"] = str(e)
await asyncio.sleep(reconnect_delay) await asyncio.sleep(reconnect_delay)
@@ -1068,15 +992,10 @@ async def restart_receiver_connections():
"""Cancel existing receiver tasks and start new ones.""" """Cancel existing receiver tasks and start new ones."""
global receivers, receiver_tasks global receivers, receiver_tasks
# Cancel old tasks and let them unwind cleanly. Awaiting the cancelled # Cancel old tasks
# tasks here (with return_exceptions) absorbs their CancelledError so it for task in receiver_tasks:
# can never propagate into the main event loop.
old_tasks = list(receiver_tasks)
receiver_tasks.clear()
for task in old_tasks:
task.cancel() task.cancel()
if old_tasks: receiver_tasks.clear()
await asyncio.gather(*old_tasks, return_exceptions=True)
# Determine receivers # Determine receivers
receiver_config = config["receivers"] receiver_config = config["receivers"]
@@ -1096,9 +1015,6 @@ async def restart_receiver_connections():
print(f"Error determining receivers: {e}") print(f"Error determining receivers: {e}")
receivers = [] receivers = []
# Reset health tracking to the new receiver set.
reset_receiver_status(receivers)
# Start new tasks # Start new tasks
for r in receivers: for r in receivers:
task = asyncio.create_task(connect_to_receiver(r, receiver_port)) task = asyncio.create_task(connect_to_receiver(r, receiver_port))
@@ -1114,28 +1030,6 @@ async def handle_admin_restart_receivers(request):
return web.json_response({"ok": True, "receivers": receivers}) 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 # Static file serving with setup redirect
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1209,8 +1103,6 @@ async def start_http_server():
app.router.add_get('/api/admin/network-info', handle_admin_network_info) 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/scan-receivers', handle_admin_scan_receivers)
app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_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_get('/api/admin/sprites', handle_admin_sprites)
app.router.add_post('/api/admin/sprites/{type}', handle_admin_sprite_upload) app.router.add_post('/api/admin/sprites/{type}', handle_admin_sprite_upload)
@@ -1256,8 +1148,6 @@ async def main():
else: else:
print(f"Using {len(receivers)} receiver(s)") print(f"Using {len(receivers)} receiver(s)")
reset_receiver_status(receivers)
# Start all tasks # Start all tasks
tasks = [ tasks = [
start_http_server(), start_http_server(),
@@ -1266,15 +1156,12 @@ async def main():
poll_aircraft_json(), poll_aircraft_json(),
] ]
# Connect to all receivers. These run as independent background tasks and # Connect to all receivers
# 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: for receiver in receivers:
task = asyncio.ensure_future(connect_to_receiver(receiver, receiver_port)) task = asyncio.ensure_future(connect_to_receiver(receiver, receiver_port))
receiver_tasks.append(task) receiver_tasks.append(task)
tasks.append(task)
# Only the core services keep the server alive.
await asyncio.gather(*tasks) await asyncio.gather(*tasks)