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>
This commit is contained in:
root
2026-06-08 08:35:07 -07:00
commit 22a3078af9
32 changed files with 5584 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
.eggs/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Claude Code
.claude/
# Gitea token
.gitea-token
# OS
.DS_Store
Thumbs.db
# Local config (uncomment if you want to ignore local changes)
# config.json
+103
View File
@@ -0,0 +1,103 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Pixel View is a retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites. It's a standalone sub-project within the larger IceNet-ADS-B system.
## Running the Server
```bash
# Start the server (port configured in config.json, default 2001)
python3 server.py
# Or use the startup script
./start.sh
```
Access at http://localhost:{web_port} (configured in config.json)
## Architecture
### Components
- **server.py** - Python WebSocket server using aiohttp
- Reads configuration from config.json
- Auto-scans for ADS-B receivers or connects to configured IPs
- Parses SBS/BaseStation format messages from receivers
- Broadcasts flight data to connected WebSocket clients every 1 second
- Cleans up flights not seen in 60 seconds
- Serves static files and receiver location API
- **config.json** - Configuration file for receiver and location settings
- See CONFIG.md for full documentation
- **pixel-view.js** - JavaScript rendering engine (Canvas-based)
- Handles WebSocket connection and flight data updates
- Renders 10 FPS retro-style canvas animation
- Layer order (bottom to top): sky gradient → clouds → sun → moon → directional background → grid → aircraft → labels
- Directional backgrounds include horizon, so low sun/moon is realistically occluded
- Aircraft sprites flip horizontally when heading west (track 90°-270°)
- View direction rotates between N/E/S/W (arrow keys or A/D), changing the background
- **index.html** - Main HTML interface with embedded styles
### Sprite Assets
All PNG sprites face right (eastward) and are flipped in-canvas for westbound aircraft:
- 6 aircraft types: smallProp, regionalJet, narrowBody, wideBody, heavy, helicopter
- Directional backgrounds: north.png, south.png, east.png, west.png (1536x1024, shown based on view direction)
- Celestial: sun.png, moon_6_phases.png (2x3 sprite sheet)
- Weather: happycloud.png (clear), raincloud.png (rain/snow)
### Aircraft Type Detection Logic
Priority order in categorization:
1. **Helicopter**: altitude < 5000 ft AND speed < 150 knots
2. **Heavy (747/A380)**: specific callsigns OR altitude > 42000 ft OR speed > 550 knots
3. **Wide Body**: altitude > 40000 ft OR speed > 500 knots
4. **Regional Jet**: specific callsigns OR (altitude < 25000 ft AND speed < 350 knots)
5. **Small Prop**: N-prefix callsigns OR (altitude < 10000 ft AND speed < 200 knots)
6. **Narrow Body**: default for remaining aircraft
### View Direction Controls
The viewer can rotate between cardinal directions (N/E/S/W), showing aircraft in a 90° field of view:
- **Keyboard**: Left/Right arrow keys or A/D keys
- **UI**: Arrow buttons on the interface
- Each direction displays a unique background image (north.png, east.png, south.png, west.png)
- Sun and moon positions are calculated based on actual azimuth and only appear when in the current field of view
### External APIs
- **Open-Meteo**: Weather and sunrise/sunset data (updates every 10 minutes)
- **ADS-B Receivers**: SBS/BaseStation protocol on port 30003
## Dependencies
Python packages required:
- aiohttp (web server and WebSocket)
- netifaces (network interface scanning)
No package.json - frontend is vanilla JavaScript with no build step.
## Key Code Patterns
- Canvas version parameter on assets (`?v=36`) for cache busting
- Aircraft direction: `const isFacingLeft = flight.track > 90 && flight.track < 270`
- Moon phases use sprite sheet cropping with 2x3 grid (3 columns, 2 rows)
- Flight data stored in global `flights` dict keyed by ICAO hex code
## Debugging
```bash
# Check for running server instances
ps aux | grep server.py
# Kill all instances
pkill -9 -f server.py
# Test receiver connectivity (replace with your receiver IP)
nc -zv <receiver_ip> 30003
```
+277
View File
@@ -0,0 +1,277 @@
# Pixel View Configuration Guide
This guide explains how to configure Pixel View for your ADS-B receiver setup.
## Configuration File
Edit `config.json` to customize your installation:
```json
{
"receivers": "AUTO",
"receiver_port": 30003,
"location": {
"name": "My Location",
"lat": 0.0,
"lon": 0.0
},
"web_port": 2001,
"theme": "desert"
}
```
**Important:** You must set your `location.lat` and `location.lon` to your actual receiver coordinates for weather data and aircraft positioning to work correctly.
### Configuration Options
| Option | Type | Description |
|--------|------|-------------|
| `receivers` | string or array | `"AUTO"` to scan network, or IP address(es) of ADS-B receiver(s) |
| `receiver_port` | number | Port for SBS/BaseStation data (default: 30003) |
| `location.name` | string | Display name shown on screen (e.g., "Seattle, WA") |
| `location.lat` | number | Latitude of your receiver location |
| `location.lon` | number | Longitude of your receiver location |
| `web_port` | number | Port for the web interface (default: 2001) |
| `theme` | string | Background theme: `"desert"` (default) or `"custom"` |
### Receiver Configuration Examples
**Auto-scan network (default):**
```json
"receivers": "AUTO"
```
**Single receiver:**
```json
"receivers": "192.168.1.100"
```
**Multiple receivers:**
```json
"receivers": ["192.168.1.100", "192.168.1.101"]
```
---
## Background Themes
Pixel View uses directional background images to show the horizon view from your receiver location. Backgrounds are organized into themes stored in the `backgrounds/` folder.
### Available Themes
| Theme | Description |
|-------|-------------|
| `desert` | Las Vegas desert landscape (default) |
| `custom` | Your own custom backgrounds |
### Theme Folder Structure
```
backgrounds/
├── desert/ # Default desert theme
│ ├── north.png
│ ├── east.png
│ ├── south.png
│ └── west.png
└── custom/ # Your custom backgrounds
├── README.md # Instructions for creating custom backgrounds
├── north.png
├── east.png
├── south.png
└── west.png
```
### Using Custom Backgrounds
1. Add your background images to `backgrounds/custom/`
2. Set `"theme": "custom"` in your `config.json`
3. Restart the server
### Image Files
Each theme folder needs these 4 directional images:
| File | Direction | Description |
|------|-----------|-------------|
| `north.png` | North (0°) | View looking north from your location |
| `east.png` | East (90°) | View looking east from your location |
| `south.png` | South (180°) | View looking south from your location |
| `west.png` | West (270°) | View looking west from your location |
### Image Requirements
- **Resolution:** 1536 x 1024 pixels (recommended)
- **Format:** PNG with transparency support
- **Aspect ratio:** 3:2 (width:height)
- **Style:** Pixel art style for best visual consistency
### Image Composition
Each background image should include:
1. **Sky area** (top ~60%): Should be transparent or very light to blend with the dynamic sky gradient
2. **Horizon line**: Where the sky meets the ground/landscape
3. **Ground/landscape** (bottom ~40%): Your local terrain features
```
┌─────────────────────────────┐
│ │
│ Transparent/Sky │ ← Dynamic sky renders here
│ (alpha = 0 or light) │
│ │
├─────────────────────────────┤ ← Horizon line
│ │
│ Ground/Landscape │ ← Your local scenery
│ (mountains, buildings, │
│ trees, desert, etc.) │
│ │
└─────────────────────────────┘
```
### Creating Custom Backgrounds
**Option 1: Pixel Art (Recommended)**
- Use a pixel art editor (Aseprite, Piskel, GIMP)
- Create at 384x256 or 768x512, then scale up 4x or 2x
- Keep colors limited for retro aesthetic
- Use the existing backgrounds as templates
**Option 2: Photo-based**
- Take photos looking N/E/S/W from your receiver location
- Apply a pixel art filter or posterize effect
- Reduce to limited color palette
- Resize to 1536x1024
**Option 3: Simplified Silhouettes**
- Create simple horizon silhouettes of local landmarks
- Mountains, buildings, trees as flat shapes
- Works well with limited artistic skills
### Tips for Good Backgrounds
1. **Consistency**: Use the same color palette across all 4 directions
2. **Horizon height**: Keep the horizon at roughly the same vertical position
3. **Landmarks**: Include recognizable local features (mountains, towers, etc.)
4. **Weather**: The sky portion should be transparent so the dynamic weather shows through
5. **Testing**: View each direction in the app to ensure smooth rotation
### Example Color Palettes
**Desert/Southwest:**
```
Ground tones: #d4a868, #b8884c, #a87840
Rock/mountain: #8c7c68, #6c5c4c
Vegetation: #54a844, #3c7c30
```
**Forest/Pacific Northwest:**
```
Ground tones: #3c5c3c, #4c6c4c, #2c4c2c
Trees: #2c5c2c, #1c4c1c, #3c6c3c
Mountains: #5c6c7c, #7c8c9c, #fcfcfc (snow)
```
**Urban/City:**
```
Buildings: #4c5c6c, #5c6c7c, #6c7c8c
Windows: #fcd444, #fcfc9c
Ground: #3c3c3c, #4c4c4c
```
**Coastal:**
```
Sand: #e4d4a8, #d4c498
Water: #5c94fc, #4c84ec
Cliffs: #8c7c68, #9c8c78
```
Sky should always be transparent (#00000000) to allow the dynamic sky gradient to show through.
---
## Other Sprite Assets
All sprite images are located in the `images/` folder. These are optional to customize:
| File | Size | Description |
|------|------|-------------|
| `images/sun.png` | 64x64 | Sun sprite |
| `images/moon_6_phases.png` | 192x128 | Moon phases (3x2 grid) |
| `images/happycloud.png` | 96x64 | Clear weather cloud |
| `images/raincloud.png` | 96x64 | Rain/storm cloud |
### Aircraft Sprites
| File | Description |
|------|-------------|
| `images/smallProp.png` | Small propeller aircraft (Cessna) |
| `images/regionalJet.png` | Regional jets (CRJ, ERJ) |
| `images/narrowBody.png` | Narrow body jets (737, A320) |
| `images/wideBody.png` | Wide body jets (777, 787) |
| `images/heavy.png` | Heavy/jumbo jets (747, A380) |
| `images/helicopter.png` | Helicopters |
All aircraft sprites should face **right (east)** - the code flips them automatically for westbound flights.
---
## Quick Start Checklist
1. [ ] Install dependencies: `pip install -r requirements.txt`
2. [ ] Edit `config.json` with your receiver IP (or leave as AUTO)
3. [ ] Set your location name, latitude, and longitude
4. [ ] (Optional) Add custom backgrounds to `backgrounds/custom/` and set `"theme": "custom"`
5. [ ] Start the server: `python3 server.py`
6. [ ] Open browser to `http://your-server-ip:2001`
---
## Running as a Service (Auto-Start)
To run Pixel-ADSB automatically on boot, create a systemd service:
### 1. Create the service file
```bash
sudo nano /etc/systemd/system/pixel-adsb.service
```
Add this content (adjust paths as needed):
```ini
[Unit]
Description=Pixel-ADSB Flight Tracker
After=network.target
[Service]
Type=simple
WorkingDirectory=/path/to/pixel-view
ExecStart=/usr/bin/python3 /path/to/pixel-view/server.py
Restart=always
RestartSec=5
StandardOutput=append:/var/log/pixel-adsb.log
StandardError=append:/var/log/pixel-adsb.log
[Install]
WantedBy=multi-user.target
```
### 2. Enable and start the service
```bash
sudo systemctl daemon-reload
sudo systemctl enable pixel-adsb
sudo systemctl start pixel-adsb
```
### 3. Check status
```bash
sudo systemctl status pixel-adsb
```
### 4. View logs
```bash
tail -f /var/log/pixel-adsb.log
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Allen Cross
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+112
View File
@@ -0,0 +1,112 @@
# Pixel-ADSB
A retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites.
![Pixel-ADSB Screenshot](screenshots/screenshot.png)
## 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)
- 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
- Canvas-based 10 FPS retro rendering
## Quick Start
```bash
# Clone the repository
git clone https://gitea.chops.one/allen/ADS-Bit.git
cd ADS-Bit
# Install dependencies
pip install -r requirements.txt
# Configure your location (required for weather and celestial positioning)
# Edit config.json and set your lat/lon coordinates
# Start the server
python3 server.py
```
Access at http://localhost:2001
## Requirements
- Python 3.8+
- ADS-B receiver providing SBS/BaseStation format on port 30003 (dump1090, readsb, etc.)
- Modern web browser with Canvas support
## Configuration
Edit `config.json` to customize your installation:
```json
{
"receivers": "AUTO",
"receiver_port": 30003,
"location": {
"name": "My Location",
"lat": 0.0,
"lon": 0.0
},
"web_port": 2001,
"theme": "desert"
}
```
**Important:** Set your `location.lat` and `location.lon` for accurate weather and sun/moon positioning.
See [CONFIG.md](CONFIG.md) for full configuration options including custom backgrounds and running as a service.
## Controls
- **Arrow Keys / A/D**: Rotate view direction
- View cycles through North, East, South, West
- Click aircraft in sidebar to highlight
## Aircraft Types
| Type | Detection |
|------|-----------|
| Helicopter | Low altitude + slow speed |
| Heavy (747/A380) | High altitude or specific callsigns |
| Wide Body | Very high altitude/speed |
| Narrow Body | Default commercial |
| Regional Jet | Regional carrier callsigns or lower altitude |
| Small Prop | N-prefix callsigns or very low/slow |
## Custom Backgrounds
Create backgrounds for your location:
1. Add 4 directional images to `backgrounds/custom/` (north.png, east.png, south.png, west.png)
2. Set `"theme": "custom"` in config.json
3. Restart the server
See [CONFIG.md](CONFIG.md) for image specifications and tips.
## Running as a Service
To auto-start on boot, see the systemd service instructions in [CONFIG.md](CONFIG.md#running-as-a-service-auto-start).
## Compatible Receivers
Works with any receiver providing SBS/BaseStation format on port 30003:
- dump1090 / dump1090-fa / dump1090-mutability
- readsb
- ADS-B Exchange feeders
- FlightAware PiAware
- Any SBS1 compatible receiver
## License
MIT License - see [LICENSE](LICENSE) for details.
## Credits
- Aircraft and environment sprites generated with AI assistance
- Weather data from [Open-Meteo](https://open-meteo.com/) (free, no API key required)
+392
View File
@@ -0,0 +1,392 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%);
color: #fcfcfc;
font-family: 'Courier New', monospace;
min-height: 100vh;
}
.hidden { display: none !important; }
/* Login Screen */
#login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-box {
background: rgba(52, 73, 94, 0.9);
border: 3px solid #5c94fc;
border-radius: 12px;
padding: 40px;
text-align: center;
max-width: 400px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.retro-title {
font-family: 'Press Start 2P', monospace;
color: #fcd444;
text-shadow: 2px 2px 0px #000;
font-size: 20px;
margin-bottom: 10px;
}
.login-subtitle {
color: #b4d4ec;
font-size: 14px;
margin-bottom: 24px;
}
#login-form {
display: flex;
flex-direction: column;
gap: 12px;
}
input[type="password"],
input[type="text"],
input[type="number"],
textarea,
select {
background: rgba(0, 0, 0, 0.5);
border: 2px solid #5c94fc;
border-radius: 6px;
color: #fcfcfc;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 10px 12px;
width: 100%;
outline: none;
transition: border-color 0.2s;
}
input:focus, textarea:focus, select:focus {
border-color: #fcd444;
}
select {
cursor: pointer;
}
select option {
background: #2c3e50;
color: #fcfcfc;
}
textarea {
resize: vertical;
min-height: 80px;
}
.btn {
font-family: 'Press Start 2P', monospace;
font-size: 11px;
padding: 10px 20px;
border: 2px solid #5c94fc;
border-radius: 6px;
background: rgba(92, 148, 252, 0.2);
color: #fcfcfc;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn:hover {
background: rgba(92, 148, 252, 0.4);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: rgba(252, 212, 68, 0.3);
border-color: #fcd444;
color: #fcd444;
}
.btn-primary:hover {
background: rgba(252, 212, 68, 0.5);
}
.btn-danger {
border-color: #fc5454;
color: #fc5454;
}
.btn-danger:hover {
background: rgba(252, 84, 84, 0.3);
}
.btn-small {
font-size: 9px;
padding: 6px 12px;
}
.error-msg {
color: #fc5454;
font-size: 12px;
margin-top: 10px;
}
.back-link {
display: inline-block;
margin-top: 16px;
color: #b4d4ec;
text-decoration: none;
font-size: 12px;
}
.back-link:hover {
color: #fcd444;
}
/* Admin Panel */
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: rgba(0, 0, 0, 0.3);
border-bottom: 3px solid #5c94fc;
}
.admin-header .retro-title {
font-size: 16px;
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
/* Tab Bar */
.tab-bar {
display: flex;
gap: 2px;
padding: 10px 20px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
padding: 8px 14px;
background: rgba(52, 73, 94, 0.6);
border: 2px solid #5c94fc;
border-bottom: none;
border-radius: 6px 6px 0 0;
color: #b4d4ec;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.tab:hover {
background: rgba(92, 148, 252, 0.3);
color: #fcfcfc;
}
.tab.active {
background: rgba(52, 73, 94, 0.9);
color: #fcd444;
border-color: #fcd444;
}
/* Tab Content */
.tab-content {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.tab-pane {
background: rgba(52, 73, 94, 0.6);
border: 2px solid #5c94fc;
border-radius: 0 8px 8px 8px;
padding: 24px;
}
.tab-pane h2 {
font-family: 'Press Start 2P', monospace;
font-size: 14px;
color: #fcd444;
margin-bottom: 20px;
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.status-card {
background: rgba(0, 0, 0, 0.4);
border: 2px solid #5c94fc;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.status-label {
display: block;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #b4d4ec;
margin-bottom: 8px;
}
.status-value {
display: block;
font-family: 'Press Start 2P', monospace;
font-size: 18px;
color: #54fc54;
}
/* Forms */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #b4d4ec;
margin-bottom: 6px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.hint {
display: block;
font-size: 11px;
color: #888;
margin-top: 4px;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #fcd444;
}
.button-row {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.info-box {
background: rgba(0, 0, 0, 0.3);
border: 2px solid #5c94fc;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.info-box h3 {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #fcd444;
margin-bottom: 10px;
}
/* Toast Notification */
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(84, 252, 84, 0.9);
color: #000;
font-family: 'Press Start 2P', monospace;
font-size: 10px;
padding: 12px 24px;
border-radius: 8px;
border: 2px solid #54fc54;
z-index: 1000;
transition: opacity 0.3s;
}
.toast.error {
background: rgba(252, 84, 84, 0.9);
border-color: #fc5454;
color: #fff;
}
/* Mobile */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
gap: 8px;
text-align: center;
}
.tab-bar {
padding: 8px 10px 0;
}
.tab {
font-size: 8px;
padding: 6px 10px;
}
.tab-content {
padding: 10px;
}
.tab-pane {
padding: 16px;
}
.form-row {
flex-direction: column;
gap: 0;
}
.status-grid {
grid-template-columns: repeat(2, 1fr);
}
.button-row {
flex-direction: column;
}
.button-row .btn {
width: 100%;
}
}
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-Bit Admin</title>
<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">
</head>
<body>
<!-- Login Screen -->
<div id="login-screen">
<div class="login-box">
<h1 class="retro-title">ADS-Bit Admin</h1>
<p class="login-subtitle">Enter admin password</p>
<form id="login-form">
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
<button type="submit" class="btn btn-primary">LOGIN</button>
</form>
<div id="login-error" class="error-msg hidden"></div>
<a href="/" class="back-link">Back to Viewer</a>
</div>
</div>
<!-- Admin Panel -->
<div id="admin-panel" class="hidden">
<header class="admin-header">
<h1 class="retro-title">ADS-Bit Admin</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>
</div>
</header>
<nav class="tab-bar">
<button class="tab active" data-tab="dashboard">DASHBOARD</button>
<button class="tab" data-tab="receivers">RECEIVERS</button>
<button class="tab" data-tab="location">LOCATION</button>
<button class="tab" data-tab="display">DISPLAY</button>
<button class="tab" data-tab="tuning">TUNING</button>
<button class="tab" data-tab="security">SECURITY</button>
</nav>
<main class="tab-content">
<!-- Dashboard Tab -->
<section id="tab-dashboard" class="tab-pane active">
<h2>Live Status</h2>
<div class="status-grid">
<div class="status-card">
<span class="status-label">ACTIVE FLIGHTS</span>
<span id="stat-flights" class="status-value">--</span>
</div>
<div class="status-card">
<span class="status-label">CONNECTED VIEWERS</span>
<span id="stat-viewers" class="status-value">--</span>
</div>
<div class="status-card">
<span class="status-label">RECEIVERS</span>
<span id="stat-receivers" class="status-value">--</span>
</div>
<div class="status-card">
<span class="status-label">UPTIME</span>
<span id="stat-uptime" class="status-value">--</span>
</div>
</div>
<div id="receiver-list" class="info-box">
<h3>Connected Receivers</h3>
<div id="receiver-ips">Loading...</div>
</div>
</section>
<!-- Receivers Tab -->
<section id="tab-receivers" class="tab-pane hidden">
<h2>Receiver Configuration</h2>
<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>
</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>
</div>
<div class="form-group">
<label>Receiver Port</label>
<input type="number" id="receiver-port" value="30003" min="1" max="65535">
</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>
</section>
<!-- Location Tab -->
<section id="tab-location" class="tab-pane hidden">
<h2>Location Settings</h2>
<div class="form-group">
<label>Location Name</label>
<input type="text" id="location-name" placeholder="My Location">
</div>
<div class="form-row">
<div class="form-group">
<label>Latitude</label>
<input type="number" id="location-lat" step="0.0001" placeholder="0.0">
</div>
<div class="form-group">
<label>Longitude</label>
<input type="number" id="location-lon" step="0.0001" placeholder="0.0">
</div>
</div>
<div class="button-row">
<button id="browser-location-btn" class="btn">USE BROWSER LOCATION</button>
<button id="save-location-btn" class="btn btn-primary">SAVE</button>
</div>
</section>
<!-- Display Tab -->
<section id="tab-display" class="tab-pane hidden">
<h2>Display Settings</h2>
<div class="form-group">
<label>Theme</label>
<select id="theme-select"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Site Title</label>
<input type="text" id="site-title" placeholder="ADS-Bit">
</div>
<div class="form-group">
<label>Subtitle</label>
<input type="text" id="site-subtitle" placeholder="Retro Flight Tracker">
</div>
</div>
<div class="form-group">
<label>Temperature Unit</label>
<select id="temp-unit">
<option value="F">Fahrenheit (F)</option>
<option value="C">Celsius (C)</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="show-weather" checked> Show Weather
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="show-sidebar" checked> Show Sidebar
</label>
</div>
</div>
<div class="form-group">
<label>Default View Direction</label>
<select id="default-direction">
<option value="0">North</option>
<option value="90">East</option>
<option value="180">South</option>
<option value="270">West</option>
</select>
</div>
<div class="button-row">
<button id="save-display-btn" class="btn btn-primary">SAVE</button>
</div>
</section>
<!-- Tuning Tab -->
<section id="tab-tuning" class="tab-pane hidden">
<h2>Performance Tuning</h2>
<div class="form-group">
<label>Flight Timeout (seconds)</label>
<input type="number" id="flight-timeout" min="10" max="600" value="60">
<span class="hint">Remove flights not seen in this many seconds</span>
</div>
<div class="form-group">
<label>Broadcast Interval (seconds)</label>
<input type="number" id="broadcast-interval" min="1" max="30" value="1">
<span class="hint">How often to push updates to viewers</span>
</div>
<div class="form-group">
<label>Cleanup Interval (seconds)</label>
<input type="number" id="cleanup-interval" min="5" max="120" value="10">
<span class="hint">How often to check for stale flights</span>
</div>
<div class="form-group">
<label>Receiver Reconnect Delay (seconds)</label>
<input type="number" id="reconnect-delay" min="1" max="60" value="5">
<span class="hint">Wait time before reconnecting to a lost receiver</span>
</div>
<div class="button-row">
<button id="save-tuning-btn" class="btn btn-primary">SAVE</button>
</div>
</section>
<!-- Security Tab -->
<section id="tab-security" class="tab-pane hidden">
<h2>Change Admin Password</h2>
<div class="form-group">
<label>Current Password</label>
<input type="password" id="current-password" autocomplete="current-password">
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" id="new-password" autocomplete="new-password">
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" id="confirm-password" autocomplete="new-password">
</div>
<div class="button-row">
<button id="change-password-btn" class="btn btn-primary">CHANGE PASSWORD</button>
</div>
</section>
</main>
<div id="toast" class="toast hidden"></div>
</div>
<script src="/admin/admin.js"></script>
</body>
</html>
+340
View File
@@ -0,0 +1,340 @@
// ADS-Bit Admin Panel
(function () {
const loginScreen = document.getElementById('login-screen');
const adminPanel = document.getElementById('admin-panel');
let statusInterval = null;
// ------- Auth -------
async function checkAuth() {
try {
const res = await fetch('/api/auth/check');
if (res.ok) {
showAdmin();
return;
}
} catch (e) { /* not logged in */ }
showLogin();
}
function showLogin() {
loginScreen.classList.remove('hidden');
adminPanel.classList.add('hidden');
if (statusInterval) clearInterval(statusInterval);
}
function showAdmin() {
loginScreen.classList.add('hidden');
adminPanel.classList.remove('hidden');
loadConfig();
loadThemes();
refreshStatus();
statusInterval = setInterval(refreshStatus, 5000);
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const pw = document.getElementById('login-password').value;
const errEl = document.getElementById('login-error');
errEl.classList.add('hidden');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
if (res.ok) {
showAdmin();
} else {
const data = await res.json();
errEl.textContent = data.error || 'Login failed';
errEl.classList.remove('hidden');
}
} catch (err) {
errEl.textContent = 'Connection error';
errEl.classList.remove('hidden');
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
showLogin();
});
// ------- Tabs -------
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden');
});
});
// ------- Dashboard -------
async function refreshStatus() {
try {
const res = await fetch('/api/admin/status');
if (!res.ok) return;
const data = await res.json();
document.getElementById('stat-flights').textContent = data.active_flights;
document.getElementById('stat-viewers').textContent = data.connected_viewers;
document.getElementById('stat-receivers').textContent = data.receiver_count;
document.getElementById('stat-uptime').textContent = data.uptime;
const ipsEl = document.getElementById('receiver-ips');
if (data.receivers && data.receivers.length) {
ipsEl.textContent = data.receivers.join(', ');
} else {
ipsEl.textContent = 'None connected';
}
} catch (e) { /* ignore */ }
}
// ------- Load Config -------
async function loadConfig() {
try {
const res = await fetch('/api/admin/config');
if (!res.ok) return;
const cfg = await res.json();
// Receivers
const mode = (cfg.receivers === 'AUTO') ? 'AUTO' : 'MANUAL';
document.getElementById('receiver-mode').value = mode;
toggleManualIps(mode);
if (mode === 'MANUAL') {
const ips = Array.isArray(cfg.receivers) ? cfg.receivers : [cfg.receivers];
document.getElementById('receiver-ips-input').value = ips.join('\n');
}
document.getElementById('receiver-port').value = cfg.receiver_port || 30003;
// Location
document.getElementById('location-name').value = cfg.location?.name || '';
document.getElementById('location-lat').value = cfg.location?.lat || 0;
document.getElementById('location-lon').value = cfg.location?.lon || 0;
// Display
document.getElementById('site-title').value = cfg.site?.title || 'ADS-Bit';
document.getElementById('site-subtitle').value = cfg.site?.subtitle || '';
document.getElementById('temp-unit').value = cfg.display?.temperature_unit || 'F';
document.getElementById('show-weather').checked = cfg.display?.show_weather !== false;
document.getElementById('show-sidebar').checked = cfg.display?.show_sidebar !== false;
document.getElementById('default-direction').value = cfg.display?.default_view_direction || 0;
// Set theme select to current theme
const themeSelect = document.getElementById('theme-select');
if (themeSelect.options.length > 0) {
themeSelect.value = cfg.theme || 'desert';
} else {
themeSelect.dataset.pending = cfg.theme || 'desert';
}
// Tuning
document.getElementById('flight-timeout').value = cfg.tuning?.flight_timeout_seconds || 60;
document.getElementById('broadcast-interval').value = cfg.tuning?.broadcast_interval_seconds || 1;
document.getElementById('cleanup-interval').value = cfg.tuning?.cleanup_interval_seconds || 10;
document.getElementById('reconnect-delay').value = cfg.tuning?.receiver_reconnect_seconds || 5;
} catch (e) {
console.error('Failed to load config', e);
}
}
// ------- Load Themes -------
async function loadThemes() {
try {
const res = await fetch('/api/admin/themes');
if (!res.ok) return;
const data = await res.json();
const sel = document.getElementById('theme-select');
sel.innerHTML = '';
(data.themes || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
sel.appendChild(opt);
});
// Apply pending value if config loaded first
if (sel.dataset.pending) {
sel.value = sel.dataset.pending;
delete sel.dataset.pending;
}
} catch (e) { /* ignore */ }
}
// ------- Receivers -------
function toggleManualIps(mode) {
document.getElementById('manual-ips-group').classList.toggle('hidden', mode === 'AUTO');
}
document.getElementById('receiver-mode').addEventListener('change', (e) => {
toggleManualIps(e.target.value);
});
document.getElementById('scan-receivers-btn').addEventListener('click', async () => {
const btn = document.getElementById('scan-receivers-btn');
btn.textContent = 'SCANNING...';
btn.disabled = true;
try {
const res = await fetch('/api/admin/scan-receivers', { method: 'POST' });
const data = await res.json();
const box = document.getElementById('scan-results');
const list = document.getElementById('scan-results-list');
box.classList.remove('hidden');
if (data.receivers && data.receivers.length) {
list.textContent = data.receivers.join(', ');
} else {
list.textContent = 'No receivers found on network';
}
} catch (e) {
toast('Scan failed', true);
}
btn.textContent = 'SCAN NOW';
btn.disabled = false;
});
document.getElementById('restart-receivers-btn').addEventListener('click', async () => {
try {
const res = await fetch('/api/admin/restart-receivers', { method: 'POST' });
if (res.ok) toast('Receivers restarted');
else toast('Failed to restart', true);
} catch (e) {
toast('Error restarting receivers', true);
}
});
document.getElementById('save-receivers-btn').addEventListener('click', async () => {
const mode = document.getElementById('receiver-mode').value;
const port = parseInt(document.getElementById('receiver-port').value) || 30003;
const body = { receiver_port: port };
if (mode === 'AUTO') {
body.receivers = 'AUTO';
} else {
const ips = document.getElementById('receiver-ips-input').value
.split('\n').map(s => s.trim()).filter(Boolean);
body.receivers = ips.length ? ips : 'AUTO';
}
await saveConfig(body);
});
// ------- Location -------
document.getElementById('browser-location-btn').addEventListener('click', () => {
if (!navigator.geolocation) {
toast('Geolocation not supported', true);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
document.getElementById('location-lat').value = pos.coords.latitude.toFixed(4);
document.getElementById('location-lon').value = pos.coords.longitude.toFixed(4);
toast('Location detected');
},
() => toast('Location access denied', true)
);
});
document.getElementById('save-location-btn').addEventListener('click', async () => {
await saveConfig({
location: {
name: document.getElementById('location-name').value,
lat: parseFloat(document.getElementById('location-lat').value) || 0,
lon: parseFloat(document.getElementById('location-lon').value) || 0
}
});
});
// ------- Display -------
document.getElementById('save-display-btn').addEventListener('click', async () => {
await saveConfig({
theme: document.getElementById('theme-select').value,
site: {
title: document.getElementById('site-title').value,
subtitle: document.getElementById('site-subtitle').value
},
display: {
temperature_unit: document.getElementById('temp-unit').value,
show_weather: document.getElementById('show-weather').checked,
show_sidebar: document.getElementById('show-sidebar').checked,
default_view_direction: parseInt(document.getElementById('default-direction').value) || 0
}
});
});
// ------- Tuning -------
document.getElementById('save-tuning-btn').addEventListener('click', async () => {
await saveConfig({
tuning: {
flight_timeout_seconds: parseInt(document.getElementById('flight-timeout').value) || 60,
broadcast_interval_seconds: parseInt(document.getElementById('broadcast-interval').value) || 1,
cleanup_interval_seconds: parseInt(document.getElementById('cleanup-interval').value) || 10,
receiver_reconnect_seconds: parseInt(document.getElementById('reconnect-delay').value) || 5
}
});
});
// ------- Security -------
document.getElementById('change-password-btn').addEventListener('click', async () => {
const current = document.getElementById('current-password').value;
const newPw = document.getElementById('new-password').value;
const confirm = document.getElementById('confirm-password').value;
if (newPw.length < 4) {
toast('Password must be at least 4 characters', true);
return;
}
if (newPw !== confirm) {
toast('Passwords do not match', true);
return;
}
try {
const res = await fetch('/api/admin/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: current, new_password: newPw })
});
if (res.ok) {
toast('Password changed');
document.getElementById('current-password').value = '';
document.getElementById('new-password').value = '';
document.getElementById('confirm-password').value = '';
} else {
const data = await res.json();
toast(data.error || 'Failed to change password', true);
}
} catch (e) {
toast('Connection error', true);
}
});
// ------- Helpers -------
async function saveConfig(body) {
try {
const res = await fetch('/api/admin/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
toast('Settings saved');
} else {
const data = await res.json();
toast(data.error || 'Save failed', true);
}
} catch (e) {
toast('Connection error', true);
}
}
function toast(msg, isError) {
const el = document.getElementById('toast');
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);
}
// ------- Init -------
checkAuth();
})();
+35
View File
@@ -0,0 +1,35 @@
# Custom Backgrounds
Place your custom background images in this folder.
## Required Files
You need to provide 4 directional background images:
- `north.png` - View looking north
- `east.png` - View looking east
- `south.png` - View looking south
- `west.png` - View looking west
## Image Specifications
- **Dimensions:** 1536 x 1024 pixels
- **Format:** PNG with transparency support
- **Orientation:** Each image should show the horizon/landscape as seen when facing that cardinal direction from your location
## Tips
- Include a horizon line in each image - the sun and moon will set behind it
- Keep the upper portion (sky area) relatively simple for aircraft visibility
- The bottom portion can have more detail (terrain, buildings, etc.)
- Consider your local landmarks and terrain for each direction
## Enabling Custom Theme
Set the theme in `config.json`:
```json
{
"theme": "custom"
}
```
Then restart the server.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+32
View File
@@ -0,0 +1,32 @@
{
"setup_complete": false,
"admin": {
"password_hash": "",
"session_secret": ""
},
"receivers": "AUTO",
"receiver_port": 30003,
"location": {
"name": "My Location",
"lat": 0.0,
"lon": 0.0
},
"web_port": 2001,
"theme": "desert",
"site": {
"title": "ADS-Bit",
"subtitle": "Retro Flight Tracker"
},
"display": {
"temperature_unit": "F",
"show_weather": true,
"show_sidebar": true,
"default_view_direction": 0
},
"tuning": {
"flight_timeout_seconds": 60,
"broadcast_interval_seconds": 1,
"cleanup_interval_seconds": 10,
"receiver_reconnect_seconds": 5
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+402
View File
@@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>ADS-Bit - Retro Flight Tracker</title>
<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">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
position: fixed;
-webkit-overflow-scrolling: touch;
}
body {
background: linear-gradient(180deg, #2c3e50 0%, #34495e 100%);
color: #fcfcfc;
font-family: 'Courier New', monospace;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
#header {
text-align: center;
padding: 8px;
font-size: clamp(18px, 5vw, 28px);
color: #fcd444;
text-shadow: 2px 2px 0px #000, -1px -1px 0px #000, 1px -1px 0px #000, -1px 1px 0px #000;
letter-spacing: 1px;
font-weight: bold;
flex-shrink: 0;
}
#main-content {
display: flex;
flex: 1;
width: 100%;
max-width: 1600px;
gap: 10px;
padding: 10px;
overflow: hidden;
}
#canvas-container {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
#pixel-canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
border: 4px solid #34495e;
border-radius: 8px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4),
inset 0 0 0 2px #5c94fc;
background: #000;
width: 100%;
height: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Aircraft Sidebar */
#aircraft-sidebar {
width: 200px;
flex-shrink: 0;
background: rgba(52, 73, 94, 0.9);
border: 3px solid #5c94fc;
border-radius: 8px;
padding: 8px;
overflow-y: auto;
max-height: 100%;
}
#sidebar-header {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #fcd444;
text-align: center;
padding: 8px 4px;
border-bottom: 2px solid #5c94fc;
margin-bottom: 8px;
}
.sidebar-aircraft {
padding: 6px 8px;
margin-bottom: 4px;
background: rgba(0, 0, 0, 0.4);
border-radius: 4px;
border-left: 3px solid #54fc54;
cursor: pointer;
transition: all 0.15s ease;
}
.sidebar-aircraft:hover {
background: rgba(92, 148, 252, 0.3);
border-left-color: #fcd444;
}
.sidebar-aircraft.selected {
background: rgba(252, 212, 68, 0.3);
border-left-color: #fcd444;
border-left-width: 5px;
}
.sidebar-aircraft.selected .sidebar-callsign {
color: #fcd444;
}
.sidebar-callsign {
display: block;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #fcfcfc;
margin-bottom: 2px;
}
.sidebar-info {
display: block;
font-family: 'Courier New', monospace;
font-size: 11px;
color: #b4d4ec;
}
.sidebar-empty {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #888;
text-align: center;
padding: 20px 8px;
}
#stats {
text-align: center;
padding: 8px;
font-size: clamp(11px, 2.5vw, 15px);
color: #fcfcfc;
text-shadow: 1px 1px 0px #000;
font-weight: bold;
flex-shrink: 0;
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
#stats span {
background: rgba(52, 73, 94, 0.7);
padding: 5px 10px;
border-radius: 4px;
border: 2px solid #5c94fc;
white-space: nowrap;
}
#weather-info {
text-align: center;
padding: 6px;
font-size: clamp(10px, 2.2vw, 14px);
color: #fcfcfc;
text-shadow: 1px 1px 0px #000;
font-weight: bold;
flex-shrink: 0;
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
}
#weather-info span {
background: rgba(52, 73, 94, 0.7);
padding: 4px 8px;
border-radius: 4px;
border: 2px solid #5c94fc;
white-space: nowrap;
}
#datetime-display {
color: #b4d4ec;
}
#weather-display {
color: #fcd444;
}
#sun-times {
color: #fc9c54;
}
#connection-status {
color: #54fc54;
}
#aircraft-count {
color: #fcd444;
}
#range-display {
color: #fc9c54;
}
.blink {
animation: blink 1s linear infinite;
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0.3; }
}
/* View direction controls */
.view-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 80px;
background: rgba(52, 73, 94, 0.8);
border: 3px solid #5c94fc;
border-radius: 8px;
color: #fcd444;
font-size: 28px;
font-family: 'Press Start 2P', monospace;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
z-index: 10;
user-select: none;
-webkit-user-select: none;
}
.view-arrow:hover {
background: rgba(92, 148, 252, 0.6);
transform: translateY(-50%) scale(1.1);
}
.view-arrow:active {
background: rgba(92, 148, 252, 0.9);
transform: translateY(-50%) scale(0.95);
}
#view-left {
left: 15px;
}
#view-right {
right: 15px;
}
#compass-direction {
color: #54fc54;
font-family: 'Press Start 2P', monospace;
}
/* Mobile responsive - move sidebar below viewer */
@media (max-width: 768px) {
#main-content {
flex-direction: column;
padding: 5px;
gap: 5px;
}
#aircraft-sidebar {
width: 100%;
max-height: 150px;
flex-direction: row;
flex-wrap: wrap;
display: flex;
align-content: flex-start;
padding: 5px;
}
#sidebar-header {
width: 100%;
padding: 5px;
margin-bottom: 5px;
font-size: 9px;
}
.sidebar-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
width: 100%;
overflow-y: auto;
max-height: 100px;
}
.sidebar-aircraft {
flex: 0 0 auto;
padding: 4px 8px;
margin-bottom: 0;
white-space: nowrap;
}
.sidebar-callsign {
font-size: 8px;
display: inline;
margin-right: 5px;
}
.sidebar-info {
font-size: 9px;
display: inline;
}
.sidebar-empty {
width: 100%;
padding: 10px;
}
#stats {
padding: 4px;
gap: 4px;
}
#stats span {
padding: 3px 6px;
font-size: 10px;
}
#weather-info {
padding: 4px;
gap: 4px;
}
#weather-info span {
padding: 3px 6px;
font-size: 9px;
}
.view-arrow {
width: 40px;
height: 60px;
font-size: 20px;
}
#view-left {
left: 5px;
}
#view-right {
right: 5px;
}
}
</style>
</head>
<body>
<div id="header">
<span id="site-title">ADS-Bit</span>
</div>
<div id="main-content">
<div id="canvas-container">
<button id="view-left" class="view-arrow"></button>
<canvas id="pixel-canvas" width="800" height="600"></canvas>
<button id="view-right" class="view-arrow"></button>
</div>
<div id="aircraft-sidebar">
<div id="sidebar-header">AIRCRAFT IN VIEW</div>
<div class="sidebar-list"></div>
</div>
</div>
<div id="stats">
<span id="connection-status" class="blink">CONNECTING...</span>
<span id="compass-direction">VIEWING: NORTH</span>
<span id="aircraft-count">AIRCRAFT: 0</span>
<span id="range-display">RANGE: 0 NM</span>
</div>
<div id="weather-info">
<span id="datetime-display">Loading...</span>
<span id="weather-display">--°F</span>
<span id="sun-times">☀ --:-- / 🌙 --:--</span>
</div>
<script src="pixel-view.js?v=47"></script>
</body>
</html>
+1973
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
aiohttp>=3.8.0
netifaces>=0.11.0
bcrypt>=4.0.0
Executable
+878
View File
@@ -0,0 +1,878 @@
#!/usr/bin/env python3
"""Pixel ADS-B Server - Connects directly to ADS-B receivers"""
import asyncio
import socket
import json
import time
import os
import copy
import secrets
import ipaddress
from pathlib import Path
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Dict, Set, List
from aiohttp import web
import netifaces
import bcrypt
WEB_DIR = Path(__file__).parent
CONFIG_FILE = WEB_DIR / "config.json"
# Track server start time
SERVER_START_TIME = time.time()
# Default configuration - all possible settings with sane defaults
DEFAULT_CONFIG = {
"setup_complete": False,
"admin": {
"password_hash": "",
"session_secret": ""
},
"receivers": "AUTO",
"receiver_port": 30003,
"location": {
"name": "My Location",
"lat": 0.0,
"lon": 0.0
},
"web_port": 2001,
"theme": "desert",
"site": {
"title": "ADS-Bit",
"subtitle": "Retro Flight Tracker"
},
"display": {
"temperature_unit": "F",
"show_weather": True,
"show_sidebar": True,
"default_view_direction": 0
},
"tuning": {
"flight_timeout_seconds": 60,
"broadcast_interval_seconds": 1,
"cleanup_interval_seconds": 10,
"receiver_reconnect_seconds": 5
}
}
def deep_merge(base: dict, override: dict) -> dict:
"""Deep merge override into base, returning new dict. Base values used as defaults."""
result = copy.deepcopy(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = copy.deepcopy(value)
return result
# Active configuration
config = copy.deepcopy(DEFAULT_CONFIG)
def load_config():
"""Load configuration from config.json, deep-merged over defaults."""
global config
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, 'r') as f:
loaded = json.load(f)
config = deep_merge(DEFAULT_CONFIG, loaded)
print(f"Loaded config from {CONFIG_FILE}")
except Exception as e:
print(f"Error loading config: {e}, using defaults")
config = copy.deepcopy(DEFAULT_CONFIG)
else:
print(f"No config file found at {CONFIG_FILE}, using defaults")
config = copy.deepcopy(DEFAULT_CONFIG)
print(f" Setup complete: {config['setup_complete']}")
print(f" Receivers: {config['receivers']}")
print(f" Receiver port: {config['receiver_port']}")
print(f" Location: {config['location']['name']} ({config['location']['lat']}, {config['location']['lon']})")
print(f" Web port: {config['web_port']}")
print(f" Theme: {config.get('theme', 'desert')}")
def save_config():
"""Persist current config to config.json."""
try:
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
print(f"Config saved to {CONFIG_FILE}")
except Exception as e:
print(f"Error saving config: {e}")
# ---------------------------------------------------------------------------
# Session management (in-memory, lost on restart)
# ---------------------------------------------------------------------------
sessions: Dict[str, float] = {} # token -> expiry timestamp
SESSION_DURATION = 86400 # 24 hours
def create_session() -> str:
token = secrets.token_hex(32)
sessions[token] = time.time() + SESSION_DURATION
return token
def validate_session(token: str) -> bool:
if not token:
return False
expiry = sessions.get(token)
if expiry is None:
return False
if time.time() > expiry:
sessions.pop(token, None)
return False
return True
def destroy_session(token: str):
sessions.pop(token, None)
def get_session_token(request) -> str:
"""Extract session token from cookie."""
return request.cookies.get('adsbit_session', '')
def require_auth(handler):
"""Decorator that checks for valid admin session."""
async def wrapper(request):
token = get_session_token(request)
if not validate_session(token):
return web.json_response({"error": "Unauthorized"}, status=401)
return await handler(request)
return wrapper
def require_setup_incomplete(handler):
"""Decorator that only allows access when setup is not complete."""
async def wrapper(request):
if config.get("setup_complete", False):
return web.json_response({"error": "Setup already complete"}, status=403)
return await handler(request)
return wrapper
# ---------------------------------------------------------------------------
# Flight data storage
# ---------------------------------------------------------------------------
flights: Dict[str, dict] = {}
connected_clients: Set = set()
receivers: List[str] = []
receiver_tasks: List[asyncio.Task] = []
@dataclass
class SBSMessage:
msg_type: str
icao: str
callsign: str = ""
altitude: int = 0
speed: float = 0
track: float = 0
lat: float = 0
lon: float = 0
vrate: int = 0
squawk: str = ""
ground: bool = False
def parse_sbs_message(line: str):
"""Parse SBS/BaseStation format message"""
parts = line.strip().split(',')
if len(parts) < 10:
return None
msg_type = parts[0]
if msg_type not in ['MSG']:
return None
icao = parts[4].strip()
if not icao:
return None
msg = SBSMessage(msg_type=msg_type, icao=icao)
if len(parts) > 10 and parts[10].strip():
msg.callsign = parts[10].strip()
if len(parts) > 11 and parts[11].strip():
try:
msg.altitude = int(parts[11])
except ValueError:
pass
if len(parts) > 12 and parts[12].strip():
try:
msg.speed = float(parts[12])
except ValueError:
pass
if len(parts) > 13 and parts[13].strip():
try:
msg.track = float(parts[13])
except ValueError:
pass
if len(parts) > 14 and parts[14].strip():
try:
msg.lat = float(parts[14])
except ValueError:
pass
if len(parts) > 15 and parts[15].strip():
try:
msg.lon = float(parts[15])
except ValueError:
pass
if len(parts) > 16 and parts[16].strip():
try:
msg.vrate = int(parts[16])
except ValueError:
pass
if len(parts) > 17 and parts[17].strip():
msg.squawk = parts[17].strip()
if len(parts) > 21 and parts[21].strip() == '-1':
msg.ground = True
return msg
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}...")
while True:
try:
reader, writer = await asyncio.open_connection(host, port)
print(f"Connected to {host}:{port}")
while True:
line = await reader.readline()
if not line:
break
line = line.decode('utf-8', errors='ignore')
msg = parse_sbs_message(line)
if msg and msg.icao:
if msg.icao not in flights:
flights[msg.icao] = {
'icao': msg.icao,
'callsign': '',
'altitude': 0,
'speed': 0,
'track': 0,
'lat': 0,
'lon': 0,
'vrate': 0,
'squawk': '',
'ground': False,
'last_seen': time.time()
}
flight = flights[msg.icao]
if msg.callsign:
flight['callsign'] = msg.callsign
if msg.altitude:
flight['altitude'] = msg.altitude
if msg.speed:
flight['speed'] = msg.speed
if msg.track:
flight['track'] = msg.track
if msg.lat:
flight['lat'] = msg.lat
if msg.lon:
flight['lon'] = msg.lon
if msg.vrate:
flight['vrate'] = msg.vrate
if msg.squawk:
flight['squawk'] = msg.squawk
flight['ground'] = msg.ground
flight['last_seen'] = time.time()
writer.close()
await writer.wait_closed()
except Exception as e:
print(f"Receiver {host}:{port} error: {e}")
await asyncio.sleep(reconnect_delay)
async def scan_for_receivers():
"""Scan local network for ADS-B receivers on configured port"""
port = config['receiver_port']
print(f"Scanning for ADS-B receivers on port {port}...")
found = []
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
ip = addr.get('addr')
netmask = addr.get('netmask')
if ip and netmask and not ip.startswith('127.'):
try:
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
print(f"Scanning {network} ({network.num_addresses} hosts)...")
host_ips = [str(host) for host in network.hosts()]
async def check_ip(test_ip):
if await test_port(test_ip, port):
return test_ip
return None
results = await asyncio.gather(*[check_ip(h) for h in host_ips])
for result in results:
if result:
print(f"Found receiver at {result}:{port}")
found.append(result)
except ValueError as e:
print(f"Invalid network {ip}/{netmask}: {e}")
return found
async def test_port(ip: str, port: int, timeout: float = 0.5):
"""Test if a port is open"""
try:
conn = asyncio.open_connection(ip, port)
reader, writer = await asyncio.wait_for(conn, timeout=timeout)
writer.close()
await writer.wait_closed()
return True
except:
return False
async def cleanup_old_flights():
"""Remove flights not seen recently (uses tuning config)."""
while True:
interval = config['tuning']['cleanup_interval_seconds']
timeout = config['tuning']['flight_timeout_seconds']
await asyncio.sleep(interval)
now = time.time()
to_remove = [icao for icao, flight in flights.items()
if now - flight['last_seen'] > timeout]
for icao in to_remove:
del flights[icao]
async def broadcast_flights():
"""Broadcast flight data to all connected WebSocket clients."""
while True:
interval = config['tuning']['broadcast_interval_seconds']
await asyncio.sleep(interval)
if connected_clients:
flight_list = [f for f in flights.values() if f['lat'] and f['lon']]
message = json.dumps({
'type': 'flights',
'flights': flight_list,
'count': len(flight_list)
})
dead_clients = set()
for client in connected_clients:
try:
await client.send_str(message)
except:
dead_clients.add(client)
connected_clients.difference_update(dead_clients)
# ---------------------------------------------------------------------------
# WebSocket handler
# ---------------------------------------------------------------------------
async def websocket_handler(request):
"""Handle WebSocket connections from browser"""
ws = web.WebSocketResponse()
await ws.prepare(request)
connected_clients.add(ws)
print(f"WebSocket client connected ({len(connected_clients)} total)")
try:
async for msg in ws:
pass
finally:
connected_clients.discard(ws)
print(f"WebSocket client disconnected ({len(connected_clients)} remaining)")
return ws
# ---------------------------------------------------------------------------
# Public API routes (no auth)
# ---------------------------------------------------------------------------
async def handle_receiver_location(request):
"""Return receiver location from config."""
return web.json_response({
"lat": config["location"]["lat"],
"lon": config["location"]["lon"],
"name": config["location"]["name"]
})
async def handle_config(request):
"""Return client-relevant configuration (public, no secrets)."""
return web.json_response({
"theme": config.get("theme", "desert"),
"location": config["location"],
"receivers": receivers,
"site": config.get("site", DEFAULT_CONFIG["site"]),
"display": config.get("display", DEFAULT_CONFIG["display"])
})
# ---------------------------------------------------------------------------
# Auth routes
# ---------------------------------------------------------------------------
async def handle_auth_login(request):
"""POST /api/auth/login - Authenticate with admin password."""
try:
body = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
password = body.get("password", "")
stored_hash = config.get("admin", {}).get("password_hash", "")
if not stored_hash:
return web.json_response({"error": "No admin password set"}, status=403)
try:
if bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
token = create_session()
resp = web.json_response({"ok": True})
resp.set_cookie('adsbit_session', token,
max_age=SESSION_DURATION,
httponly=True,
samesite='Lax',
path='/')
return resp
except Exception:
pass
return web.json_response({"error": "Invalid password"}, status=401)
async def handle_auth_logout(request):
"""POST /api/auth/logout - Destroy session."""
token = get_session_token(request)
destroy_session(token)
resp = web.json_response({"ok": True})
resp.del_cookie('adsbit_session', path='/')
return resp
async def handle_auth_check(request):
"""GET /api/auth/check - Check if current session is valid."""
token = get_session_token(request)
if validate_session(token):
return web.json_response({"authenticated": True})
return web.json_response({"authenticated": False}, status=401)
# ---------------------------------------------------------------------------
# Setup routes (only when setup_complete is false)
# ---------------------------------------------------------------------------
@require_setup_incomplete
async def handle_setup_password(request):
"""POST /api/setup/password - Set initial admin password."""
try:
body = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
password = body.get("password", "")
if len(password) < 4:
return web.json_response({"error": "Password must be at least 4 characters"}, status=400)
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
config['admin']['password_hash'] = hashed
config['admin']['session_secret'] = secrets.token_hex(16)
save_config()
return web.json_response({"ok": True})
@require_setup_incomplete
async def handle_setup_location(request):
"""POST /api/setup/location - Set location during setup."""
try:
body = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
config['location']['name'] = body.get("name", config['location']['name'])
config['location']['lat'] = float(body.get("lat", config['location']['lat']))
config['location']['lon'] = float(body.get("lon", config['location']['lon']))
save_config()
return web.json_response({"ok": True, "location": config['location']})
@require_setup_incomplete
async def handle_setup_receivers(request):
"""POST /api/setup/receivers - Configure receivers during setup."""
try:
body = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
mode = body.get("mode", "AUTO")
if mode == "AUTO":
config['receivers'] = "AUTO"
else:
ips = body.get("ips", [])
if isinstance(ips, list) and ips:
config['receivers'] = ips
else:
config['receivers'] = "AUTO"
if "port" in body:
config['receiver_port'] = int(body['port'])
save_config()
return web.json_response({"ok": True})
@require_setup_incomplete
async def handle_setup_scan(request):
"""GET /api/setup/scan - Scan for receivers during setup."""
found = await scan_for_receivers()
return web.json_response({"receivers": found})
@require_setup_incomplete
async def handle_setup_theme(request):
"""POST /api/setup/theme - Set theme and preferences during setup."""
try:
body = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
if "theme" in body:
config['theme'] = body['theme']
if "temperature_unit" in body:
config['display']['temperature_unit'] = body['temperature_unit']
if "site_title" in body:
config['site']['title'] = body['site_title']
if "site_subtitle" in body:
config['site']['subtitle'] = body['site_subtitle']
save_config()
return web.json_response({"ok": True})
@require_setup_incomplete
async def handle_setup_complete(request):
"""POST /api/setup/complete - Mark setup as done."""
# Verify a password was set
if not config.get('admin', {}).get('password_hash', ''):
return web.json_response({"error": "Admin password must be set first"}, status=400)
config['setup_complete'] = True
save_config()
# Restart receiver connections with new config
await restart_receiver_connections()
return web.json_response({"ok": True})
# ---------------------------------------------------------------------------
# Admin API routes (require auth)
# ---------------------------------------------------------------------------
@require_auth
async def handle_admin_config(request):
"""GET /api/admin/config - Return full config (minus password hash)."""
safe_config = copy.deepcopy(config)
safe_config['admin'] = {"password_set": bool(config['admin']['password_hash'])}
return web.json_response(safe_config)
@require_auth
async def handle_admin_config_update(request):
"""PUT /api/admin/config - Update config sections."""
try:
body = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
# Updatable sections
if "location" in body:
loc = body["location"]
if "name" in loc:
config['location']['name'] = loc['name']
if "lat" in loc:
config['location']['lat'] = float(loc['lat'])
if "lon" in loc:
config['location']['lon'] = float(loc['lon'])
if "receivers" in body:
config['receivers'] = body['receivers']
if "receiver_port" in body:
config['receiver_port'] = int(body['receiver_port'])
if "theme" in body:
config['theme'] = body['theme']
if "site" in body:
for k in ('title', 'subtitle'):
if k in body['site']:
config['site'][k] = body['site'][k]
if "display" in body:
for k in ('temperature_unit', 'show_weather', 'show_sidebar', 'default_view_direction'):
if k in body['display']:
config['display'][k] = body['display'][k]
if "tuning" in body:
for k in ('flight_timeout_seconds', 'broadcast_interval_seconds',
'cleanup_interval_seconds', 'receiver_reconnect_seconds'):
if k in body['tuning']:
config['tuning'][k] = int(body['tuning'][k])
save_config()
return web.json_response({"ok": True})
@require_auth
async def handle_admin_status(request):
"""GET /api/admin/status - Live dashboard data."""
flight_list = [f for f in flights.values() if f['lat'] and f['lon']]
uptime = int(time.time() - SERVER_START_TIME)
hours, remainder = divmod(uptime, 3600)
minutes, seconds = divmod(remainder, 60)
return web.json_response({
"active_flights": len(flight_list),
"total_tracked": len(flights),
"connected_viewers": len(connected_clients),
"receivers": receivers,
"receiver_count": len(receivers),
"uptime": f"{hours}h {minutes}m {seconds}s",
"uptime_seconds": uptime
})
@require_auth
async def handle_admin_themes(request):
"""GET /api/admin/themes - List available themes."""
bg_dir = WEB_DIR / "backgrounds"
themes = []
if bg_dir.exists():
for d in sorted(bg_dir.iterdir()):
if d.is_dir() and (d / "north.png").exists():
themes.append(d.name)
return web.json_response({"themes": themes})
@require_auth
async def handle_admin_password(request):
"""POST /api/admin/password - Change admin password."""
try:
body = await request.json()
except:
return web.json_response({"error": "Invalid JSON"}, status=400)
current = body.get("current_password", "")
new_pw = body.get("new_password", "")
if len(new_pw) < 4:
return web.json_response({"error": "Password must be at least 4 characters"}, status=400)
stored_hash = config.get("admin", {}).get("password_hash", "")
if stored_hash:
try:
if not bcrypt.checkpw(current.encode('utf-8'), stored_hash.encode('utf-8')):
return web.json_response({"error": "Current password is incorrect"}, status=401)
except Exception:
return web.json_response({"error": "Current password is incorrect"}, status=401)
hashed = bcrypt.hashpw(new_pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
config['admin']['password_hash'] = hashed
save_config()
return web.json_response({"ok": True})
@require_auth
async def handle_admin_scan_receivers(request):
"""POST /api/admin/scan-receivers - Trigger a receiver scan."""
found = await scan_for_receivers()
return web.json_response({"receivers": found})
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()
receiver_tasks.clear()
# Determine receivers
receiver_config = config["receivers"]
receiver_port = config["receiver_port"]
try:
if receiver_config == "AUTO":
found = await scan_for_receivers()
receivers = found
elif isinstance(receiver_config, list):
receivers = list(receiver_config)
elif isinstance(receiver_config, str) and receiver_config != "AUTO":
receivers = [receiver_config]
else:
receivers = []
except Exception as e:
print(f"Error determining receivers: {e}")
receivers = []
# Start new tasks
for r in receivers:
task = asyncio.create_task(connect_to_receiver(r, receiver_port))
receiver_tasks.append(task)
print(f"Receiver connections restarted: {len(receivers)} receiver(s)")
@require_auth
async def handle_admin_restart_receivers(request):
"""POST /api/admin/restart-receivers - Restart all receiver connections."""
await restart_receiver_connections()
return web.json_response({"ok": True, "receivers": receivers})
# ---------------------------------------------------------------------------
# Static file serving with setup redirect
# ---------------------------------------------------------------------------
async def handle_http(request):
"""Serve static files, with setup redirect logic."""
path = request.path
# Root redirect: if setup not complete, redirect to /setup
if path == '/' or path == '/index.html':
if not config.get('setup_complete', False):
raise web.HTTPFound('/setup')
path = '/index.html'
# Serve /setup -> setup/setup.html
if path == '/setup' or path == '/setup/':
file_path = WEB_DIR / 'setup' / 'setup.html'
if file_path.exists():
return web.FileResponse(file_path)
return web.Response(status=404, text="Setup page not found")
# Serve /admin -> admin/admin.html
if path == '/admin' or path == '/admin/':
file_path = WEB_DIR / 'admin' / 'admin.html'
if file_path.exists():
return web.FileResponse(file_path)
return web.Response(status=404, text="Admin page not found")
file_path = WEB_DIR / path.lstrip('/')
if file_path.exists() and file_path.is_file():
return web.FileResponse(file_path)
return web.Response(status=404, text="Not Found")
# ---------------------------------------------------------------------------
# Application setup
# ---------------------------------------------------------------------------
async def start_http_server():
"""Start HTTP server with WebSocket support."""
port = config["web_port"]
app = web.Application()
# WebSocket
app.router.add_get('/ws', websocket_handler)
# Public API
app.router.add_get('/api/receiver-location', handle_receiver_location)
app.router.add_get('/api/config', handle_config)
# Auth API
app.router.add_post('/api/auth/login', handle_auth_login)
app.router.add_post('/api/auth/logout', handle_auth_logout)
app.router.add_get('/api/auth/check', handle_auth_check)
# Setup API
app.router.add_post('/api/setup/password', handle_setup_password)
app.router.add_post('/api/setup/location', handle_setup_location)
app.router.add_post('/api/setup/receivers', handle_setup_receivers)
app.router.add_get('/api/setup/scan', handle_setup_scan)
app.router.add_post('/api/setup/theme', handle_setup_theme)
app.router.add_post('/api/setup/complete', handle_setup_complete)
# Admin API
app.router.add_get('/api/admin/config', handle_admin_config)
app.router.add_put('/api/admin/config', handle_admin_config_update)
app.router.add_get('/api/admin/status', handle_admin_status)
app.router.add_get('/api/admin/themes', handle_admin_themes)
app.router.add_post('/api/admin/password', handle_admin_password)
app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers)
app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers)
# Static files (must be last - catch-all)
app.router.add_get('/{tail:.*}', handle_http)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '0.0.0.0', port)
await site.start()
print(f"HTTP server running on http://0.0.0.0:{port}")
if not config.get('setup_complete', False):
print(f"First-run setup at http://0.0.0.0:{port}/setup")
print(f"Admin panel at http://0.0.0.0:{port}/admin")
async def main():
"""Main entry point"""
global receivers, receiver_tasks
print("Pixel ADS-B Server Starting...")
load_config()
print(f"Access at http://0.0.0.0:{config['web_port']}")
# Get receivers based on config
receiver_config = config["receivers"]
receiver_port = config["receiver_port"]
if receiver_config == "AUTO":
found = await scan_for_receivers()
receivers = found
elif isinstance(receiver_config, list):
receivers = receiver_config
print(f"Using configured receivers: {receivers}")
elif isinstance(receiver_config, str):
receivers = [receiver_config]
print(f"Using configured receiver: {receivers[0]}")
if not receivers:
print("WARNING: No receivers found or configured!")
else:
print(f"Using {len(receivers)} receiver(s)")
# Start all tasks
tasks = [
start_http_server(),
cleanup_old_flights(),
broadcast_flights(),
]
# Connect to all receivers
for receiver in receivers:
task = asyncio.ensure_future(connect_to_receiver(receiver, receiver_port))
receiver_tasks.append(task)
tasks.append(task)
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())
+336
View File
@@ -0,0 +1,336 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%);
color: #fcfcfc;
font-family: 'Courier New', monospace;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.hidden { display: none !important; }
.setup-container {
max-width: 560px;
width: 100%;
}
/* Progress */
.progress-bar {
height: 6px;
background: rgba(92, 148, 252, 0.2);
border-radius: 3px;
margin-bottom: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: #fcd444;
border-radius: 3px;
transition: width 0.4s ease;
}
.progress-steps {
display: flex;
justify-content: space-between;
margin-bottom: 24px;
padding: 0 4px;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(92, 148, 252, 0.3);
border: 2px solid #5c94fc;
transition: all 0.3s;
}
.step-dot.active {
background: #fcd444;
border-color: #fcd444;
}
.step-dot.done {
background: #54fc54;
border-color: #54fc54;
}
/* Steps */
.step {
display: none;
background: rgba(52, 73, 94, 0.9);
border: 3px solid #5c94fc;
border-radius: 12px;
padding: 32px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.3s ease;
}
.step.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.step-icon {
font-size: 48px;
color: #fcd444;
margin-bottom: 12px;
}
.retro-title {
font-family: 'Press Start 2P', monospace;
color: #fcd444;
text-shadow: 2px 2px 0px #000;
font-size: 20px;
margin-bottom: 16px;
}
.step-title {
font-family: 'Press Start 2P', monospace;
color: #fcd444;
text-shadow: 2px 2px 0px #000;
font-size: 14px;
margin-bottom: 12px;
}
.step-desc {
color: #b4d4ec;
font-size: 14px;
line-height: 1.6;
margin-bottom: 16px;
}
.feature-list {
list-style: none;
margin-bottom: 24px;
text-align: left;
display: inline-block;
}
.feature-list li {
padding: 4px 0;
color: #b4d4ec;
font-size: 13px;
}
.feature-list li::before {
content: '\2713 ';
color: #54fc54;
font-weight: bold;
}
/* Forms */
.form-group {
margin-bottom: 16px;
text-align: left;
}
.form-group label {
display: block;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #b4d4ec;
margin-bottom: 6px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
input[type="password"],
input[type="text"],
input[type="number"],
textarea,
select {
background: rgba(0, 0, 0, 0.5);
border: 2px solid #5c94fc;
border-radius: 6px;
color: #fcfcfc;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 10px 12px;
width: 100%;
outline: none;
transition: border-color 0.2s;
}
input:focus, textarea:focus, select:focus {
border-color: #fcd444;
}
select option {
background: #2c3e50;
color: #fcfcfc;
}
textarea {
resize: vertical;
}
/* Buttons */
.btn {
font-family: 'Press Start 2P', monospace;
font-size: 11px;
padding: 10px 20px;
border: 2px solid #5c94fc;
border-radius: 6px;
background: rgba(92, 148, 252, 0.2);
color: #fcfcfc;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover {
background: rgba(92, 148, 252, 0.4);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: rgba(252, 212, 68, 0.3);
border-color: #fcd444;
color: #fcd444;
}
.btn-primary:hover {
background: rgba(252, 212, 68, 0.5);
}
.btn-large {
font-size: 12px;
padding: 14px 32px;
}
.btn-detect {
font-size: 9px;
padding: 8px 14px;
margin-bottom: 12px;
border-color: #54fc54;
color: #54fc54;
}
.btn-detect:hover {
background: rgba(84, 252, 84, 0.2);
}
.step-buttons {
display: flex;
justify-content: space-between;
margin-top: 24px;
gap: 12px;
}
/* Messages */
.error-msg {
color: #fc5454;
font-size: 12px;
margin-top: 8px;
}
.status-msg {
font-size: 12px;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 12px;
}
.status-msg.success {
color: #54fc54;
background: rgba(84, 252, 84, 0.1);
border: 1px solid rgba(84, 252, 84, 0.3);
}
.status-msg.info {
color: #54d4fc;
background: rgba(84, 212, 252, 0.1);
border: 1px solid rgba(84, 212, 252, 0.3);
}
.status-msg.error {
color: #fc5454;
background: rgba(252, 84, 84, 0.1);
border: 1px solid rgba(252, 84, 84, 0.3);
}
/* Summary */
.summary-box {
background: rgba(0, 0, 0, 0.4);
border: 2px solid #5c94fc;
border-radius: 8px;
padding: 16px;
text-align: left;
margin-bottom: 20px;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid rgba(92, 148, 252, 0.2);
font-size: 12px;
}
.summary-row:last-child {
border-bottom: none;
}
.summary-label {
color: #b4d4ec;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
}
.summary-value {
color: #fcd444;
}
/* Mobile */
@media (max-width: 480px) {
.step {
padding: 20px;
}
.retro-title {
font-size: 16px;
}
.step-title {
font-size: 12px;
}
.form-row {
flex-direction: column;
gap: 0;
}
.step-buttons {
flex-direction: column;
}
.step-buttons .btn {
width: 100%;
}
}
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-Bit Setup</title>
<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="/setup/setup.css">
</head>
<body>
<div class="setup-container">
<!-- Progress Bar -->
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-steps">
<span class="step-dot active" data-step="0"></span>
<span class="step-dot" data-step="1"></span>
<span class="step-dot" data-step="2"></span>
<span class="step-dot" data-step="3"></span>
<span class="step-dot" data-step="4"></span>
<span class="step-dot" data-step="5"></span>
</div>
<!-- Step 0: Welcome -->
<div class="step active" id="step-0">
<div class="step-icon">&#9733;</div>
<h1 class="retro-title">Welcome to ADS-Bit</h1>
<p class="step-desc">Your retro SNES-style ADS-B flight tracker. Let's get everything configured so you can start watching aircraft.</p>
<p class="step-desc">This wizard will guide you through:</p>
<ul class="feature-list">
<li>Setting an admin password</li>
<li>Configuring your location</li>
<li>Finding ADS-B receivers</li>
<li>Choosing your theme</li>
</ul>
<button class="btn btn-primary btn-large" onclick="wizard.next()">LET'S GET STARTED</button>
</div>
<!-- Step 1: Password -->
<div class="step" id="step-1">
<h2 class="step-title">Set Admin Password</h2>
<p class="step-desc">Protect your admin panel with a password. You'll use this to change settings later.</p>
<div class="form-group">
<label>Password (min 4 characters)</label>
<input type="password" id="setup-password" placeholder="Enter password" autocomplete="new-password">
</div>
<div class="form-group">
<label>Confirm Password</label>
<input type="password" id="setup-password-confirm" placeholder="Confirm password" autocomplete="new-password">
</div>
<div id="password-error" class="error-msg hidden"></div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.savePassword()">NEXT</button>
</div>
</div>
<!-- Step 2: Location -->
<div class="step" id="step-2">
<h2 class="step-title">Your Location</h2>
<p class="step-desc">This is used for weather data, sunrise/sunset times, and positioning aircraft relative to you.</p>
<div class="form-group">
<label>Location Name</label>
<input type="text" id="setup-location-name" placeholder="e.g. Denver, CO">
</div>
<div class="form-row">
<div class="form-group">
<label>Latitude</label>
<input type="number" id="setup-lat" step="0.0001" placeholder="39.7392">
</div>
<div class="form-group">
<label>Longitude</label>
<input type="number" id="setup-lon" step="0.0001" placeholder="-104.9903">
</div>
</div>
<button class="btn btn-detect" id="detect-location-btn" onclick="wizard.detectLocation()">USE BROWSER LOCATION</button>
<div id="location-status" class="status-msg hidden"></div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.saveLocation()">NEXT</button>
</div>
</div>
<!-- Step 3: Receivers -->
<div class="step" id="step-3">
<h2 class="step-title">Find Receivers</h2>
<p class="step-desc">ADS-Bit connects to ADS-B receivers on your network (port 30003 by default). You can auto-scan or enter IPs manually.</p>
<div class="form-group">
<label>Discovery Mode</label>
<select id="setup-receiver-mode" onchange="wizard.toggleReceiverMode()">
<option value="AUTO">AUTO - Scan network automatically</option>
<option value="MANUAL">MANUAL - Enter IP addresses</option>
</select>
</div>
<div id="setup-manual-group" class="form-group hidden">
<label>Receiver IPs (one per line)</label>
<textarea id="setup-receiver-ips" rows="3" placeholder="192.168.1.100"></textarea>
</div>
<div class="form-group">
<label>Receiver Port</label>
<input type="number" id="setup-receiver-port" value="30003" min="1" max="65535">
</div>
<button class="btn btn-detect" id="scan-btn" onclick="wizard.scanReceivers()">SCAN FOR RECEIVERS</button>
<div id="scan-status" class="status-msg hidden"></div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.saveReceivers()">NEXT</button>
</div>
</div>
<!-- Step 4: Theme & Preferences -->
<div class="step" id="step-4">
<h2 class="step-title">Theme & Preferences</h2>
<p class="step-desc">Customize how ADS-Bit looks and feels.</p>
<div class="form-group">
<label>Background Theme</label>
<select id="setup-theme"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Site Title</label>
<input type="text" id="setup-site-title" placeholder="ADS-Bit" value="ADS-Bit">
</div>
<div class="form-group">
<label>Temperature Unit</label>
<select id="setup-temp-unit">
<option value="F">Fahrenheit (F)</option>
<option value="C">Celsius (C)</option>
</select>
</div>
</div>
<div class="step-buttons">
<button class="btn" onclick="wizard.prev()">BACK</button>
<button class="btn btn-primary" onclick="wizard.saveTheme()">NEXT</button>
</div>
</div>
<!-- Step 5: Complete -->
<div class="step" id="step-5">
<div class="step-icon">&#9733;</div>
<h2 class="step-title">Setup Complete!</h2>
<p class="step-desc">Everything is configured and ready to go. Here's a summary:</p>
<div class="summary-box" id="setup-summary"></div>
<p class="step-desc">You can change any of these settings later in the admin panel.</p>
<button class="btn btn-primary btn-large" onclick="wizard.finish()">LAUNCH ADS-BIT</button>
</div>
</div>
<script src="/setup/setup.js"></script>
</body>
</html>
+265
View File
@@ -0,0 +1,265 @@
// ADS-Bit First-Run Setup Wizard
const wizard = {
currentStep: 0,
totalSteps: 6,
init() {
this.updateProgress();
this.loadThemes();
},
show(step) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
const el = document.getElementById('step-' + step);
if (el) el.classList.add('active');
this.currentStep = step;
this.updateProgress();
if (step === 5) this.buildSummary();
},
next() {
if (this.currentStep < this.totalSteps - 1) {
this.show(this.currentStep + 1);
}
},
prev() {
if (this.currentStep > 0) {
this.show(this.currentStep - 1);
}
},
updateProgress() {
const pct = (this.currentStep / (this.totalSteps - 1)) * 100;
document.getElementById('progress-fill').style.width = pct + '%';
document.querySelectorAll('.step-dot').forEach(dot => {
const s = parseInt(dot.dataset.step);
dot.classList.remove('active', 'done');
if (s === this.currentStep) dot.classList.add('active');
else if (s < this.currentStep) dot.classList.add('done');
});
},
// Step 1: Password
async savePassword() {
const pw = document.getElementById('setup-password').value;
const confirm = document.getElementById('setup-password-confirm').value;
const errEl = document.getElementById('password-error');
errEl.classList.add('hidden');
if (pw.length < 4) {
errEl.textContent = 'Password must be at least 4 characters';
errEl.classList.remove('hidden');
return;
}
if (pw !== confirm) {
errEl.textContent = 'Passwords do not match';
errEl.classList.remove('hidden');
return;
}
try {
const res = await fetch('/api/setup/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
if (res.ok) {
this.next();
} else {
const data = await res.json();
errEl.textContent = data.error || 'Failed to set password';
errEl.classList.remove('hidden');
}
} catch (e) {
errEl.textContent = 'Connection error';
errEl.classList.remove('hidden');
}
},
// Step 2: Location
detectLocation() {
const statusEl = document.getElementById('location-status');
if (!navigator.geolocation) {
statusEl.textContent = 'Geolocation not supported by your browser';
statusEl.className = 'status-msg error';
statusEl.classList.remove('hidden');
return;
}
statusEl.textContent = 'Detecting location...';
statusEl.className = 'status-msg info';
statusEl.classList.remove('hidden');
navigator.geolocation.getCurrentPosition(
(pos) => {
document.getElementById('setup-lat').value = pos.coords.latitude.toFixed(4);
document.getElementById('setup-lon').value = pos.coords.longitude.toFixed(4);
statusEl.textContent = 'Location detected! You can edit the values if needed.';
statusEl.className = 'status-msg success';
},
(err) => {
statusEl.textContent = 'Could not detect location. Please enter manually.';
statusEl.className = 'status-msg error';
}
);
},
async saveLocation() {
const name = document.getElementById('setup-location-name').value || 'My Location';
const lat = parseFloat(document.getElementById('setup-lat').value) || 0;
const lon = parseFloat(document.getElementById('setup-lon').value) || 0;
try {
const res = await fetch('/api/setup/location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon })
});
if (res.ok) this.next();
} catch (e) { /* proceed anyway */ this.next(); }
},
// Step 3: Receivers
toggleReceiverMode() {
const mode = document.getElementById('setup-receiver-mode').value;
document.getElementById('setup-manual-group').classList.toggle('hidden', mode === 'AUTO');
},
async scanReceivers() {
const btn = document.getElementById('scan-btn');
const statusEl = document.getElementById('scan-status');
btn.textContent = 'SCANNING...';
btn.disabled = true;
statusEl.textContent = 'Scanning your network for ADS-B receivers...';
statusEl.className = 'status-msg info';
statusEl.classList.remove('hidden');
try {
const res = await fetch('/api/setup/scan');
const data = await res.json();
if (data.receivers && data.receivers.length) {
statusEl.textContent = `Found ${data.receivers.length} receiver(s): ${data.receivers.join(', ')}`;
statusEl.className = 'status-msg success';
} else {
statusEl.textContent = 'No receivers found. You can add them manually or try again later.';
statusEl.className = 'status-msg info';
}
} catch (e) {
statusEl.textContent = 'Scan failed. Check your network connection.';
statusEl.className = 'status-msg error';
}
btn.textContent = 'SCAN FOR RECEIVERS';
btn.disabled = false;
},
async saveReceivers() {
const mode = document.getElementById('setup-receiver-mode').value;
const port = parseInt(document.getElementById('setup-receiver-port').value) || 30003;
const body = { mode, port };
if (mode === 'MANUAL') {
const ips = document.getElementById('setup-receiver-ips').value
.split('\n').map(s => s.trim()).filter(Boolean);
body.ips = ips;
}
try {
await fetch('/api/setup/receivers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (e) { /* proceed */ }
this.next();
},
// Step 4: Theme
async loadThemes() {
try {
const res = await fetch('/api/admin/themes');
if (!res.ok) {
// Not authenticated yet, try fetching available backgrounds
this.addDefaultThemes();
return;
}
const data = await res.json();
const sel = document.getElementById('setup-theme');
sel.innerHTML = '';
(data.themes || ['desert']).forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
sel.appendChild(opt);
});
} catch (e) {
this.addDefaultThemes();
}
},
addDefaultThemes() {
const sel = document.getElementById('setup-theme');
sel.innerHTML = '';
['desert', 'custom'].forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
sel.appendChild(opt);
});
},
async saveTheme() {
const theme = document.getElementById('setup-theme').value;
const tempUnit = document.getElementById('setup-temp-unit').value;
const siteTitle = document.getElementById('setup-site-title').value || 'ADS-Bit';
try {
await fetch('/api/setup/theme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
theme,
temperature_unit: tempUnit,
site_title: siteTitle
})
});
} catch (e) { /* proceed */ }
this.next();
},
// Step 5: Summary & Complete
buildSummary() {
const container = document.getElementById('setup-summary');
const rows = [
{ label: 'LOCATION', value: document.getElementById('setup-location-name').value || 'My Location' },
{ label: 'COORDINATES', value: `${document.getElementById('setup-lat').value || '0'}, ${document.getElementById('setup-lon').value || '0'}` },
{ label: 'RECEIVERS', value: document.getElementById('setup-receiver-mode').value },
{ label: 'THEME', value: document.getElementById('setup-theme').value || 'desert' },
{ label: 'TEMP UNIT', value: document.getElementById('setup-temp-unit').value || 'F' },
{ label: 'PASSWORD', value: 'Set' }
];
container.innerHTML = rows.map(r =>
`<div class="summary-row"><span class="summary-label">${r.label}</span><span class="summary-value">${r.value}</span></div>`
).join('');
},
async finish() {
try {
const res = await fetch('/api/setup/complete', { method: 'POST' });
if (res.ok) {
window.location.href = '/';
} else {
const data = await res.json();
alert(data.error || 'Setup could not be completed');
}
} catch (e) {
alert('Connection error');
}
}
};
wizard.init();
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")"
echo "Starting Pixel View ADS-B server..."
python3 server.py