diff --git a/docs/USAGE.md b/docs/USAGE.md index 3d677f3..963200c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -539,6 +539,150 @@ Enable "Show All Agents" to aggregate data from all registered agents simultaneo For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md). +## Webhooks & Notifications + +INTERCEPT has a built-in alert engine that fires webhooks when decoded events match configurable rules. This lets you forward pager messages (or events from any other mode) to Discord, Slack, n8n, Home Assistant, or any HTTP endpoint. + +### How it works + +1. You configure **alert rules** via the Alerts UI — each rule defines which mode and event type to watch, optional match criteria, and a severity level. +2. When an incoming event matches a rule, INTERCEPT stores it in the alert log and POSTs a JSON payload to your configured webhook URL. +3. All modes are supported: pager, sensor, ADS-B, AIS, ACARS, WiFi, Bluetooth, and more. + +### Enable the webhook + +Set these environment variables in your `.env` file or `docker-compose.yml`: + +| Variable | Default | Description | +|----------|---------|-------------| +| `ALERT_WEBHOOK_URL` | _(empty)_ | URL to POST alert payloads to | +| `ALERT_WEBHOOK_SECRET` | _(empty)_ | Optional token sent as `X-Alert-Token` header | +| `ALERT_WEBHOOK_TIMEOUT` | `5` | HTTP timeout in seconds | + +**Local install (`.env`):** +```env +ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts +ALERT_WEBHOOK_SECRET=mysecrettoken +``` + +**Docker (`.env` or `docker-compose.yml` environment block):** +```env +ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts +ALERT_WEBHOOK_SECRET=mysecrettoken +``` + +### Create an alert rule + +1. Open the **Alerts** panel in INTERCEPT +2. Click **New Rule** +3. Configure: + - **Mode**: `pager` (or any other mode, or leave blank to match all) + - **Event type**: `message` for pager decodes (or blank to match all event types) + - **Match criteria**: leave empty to forward everything, or add filters (e.g. capcode equals `1234567`, or message contains `FIRE`) + - **Severity**: `low`, `medium`, or `high` +4. Save and enable the rule + +### Webhook payload format + +INTERCEPT sends a POST request with `Content-Type: application/json`: + +```json +{ + "id": 42, + "rule_id": 1, + "mode": "pager", + "event_type": "message", + "severity": "medium", + "title": "My Pager Rule", + "message": "message | 1234567", + "created_at": "2026-04-13T10:00:00+00:00", + "payload": { + "mode": "pager", + "event_type": "message", + "event": { + "capcode": "1234567", + "message": "UNIT 4 RESPOND TO 123 MAIN ST", + "type": "POCSAG1200" + }, + "rule": { "id": 1, "name": "My Pager Rule" } + } +} +``` + +### Sending to Discord + +Discord webhooks expect a specific JSON format (`content`, `embeds`), so you need a small relay between INTERCEPT and Discord. Two options: + +**Option A — No-code relay (recommended)** + +Use [n8n](https://n8n.io), [Make](https://make.com), or [Pipedream](https://pipedream.com) to receive INTERCEPT's webhook and forward it to Discord with a custom message template. Point `ALERT_WEBHOOK_URL` at your workflow's ingest URL. + +**Option B — Self-hosted Python relay** + +Save this as `discord_relay.py` and run it alongside INTERCEPT: + +```python +from flask import Flask, request +import urllib.request, json + +app = Flask(__name__) + +DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN" + +@app.post("/relay") +def relay(): + data = request.get_json(force=True) + mode = data.get("mode", "unknown").upper() + title = data.get("title", "Alert") + message = data.get("message", "") + event = data.get("payload", {}).get("event", {}) + + # Build a readable Discord message + lines = [f"**[{mode}]** {title}", message] + if event.get("capcode"): + lines.append(f"Capcode: `{event['capcode']}`") + if event.get("type"): + lines.append(f"Protocol: {event['type']}") + + payload = json.dumps({"content": "\n".join(lines)}).encode() + req = urllib.request.Request( + DISCORD_WEBHOOK_URL, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + urllib.request.urlopen(req, timeout=5) + return "", 204 + +app.run(host="0.0.0.0", port=5051) +``` + +Then set: +```env +ALERT_WEBHOOK_URL=http://localhost:5051/relay +``` + +Run the relay: `python3 discord_relay.py` + +The relay formats pager decodes as Discord messages like: + +``` +[PAGER] My Pager Rule +message | 1234567 +Capcode: `1234567` +Protocol: POCSAG1200 +``` + +### Filtering specific capcodes + +To only forward decodes from a specific capcode, set the rule's **Match criteria**: + +| Field | Operator | Value | +|-------|----------|-------| +| `capcode` | equals | `1234567` | + +Multiple rules can coexist — e.g. one rule for all pager traffic to a general Discord channel, and a second rule for emergency capcodes with `high` severity to a separate channel (using a second relay instance on a different port). + ## Configuration INTERCEPT can be configured via environment variables: diff --git a/utils/database.py b/utils/database.py index 29aba76..b0f2b0e 100644 --- a/utils/database.py +++ b/utils/database.py @@ -14,14 +14,23 @@ from typing import Any from werkzeug.security import check_password_hash, generate_password_hash -logger = logging.getLogger('intercept.database') +logger = logging.getLogger("intercept.database") # Database file location -DB_DIR = Path(__file__).parent.parent / 'instance' -DB_PATH = DB_DIR / 'intercept.db' +DB_DIR = Path(__file__).parent.parent / "instance" +DB_PATH = DB_DIR / "intercept.db" -# Thread-local storage for connections -_local = threading.local() +# Per-greenlet (or per-thread) local storage for connections. +# Under gunicorn + gevent, all greenlets share a single OS thread, so +# threading.local() would give every concurrent request the same connection, +# causing sqlite3.ProgrammingError / database-locked crashes. Use gevent's +# local() when available so each greenlet gets its own connection. +try: + from gevent.local import local as _LocalClass +except ImportError: + _LocalClass = threading.local # type: ignore[assignment] + +_local = _LocalClass() def get_db_path() -> Path: @@ -32,15 +41,15 @@ def get_db_path() -> Path: def get_connection() -> sqlite3.Connection: """Get a thread-local database connection.""" - if not hasattr(_local, 'connection') or _local.connection is None: + if not hasattr(_local, "connection") or _local.connection is None: db_path = get_db_path() try: _local.connection = sqlite3.connect(str(db_path), check_same_thread=False) _local.connection.row_factory = sqlite3.Row # Enable foreign keys - _local.connection.execute('PRAGMA foreign_keys = ON') + _local.connection.execute("PRAGMA foreign_keys = ON") # Use WAL mode for better concurrent read/write performance - _local.connection.execute('PRAGMA journal_mode = WAL') + _local.connection.execute("PRAGMA journal_mode = WAL") except sqlite3.OperationalError as e: logger.error( f"Cannot open database at {db_path}: {e}. " @@ -82,10 +91,7 @@ def _check_db_writable(db_path: Path) -> None: # Check file writability if it already exists if db_path.exists() and not os.access(db_path, os.W_OK): owner = _file_owner(db_path) - msg = ( - f"Database {db_path} is not writable (owned by {owner}). " - f"Fix with: sudo chown -R $(whoami) {db_dir}" - ) + msg = f"Database {db_path} is not writable (owned by {owner}). Fix with: sudo chown -R $(whoami) {db_dir}" logger.error(msg) raise sqlite3.OperationalError(msg) @@ -94,6 +100,7 @@ def _file_owner(path: Path) -> str: """Return the owner username of a file, or UID if lookup fails.""" try: import pwd + return pwd.getpwuid(path.stat().st_uid).pw_name except (ImportError, KeyError): return str(path.stat().st_uid) @@ -108,17 +115,17 @@ def init_db() -> None: with get_db() as conn: # Settings table for key-value storage - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, value_type TEXT DEFAULT 'string', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Signal history table for graphs - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS signal_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, mode TEXT NOT NULL, @@ -127,16 +134,16 @@ def init_db() -> None: timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, metadata TEXT ) - ''') + """) # Create index for faster queries - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_signal_history_mode_device ON signal_history(mode, device_id, timestamp) - ''') + """) # Device correlation table - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS device_correlations ( id INTEGER PRIMARY KEY AUTOINCREMENT, wifi_mac TEXT, @@ -147,10 +154,10 @@ def init_db() -> None: metadata TEXT, UNIQUE(wifi_mac, bt_mac) ) - ''') + """) # Alert rules - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -162,10 +169,10 @@ def init_db() -> None: notify TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Alert events - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_id INTEGER, @@ -178,10 +185,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL ) - ''') + """) # Session recordings - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS recording_sessions ( id TEXT PRIMARY KEY, mode TEXT NOT NULL, @@ -193,10 +200,10 @@ def init_db() -> None: size_bytes INTEGER DEFAULT 0, metadata TEXT ) - ''') + """) # Alert rules - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -208,10 +215,10 @@ def init_db() -> None: notify TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Alert events - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS alert_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_id INTEGER, @@ -224,10 +231,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL ) - ''') + """) # Session recordings - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS recording_sessions ( id TEXT PRIMARY KEY, mode TEXT NOT NULL, @@ -239,10 +246,10 @@ def init_db() -> None: size_bytes INTEGER DEFAULT 0, metadata TEXT ) - ''') + """) # Users table for authentication - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, @@ -250,11 +257,11 @@ def init_db() -> None: role TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) from config import ADMIN_PASSWORD, ADMIN_USERNAME - cursor = conn.execute('SELECT COUNT(*) FROM users') + cursor = conn.execute("SELECT COUNT(*) FROM users") if cursor.fetchone()[0] == 0: # First run — seed the admin user from config / env vars. admin_password = ADMIN_PASSWORD @@ -265,7 +272,7 @@ def init_db() -> None: logger.warning(f"Generated admin password: {admin_password}") logger.warning("Set INTERCEPT_ADMIN_PASSWORD env var to use a fixed password.") try: - pw_path = Path('instance/.initial_password') + pw_path = Path("instance/.initial_password") pw_path.parent.mkdir(parents=True, exist_ok=True) pw_path.write_text(f"{ADMIN_USERNAME}:{admin_password}\n") except OSError as e: @@ -274,29 +281,32 @@ def init_db() -> None: logger.info(f"Creating default admin user: {ADMIN_USERNAME}") hashed_pw = generate_password_hash(admin_password) - conn.execute(''' + conn.execute( + """ INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) - ''', (ADMIN_USERNAME, hashed_pw, 'admin')) + """, + (ADMIN_USERNAME, hashed_pw, "admin"), + ) elif ADMIN_PASSWORD: # Sync admin credentials from config on every startup so that # changes to config.py / env vars take effect without wiping the DB. row = conn.execute( - 'SELECT password_hash FROM users WHERE username = ? AND role = ?', - (ADMIN_USERNAME, 'admin'), + "SELECT password_hash FROM users WHERE username = ? AND role = ?", + (ADMIN_USERNAME, "admin"), ).fetchone() if row: - if not check_password_hash(row['password_hash'], ADMIN_PASSWORD): + if not check_password_hash(row["password_hash"], ADMIN_PASSWORD): conn.execute( - 'UPDATE users SET password_hash = ? WHERE username = ? AND role = ?', - (generate_password_hash(ADMIN_PASSWORD), ADMIN_USERNAME, 'admin'), + "UPDATE users SET password_hash = ? WHERE username = ? AND role = ?", + (generate_password_hash(ADMIN_PASSWORD), ADMIN_USERNAME, "admin"), ) logger.info(f"Admin password updated from config for user '{ADMIN_USERNAME}'.") else: # Admin user doesn't exist (maybe renamed) — create it. conn.execute( - 'INSERT OR IGNORE INTO users (username, password_hash, role) VALUES (?, ?, ?)', - (ADMIN_USERNAME, generate_password_hash(ADMIN_PASSWORD), 'admin'), + "INSERT OR IGNORE INTO users (username, password_hash, role) VALUES (?, ?, ?)", + (ADMIN_USERNAME, generate_password_hash(ADMIN_PASSWORD), "admin"), ) logger.info(f"Created admin user '{ADMIN_USERNAME}' from config.") # ===================================================================== @@ -304,7 +314,7 @@ def init_db() -> None: # ===================================================================== # TSCM Baselines - Environment snapshots for comparison - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_baselines ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -318,18 +328,18 @@ def init_db() -> None: gps_coords TEXT, is_active BOOLEAN DEFAULT 0 ) - ''') + """) # Ensure new columns exist for older databases try: - columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")} - if 'wifi_clients' not in columns: - conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT') + columns = {row["name"] for row in conn.execute("PRAGMA table_info(tscm_baselines)")} + if "wifi_clients" not in columns: + conn.execute("ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT") except Exception as e: logger.debug(f"Schema update skipped for tscm_baselines: {e}") # TSCM Sweeps - Individual sweep sessions - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_sweeps ( id INTEGER PRIMARY KEY AUTOINCREMENT, baseline_id INTEGER, @@ -345,10 +355,10 @@ def init_db() -> None: threats_found INTEGER DEFAULT 0, FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id) ) - ''') + """) # TSCM Threats - Detected threats/anomalies - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_threats ( id INTEGER PRIMARY KEY AUTOINCREMENT, sweep_id INTEGER, @@ -366,10 +376,10 @@ def init_db() -> None: gps_coords TEXT, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Scheduled Sweeps - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -384,10 +394,10 @@ def init_db() -> None: notify_email TEXT, FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id) ) - ''') + """) # TSCM Device Timelines - Periodic observations per device - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_device_timelines ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_identifier TEXT NOT NULL, @@ -401,10 +411,10 @@ def init_db() -> None: attributes TEXT, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Known-Good Registry - Whitelist of expected devices - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_known_devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, identifier TEXT NOT NULL UNIQUE, @@ -419,10 +429,10 @@ def init_db() -> None: score_modifier INTEGER DEFAULT -2, metadata TEXT ) - ''') + """) # TSCM Cases - Grouping sweeps, threats, and notes - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_cases ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -438,10 +448,10 @@ def init_db() -> None: notes TEXT, metadata TEXT ) - ''') + """) # TSCM Case Sweeps - Link sweeps to cases - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_case_sweeps ( case_id INTEGER, sweep_id INTEGER, @@ -450,10 +460,10 @@ def init_db() -> None: FOREIGN KEY (case_id) REFERENCES tscm_cases(id), FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Case Threats - Link threats to cases - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_case_threats ( case_id INTEGER, threat_id INTEGER, @@ -462,10 +472,10 @@ def init_db() -> None: FOREIGN KEY (case_id) REFERENCES tscm_cases(id), FOREIGN KEY (threat_id) REFERENCES tscm_threats(id) ) - ''') + """) # TSCM Case Notes - Notes attached to cases - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_case_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, case_id INTEGER, @@ -475,10 +485,10 @@ def init_db() -> None: created_by TEXT, FOREIGN KEY (case_id) REFERENCES tscm_cases(id) ) - ''') + """) # TSCM Meeting Windows - Track sensitive periods - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_meeting_windows ( id INTEGER PRIMARY KEY AUTOINCREMENT, sweep_id INTEGER, @@ -489,10 +499,10 @@ def init_db() -> None: notes TEXT, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM Sweep Capabilities - Store sweep capability snapshot - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tscm_sweep_capabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, sweep_id INTEGER UNIQUE, @@ -501,45 +511,45 @@ def init_db() -> None: recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id) ) - ''') + """) # TSCM indexes for performance - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_threats_sweep ON tscm_threats(sweep_id) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_threats_severity ON tscm_threats(severity, detected_at) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_sweeps_baseline ON tscm_sweeps(baseline_id) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_timelines_device ON tscm_device_timelines(device_identifier, timestamp) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_known_devices_identifier ON tscm_known_devices(identifier) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tscm_cases_status ON tscm_cases(status, created_at) - ''') + """) # ===================================================================== # DSC (Digital Selective Calling) Tables # ===================================================================== # DSC Alerts - Permanent storage for DISTRESS/URGENCY messages - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS dsc_alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -555,24 +565,24 @@ def init_db() -> None: acknowledged BOOLEAN DEFAULT 0, notes TEXT ) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_dsc_alerts_category ON dsc_alerts(category, received_at) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_dsc_alerts_mmsi ON dsc_alerts(source_mmsi, received_at) - ''') + """) # ===================================================================== # Remote Agent Tables (for distributed/controller mode) # ===================================================================== # Remote agents registry - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS agents ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, @@ -586,10 +596,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT 1 ) - ''') + """) # Push payloads received from remote agents - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS push_payloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id INTEGER NOT NULL, @@ -599,21 +609,21 @@ def init_db() -> None: received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (agent_id) REFERENCES agents(id) ) - ''') + """) # Indexes for agent tables - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name) - ''') + """) - conn.execute(''' + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_push_payloads_agent ON push_payloads(agent_id, received_at) - ''') + """) # Tracked satellites table for persistent satellite management - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS tracked_satellites ( id INTEGER PRIMARY KEY AUTOINCREMENT, norad_id TEXT UNIQUE NOT NULL, @@ -624,51 +634,51 @@ def init_db() -> None: builtin BOOLEAN DEFAULT 0, added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Seed builtin satellites if not already present - conn.execute(''' + 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) - ''') - conn.execute(''' - INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) - VALUES ('57166', 'METEOR-M2-3', NULL, NULL, 1, 1) - ''') - conn.execute(''' - INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) - VALUES ('59051', 'METEOR-M2-4', 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) + """) + conn.execute(""" + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('57166', 'METEOR-M2-3', NULL, NULL, 1, 1) + """) + conn.execute(""" + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('59051', 'METEOR-M2-4', NULL, NULL, 1, 1) + """) # ===================================================================== # Ground Station Tables (automated observations, IQ recordings) # ===================================================================== # Observation profiles — per-satellite capture configuration - conn.execute(''' - CREATE TABLE IF NOT EXISTS observation_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - norad_id INTEGER UNIQUE NOT NULL, - name TEXT NOT NULL, - frequency_mhz REAL NOT NULL, - decoder_type TEXT NOT NULL DEFAULT 'fm', - tasks_json TEXT, - gain REAL DEFAULT 40.0, - bandwidth_hz INTEGER DEFAULT 200000, - min_elevation REAL DEFAULT 10.0, - enabled BOOLEAN DEFAULT 1, - record_iq BOOLEAN DEFAULT 0, + conn.execute(""" + CREATE TABLE IF NOT EXISTS observation_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + norad_id INTEGER UNIQUE NOT NULL, + name TEXT NOT NULL, + frequency_mhz REAL NOT NULL, + decoder_type TEXT NOT NULL DEFAULT 'fm', + tasks_json TEXT, + gain REAL DEFAULT 40.0, + bandwidth_hz INTEGER DEFAULT 200000, + min_elevation REAL DEFAULT 10.0, + enabled BOOLEAN DEFAULT 1, + record_iq BOOLEAN DEFAULT 0, iq_sample_rate INTEGER DEFAULT 2400000, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) # Observation history — one row per captured pass - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS ground_station_observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER, @@ -682,10 +692,10 @@ def init_db() -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (profile_id) REFERENCES observation_profiles(id) ON DELETE SET NULL ) - ''') + """) # Per-observation events (packets decoded, Doppler updates, etc.) - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS ground_station_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, observation_id INTEGER, @@ -694,90 +704,88 @@ def init_db() -> None: timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE ) - ''') + """) # SigMF recordings — one row per IQ recording file pair - conn.execute(''' - CREATE TABLE IF NOT EXISTS sigmf_recordings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - observation_id INTEGER, - sigmf_data_path TEXT NOT NULL, + conn.execute(""" + CREATE TABLE IF NOT EXISTS sigmf_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observation_id INTEGER, + sigmf_data_path TEXT NOT NULL, sigmf_meta_path TEXT NOT NULL, size_bytes INTEGER DEFAULT 0, sample_rate INTEGER, center_freq_hz INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE SET NULL - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS ground_station_outputs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - observation_id INTEGER, - norad_id INTEGER, - output_type TEXT NOT NULL, - backend TEXT, - file_path TEXT NOT NULL, - preview_path TEXT, - metadata_json TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS ground_station_decode_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - observation_id INTEGER, - norad_id INTEGER, - backend TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'queued', - input_path TEXT, - output_dir TEXT, - error_message TEXT, - details_json TEXT, - started_at TIMESTAMP, - completed_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE - ) - ''') + FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE SET NULL + ) + """) - conn.execute(''' + conn.execute(""" + CREATE TABLE IF NOT EXISTS ground_station_outputs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observation_id INTEGER, + norad_id INTEGER, + output_type TEXT NOT NULL, + backend TEXT, + file_path TEXT NOT NULL, + preview_path TEXT, + metadata_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE + ) + """) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS ground_station_decode_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observation_id INTEGER, + norad_id INTEGER, + backend TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + input_path TEXT, + output_dir TEXT, + error_message TEXT, + details_json TEXT, + started_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE + ) + """) + + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_gs_observations_norad ON ground_station_observations(norad_id, created_at) - ''') + """) - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_gs_events_observation - ON ground_station_events(observation_id, timestamp) - ''') - - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_gs_outputs_observation - ON ground_station_outputs(observation_id, created_at) - ''') - - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_gs_decode_jobs_observation - ON ground_station_decode_jobs(observation_id, created_at) - ''') - - # Lightweight schema migrations for existing installs. - profile_cols = { - row['name'] for row in conn.execute('PRAGMA table_info(observation_profiles)') - } - if 'tasks_json' not in profile_cols: - conn.execute('ALTER TABLE observation_profiles ADD COLUMN tasks_json TEXT') - - logger.info("Database initialized successfully") + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_gs_events_observation + ON ground_station_events(observation_id, timestamp) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_gs_outputs_observation + ON ground_station_outputs(observation_id, created_at) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_gs_decode_jobs_observation + ON ground_station_decode_jobs(observation_id, created_at) + """) + + # Lightweight schema migrations for existing installs. + profile_cols = {row["name"] for row in conn.execute("PRAGMA table_info(observation_profiles)")} + if "tasks_json" not in profile_cols: + conn.execute("ALTER TABLE observation_profiles ADD COLUMN tasks_json TEXT") + + logger.info("Database initialized successfully") def close_db() -> None: - """Close the thread-local database connection.""" - if hasattr(_local, 'connection') and _local.connection is not None: + """Close the per-greenlet (or per-thread) database connection.""" + if hasattr(_local, "connection") and _local.connection is not None: _local.connection.close() _local.connection = None @@ -786,6 +794,7 @@ def close_db() -> None: # Settings Functions # ============================================================================= + def get_setting(key: str, default: Any = None) -> Any: """ Get a setting value by key. @@ -799,29 +808,26 @@ def get_setting(key: str, default: Any = None) -> Any: """ try: with get_db() as conn: - cursor = conn.execute( - 'SELECT value, value_type FROM settings WHERE key = ?', - (key,) - ) + cursor = conn.execute("SELECT value, value_type FROM settings WHERE key = ?", (key,)) row = cursor.fetchone() if row is None: return default - value, value_type = row['value'], row['value_type'] + value, value_type = row["value"], row["value_type"] # Convert based on type - if value_type == 'json': + if value_type == "json": try: return json.loads(value) except json.JSONDecodeError: return default - elif value_type == 'int': + elif value_type == "int": return int(value) - elif value_type == 'float': + elif value_type == "float": return float(value) - elif value_type == 'bool': - return value.lower() in ('true', '1', 'yes') + elif value_type == "bool": + return value.lower() in ("true", "1", "yes") else: return value except sqlite3.OperationalError: @@ -839,30 +845,33 @@ def set_setting(key: str, value: Any) -> None: """ # Determine value type and string representation if isinstance(value, bool): - value_type = 'bool' - str_value = 'true' if value else 'false' + value_type = "bool" + str_value = "true" if value else "false" elif isinstance(value, int): - value_type = 'int' + value_type = "int" str_value = str(value) elif isinstance(value, float): - value_type = 'float' + value_type = "float" str_value = str(value) elif isinstance(value, (dict, list)): - value_type = 'json' + value_type = "json" str_value = json.dumps(value) else: - value_type = 'string' + value_type = "string" str_value = str(value) with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO settings (key, value, value_type, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, value_type = excluded.value_type, updated_at = CURRENT_TIMESTAMP - ''', (key, str_value, value_type)) + """, + (key, str_value, value_type), + ) def delete_setting(key: str) -> bool: @@ -876,30 +885,30 @@ def delete_setting(key: str) -> bool: True if setting was deleted, False if not found """ with get_db() as conn: - cursor = conn.execute('DELETE FROM settings WHERE key = ?', (key,)) + cursor = conn.execute("DELETE FROM settings WHERE key = ?", (key,)) return cursor.rowcount > 0 def get_all_settings() -> dict[str, Any]: """Get all settings as a dictionary.""" with get_db() as conn: - cursor = conn.execute('SELECT key, value, value_type FROM settings') + cursor = conn.execute("SELECT key, value, value_type FROM settings") settings = {} for row in cursor: - key, value, value_type = row['key'], row['value'], row['value_type'] + key, value, value_type = row["key"], row["value"], row["value_type"] - if value_type == 'json': + if value_type == "json": try: settings[key] = json.loads(value) except json.JSONDecodeError: settings[key] = value - elif value_type == 'int': + elif value_type == "int": settings[key] = int(value) - elif value_type == 'float': + elif value_type == "float": settings[key] = float(value) - elif value_type == 'bool': - settings[key] = value.lower() in ('true', '1', 'yes') + elif value_type == "bool": + settings[key] = value.lower() in ("true", "1", "yes") else: settings[key] = value @@ -910,26 +919,20 @@ def get_all_settings() -> dict[str, Any]: # Signal History Functions # ============================================================================= -def add_signal_reading( - mode: str, - device_id: str, - signal_strength: float, - metadata: dict | None = None -) -> None: + +def add_signal_reading(mode: str, device_id: str, signal_strength: float, metadata: dict | None = None) -> None: """Add a signal strength reading.""" with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO signal_history (mode, device_id, signal_strength, metadata) VALUES (?, ?, ?, ?) - ''', (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None)) + """, + (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None), + ) -def get_signal_history( - mode: str, - device_id: str, - limit: int = 100, - since_minutes: int = 60 -) -> list[dict]: +def get_signal_history(mode: str, device_id: str, limit: int = 100, since_minutes: int = 60) -> list[dict]: """ Get signal history for a device. @@ -943,22 +946,27 @@ def get_signal_history( List of signal readings with timestamp """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT signal_strength, timestamp, metadata FROM signal_history WHERE mode = ? AND device_id = ? AND timestamp > datetime('now', ?) ORDER BY timestamp DESC LIMIT ? - ''', (mode, device_id, f'-{since_minutes} minutes', limit)) + """, + (mode, device_id, f"-{since_minutes} minutes", limit), + ) results = [] for row in cursor: - results.append({ - 'signal': row['signal_strength'], - 'timestamp': row['timestamp'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None - }) + results.append( + { + "signal": row["signal_strength"], + "timestamp": row["timestamp"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, + } + ) return list(reversed(results)) # Return in chronological order @@ -974,10 +982,13 @@ def cleanup_old_signal_history(max_age_hours: int = 24) -> int: Number of deleted entries """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM signal_history WHERE timestamp < datetime('now', ?) - ''', (f'-{max_age_hours} hours',)) + """, + (f"-{max_age_hours} hours",), + ) return cursor.rowcount @@ -985,44 +996,48 @@ def cleanup_old_signal_history(max_age_hours: int = 24) -> int: # Device Correlation Functions # ============================================================================= -def add_correlation( - wifi_mac: str, - bt_mac: str, - confidence: float, - metadata: dict | None = None -) -> None: + +def add_correlation(wifi_mac: str, bt_mac: str, confidence: float, metadata: dict | None = None) -> None: """Add or update a device correlation.""" with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO device_correlations (wifi_mac, bt_mac, confidence, metadata, last_seen) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(wifi_mac, bt_mac) DO UPDATE SET confidence = excluded.confidence, last_seen = CURRENT_TIMESTAMP, metadata = excluded.metadata - ''', (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None)) + """, + (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None), + ) def get_correlations(min_confidence: float = 0.5) -> list[dict]: """Get all device correlations above minimum confidence.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT wifi_mac, bt_mac, confidence, first_seen, last_seen, metadata FROM device_correlations WHERE confidence >= ? ORDER BY confidence DESC - ''', (min_confidence,)) + """, + (min_confidence,), + ) results = [] for row in cursor: - results.append({ - 'wifi_mac': row['wifi_mac'], - 'bt_mac': row['bt_mac'], - 'confidence': row['confidence'], - 'first_seen': row['first_seen'], - 'last_seen': row['last_seen'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None - }) + results.append( + { + "wifi_mac": row["wifi_mac"], + "bt_mac": row["bt_mac"], + "confidence": row["confidence"], + "first_seen": row["first_seen"], + "last_seen": row["last_seen"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, + } + ) return results @@ -1031,6 +1046,7 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]: # TSCM Functions # ============================================================================= + def create_tscm_baseline( name: str, location: str | None = None, @@ -1039,7 +1055,7 @@ def create_tscm_baseline( wifi_clients: list | None = None, bt_devices: list | None = None, rf_frequencies: list | None = None, - gps_coords: dict | None = None + gps_coords: dict | None = None, ) -> int: """ Create a new TSCM baseline. @@ -1048,57 +1064,63 @@ def create_tscm_baseline( The ID of the created baseline """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_baselines (name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - location, - description, - json.dumps(wifi_networks) if wifi_networks else None, - json.dumps(wifi_clients) if wifi_clients else None, - json.dumps(bt_devices) if bt_devices else None, - json.dumps(rf_frequencies) if rf_frequencies else None, - json.dumps(gps_coords) if gps_coords else None - )) + """, + ( + name, + location, + description, + json.dumps(wifi_networks) if wifi_networks else None, + json.dumps(wifi_clients) if wifi_clients else None, + json.dumps(bt_devices) if bt_devices else None, + json.dumps(rf_frequencies) if rf_frequencies else None, + json.dumps(gps_coords) if gps_coords else None, + ), + ) return cursor.lastrowid def get_tscm_baseline(baseline_id: int) -> dict | None: """Get a specific TSCM baseline by ID.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_baselines WHERE id = ? - ''', (baseline_id,)) + """, + (baseline_id,), + ) row = cursor.fetchone() if row is None: return None return { - 'id': row['id'], - 'name': row['name'], - 'location': row['location'], - 'description': row['description'], - 'created_at': row['created_at'], - 'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [], - 'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [], - 'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [], - 'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [], - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, - 'is_active': bool(row['is_active']) + "id": row["id"], + "name": row["name"], + "location": row["location"], + "description": row["description"], + "created_at": row["created_at"], + "wifi_networks": json.loads(row["wifi_networks"]) if row["wifi_networks"] else [], + "wifi_clients": json.loads(row["wifi_clients"]) if row["wifi_clients"] else [], + "bt_devices": json.loads(row["bt_devices"]) if row["bt_devices"] else [], + "rf_frequencies": json.loads(row["rf_frequencies"]) if row["rf_frequencies"] else [], + "gps_coords": json.loads(row["gps_coords"]) if row["gps_coords"] else None, + "is_active": bool(row["is_active"]), } def get_all_tscm_baselines() -> list[dict]: """Get all TSCM baselines.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT id, name, location, description, created_at, is_active FROM tscm_baselines ORDER BY created_at DESC - ''') + """) return [dict(row) for row in cursor] @@ -1106,27 +1128,24 @@ def get_all_tscm_baselines() -> list[dict]: def get_active_tscm_baseline() -> dict | None: """Get the currently active TSCM baseline.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT * FROM tscm_baselines WHERE is_active = 1 LIMIT 1 - ''') + """) row = cursor.fetchone() if row is None: return None - return get_tscm_baseline(row['id']) + return get_tscm_baseline(row["id"]) def set_active_tscm_baseline(baseline_id: int) -> bool: """Set a baseline as active (deactivates others).""" with get_db() as conn: # Deactivate all - conn.execute('UPDATE tscm_baselines SET is_active = 0') + conn.execute("UPDATE tscm_baselines SET is_active = 0") # Activate selected - cursor = conn.execute( - 'UPDATE tscm_baselines SET is_active = 1 WHERE id = ?', - (baseline_id,) - ) + cursor = conn.execute("UPDATE tscm_baselines SET is_active = 1 WHERE id = ?", (baseline_id,)) return cursor.rowcount > 0 @@ -1135,23 +1154,23 @@ def update_tscm_baseline( wifi_networks: list | None = None, wifi_clients: list | None = None, bt_devices: list | None = None, - rf_frequencies: list | None = None + rf_frequencies: list | None = None, ) -> bool: """Update baseline device lists.""" updates = [] params = [] if wifi_networks is not None: - updates.append('wifi_networks = ?') + updates.append("wifi_networks = ?") params.append(json.dumps(wifi_networks)) if wifi_clients is not None: - updates.append('wifi_clients = ?') + updates.append("wifi_clients = ?") params.append(json.dumps(wifi_clients)) if bt_devices is not None: - updates.append('bt_devices = ?') + updates.append("bt_devices = ?") params.append(json.dumps(bt_devices)) if rf_frequencies is not None: - updates.append('rf_frequencies = ?') + updates.append("rf_frequencies = ?") params.append(json.dumps(rf_frequencies)) if not updates: @@ -1160,20 +1179,14 @@ def update_tscm_baseline( params.append(baseline_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_baselines SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_baselines SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 def delete_tscm_baseline(baseline_id: int) -> bool: """Delete a TSCM baseline.""" with get_db() as conn: - cursor = conn.execute( - 'DELETE FROM tscm_baselines WHERE id = ?', - (baseline_id,) - ) + cursor = conn.execute("DELETE FROM tscm_baselines WHERE id = ?", (baseline_id,)) return cursor.rowcount > 0 @@ -1182,7 +1195,7 @@ def create_tscm_sweep( baseline_id: int | None = None, wifi_enabled: bool = True, bt_enabled: bool = True, - rf_enabled: bool = True + rf_enabled: bool = True, ) -> int: """ Create a new TSCM sweep session. @@ -1191,11 +1204,14 @@ def create_tscm_sweep( The ID of the created sweep """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_sweeps (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled) VALUES (?, ?, ?, ?, ?) - ''', (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled)) + """, + (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled), + ) return cursor.lastrowid @@ -1205,26 +1221,26 @@ def update_tscm_sweep( results: dict | None = None, anomalies: list | None = None, threats_found: int | None = None, - completed: bool = False + completed: bool = False, ) -> bool: """Update a TSCM sweep.""" updates = [] params = [] if status is not None: - updates.append('status = ?') + updates.append("status = ?") params.append(status) if results is not None: - updates.append('results = ?') + updates.append("results = ?") params.append(json.dumps(results)) if anomalies is not None: - updates.append('anomalies = ?') + updates.append("anomalies = ?") params.append(json.dumps(anomalies)) if threats_found is not None: - updates.append('threats_found = ?') + updates.append("threats_found = ?") params.append(threats_found) if completed: - updates.append('completed_at = CURRENT_TIMESTAMP') + updates.append("completed_at = CURRENT_TIMESTAMP") if not updates: return False @@ -1232,35 +1248,32 @@ def update_tscm_sweep( params.append(sweep_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_sweeps SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_sweeps SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 def get_tscm_sweep(sweep_id: int) -> dict | None: """Get a specific TSCM sweep by ID.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM tscm_sweeps WHERE id = ?', (sweep_id,)) + cursor = conn.execute("SELECT * FROM tscm_sweeps WHERE id = ?", (sweep_id,)) row = cursor.fetchone() if row is None: return None return { - 'id': row['id'], - 'baseline_id': row['baseline_id'], - 'started_at': row['started_at'], - 'completed_at': row['completed_at'], - 'status': row['status'], - 'sweep_type': row['sweep_type'], - 'wifi_enabled': bool(row['wifi_enabled']), - 'bt_enabled': bool(row['bt_enabled']), - 'rf_enabled': bool(row['rf_enabled']), - 'results': json.loads(row['results']) if row['results'] else None, - 'anomalies': json.loads(row['anomalies']) if row['anomalies'] else [], - 'threats_found': row['threats_found'] + "id": row["id"], + "baseline_id": row["baseline_id"], + "started_at": row["started_at"], + "completed_at": row["completed_at"], + "status": row["status"], + "sweep_type": row["sweep_type"], + "wifi_enabled": bool(row["wifi_enabled"]), + "bt_enabled": bool(row["bt_enabled"]), + "rf_enabled": bool(row["rf_enabled"]), + "results": json.loads(row["results"]) if row["results"] else None, + "anomalies": json.loads(row["anomalies"]) if row["anomalies"] else [], + "threats_found": row["threats_found"], } @@ -1274,7 +1287,7 @@ def add_tscm_threat( signal_strength: int | None = None, frequency: float | None = None, details: dict | None = None, - gps_coords: dict | None = None + gps_coords: dict | None = None, ) -> int: """ Add a detected threat to a TSCM sweep. @@ -1283,69 +1296,80 @@ def add_tscm_threat( The ID of the created threat """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_threats (sweep_id, threat_type, severity, source, identifier, name, signal_strength, frequency, details, gps_coords) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - sweep_id, threat_type, severity, source, identifier, name, - signal_strength, frequency, - json.dumps(details) if details else None, - json.dumps(gps_coords) if gps_coords else None - )) + """, + ( + sweep_id, + threat_type, + severity, + source, + identifier, + name, + signal_strength, + frequency, + json.dumps(details) if details else None, + json.dumps(gps_coords) if gps_coords else None, + ), + ) return cursor.lastrowid def get_tscm_threats( - sweep_id: int | None = None, - severity: str | None = None, - acknowledged: bool | None = None, - limit: int = 100 + sweep_id: int | None = None, severity: str | None = None, acknowledged: bool | None = None, limit: int = 100 ) -> list[dict]: """Get TSCM threats with optional filters.""" conditions = [] params = [] if sweep_id is not None: - conditions.append('sweep_id = ?') + conditions.append("sweep_id = ?") params.append(sweep_id) if severity is not None: - conditions.append('severity = ?') + conditions.append("severity = ?") params.append(severity) if acknowledged is not None: - conditions.append('acknowledged = ?') + conditions.append("acknowledged = ?") params.append(1 if acknowledged else 0) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_threats {where_clause} ORDER BY detected_at DESC LIMIT ? - ''', params) + """, + params, + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'sweep_id': row['sweep_id'], - 'detected_at': row['detected_at'], - 'threat_type': row['threat_type'], - 'severity': row['severity'], - 'source': row['source'], - 'identifier': row['identifier'], - 'name': row['name'], - 'signal_strength': row['signal_strength'], - 'frequency': row['frequency'], - 'details': json.loads(row['details']) if row['details'] else None, - 'acknowledged': bool(row['acknowledged']), - 'notes': row['notes'], - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None - }) + results.append( + { + "id": row["id"], + "sweep_id": row["sweep_id"], + "detected_at": row["detected_at"], + "threat_type": row["threat_type"], + "severity": row["severity"], + "source": row["source"], + "identifier": row["identifier"], + "name": row["name"], + "signal_strength": row["signal_strength"], + "frequency": row["frequency"], + "details": json.loads(row["details"]) if row["details"] else None, + "acknowledged": bool(row["acknowledged"]), + "notes": row["notes"], + "gps_coords": json.loads(row["gps_coords"]) if row["gps_coords"] else None, + } + ) return results @@ -1355,31 +1379,27 @@ def acknowledge_tscm_threat(threat_id: int, notes: str | None = None) -> bool: with get_db() as conn: if notes: cursor = conn.execute( - 'UPDATE tscm_threats SET acknowledged = 1, notes = ? WHERE id = ?', - (notes, threat_id) + "UPDATE tscm_threats SET acknowledged = 1, notes = ? WHERE id = ?", (notes, threat_id) ) else: - cursor = conn.execute( - 'UPDATE tscm_threats SET acknowledged = 1 WHERE id = ?', - (threat_id,) - ) + cursor = conn.execute("UPDATE tscm_threats SET acknowledged = 1 WHERE id = ?", (threat_id,)) return cursor.rowcount > 0 def get_tscm_threat_summary() -> dict: """Get summary counts of threats by severity.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT severity, COUNT(*) as count FROM tscm_threats WHERE acknowledged = 0 GROUP BY severity - ''') + """) - summary = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'total': 0} + summary = {"critical": 0, "high": 0, "medium": 0, "low": 0, "total": 0} for row in cursor: - summary[row['severity']] = row['count'] - summary['total'] += row['count'] + summary[row["severity"]] = row["count"] + summary["total"] += row["count"] return summary @@ -1388,6 +1408,7 @@ def get_tscm_threat_summary() -> dict: # TSCM Device Timeline Functions # ============================================================================= + def add_device_timeline_entry( device_identifier: str, protocol: str, @@ -1396,60 +1417,73 @@ def add_device_timeline_entry( presence: bool = True, channel: int | None = None, frequency: float | None = None, - attributes: dict | None = None + attributes: dict | None = None, ) -> int: """Add a device timeline observation entry.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_device_timelines (device_identifier, protocol, sweep_id, rssi, presence, channel, frequency, attributes) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - device_identifier, protocol, sweep_id, rssi, presence, - channel, frequency, json.dumps(attributes) if attributes else None - )) + """, + ( + device_identifier, + protocol, + sweep_id, + rssi, + presence, + channel, + frequency, + json.dumps(attributes) if attributes else None, + ), + ) return cursor.lastrowid -def get_device_timeline( - device_identifier: str, - limit: int = 100, - since_hours: int = 24 -) -> list[dict]: +def get_device_timeline(device_identifier: str, limit: int = 100, since_hours: int = 24) -> list[dict]: """Get timeline entries for a device.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_device_timelines WHERE device_identifier = ? AND timestamp > datetime('now', ?) ORDER BY timestamp DESC LIMIT ? - ''', (device_identifier, f'-{since_hours} hours', limit)) + """, + (device_identifier, f"-{since_hours} hours", limit), + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'device_identifier': row['device_identifier'], - 'protocol': row['protocol'], - 'sweep_id': row['sweep_id'], - 'timestamp': row['timestamp'], - 'rssi': row['rssi'], - 'presence': bool(row['presence']), - 'channel': row['channel'], - 'frequency': row['frequency'], - 'attributes': json.loads(row['attributes']) if row['attributes'] else None - }) + results.append( + { + "id": row["id"], + "device_identifier": row["device_identifier"], + "protocol": row["protocol"], + "sweep_id": row["sweep_id"], + "timestamp": row["timestamp"], + "rssi": row["rssi"], + "presence": bool(row["presence"]), + "channel": row["channel"], + "frequency": row["frequency"], + "attributes": json.loads(row["attributes"]) if row["attributes"] else None, + } + ) return list(reversed(results)) def cleanup_old_timeline_entries(max_age_hours: int = 72) -> int: """Remove old timeline entries.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM tscm_device_timelines WHERE timestamp < datetime('now', ?) - ''', (f'-{max_age_hours} hours',)) + """, + (f"-{max_age_hours} hours",), + ) return cursor.rowcount @@ -1457,20 +1491,22 @@ def cleanup_old_timeline_entries(max_age_hours: int = 72) -> int: # TSCM Known-Good Registry Functions # ============================================================================= + def add_known_device( identifier: str, protocol: str, name: str | None = None, description: str | None = None, location: str | None = None, - scope: str = 'global', + scope: str = "global", added_by: str | None = None, score_modifier: int = -2, - metadata: dict | None = None + metadata: dict | None = None, ) -> int: """Add a device to the known-good registry.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_known_devices (identifier, protocol, name, description, location, scope, added_by, score_modifier, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -1482,77 +1518,83 @@ def add_known_device( score_modifier = excluded.score_modifier, metadata = excluded.metadata, last_verified = CURRENT_TIMESTAMP - ''', ( - identifier.upper(), protocol, name, description, location, - scope, added_by, score_modifier, json.dumps(metadata) if metadata else None - )) + """, + ( + identifier.upper(), + protocol, + name, + description, + location, + scope, + added_by, + score_modifier, + json.dumps(metadata) if metadata else None, + ), + ) return cursor.lastrowid def get_known_device(identifier: str) -> dict | None: """Get a known device by identifier.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM tscm_known_devices WHERE identifier = ?', - (identifier.upper(),) - ) + cursor = conn.execute("SELECT * FROM tscm_known_devices WHERE identifier = ?", (identifier.upper(),)) row = cursor.fetchone() if not row: return None return { - 'id': row['id'], - 'identifier': row['identifier'], - 'protocol': row['protocol'], - 'name': row['name'], - 'description': row['description'], - 'location': row['location'], - 'scope': row['scope'], - 'added_at': row['added_at'], - 'added_by': row['added_by'], - 'last_verified': row['last_verified'], - 'score_modifier': row['score_modifier'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None + "id": row["id"], + "identifier": row["identifier"], + "protocol": row["protocol"], + "name": row["name"], + "description": row["description"], + "location": row["location"], + "scope": row["scope"], + "added_at": row["added_at"], + "added_by": row["added_by"], + "last_verified": row["last_verified"], + "score_modifier": row["score_modifier"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, } -def get_all_known_devices( - location: str | None = None, - scope: str | None = None -) -> list[dict]: +def get_all_known_devices(location: str | None = None, scope: str | None = None) -> list[dict]: """Get all known devices, optionally filtered by location or scope.""" conditions = [] params = [] if location: - conditions.append('(location = ? OR scope = ?)') - params.extend([location, 'global']) + conditions.append("(location = ? OR scope = ?)") + params.extend([location, "global"]) if scope: - conditions.append('scope = ?') + conditions.append("scope = ?") params.append(scope) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_known_devices {where_clause} ORDER BY added_at DESC - ''', params) + """, + params, + ) return [ { - 'id': row['id'], - 'identifier': row['identifier'], - 'protocol': row['protocol'], - 'name': row['name'], - 'description': row['description'], - 'location': row['location'], - 'scope': row['scope'], - 'added_at': row['added_at'], - 'added_by': row['added_by'], - 'last_verified': row['last_verified'], - 'score_modifier': row['score_modifier'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None + "id": row["id"], + "identifier": row["identifier"], + "protocol": row["protocol"], + "name": row["name"], + "description": row["description"], + "location": row["location"], + "scope": row["scope"], + "added_at": row["added_at"], + "added_by": row["added_by"], + "last_verified": row["last_verified"], + "score_modifier": row["score_modifier"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, } for row in cursor ] @@ -1561,10 +1603,7 @@ def get_all_known_devices( def delete_known_device(identifier: str) -> bool: """Remove a device from the known-good registry.""" with get_db() as conn: - cursor = conn.execute( - 'DELETE FROM tscm_known_devices WHERE identifier = ?', - (identifier.upper(),) - ) + cursor = conn.execute("DELETE FROM tscm_known_devices WHERE identifier = ?", (identifier.upper(),)) return cursor.rowcount > 0 @@ -1572,10 +1611,11 @@ def delete_known_device(identifier: str) -> bool: # TSCM Schedule Functions # ============================================================================= + def create_tscm_schedule( name: str, cron_expression: str, - sweep_type: str = 'standard', + sweep_type: str = "standard", baseline_id: int | None = None, zone_name: str | None = None, enabled: bool = True, @@ -1586,59 +1626,59 @@ def create_tscm_schedule( ) -> int: """Create a new TSCM sweep schedule.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_schedules (name, baseline_id, zone_name, cron_expression, sweep_type, enabled, last_run, next_run, notify_on_threat, notify_email) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - baseline_id, - zone_name, - cron_expression, - sweep_type, - 1 if enabled else 0, - last_run, - next_run, - 1 if notify_on_threat else 0, - notify_email, - )) + """, + ( + name, + baseline_id, + zone_name, + cron_expression, + sweep_type, + 1 if enabled else 0, + last_run, + next_run, + 1 if notify_on_threat else 0, + notify_email, + ), + ) return cursor.lastrowid def get_tscm_schedule(schedule_id: int) -> dict | None: """Get a TSCM schedule by ID.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM tscm_schedules WHERE id = ?', - (schedule_id,) - ) + cursor = conn.execute("SELECT * FROM tscm_schedules WHERE id = ?", (schedule_id,)) row = cursor.fetchone() return dict(row) if row else None -def get_all_tscm_schedules( - enabled: bool | None = None, - limit: int = 200 -) -> list[dict]: +def get_all_tscm_schedules(enabled: bool | None = None, limit: int = 200) -> list[dict]: """Get all TSCM schedules.""" conditions = [] params = [] if enabled is not None: - conditions.append('enabled = ?') + conditions.append("enabled = ?") params.append(1 if enabled else 0) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_schedules {where_clause} ORDER BY id DESC LIMIT ? - ''', params) + """, + params, + ) return [dict(row) for row in cursor] @@ -1651,26 +1691,20 @@ def update_tscm_schedule(schedule_id: int, **fields) -> bool: params = [] for key, value in fields.items(): - updates.append(f'{key} = ?') + updates.append(f"{key} = ?") params.append(value) params.append(schedule_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_schedules SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 def delete_tscm_schedule(schedule_id: int) -> bool: """Delete a TSCM schedule.""" with get_db() as conn: - cursor = conn.execute( - 'DELETE FROM tscm_schedules WHERE id = ?', - (schedule_id,) - ) + cursor = conn.execute("DELETE FROM tscm_schedules WHERE id = ?", (schedule_id,)) return cursor.rowcount > 0 @@ -1678,23 +1712,23 @@ def is_known_good_device(identifier: str, location: str | None = None) -> dict | """Check if a device is in the known-good registry for a location.""" with get_db() as conn: if location: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_known_devices WHERE identifier = ? AND (location = ? OR scope = 'global') - ''', (identifier.upper(), location)) - else: - cursor = conn.execute( - 'SELECT * FROM tscm_known_devices WHERE identifier = ?', - (identifier.upper(),) + """, + (identifier.upper(), location), ) + else: + cursor = conn.execute("SELECT * FROM tscm_known_devices WHERE identifier = ?", (identifier.upper(),)) row = cursor.fetchone() if not row: return None return { - 'identifier': row['identifier'], - 'name': row['name'], - 'score_modifier': row['score_modifier'], - 'scope': row['scope'] + "identifier": row["identifier"], + "name": row["name"], + "score_modifier": row["score_modifier"], + "scope": row["scope"], } @@ -1702,103 +1736,115 @@ def is_known_good_device(identifier: str, location: str | None = None) -> dict | # TSCM Case Functions # ============================================================================= + def create_tscm_case( name: str, description: str | None = None, location: str | None = None, - priority: str = 'normal', + priority: str = "normal", created_by: str | None = None, - metadata: dict | None = None + metadata: dict | None = None, ) -> int: """Create a new TSCM case.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_cases (name, description, location, priority, created_by, metadata) VALUES (?, ?, ?, ?, ?, ?) - ''', (name, description, location, priority, created_by, - json.dumps(metadata) if metadata else None)) + """, + (name, description, location, priority, created_by, json.dumps(metadata) if metadata else None), + ) return cursor.lastrowid def get_tscm_case(case_id: int) -> dict | None: """Get a TSCM case by ID.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM tscm_cases WHERE id = ?', (case_id,)) + cursor = conn.execute("SELECT * FROM tscm_cases WHERE id = ?", (case_id,)) row = cursor.fetchone() if not row: return None case = { - 'id': row['id'], - 'name': row['name'], - 'description': row['description'], - 'location': row['location'], - 'status': row['status'], - 'priority': row['priority'], - 'created_at': row['created_at'], - 'updated_at': row['updated_at'], - 'closed_at': row['closed_at'], - 'created_by': row['created_by'], - 'assigned_to': row['assigned_to'], - 'notes': row['notes'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else None, - 'sweeps': [], - 'threats': [], - 'case_notes': [] + "id": row["id"], + "name": row["name"], + "description": row["description"], + "location": row["location"], + "status": row["status"], + "priority": row["priority"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "closed_at": row["closed_at"], + "created_by": row["created_by"], + "assigned_to": row["assigned_to"], + "notes": row["notes"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else None, + "sweeps": [], + "threats": [], + "case_notes": [], } # Get linked sweeps - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT s.* FROM tscm_sweeps s JOIN tscm_case_sweeps cs ON s.id = cs.sweep_id WHERE cs.case_id = ? ORDER BY s.started_at DESC - ''', (case_id,)) - case['sweeps'] = [dict(row) for row in cursor] + """, + (case_id,), + ) + case["sweeps"] = [dict(row) for row in cursor] # Get linked threats - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT t.* FROM tscm_threats t JOIN tscm_case_threats ct ON t.id = ct.threat_id WHERE ct.case_id = ? ORDER BY t.detected_at DESC - ''', (case_id,)) - case['threats'] = [dict(row) for row in cursor] + """, + (case_id,), + ) + case["threats"] = [dict(row) for row in cursor] # Get case notes - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_case_notes WHERE case_id = ? ORDER BY created_at DESC - ''', (case_id,)) - case['case_notes'] = [dict(row) for row in cursor] + """, + (case_id,), + ) + case["case_notes"] = [dict(row) for row in cursor] return case -def get_all_tscm_cases( - status: str | None = None, - limit: int = 50 -) -> list[dict]: +def get_all_tscm_cases(status: str | None = None, limit: int = 50) -> list[dict]: """Get all TSCM cases.""" conditions = [] params = [] if status: - conditions.append('status = ?') + conditions.append("status = ?") params.append(status) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM tscm_cases {where_clause} ORDER BY updated_at DESC LIMIT ? - ''', params) + """, + params, + ) return [dict(row) for row in cursor] @@ -1807,34 +1853,31 @@ def update_tscm_case( status: str | None = None, priority: str | None = None, assigned_to: str | None = None, - notes: str | None = None + notes: str | None = None, ) -> bool: """Update a TSCM case.""" - updates = ['updated_at = CURRENT_TIMESTAMP'] + updates = ["updated_at = CURRENT_TIMESTAMP"] params = [] if status: - updates.append('status = ?') + updates.append("status = ?") params.append(status) - if status == 'closed': - updates.append('closed_at = CURRENT_TIMESTAMP') + if status == "closed": + updates.append("closed_at = CURRENT_TIMESTAMP") if priority: - updates.append('priority = ?') + updates.append("priority = ?") params.append(priority) if assigned_to is not None: - updates.append('assigned_to = ?') + updates.append("assigned_to = ?") params.append(assigned_to) if notes is not None: - updates.append('notes = ?') + updates.append("notes = ?") params.append(notes) params.append(case_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE tscm_cases SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE tscm_cases SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 @@ -1842,14 +1885,14 @@ def add_sweep_to_case(case_id: int, sweep_id: int) -> bool: """Link a sweep to a case.""" with get_db() as conn: try: - conn.execute(''' + conn.execute( + """ INSERT INTO tscm_case_sweeps (case_id, sweep_id) VALUES (?, ?) - ''', (case_id, sweep_id)) - conn.execute( - 'UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', - (case_id,) + """, + (case_id, sweep_id), ) + conn.execute("UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (case_id,)) return True except sqlite3.IntegrityError: return False @@ -1859,35 +1902,30 @@ def add_threat_to_case(case_id: int, threat_id: int) -> bool: """Link a threat to a case.""" with get_db() as conn: try: - conn.execute(''' + conn.execute( + """ INSERT INTO tscm_case_threats (case_id, threat_id) VALUES (?, ?) - ''', (case_id, threat_id)) - conn.execute( - 'UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', - (case_id,) + """, + (case_id, threat_id), ) + conn.execute("UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (case_id,)) return True except sqlite3.IntegrityError: return False -def add_case_note( - case_id: int, - content: str, - note_type: str = 'general', - created_by: str | None = None -) -> int: +def add_case_note(case_id: int, content: str, note_type: str = "general", created_by: str | None = None) -> int: """Add a note to a case.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_case_notes (case_id, content, note_type, created_by) VALUES (?, ?, ?, ?) - ''', (case_id, content, note_type, created_by)) - conn.execute( - 'UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', - (case_id,) + """, + (case_id, content, note_type, created_by), ) + conn.execute("UPDATE tscm_cases SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (case_id,)) return cursor.lastrowid @@ -1895,29 +1933,33 @@ def add_case_note( # TSCM Meeting Window Functions # ============================================================================= + def start_meeting_window( - sweep_id: int | None = None, - name: str | None = None, - location: str | None = None, - notes: str | None = None + sweep_id: int | None = None, name: str | None = None, location: str | None = None, notes: str | None = None ) -> int: """Start a meeting window.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_meeting_windows (sweep_id, name, start_time, location, notes) VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?) - ''', (sweep_id, name, location, notes)) + """, + (sweep_id, name, location, notes), + ) return cursor.lastrowid def end_meeting_window(meeting_id: int) -> bool: """End a meeting window.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ UPDATE tscm_meeting_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ? AND end_time IS NULL - ''', (meeting_id,)) + """, + (meeting_id,), + ) return cursor.rowcount > 0 @@ -1925,17 +1967,20 @@ def get_active_meeting_window(sweep_id: int | None = None) -> dict | None: """Get currently active meeting window.""" with get_db() as conn: if sweep_id: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_meeting_windows WHERE sweep_id = ? AND end_time IS NULL ORDER BY start_time DESC LIMIT 1 - ''', (sweep_id,)) + """, + (sweep_id,), + ) else: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT * FROM tscm_meeting_windows WHERE end_time IS NULL ORDER BY start_time DESC LIMIT 1 - ''') + """) row = cursor.fetchone() if row: return dict(row) @@ -1945,11 +1990,14 @@ def get_active_meeting_window(sweep_id: int | None = None) -> dict | None: def get_meeting_windows(sweep_id: int) -> list[dict]: """Get all meeting windows for a sweep.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT * FROM tscm_meeting_windows WHERE sweep_id = ? ORDER BY start_time - ''', (sweep_id,)) + """, + (sweep_id,), + ) return [dict(row) for row in cursor] @@ -1957,40 +2005,36 @@ def get_meeting_windows(sweep_id: int) -> list[dict]: # TSCM Sweep Capabilities Functions # ============================================================================= -def save_sweep_capabilities( - sweep_id: int, - capabilities: dict, - limitations: list[str] | None = None -) -> int: + +def save_sweep_capabilities(sweep_id: int, capabilities: dict, limitations: list[str] | None = None) -> int: """Save sweep capabilities snapshot.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO tscm_sweep_capabilities (sweep_id, capabilities, limitations) VALUES (?, ?, ?) ON CONFLICT(sweep_id) DO UPDATE SET capabilities = excluded.capabilities, limitations = excluded.limitations, recorded_at = CURRENT_TIMESTAMP - ''', (sweep_id, json.dumps(capabilities), - json.dumps(limitations) if limitations else None)) + """, + (sweep_id, json.dumps(capabilities), json.dumps(limitations) if limitations else None), + ) return cursor.lastrowid def get_sweep_capabilities(sweep_id: int) -> dict | None: """Get capabilities for a sweep.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM tscm_sweep_capabilities WHERE sweep_id = ?', - (sweep_id,) - ) + cursor = conn.execute("SELECT * FROM tscm_sweep_capabilities WHERE sweep_id = ?", (sweep_id,)) row = cursor.fetchone() if not row: return None return { - 'sweep_id': row['sweep_id'], - 'capabilities': json.loads(row['capabilities']), - 'limitations': json.loads(row['limitations']) if row['limitations'] else [], - 'recorded_at': row['recorded_at'] + "sweep_id": row["sweep_id"], + "capabilities": json.loads(row["capabilities"]), + "limitations": json.loads(row["limitations"]) if row["limitations"] else [], + "recorded_at": row["recorded_at"], } @@ -1998,6 +2042,7 @@ def get_sweep_capabilities(sweep_id: int) -> dict | None: # DSC (Digital Selective Calling) Functions # ============================================================================= + def store_dsc_alert( source_mmsi: str, format_code: str, @@ -2007,7 +2052,7 @@ def store_dsc_alert( nature_of_distress: str | None = None, latitude: float | None = None, longitude: float | None = None, - raw_message: str | None = None + raw_message: str | None = None, ) -> int: """ Store a DSC alert (typically DISTRESS or URGENCY) to permanent storage. @@ -2016,15 +2061,25 @@ def store_dsc_alert( The ID of the created alert """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO dsc_alerts (source_mmsi, source_name, dest_mmsi, format_code, category, nature_of_distress, latitude, longitude, raw_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - source_mmsi, source_name, dest_mmsi, format_code, category, - nature_of_distress, latitude, longitude, raw_message - )) + """, + ( + source_mmsi, + source_name, + dest_mmsi, + format_code, + category, + nature_of_distress, + latitude, + longitude, + raw_message, + ), + ) return cursor.lastrowid @@ -2033,7 +2088,7 @@ def get_dsc_alerts( acknowledged: bool | None = None, source_mmsi: str | None = None, limit: int = 100, - offset: int = 0 + offset: int = 0, ) -> list[dict]: """ Get DSC alerts with optional filters. @@ -2052,70 +2107,72 @@ def get_dsc_alerts( params = [] if category is not None: - conditions.append('category = ?') + conditions.append("category = ?") params.append(category) if acknowledged is not None: - conditions.append('acknowledged = ?') + conditions.append("acknowledged = ?") params.append(1 if acknowledged else 0) if source_mmsi is not None: - conditions.append('source_mmsi = ?') + conditions.append("source_mmsi = ?") params.append(source_mmsi) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.extend([limit, offset]) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT * FROM dsc_alerts {where_clause} ORDER BY received_at DESC LIMIT ? OFFSET ? - ''', params) + """, + params, + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'received_at': row['received_at'], - 'source_mmsi': row['source_mmsi'], - 'source_name': row['source_name'], - 'dest_mmsi': row['dest_mmsi'], - 'format_code': row['format_code'], - 'category': row['category'], - 'nature_of_distress': row['nature_of_distress'], - 'latitude': row['latitude'], - 'longitude': row['longitude'], - 'raw_message': row['raw_message'], - 'acknowledged': bool(row['acknowledged']), - 'notes': row['notes'] - }) + results.append( + { + "id": row["id"], + "received_at": row["received_at"], + "source_mmsi": row["source_mmsi"], + "source_name": row["source_name"], + "dest_mmsi": row["dest_mmsi"], + "format_code": row["format_code"], + "category": row["category"], + "nature_of_distress": row["nature_of_distress"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "raw_message": row["raw_message"], + "acknowledged": bool(row["acknowledged"]), + "notes": row["notes"], + } + ) return results def get_dsc_alert(alert_id: int) -> dict | None: """Get a specific DSC alert by ID.""" with get_db() as conn: - cursor = conn.execute( - 'SELECT * FROM dsc_alerts WHERE id = ?', - (alert_id,) - ) + cursor = conn.execute("SELECT * FROM dsc_alerts WHERE id = ?", (alert_id,)) row = cursor.fetchone() if not row: return None return { - 'id': row['id'], - 'received_at': row['received_at'], - 'source_mmsi': row['source_mmsi'], - 'source_name': row['source_name'], - 'dest_mmsi': row['dest_mmsi'], - 'format_code': row['format_code'], - 'category': row['category'], - 'nature_of_distress': row['nature_of_distress'], - 'latitude': row['latitude'], - 'longitude': row['longitude'], - 'raw_message': row['raw_message'], - 'acknowledged': bool(row['acknowledged']), - 'notes': row['notes'] + "id": row["id"], + "received_at": row["received_at"], + "source_mmsi": row["source_mmsi"], + "source_name": row["source_name"], + "dest_mmsi": row["dest_mmsi"], + "format_code": row["format_code"], + "category": row["category"], + "nature_of_distress": row["nature_of_distress"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "raw_message": row["raw_message"], + "acknowledged": bool(row["acknowledged"]), + "notes": row["notes"], } @@ -2132,34 +2189,28 @@ def acknowledge_dsc_alert(alert_id: int, notes: str | None = None) -> bool: """ with get_db() as conn: if notes: - cursor = conn.execute( - 'UPDATE dsc_alerts SET acknowledged = 1, notes = ? WHERE id = ?', - (notes, alert_id) - ) + cursor = conn.execute("UPDATE dsc_alerts SET acknowledged = 1, notes = ? WHERE id = ?", (notes, alert_id)) else: - cursor = conn.execute( - 'UPDATE dsc_alerts SET acknowledged = 1 WHERE id = ?', - (alert_id,) - ) + cursor = conn.execute("UPDATE dsc_alerts SET acknowledged = 1 WHERE id = ?", (alert_id,)) return cursor.rowcount > 0 def get_dsc_alert_summary() -> dict: """Get summary counts of DSC alerts by category.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT category, COUNT(*) as count FROM dsc_alerts WHERE acknowledged = 0 GROUP BY category - ''') + """) - summary = {'distress': 0, 'urgency': 0, 'safety': 0, 'routine': 0, 'total': 0} + summary = {"distress": 0, "urgency": 0, "safety": 0, "routine": 0, "total": 0} for row in cursor: - cat = row['category'].lower() + cat = row["category"].lower() if cat in summary: - summary[cat] = row['count'] - summary['total'] += row['count'] + summary[cat] = row["count"] + summary["total"] += row["count"] return summary @@ -2175,11 +2226,14 @@ def cleanup_old_dsc_alerts(max_age_days: int = 30) -> int: Number of deleted alerts """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM dsc_alerts WHERE acknowledged = 1 AND received_at < datetime('now', ?) - ''', (f'-{max_age_days} days',)) + """, + (f"-{max_age_days} days",), + ) return cursor.rowcount @@ -2187,6 +2241,7 @@ def cleanup_old_dsc_alerts(max_age_days: int = 30) -> int: # Remote Agent Functions (for distributed/controller mode) # ============================================================================= + def create_agent( name: str, base_url: str, @@ -2194,7 +2249,7 @@ def create_agent( description: str | None = None, capabilities: dict | None = None, interfaces: dict | None = None, - gps_coords: dict | None = None + gps_coords: dict | None = None, ) -> int: """ Create a new remote agent. @@ -2203,26 +2258,29 @@ def create_agent( The ID of the created agent """ with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO agents (name, base_url, api_key, description, capabilities, interfaces, gps_coords) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - base_url.rstrip('/'), - api_key, - description, - json.dumps(capabilities) if capabilities else None, - json.dumps(interfaces) if interfaces else None, - json.dumps(gps_coords) if gps_coords else None - )) + """, + ( + name, + base_url.rstrip("/"), + api_key, + description, + json.dumps(capabilities) if capabilities else None, + json.dumps(interfaces) if interfaces else None, + json.dumps(gps_coords) if gps_coords else None, + ), + ) return cursor.lastrowid def get_agent(agent_id: int) -> dict | None: """Get an agent by ID.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM agents WHERE id = ?', (agent_id,)) + cursor = conn.execute("SELECT * FROM agents WHERE id = ?", (agent_id,)) row = cursor.fetchone() if not row: return None @@ -2232,7 +2290,7 @@ def get_agent(agent_id: int) -> dict | None: def get_agent_by_name(name: str) -> dict | None: """Get an agent by name.""" with get_db() as conn: - cursor = conn.execute('SELECT * FROM agents WHERE name = ?', (name,)) + cursor = conn.execute("SELECT * FROM agents WHERE name = ?", (name,)) row = cursor.fetchone() if not row: return None @@ -2242,17 +2300,17 @@ def get_agent_by_name(name: str) -> dict | None: def _row_to_agent(row) -> dict: """Convert database row to agent dict.""" return { - 'id': row['id'], - 'name': row['name'], - 'base_url': row['base_url'], - 'description': row['description'], - 'api_key': row['api_key'], - 'capabilities': json.loads(row['capabilities']) if row['capabilities'] else None, - 'interfaces': json.loads(row['interfaces']) if row['interfaces'] else None, - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, - 'last_seen': row['last_seen'], - 'created_at': row['created_at'], - 'is_active': bool(row['is_active']) + "id": row["id"], + "name": row["name"], + "base_url": row["base_url"], + "description": row["description"], + "api_key": row["api_key"], + "capabilities": json.loads(row["capabilities"]) if row["capabilities"] else None, + "interfaces": json.loads(row["interfaces"]) if row["interfaces"] else None, + "gps_coords": json.loads(row["gps_coords"]) if row["gps_coords"] else None, + "last_seen": row["last_seen"], + "created_at": row["created_at"], + "is_active": bool(row["is_active"]), } @@ -2260,11 +2318,9 @@ def list_agents(active_only: bool = True) -> list[dict]: """Get all agents.""" with get_db() as conn: if active_only: - cursor = conn.execute( - 'SELECT * FROM agents WHERE is_active = 1 ORDER BY name' - ) + cursor = conn.execute("SELECT * FROM agents WHERE is_active = 1 ORDER BY name") else: - cursor = conn.execute('SELECT * FROM agents ORDER BY name') + cursor = conn.execute("SELECT * FROM agents ORDER BY name") return [_row_to_agent(row) for row in cursor] @@ -2277,35 +2333,35 @@ def update_agent( interfaces: dict | None = None, gps_coords: dict | None = None, is_active: bool | None = None, - update_last_seen: bool = False + update_last_seen: bool = False, ) -> bool: """Update an agent's fields.""" updates = [] params = [] if base_url is not None: - updates.append('base_url = ?') - params.append(base_url.rstrip('/')) + updates.append("base_url = ?") + params.append(base_url.rstrip("/")) if description is not None: - updates.append('description = ?') + updates.append("description = ?") params.append(description) if api_key is not None: - updates.append('api_key = ?') + updates.append("api_key = ?") params.append(api_key) if capabilities is not None: - updates.append('capabilities = ?') + updates.append("capabilities = ?") params.append(json.dumps(capabilities)) if interfaces is not None: - updates.append('interfaces = ?') + updates.append("interfaces = ?") params.append(json.dumps(interfaces)) if gps_coords is not None: - updates.append('gps_coords = ?') + updates.append("gps_coords = ?") params.append(json.dumps(gps_coords)) if is_active is not None: - updates.append('is_active = ?') + updates.append("is_active = ?") params.append(1 if is_active else 0) if update_last_seen: - updates.append('last_seen = CURRENT_TIMESTAMP') + updates.append("last_seen = CURRENT_TIMESTAMP") if not updates: return False @@ -2313,10 +2369,7 @@ def update_agent( params.append(agent_id) with get_db() as conn: - cursor = conn.execute( - f'UPDATE agents SET {", ".join(updates)} WHERE id = ?', - params - ) + cursor = conn.execute(f"UPDATE agents SET {', '.join(updates)} WHERE id = ?", params) return cursor.rowcount > 0 @@ -2324,17 +2377,13 @@ def delete_agent(agent_id: int) -> bool: """Delete an agent and its push payloads.""" with get_db() as conn: # Delete push payloads first (foreign key) - conn.execute('DELETE FROM push_payloads WHERE agent_id = ?', (agent_id,)) - cursor = conn.execute('DELETE FROM agents WHERE id = ?', (agent_id,)) + conn.execute("DELETE FROM push_payloads WHERE agent_id = ?", (agent_id,)) + cursor = conn.execute("DELETE FROM agents WHERE id = ?", (agent_id,)) return cursor.rowcount > 0 def store_push_payload( - agent_id: int, - scan_type: str, - payload: dict, - interface: str | None = None, - received_at: str | None = None + agent_id: int, scan_type: str, payload: dict, interface: str | None = None, received_at: str | None = None ) -> int: """ Store a push payload from a remote agent. @@ -2344,75 +2393,82 @@ def store_push_payload( """ with get_db() as conn: if received_at: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO push_payloads (agent_id, scan_type, interface, payload, received_at) VALUES (?, ?, ?, ?, ?) - ''', (agent_id, scan_type, interface, json.dumps(payload), received_at)) + """, + (agent_id, scan_type, interface, json.dumps(payload), received_at), + ) else: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO push_payloads (agent_id, scan_type, interface, payload) VALUES (?, ?, ?, ?) - ''', (agent_id, scan_type, interface, json.dumps(payload))) + """, + (agent_id, scan_type, interface, json.dumps(payload)), + ) # Update agent last_seen - conn.execute( - 'UPDATE agents SET last_seen = CURRENT_TIMESTAMP WHERE id = ?', - (agent_id,) - ) + conn.execute("UPDATE agents SET last_seen = CURRENT_TIMESTAMP WHERE id = ?", (agent_id,)) return cursor.lastrowid -def get_recent_payloads( - agent_id: int | None = None, - scan_type: str | None = None, - limit: int = 100 -) -> list[dict]: +def get_recent_payloads(agent_id: int | None = None, scan_type: str | None = None, limit: int = 100) -> list[dict]: """Get recent push payloads, optionally filtered.""" conditions = [] params = [] if agent_id is not None: - conditions.append('p.agent_id = ?') + conditions.append("p.agent_id = ?") params.append(agent_id) if scan_type is not None: - conditions.append('p.scan_type = ?') + conditions.append("p.scan_type = ?") params.append(scan_type) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" params.append(limit) with get_db() as conn: - cursor = conn.execute(f''' + cursor = conn.execute( + f""" SELECT p.*, a.name as agent_name FROM push_payloads p JOIN agents a ON p.agent_id = a.id {where_clause} ORDER BY p.received_at DESC LIMIT ? - ''', params) + """, + params, + ) results = [] for row in cursor: - results.append({ - 'id': row['id'], - 'agent_id': row['agent_id'], - 'agent_name': row['agent_name'], - 'scan_type': row['scan_type'], - 'interface': row['interface'], - 'payload': json.loads(row['payload']), - 'received_at': row['received_at'] - }) + results.append( + { + "id": row["id"], + "agent_id": row["agent_id"], + "agent_name": row["agent_name"], + "scan_type": row["scan_type"], + "interface": row["interface"], + "payload": json.loads(row["payload"]), + "received_at": row["received_at"], + } + ) return results def cleanup_old_payloads(max_age_hours: int = 24) -> int: """Remove old push payloads.""" with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ DELETE FROM push_payloads WHERE received_at < datetime('now', ?) - ''', (f'-{max_age_hours} hours',)) + """, + (f"-{max_age_hours} hours",), + ) return cursor.rowcount @@ -2420,28 +2476,29 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int: # 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' + "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' + "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], + "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 ] @@ -2459,9 +2516,9 @@ def add_tracked_satellite( with get_db() as conn: try: conn.execute( - 'INSERT OR IGNORE INTO tracked_satellites ' - '(norad_id, name, tle_line1, tle_line2, enabled, builtin) ' - 'VALUES (?, ?, ?, ?, ?, ?)', + "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 @@ -2478,27 +2535,29 @@ def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int: rows = [] for sat in satellites_list: try: - rows.append(( - str(sat['norad_id']), - sat['name'], - sat.get('tle_line1'), - sat.get('tle_line2'), - int(sat.get('enabled', True)), - int(sat.get('builtin', False)), - )) + rows.append( + ( + str(sat["norad_id"]), + sat["name"], + sat.get("tle_line1"), + sat.get("tle_line2"), + int(sat.get("enabled", True)), + int(sat.get("builtin", False)), + ) + ) except (KeyError, TypeError) as e: logger.warning(f"Skipping malformed satellite entry: {e}") norad_ids = [r[0] for r in rows] - placeholders = ','.join('?' * len(norad_ids)) - count_sql = f'SELECT COUNT(*) FROM tracked_satellites WHERE norad_id IN ({placeholders})' + placeholders = ",".join("?" * len(norad_ids)) + count_sql = f"SELECT COUNT(*) FROM tracked_satellites WHERE norad_id IN ({placeholders})" with get_db() as conn: before = conn.execute(count_sql, norad_ids).fetchone()[0] conn.executemany( - 'INSERT OR IGNORE INTO tracked_satellites ' - '(norad_id, name, tle_line1, tle_line2, enabled, builtin) ' - 'VALUES (?, ?, ?, ?, ?, ?)', + "INSERT OR IGNORE INTO tracked_satellites " + "(norad_id, name, tle_line1, tle_line2, enabled, builtin) " + "VALUES (?, ?, ?, ?, ?, ?)", rows, ) after = conn.execute(count_sql, norad_ids).fetchone()[0] @@ -2509,7 +2568,7 @@ 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 = ?', + "UPDATE tracked_satellites SET enabled = ? WHERE norad_id = ?", (int(enabled), str(norad_id)), ) return cursor.rowcount > 0 @@ -2519,17 +2578,15 @@ 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 = ?', + "SELECT builtin FROM tracked_satellites WHERE norad_id = ?", (str(norad_id),), ).fetchone() if row is None: - return False, 'Satellite not found' + return False, "Satellite not found" if row[0]: - return False, 'Cannot remove builtin satellite' + return False, "Cannot remove builtin satellite" conn.execute( - 'DELETE FROM tracked_satellites WHERE norad_id = ?', + "DELETE FROM tracked_satellites WHERE norad_id = ?", (str(norad_id),), ) - return True, 'Removed' - - + return True, "Removed"