diff --git a/routes/satellite.py b/routes/satellite.py index c6e3fdf..9134b54 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -16,6 +16,13 @@ from flask import Blueprint, jsonify, request, render_template, Response from config import SHARED_OBSERVER_LOCATION_ENABLED from data.satellites import TLE_SATELLITES +from utils.database import ( + get_tracked_satellites, + add_tracked_satellite, + bulk_add_tracked_satellites, + update_tracked_satellite, + remove_tracked_satellite, +) from utils.logging import satellite_logger as logger from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation @@ -31,18 +38,38 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www _tle_cache = dict(TLE_SATELLITES) +def _load_db_satellites_into_cache(): + """Load user-tracked satellites from DB into the TLE cache.""" + global _tle_cache + try: + db_sats = get_tracked_satellites() + loaded = 0 + for sat in db_sats: + if sat['tle_line1'] and sat['tle_line2']: + # Use a cache key derived from name (sanitised) + cache_key = sat['name'].replace(' ', '-').upper() + if cache_key not in _tle_cache: + _tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2']) + loaded += 1 + if loaded: + logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache") + except Exception as e: + logger.warning(f"Failed to load DB satellites into TLE cache: {e}") + + def init_tle_auto_refresh(): """Initialize TLE auto-refresh. Called by app.py after initialization.""" import threading - + def _auto_refresh_tle(): try: + _load_db_satellites_into_cache() updated = refresh_tle_data() if updated: logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}") except Exception as e: logger.warning(f"Auto TLE refresh failed: {e}") - + # Start auto-refresh in background threading.Timer(2.0, _auto_refresh_tle).start() logger.info("TLE auto-refresh scheduled") @@ -168,13 +195,13 @@ def predict_passes(): except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 - norad_to_name = { - 25544: 'ISS', - 40069: 'METEOR-M2', - 57166: 'METEOR-M2-3' - } - - sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3']) + norad_to_name = { + 25544: 'ISS', + 40069: 'METEOR-M2', + 57166: 'METEOR-M2-3' + } + + sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3']) satellites = [] for sat in sat_input: if isinstance(sat, int) and sat in norad_to_name: @@ -183,11 +210,11 @@ def predict_passes(): satellites.append(sat) passes = [] - colors = { - 'ISS': '#00ffff', - 'METEOR-M2': '#9370DB', - 'METEOR-M2-3': '#ff00ff' - } + colors = { + 'ISS': '#00ffff', + 'METEOR-M2': '#9370DB', + 'METEOR-M2-3': '#ff00ff' + } name_to_norad = {v: k for k, v in norad_to_name.items()} ts = load.timescale() @@ -319,11 +346,11 @@ def get_satellite_position(): sat_input = data.get('satellites', []) include_track = bool(data.get('includeTrack', True)) - norad_to_name = { - 25544: 'ISS', - 40069: 'METEOR-M2', - 57166: 'METEOR-M2-3' - } + norad_to_name = { + 25544: 'ISS', + 40069: 'METEOR-M2', + 57166: 'METEOR-M2-3' + } satellites = [] for sat in sat_input: @@ -543,3 +570,75 @@ def fetch_celestrak(category): except Exception as e: logger.error(f"Error fetching CelesTrak data: {e}") return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'}) + + +# ============================================================================= +# Tracked Satellites CRUD +# ============================================================================= + +@satellite_bp.route('/tracked', methods=['GET']) +def list_tracked_satellites(): + """Return all tracked satellites from the database.""" + enabled_only = request.args.get('enabled', '').lower() == 'true' + sats = get_tracked_satellites(enabled_only=enabled_only) + return jsonify({'status': 'success', 'satellites': sats}) + + +@satellite_bp.route('/tracked', methods=['POST']) +def add_tracked_satellites_endpoint(): + """Add one or more tracked satellites.""" + global _tle_cache + data = request.json + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Accept a single satellite dict or a list + sat_list = data if isinstance(data, list) else [data] + + added = 0 + for sat in sat_list: + norad_id = str(sat.get('norad_id', sat.get('norad', ''))) + name = sat.get('name', '') + if not norad_id or not name: + continue + tle1 = sat.get('tle_line1', sat.get('tle1')) + tle2 = sat.get('tle_line2', sat.get('tle2')) + enabled = sat.get('enabled', True) + + if add_tracked_satellite(norad_id, name, tle1, tle2, enabled): + added += 1 + + # Also inject into TLE cache if we have TLE data + if tle1 and tle2: + cache_key = name.replace(' ', '-').upper() + _tle_cache[cache_key] = (name, tle1, tle2) + + return jsonify({ + 'status': 'success', + 'added': added, + 'satellites': get_tracked_satellites(), + }) + + +@satellite_bp.route('/tracked/', methods=['PUT']) +def update_tracked_satellite_endpoint(norad_id): + """Update the enabled state of a tracked satellite.""" + data = request.json or {} + enabled = data.get('enabled') + if enabled is None: + return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400 + + ok = update_tracked_satellite(str(norad_id), bool(enabled)) + if ok: + return jsonify({'status': 'success'}) + return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404 + + +@satellite_bp.route('/tracked/', methods=['DELETE']) +def delete_tracked_satellite_endpoint(norad_id): + """Remove a tracked satellite by NORAD ID.""" + ok, msg = remove_tracked_satellite(str(norad_id)) + if ok: + return jsonify({'status': 'success', 'message': msg}) + status_code = 403 if 'builtin' in msg.lower() else 404 + return jsonify({'status': 'error', 'message': msg}), status_code diff --git a/templates/index.html b/templates/index.html index 7b81929..cf39369 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10621,10 +10621,7 @@ } // Satellite management - let trackedSatellites = [ - { id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true }, - { id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true } - ]; + let trackedSatellites = []; function renderSatelliteList() { const list = document.getElementById('satTrackingList'); @@ -10643,14 +10640,27 @@ } function toggleSatellite(idx) { - trackedSatellites[idx].checked = !trackedSatellites[idx].checked; + const sat = trackedSatellites[idx]; + sat.checked = !sat.checked; + fetch(`/satellite/tracked/${sat.norad}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: sat.checked }) + }).catch(() => {}); } function removeSatellite(idx) { - if (!trackedSatellites[idx].builtin) { - trackedSatellites.splice(idx, 1); - renderSatelliteList(); - } + const sat = trackedSatellites[idx]; + if (sat.builtin) return; + fetch(`/satellite/tracked/${sat.norad}`, { method: 'DELETE' }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + trackedSatellites.splice(idx, 1); + renderSatelliteList(); + } + }) + .catch(() => {}); } function getSelectedSatellites() { @@ -10686,7 +10696,7 @@ } const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l); - let added = 0; + const toAdd = []; for (let i = 0; i < lines.length; i += 3) { if (i + 2 < lines.length) { @@ -10696,32 +10706,33 @@ if (line1.startsWith('1 ') && line2.startsWith('2 ')) { const norad = line1.substring(2, 7).trim(); - const id = name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(); - - // Check if already exists if (!trackedSatellites.find(s => s.norad === norad)) { - trackedSatellites.push({ - id: id, - name: name, - norad: norad, - builtin: false, - checked: true, - tle: [name, line1, line2] - }); - added++; + toAdd.push({ norad_id: norad, name: name, tle1: line1, tle2: line2, enabled: true }); } } } } - if (added > 0) { - renderSatelliteList(); - document.getElementById('tleInput').value = ''; - closeSatModal(); - showInfo(`Added ${added} satellite(s)`); - } else { + if (toAdd.length === 0) { alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)'); + return; } + + fetch('/satellite/tracked', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(toAdd) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + _loadSatellitesFromAPI(); + document.getElementById('tleInput').value = ''; + closeSatModal(); + showInfo(`Added ${data.added} satellite(s)`); + } + }) + .catch(() => alert('Failed to save satellites')); } function fetchCelestrak() { @@ -10737,35 +10748,76 @@ .then(r => r.json()) .then(data => { if (data.status === 'success' && data.satellites) { - let added = 0; - data.satellites.forEach(sat => { - const noradStr = String(sat.norad); - if (!trackedSatellites.find(s => s.norad === noradStr)) { - trackedSatellites.push({ - id: sat.name.replace(/[^a-zA-Z0-9-]/g, '-'), - name: sat.name, - norad: noradStr, - builtin: false, - checked: false, // Don't auto-select - tle: [sat.name, sat.tle1, sat.tle2] - }); - added++; + const toAdd = data.satellites + .filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad))) + .map(sat => ({ + norad_id: String(sat.norad), + name: sat.name, + tle1: sat.tle1, + tle2: sat.tle2, + enabled: false + })); + + if (toAdd.length === 0) { + status.innerHTML = `All ${data.satellites.length} satellites already tracked`; + return; + } + + fetch('/satellite/tracked', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(toAdd) + }) + .then(r => r.json()) + .then(result => { + if (result.status === 'success') { + _loadSatellitesFromAPI(); + status.innerHTML = `Added ${result.added} satellites (${data.satellites.length} total in category)`; } + }) + .catch(() => { + status.innerHTML = `Failed to save satellites`; }); - renderSatelliteList(); - status.innerHTML = `Added ${added} satellites (${data.satellites.length} total in category)`; } else { status.innerHTML = `Error: ${data.message || 'Failed to fetch'}`; } }) - .catch(err => { + .catch(() => { status.innerHTML = `Network error`; }); } + function _loadSatellitesFromAPI() { + fetch('/satellite/tracked') + .then(r => r.json()) + .then(data => { + if (data.status === 'success' && data.satellites) { + trackedSatellites = data.satellites.map(sat => ({ + id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(), + name: sat.name, + norad: sat.norad_id, + builtin: sat.builtin, + checked: sat.enabled, + tle: sat.tle_line1 ? [sat.name, sat.tle_line1, sat.tle_line2] : null + })); + renderSatelliteList(); + } + }) + .catch(() => { + // Fallback to hardcoded defaults if API fails + if (trackedSatellites.length === 0) { + trackedSatellites = [ + { id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true }, + { id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true } + ]; + renderSatelliteList(); + } + }); + } + // Initialize satellite list when satellite mode is loaded function initSatelliteList() { - renderSatelliteList(); + _loadSatellitesFromAPI(); } // Utility function diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html index d487052..872eee6 100644 --- a/templates/satellite_dashboard.html +++ b/templates/satellite_dashboard.html @@ -269,12 +269,42 @@ let currentLocationSource = 'local'; let agents = []; - const satellites = { + let satellites = { 25544: { name: 'ISS (ZARYA)', color: '#00ffff' }, 40069: { name: 'METEOR-M2', color: '#9370DB' }, 57166: { name: 'METEOR-M2-3', color: '#ff00ff' } }; + const satColors = ['#00ffff', '#9370DB', '#ff00ff', '#00ff00', '#ff6600', '#ffff00', '#ff69b4', '#7b68ee']; + + function loadDashboardSatellites() { + fetch('/satellite/tracked?enabled=true') + .then(r => r.json()) + .then(data => { + if (data.status === 'success' && data.satellites && data.satellites.length > 0) { + const newSats = {}; + const select = document.getElementById('satSelect'); + select.innerHTML = ''; + data.satellites.forEach((sat, i) => { + const norad = parseInt(sat.norad_id); + newSats[norad] = { + name: sat.name, + color: satellites[norad]?.color || satColors[i % satColors.length] + }; + const opt = document.createElement('option'); + opt.value = norad; + opt.textContent = sat.name; + select.appendChild(opt); + }); + satellites = newSats; + // Default to ISS if available + if (newSats[25544]) select.value = '25544'; + selectedSatellite = parseInt(select.value); + } + }) + .catch(() => {}); + } + function onSatelliteChange() { const select = document.getElementById('satSelect'); selectedSatellite = parseInt(select.value); @@ -331,6 +361,7 @@ } document.addEventListener('DOMContentLoaded', () => { + loadDashboardSatellites(); setupEmbeddedMode(); const usedShared = applySharedObserverLocation(); initGroundMap(); diff --git a/utils/database.py b/utils/database.py index 3211624..c56890a 100644 --- a/utils/database.py +++ b/utils/database.py @@ -531,6 +531,30 @@ def init_db() -> None: ON push_payloads(agent_id, received_at) ''') + # Tracked satellites table for persistent satellite management + conn.execute(''' + CREATE TABLE IF NOT EXISTS tracked_satellites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + norad_id TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + tle_line1 TEXT, + tle_line2 TEXT, + enabled BOOLEAN DEFAULT 1, + builtin BOOLEAN DEFAULT 0, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Seed builtin satellites if not already present + conn.execute(''' + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1) + ''') + conn.execute(''' + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) + ''') + logger.info("Database initialized successfully") @@ -2170,3 +2194,111 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int: ''', (f'-{max_age_hours} hours',)) return cursor.rowcount + +# ============================================================================= +# Tracked Satellites Functions +# ============================================================================= + +def get_tracked_satellites(enabled_only: bool = False) -> list[dict]: + """Return all tracked satellites, optionally filtered to enabled only.""" + with get_db() as conn: + if enabled_only: + rows = conn.execute( + 'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at ' + 'FROM tracked_satellites WHERE enabled = 1 ORDER BY builtin DESC, name' + ).fetchall() + else: + rows = conn.execute( + 'SELECT norad_id, name, tle_line1, tle_line2, enabled, builtin, added_at ' + 'FROM tracked_satellites ORDER BY builtin DESC, name' + ).fetchall() + return [ + { + 'norad_id': r[0], + 'name': r[1], + 'tle_line1': r[2], + 'tle_line2': r[3], + 'enabled': bool(r[4]), + 'builtin': bool(r[5]), + 'added_at': r[6], + } + for r in rows + ] + + +def add_tracked_satellite( + norad_id: str, + name: str, + tle_line1: str | None = None, + tle_line2: str | None = None, + enabled: bool = True, + builtin: bool = False, +) -> bool: + """Insert a tracked satellite. Returns True if inserted, False if duplicate.""" + with get_db() as conn: + try: + conn.execute( + 'INSERT OR IGNORE INTO tracked_satellites ' + '(norad_id, name, tle_line1, tle_line2, enabled, builtin) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + (str(norad_id), name, tle_line1, tle_line2, int(enabled), int(builtin)), + ) + return conn.total_changes > 0 + except sqlite3.Error as e: + logger.error(f"Error adding tracked satellite {norad_id}: {e}") + return False + + +def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int: + """Insert many tracked satellites at once. Returns count of newly inserted.""" + added = 0 + with get_db() as conn: + for sat in satellites_list: + try: + cursor = conn.execute( + 'INSERT OR IGNORE INTO tracked_satellites ' + '(norad_id, name, tle_line1, tle_line2, enabled, builtin) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + ( + str(sat['norad_id']), + sat['name'], + sat.get('tle_line1'), + sat.get('tle_line2'), + int(sat.get('enabled', True)), + int(sat.get('builtin', False)), + ), + ) + if cursor.rowcount > 0: + added += 1 + except (sqlite3.Error, KeyError) as e: + logger.warning(f"Error bulk-adding satellite: {e}") + return added + + +def update_tracked_satellite(norad_id: str, enabled: bool) -> bool: + """Toggle enabled state for a tracked satellite.""" + with get_db() as conn: + cursor = conn.execute( + 'UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?', + (int(enabled), str(norad_id)), + ) + return cursor.rowcount > 0 + + +def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]: + """Delete a tracked satellite by NORAD ID. Refuses to delete builtins.""" + with get_db() as conn: + row = conn.execute( + 'SELECT builtin FROM tracked_satellites WHERE norad_id = ?', + (str(norad_id),), + ).fetchone() + if row is None: + return False, 'Satellite not found' + if row[0]: + return False, 'Cannot remove builtin satellite' + conn.execute( + 'DELETE FROM tracked_satellites WHERE norad_id = ?', + (str(norad_id),), + ) + return True, 'Removed' +