17 Commits

Author SHA1 Message Date
root 9f122f47aa v1.1.1: harden deployment for easier fielding
Make a fresh deploy (especially Docker) work with no manual steps and behave
well on real-world hosts:

- Auto-scan skips subnets larger than /20 (e.g. a docker bridge 172.17.0.0/16
  = 65k hosts) so AUTO discovery no longer stalls startup or hammers the
  network on any host with Docker. Manual scan shares the same MIN_SCAN_PREFIX.
- Docker: config.json is auto-seeded from config.json.example by a new
  docker-entrypoint.sh and persisted on a ./data directory mount — no more
  single-file bind-mount footgun, no manual cp before first run.
- Config path is overridable via ADSBIT_CONFIG (defaults to ./config.json for
  bare metal; /app/data/config.json in the container).
- New /health endpoint (status/version/receivers/flights) + Docker HEALTHCHECK.
- Clean SIGTERM/SIGINT shutdown (docker stop / systemctl stop) — no traceback.
- .gitignore/.dockerignore exclude the data/ config volume.
- README/CLAUDE.md updated; VERSION bumped to 1.1.1.

Verified: image builds, fresh `docker run` seeds config and reports healthy,
oversized subnets skipped in-container, /health responds, graceful shutdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:32:36 -07:00
allen 14de77926f Merge pull request 'Prep v1.1.0 release: version string, gitignore secrets, docs' (#8) from release/v1.1.0 into main 2026-06-09 23:41:25 -07:00
root 3cbf36e0ad Prep v1.1.0 release: version string, gitignore secrets, docs
- Add VERSION = "1.1.0" to server.py; print at startup, expose via public
  GET /api/config, and show it in the admin header (so deployed == released
  is verifiable at a glance).
- Ignore config.json in .gitignore — it holds the admin password hash and
  session secret; fresh installs use config.json.example.
- README/CLAUDE.md: document the in-app pixel editor (now with shape tools,
  select/move, reference overlay, custom/recent palettes), the receiver health
  dashboard, 9 sprite types, and the asset cache-busting convention.
- Bump admin asset cache-bust to ?v=20260609d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:41:00 -07:00
allen 8204d8d41e Merge pull request 'Pixel editor: shape tools, select/move, reference overlay, color tools' (#7) from feature/pixel-editor-enhancements into main 2026-06-09 15:53:05 -07:00
root a95db23240 Add shape tools, select/move, reference overlay, and color tools to pixel editor
Extends the in-app sprite editor with four feature sets:

- Shape tools: line (L), rectangle (R), ellipse (O) with click-drag live
  preview and a FILL toggle for outline vs filled rect/ellipse.
- Select & move: rectangular marquee (M) to select, drag-to-move via a
  floating layer, arrow-key nudge, and clipboard ops (Ctrl+C/X/V, Delete,
  Enter/Esc to commit). Undo/redo and save auto-bake any pending move.
- Reference overlay: load any image as an adjustable-opacity backdrop to
  trace over (rendered under the artwork), with a show/hide toggle.
- Color tools: brush opacity slider, persistent custom palette
  (localStorage; right-click to remove), and an auto-updating recent colors row.

Cache-bust admin assets to ?v=20260609c so the update loads without a manual
hard-refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:52:31 -07:00
allen 005817d499 Merge pull request 'Admin: in-app sprite pixel editor + receiver health UX & restart-crash fix' (#6) from feature/pixel-editor into main 2026-06-09 12:50:34 -07:00
root 7a80057cb5 Add receiver health UX, fix restart crash, add interface scan selector
Receiver management improvements for the admin Receivers tab:

- Per-receiver health: server now tracks each connection's state
  (connecting/connected/down), message count, and last-message time, exposed
  via new GET /api/admin/receiver-status and POST /api/admin/test-receivers.
- Receivers tab redesign: live "Active Receivers" panel with colored
  indicators (green=receiving, yellow=connected/no data, red=unreachable),
  a clear select -> SAVE & APPLY -> auto-test flow, and selectable scan
  results with a "USE SELECTED RECEIVERS" action.
- Network scan interface selector: scan a chosen interface/subnet instead of
  every local subnet. Oversized subnets (>/20, e.g. a docker /16) are disabled
  in the UI and rejected server-side, eliminating slow/hanging scans.

Critical fix — server crash on receiver restart:

- Receiver connection tasks were awaited inside main()'s top-level
  asyncio.gather. Restarting receivers cancels those tasks, and the resulting
  CancelledError propagated into the gather and killed the whole server
  process (every action after a restart then failed). Receiver tasks now run
  as independent background tasks; restart cancels and awaits them with
  return_exceptions=True so cancellation can never tear down the server.

Also cache-bust admin.css/admin.js/pixel-editor.js (?v=) so updated assets
load without a manual hard-refresh, and surface a clear message instead of a
silent "Loading…" when the status endpoint is unavailable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:49:45 -07:00
root 214ee8dd90 Add in-app pixel editor for sprites in admin UI
Build a dependency-free Canvas2D pixel editor integrated into the admin
Sprites tab. Each sprite card gains an EDIT button that opens a full-screen
editor for the sprite at its native 500x333 resolution.

Tools: pencil (1-8px brush, Bresenham interpolation), eraser, color picker
(palette + native input + pick-from-canvas), fill bucket (flood fill), pan.
Features: cursor-centered zoom, space-drag pan, undo/redo (snapshot per
stroke), pixel grid overlay, transparency checkerboard, load existing sprite,
and keyboard shortcuts (B/E/I/F, Space, +/-/0, Ctrl+Z/Y, G, Esc).

Export reuses the existing POST /api/admin/sprites/{type} upload endpoint via
srcCanvas.toBlob, so no server changes are required.

Closes #5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:39:31 -07:00
root 38043d7d1f 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 7af9789db7 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 9f341a5b14 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 3391523d45 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 23d469d008 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 a3fa38b740 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 c996ade142 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 2a2c29de29 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 22a3078af9 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
13 changed files with 2060 additions and 57 deletions
+1
View File
@@ -3,6 +3,7 @@ __pycache__
*.pyc
*.pyo
config.json
data/
screenshots/
.claude/
CLAUDE.md
+6 -2
View File
@@ -23,5 +23,9 @@ __pycache__/
.DS_Store
Thumbs.db
# Local config (uncomment if you want to ignore local changes)
# config.json
# Local config — contains the admin password hash and session secret.
# Fresh installs start from config.json.example (see CONFIG.md).
config.json
# Docker config volume (holds the live config.json with secrets)
data/
+17
View File
@@ -18,6 +18,14 @@ python3 server.py
Access at http://localhost:{web_port} (configured in config.json)
### Deployment / fielding notes
- **Config path** is overridable via the `ADSBIT_CONFIG` env var (defaults to `config.json` beside `server.py`). Docker uses `/app/data/config.json` on a mounted volume.
- **`/health`** (no auth) returns status/version/receiver/flight counts — used by the Docker `HEALTHCHECK` and any external monitoring.
- **`VERSION`** constant in `server.py` is the single source of truth; surfaced at startup, via `GET /api/config`, and in the admin header. Bump it for releases.
- **Auto-scan** (`scan_for_receivers`) skips subnets larger than `MIN_SCAN_PREFIX` (/20) so a docker `/16` doesn't stall startup; the manual scan endpoint enforces the same limit.
- **`docker-entrypoint.sh`** seeds `config.json` from `config.json.example` on first run, so a fresh `docker compose up` needs no manual steps. SIGTERM/SIGINT shut down cleanly (no traceback).
## Architecture
### Components
@@ -48,6 +56,15 @@ 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)
- Receivers tab: live per-receiver health (via `GET /api/admin/receiver-status` and `POST /api/admin/test-receivers`), a select → SAVE & APPLY → auto-test flow, selectable scan results, and a per-interface scan selector
- Version (from `server.py` `VERSION`) is surfaced via public `GET /api/config` and shown in the admin header
- **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, line, rectangle, ellipse (outline/filled), rectangular select
- Select & move: marquee + floating layer, drag/arrow-nudge, Ctrl+C/X/V clipboard, Delete, Enter/Esc commit
- Zoom/pan, undo/redo (snapshot-per-stroke), grid overlay, brush size & opacity, retro + custom (localStorage) + recent palettes, adjustable reference-image overlay
- 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/L/R/O/M tools, Space=pan, +/-/0 zoom, Ctrl+Z/Y undo/redo, Ctrl+C/X/V, arrows nudge, G grid, Esc close
- **Asset cache-busting:** `admin.html` references `admin.css`/`admin.js`/`pixel-editor.js` with a `?v=YYYYMMDD[x]` query — bump it whenever those files change so browsers reload them
- **setup/** - First-run setup wizard (multi-step configuration)
+9 -2
View File
@@ -13,9 +13,16 @@ RUN pip install --no-cache-dir -r requirements.txt && \
rm -rf /var/lib/apt/lists/*
COPY . .
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 2001
ENV PYTHONUNBUFFERED=1
# Persist config on a mounted volume by default (see docker-compose.yml).
ENV PYTHONUNBUFFERED=1 \
ADSBIT_CONFIG=/app/data/config.json
CMD ["python3", "server.py"]
# Liveness probe against the unauthenticated /health endpoint.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:2001/health',timeout=4).status==200 else 1)" || exit 1
ENTRYPOINT ["/app/docker-entrypoint.sh"]
+15 -11
View File
@@ -7,14 +7,16 @@ A retro SNES-style side-view flight tracker that displays ADS-B aircraft data wi
## Features
- Real-time aircraft tracking via ADS-B receivers
- Custom pixel art sprites for 6 aircraft types (small prop, regional jet, narrow body, wide body, heavy, helicopter)
- Custom pixel art sprites for 9 aircraft types (small prop, regional jet, narrow body, wide body, heavy, helicopter, balloon, glider, UAV)
- Animated sun and moon with accurate astronomical positions
- Dynamic sky colors based on time of day
- Weather visualization with cloud sprites
- Directional view (N/E/S/W) with themed backgrounds
- Auto-discovery of ADS-B receivers on your network
- Auto-discovery of ADS-B receivers on your network, with per-interface scanning
- Canvas-based 10 FPS retro rendering
- Admin panel with password authentication
- **In-app pixel editor** — draw and edit sprites in the browser (pencil, shapes, fill, select & move, reference overlay, custom palettes)
- **Receiver health dashboard** — live per-receiver status (receiving / no data / unreachable), connection testing, and select → save → apply flow
- First-run setup wizard
## Quick Start
@@ -42,14 +44,16 @@ On first run, visit http://localhost:2001 and the setup wizard will guide you th
### First Run
```bash
# Create a config file (the setup wizard runs if you skip this)
cp config.json.example config.json
# Build and start
# Build and start — no config step needed
docker compose up -d
```
The web UI is available at http://localhost:2001.
A config file is created automatically in `data/` on first run, then the
browser **setup wizard** walks you through receiver, location, and password
setup. The web UI is available at http://localhost:2001.
A `/health` endpoint (used by the container healthcheck) reports status,
version, and receiver/flight counts: `curl http://localhost:2001/health`.
### Useful Commands
@@ -80,11 +84,11 @@ The following paths are bind-mounted from the repo directory and persist across
| Path | Contents |
|------|----------|
| `config.json` | Server configuration and credentials |
| `data/` | Server configuration and credentials (`config.json`, auto-seeded) |
| `images/` | Aircraft and UI sprites (including uploads) |
| `backgrounds/` | Theme background images (including custom themes) |
Host networking (`network_mode: host`) is used so the server can auto-scan your LAN for ADS-B receivers.
Host networking (`network_mode: host`) is used so the server can auto-scan your LAN for ADS-B receivers. Auto-scan skips oversized subnets (larger than /20, e.g. a Docker bridge `172.17.0.0/16`); if your receiver lives on such a network, set it explicitly via the admin Receivers tab.
## Requirements
@@ -102,8 +106,8 @@ ADS-Bit uses a first-run setup wizard to configure your installation. You can al
"receiver_port": 30003,
"location": {
"name": "My Location",
"lat": 36.2788,
"lon": -115.2283
"lat": 0.0,
"lon": 0.0
},
"web_port": 2001,
"theme": "desert"
+387
View File
@@ -169,6 +169,15 @@ textarea {
margin: 0;
}
.admin-version {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #54fc54;
text-shadow: none;
margin-left: 6px;
vertical-align: middle;
}
.header-actions {
display: flex;
gap: 8px;
@@ -592,6 +601,384 @@ 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; }
/* Sidebar: opacity, palettes, reference */
.pe-opacity-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.pe-opacity-group input[type="range"] { width: 100%; accent-color: #fcd444; }
.pe-subhead {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #b4d4ec;
margin-top: 4px;
letter-spacing: 1px;
}
.pe-mini-btn {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
padding: 6px 4px;
border: 2px solid #5c94fc;
border-radius: 5px;
background: rgba(92, 148, 252, 0.15);
color: #fcfcfc;
cursor: pointer;
transition: all 0.12s;
text-align: center;
}
.pe-mini-btn:hover { background: rgba(92, 148, 252, 0.4); }
#pe-custom-palette:empty::after,
#pe-recent-palette:empty::after {
content: '—';
color: #555;
font-size: 11px;
}
.pe-ref-controls {
display: flex;
flex-direction: column;
gap: 6px;
}
.pe-ref-row { display: flex; gap: 6px; }
.pe-ref-row .pe-mini-btn { flex: 1; }
.pe-ref-load { display: inline-block; }
.pe-ref-controls input[type="range"] { width: 100%; accent-color: #fcd444; }
/* 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 {
+45 -12
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="/admin/admin.css">
<link rel="stylesheet" href="/admin/admin.css?v=20260609d">
</head>
<body>
<!-- Login Screen -->
@@ -27,7 +27,7 @@
<!-- Admin Panel -->
<div id="admin-panel" class="hidden">
<header class="admin-header">
<h1 class="retro-title">ADS-Bit Admin</h1>
<h1 class="retro-title">ADS-Bit Admin <span id="admin-version" class="admin-version"></span></h1>
<div class="header-actions">
<a href="/" class="btn btn-small">VIEWER</a>
<button id="logout-btn" class="btn btn-small btn-danger">LOGOUT</button>
@@ -76,37 +76,69 @@
<!-- Receivers Tab -->
<section id="tab-receivers" class="tab-pane hidden">
<h2>Receiver Configuration</h2>
<div id="network-info-box" class="info-box">
<h3>Host Network</h3>
<div id="network-info">Loading...</div>
<!-- Live status of the receivers the app is currently using -->
<div id="receiver-status-box" class="info-box">
<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>
<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">
<label>Discovery Mode</label>
<select id="receiver-mode">
<option value="AUTO">AUTO (scan network)</option>
<option value="MANUAL">MANUAL (specify IPs)</option>
<option value="AUTO">AUTO (use all receivers found on the network)</option>
<option value="MANUAL">MANUAL (use only the IPs I specify)</option>
</select>
</div>
<div id="manual-ips-group" class="form-group hidden">
<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>
<span class="hint">The app connects to every IP listed here.</span>
</div>
<div class="form-group">
<label>Receiver Port</label>
<input type="number" id="receiver-port" value="30003" min="1" max="65535">
</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">
<label>Custom Subnet (optional)</label>
<input type="text" id="scan-subnet" placeholder="e.g. 10.0.0.0/24 (leave empty to scan all local subnets)">
<label>Interface / Subnet to Scan</label>
<select id="scan-interface"></select>
<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 class="button-row">
<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 id="scan-results" class="info-box hidden">
<h3>Scan Results</h3>
<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>
</section>
@@ -265,6 +297,7 @@
<div id="toast" class="toast hidden"></div>
</div>
<script src="/admin/admin.js"></script>
<script src="/admin/pixel-editor.js?v=20260609d"></script>
<script src="/admin/admin.js?v=20260609d"></script>
</body>
</html>
+247 -18
View File
@@ -28,10 +28,22 @@
loadConfig();
loadThemes();
loadNetworkInfo();
loadVersion();
refreshStatus();
statusInterval = setInterval(refreshStatus, 5000);
}
async function loadVersion() {
try {
const res = await fetch('/api/config');
if (!res.ok) return;
const data = await res.json();
if (data.version) {
document.getElementById('admin-version').textContent = 'v' + data.version;
}
} catch (e) { /* ignore */ }
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const pw = document.getElementById('login-password').value;
@@ -78,6 +90,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 +209,44 @@
});
html += '</table>';
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 +261,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 +272,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 +280,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 = `
<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 () => {
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 +360,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 = '<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 -------
document.getElementById('browser-location-btn').addEventListener('click', () => {
@@ -522,6 +738,7 @@
<div class="sprite-categories">${sprite.categories || ''}</div>
<div class="sprite-preview">${imgHtml}</div>
<div class="sprite-btn-row">
<button class="sprite-upload-label sprite-edit-btn" data-type="${sprite.type}">EDIT</button>
<label class="sprite-upload-label">
UPLOAD
<input type="file" class="sprite-upload-input" accept=".png,image/png" data-type="${sprite.type}">
@@ -530,6 +747,15 @@
</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');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
@@ -570,7 +796,7 @@
}
// ------- Helpers -------
async function saveConfig(body) {
async function saveConfig(body, silent) {
try {
const res = await fetch('/api/admin/config', {
method: 'PUT',
@@ -578,13 +804,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;
}
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -6,9 +6,11 @@ services:
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
- ADSBIT_CONFIG=/app/data/config.json
volumes:
# Persist config (copy config.json.example to config.json before first run)
- ./config.json:/app/config.json
# Config persists here; auto-seeded from config.json.example on first run
# (a directory mount, so a fresh `docker compose up` just works).
- ./data:/app/data
# Persist custom sprite uploads
- ./images:/app/images
# Persist custom theme backgrounds
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Seed a config file on first run so `docker compose up` works on a fresh
# clone with no manual steps. The config lives on a mounted data volume so it
# persists across container rebuilds.
set -e
: "${ADSBIT_CONFIG:=/app/config.json}"
CONFIG_DIR=$(dirname "$ADSBIT_CONFIG")
mkdir -p "$CONFIG_DIR"
if [ ! -f "$ADSBIT_CONFIG" ]; then
echo "[entrypoint] No config at $ADSBIT_CONFIG — seeding from config.json.example"
cp /app/config.json.example "$ADSBIT_CONFIG"
fi
exec python3 server.py
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

+161 -10
View File
@@ -17,8 +17,16 @@ from aiohttp import web, ClientSession, ClientTimeout
import netifaces
import bcrypt
VERSION = "1.1.1"
WEB_DIR = Path(__file__).parent
CONFIG_FILE = WEB_DIR / "config.json"
# Config path is overridable (handy for containers persisting config on a
# named volume); defaults to config.json next to this script for bare-metal.
CONFIG_FILE = Path(os.environ.get("ADSBIT_CONFIG") or (WEB_DIR / "config.json"))
# Smallest subnet prefix (largest network) we'll auto-scan: /20 = 4094 hosts.
# Anything bigger (e.g. a docker /16) is skipped to keep scans fast and safe.
MIN_SCAN_PREFIX = 20
# Track server start time
SERVER_START_TIME = time.time()
@@ -170,6 +178,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 +317,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 +337,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 +375,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)
@@ -336,6 +420,15 @@ async def scan_for_receivers():
if ip and netmask and not ip.startswith('127.'):
try:
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
# Skip subnets too large to scan quickly (e.g. a docker
# bridge /16 = 65k hosts). Without this, AUTO discovery
# hammers the network and stalls startup on any host with
# Docker or a large/16. Use MANUAL or a custom subnet for
# receivers that live on such networks.
if network.prefixlen < MIN_SCAN_PREFIX:
print(f"Skipping {network} on {iface}: too large "
f"({network.num_addresses} hosts) for auto-scan")
continue
found.extend(await _scan_subnet(network, port))
except ValueError as e:
print(f"Invalid network {ip}/{netmask}: {e}")
@@ -473,6 +566,7 @@ async def handle_receiver_location(request):
async def handle_config(request):
"""Return client-relevant configuration (public, no secrets)."""
return web.json_response({
"version": VERSION,
"theme": config.get("theme", "desert"),
"location": config["location"],
"receivers": receivers,
@@ -905,9 +999,10 @@ async def handle_admin_scan_receivers(request):
return web.json_response(
{"error": f"Invalid subnet: {subnet}"}, status=400)
if network.prefixlen < 20:
if network.prefixlen < MIN_SCAN_PREFIX:
return web.json_response(
{"error": "Subnet too large (max /20 = 4096 hosts)"}, status=400)
{"error": f"Subnet too large (max /{MIN_SCAN_PREFIX} = "
f"{2 ** (32 - MIN_SCAN_PREFIX) - 2} hosts)"}, status=400)
port = config['receiver_port']
found = await _scan_subnet(network, port)
@@ -992,10 +1087,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 +1115,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 +1133,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
# ---------------------------------------------------------------------------
@@ -1063,6 +1188,17 @@ async def handle_http(request):
return web.Response(status=404, text="Not Found")
async def handle_health(request):
"""GET /health - liveness/readiness probe (no auth, no secrets)."""
return web.json_response({
"status": "ok",
"version": VERSION,
"receivers": len(receivers),
"flights": len(flights),
"uptime_seconds": int(time.time() - SERVER_START_TIME),
})
# ---------------------------------------------------------------------------
# Application setup
# ---------------------------------------------------------------------------
@@ -1075,6 +1211,7 @@ async def start_http_server():
app.router.add_get('/ws', websocket_handler)
# Public API
app.router.add_get('/health', handle_health)
app.router.add_get('/api/receiver-location', handle_receiver_location)
app.router.add_get('/api/config', handle_config)
@@ -1103,6 +1240,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)
@@ -1123,7 +1262,7 @@ async def main():
"""Main entry point"""
global receivers, receiver_tasks
print("ADS-Bit Server Starting...")
print(f"ADS-Bit Server v{VERSION} Starting...")
load_config()
@@ -1148,6 +1287,8 @@ async def main():
else:
print(f"Using {len(receivers)} receiver(s)")
reset_receiver_status(receivers)
# Start all tasks
tasks = [
start_http_server(),
@@ -1156,14 +1297,24 @@ 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)
if __name__ == "__main__":
asyncio.run(main())
# Make SIGTERM (docker stop, systemctl stop) raise KeyboardInterrupt like
# SIGINT does, so shutdown is clean and quiet instead of a stack trace.
import signal
signal.signal(signal.SIGTERM, signal.default_int_handler)
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
print("ADS-Bit server shutting down.")