diff --git a/.DS_Store b/.DS_Store index 590979c..751860e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fbb448 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +website/ diff --git a/api.py b/api.py new file mode 100644 index 0000000..515025d --- /dev/null +++ b/api.py @@ -0,0 +1,159 @@ +import requests, time, threading +from flask import Flask, jsonify, request + +app = Flask(__name__) + +_cache = {} +_cache_lock = threading.Lock() +CACHE_TTL = 300 # 5 minutes + +LL2_BASE = "https://ll.thespacedevs.com/2.2.0" +WEATHER_BASE = "https://api.open-meteo.com/v1" + +SITES = { + 'cape': {'lat': 28.5623, 'lon': -80.5774}, + 'vandenberg': {'lat': 34.7420, 'lon': -120.5724}, +} + +def _fetch_launches(): + try: + r = requests.get(f"{LL2_BASE}/launch/upcoming/", + params={'limit': 10, 'format': 'json'}, + timeout=15) + return r.json().get('results', []) + except Exception as e: + print(f"Launch fetch error: {e}") + return None + +def _fetch_weather(lat, lon): + try: + r = requests.get(f"{WEATHER_BASE}/forecast", params={ + 'latitude': lat, 'longitude': lon, + 'current': 'temperature_2m,windspeed_10m,cloudcover,weathercode', + 'daily': 'sunrise,sunset', + 'timezone': 'auto', 'forecast_days': 1 + }, timeout=10) + return r.json() + except Exception as e: + print(f"Weather fetch error: {e}") + return None + +def _refresh(): + with _cache_lock: + now = time.time() + if now - _cache.get('_fetched_at', 0) < CACHE_TTL: + return + launches = _fetch_launches() + if launches is not None: + _cache['launches'] = launches + for site_id, site in SITES.items(): + wx = _fetch_weather(site['lat'], site['lon']) + if wx: + _cache[f'weather_{site_id}'] = wx + _cache['_fetched_at'] = now + print(f"Cache refreshed at {time.strftime('%H:%M:%S')}") + +def _bg_refresh(): + while True: + try: + _refresh() + except Exception as e: + print(f"BG refresh error: {e}") + time.sleep(60) + +@app.route('/api/launches') +def launches(): + _refresh() + return jsonify(_cache.get('launches', [])) + +@app.route('/api/weather') +def weather(): + site = request.args.get('site', 'cape') + _refresh() + return jsonify(_cache.get(f'weather_{site}', {})) + +@app.route('/api/health') +def health(): + return jsonify({'ok': True, 'cached_at': _cache.get('_fetched_at', 0)}) + +# ── Unit tracking ────────────────────────────────────────────────────────────── + +_units = {} +_commands = {} # unit_id -> list of pending command dicts +_acks = {} # unit_id -> list of {command, ts} +_cmd_lock = threading.Lock() + +UNIT_TTL = 3600 # drop units not seen in 1h + +@app.route('/api/unit/ping', methods=['POST']) +def unit_ping(): + data = request.get_json() or {} + unit_id = data.get('unit_id', 'unknown') + now = time.time() + # Expire stale units + stale = [k for k, v in _units.items() if now - v.get('last_seen', 0) > UNIT_TTL] + for k in stale: + del _units[k] + _units[unit_id] = {**data, 'last_seen': now} + return jsonify({'ok': True}) + +@app.route('/api/units') +def units(): + now = time.time() + active = {k: v for k, v in _units.items() if now - v.get('last_seen', 0) < UNIT_TTL} + return jsonify(active) + +@app.route('/api/unit/', methods=['DELETE']) +def delete_unit(unit_id): + _units.pop(unit_id, None) + return jsonify({'ok': True}) + +# ── Command queue ────────────────────────────────────────────────────────────── + +@app.route('/api/unit/command', methods=['POST']) +def send_command(): + """Dashboard posts a command for a specific unit.""" + data = request.get_json() or {} + unit_id = data.get('unit_id') + cmd = data.get('command') # 'restart', 'update', 'reboot' + if not unit_id or not cmd: + return jsonify({'ok': False, 'error': 'unit_id and command required'}), 400 + with _cmd_lock: + if unit_id not in _commands: + _commands[unit_id] = [] + _commands[unit_id].append({'command': cmd, 'queued_at': time.time()}) + print(f"Command '{cmd}' queued for {unit_id}") + return jsonify({'ok': True}) + +@app.route('/api/unit/commands/') +def get_commands(unit_id): + """Pi polls this to get pending commands.""" + with _cmd_lock: + cmds = _commands.pop(unit_id, []) + return jsonify(cmds) + +@app.route('/api/unit/ack', methods=['POST']) +def unit_ack(): + """Pi confirms a command was received and is executing.""" + data = request.get_json() or {} + unit_id = data.get('unit_id') + cmd = data.get('command') + if unit_id and cmd: + _acks.setdefault(unit_id, []).append({'command': cmd, 'ts': time.time()}) + return jsonify({'ok': True}) + +@app.route('/api/unit/acks/') +def get_acks(unit_id): + """Dashboard polls this; clears on read.""" + return jsonify(_acks.pop(unit_id, [])) + +@app.after_request +def add_cors(r): + r.headers['Access-Control-Allow-Origin'] = '*' + r.headers['Access-Control-Allow-Headers'] = 'Content-Type' + r.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + return r + +if __name__ == '__main__': + threading.Thread(target=_bg_refresh, daemon=True).start() + app.run(host='0.0.0.0', port=5000) diff --git a/launch-timer/.DS_Store b/launch-timer/.DS_Store index def3015..b3a5283 100644 Binary files a/launch-timer/.DS_Store and b/launch-timer/.DS_Store differ diff --git a/launch-timer/Phase2/.DS_Store b/launch-timer/Phase2/.DS_Store index 5008ddf..359afbe 100644 Binary files a/launch-timer/Phase2/.DS_Store and b/launch-timer/Phase2/.DS_Store differ diff --git a/launch-timer/Phase2/.claude/settings.local.json b/launch-timer/Phase2/.claude/settings.local.json new file mode 100644 index 0000000..9ae86a9 --- /dev/null +++ b/launch-timer/Phase2/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s http://localhost:5001/api/data)", + "Bash(python3 -m json.tool)", + "Bash(curl -s \"https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=3&format=json\")", + "Bash(curl -s -X POST http://localhost:5001/api/launches/invalidate)", + "Bash(curl -v http://localhost:5000/)", + "Bash(python3 -c \":*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)" + ] + } +} diff --git a/launch-timer/Phase2/.gitignore b/launch-timer/Phase2/.gitignore new file mode 100644 index 0000000..fa32db3 --- /dev/null +++ b/launch-timer/Phase2/.gitignore @@ -0,0 +1,3 @@ +data_cache.json +__pycache__/ +*.pyc diff --git a/launch-timer/Phase2/health.py b/launch-timer/Phase2/health.py new file mode 100644 index 0000000..e2d06a2 --- /dev/null +++ b/launch-timer/Phase2/health.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python3 +""" +RangeTrack OS — Health Monitor +Handles both daily digest and immediate alerting. + +Usage: + python3 health.py digest — send daily health report + python3 health.py check — check for alert conditions and send if triggered + +Cron setup (run: crontab -e on Pi): + 0 8 * * * python3 /home/pi/Desktop/LaunchTracker2D/launch-timer/Phase2/health.py digest >> /home/pi/health.log 2>&1 + */5 * * * * python3 /home/pi/Desktop/LaunchTracker2D/launch-timer/Phase2/health.py check >> /home/pi/health.log 2>&1 +""" + +import smtplib +import sys +import subprocess +import time +import json +import os +import base64 +import tempfile +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from datetime import datetime + +# ── Config ──────────────────────────────────────────────────────────────────── +def _get_unit_id(): + settings_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings.json') + try: + with open(settings_file) as f: + uid = json.load(f).get('unit_id', '').strip() + if uid: + return uid + except Exception: + pass + try: + mac = open('/sys/class/net/wlan0/address').read().strip() + return 'LT-' + mac.replace(':', '')[-4:].upper() + except Exception: + return 'Unit-001' + +UNIT_ID = _get_unit_id() +FROM = 'rangetrack551@gmail.com' +TO = 'rangetrack551@gmail.com' +PASS_FILE = '/home/pi/.rangetrack_gmail_pass' +SERVER_SCRIPT = 'server.py' +DATA_CACHE = '/home/pi/Desktop/LaunchTracker2D/launch-timer/Phase2/data_cache.json' +ALERT_STATE = '/home/pi/.rangetrack_alert_state.json' +UPDATE_LOG = '/home/pi/.rangetrack_updates.json' + +TEMP_ALERT_C = 80.0 +LOS_ALERT_MINUTES = 15 +DISK_ALERT_PCT = 90 + +# ── Email ───────────────────────────────────────────────────────────────────── +def send_email(subject, html): + if not os.path.exists(PASS_FILE): + print(f'[{ts()}] ERROR: Gmail password file not found at {PASS_FILE}') + print(f'[{ts()}] FIX: Run this on the Pi:') + print(f'[{ts()}] echo "YOUR_APP_PASSWORD" > {PASS_FILE}') + print(f'[{ts()}] chmod 600 {PASS_FILE}') + return + try: + with open(PASS_FILE) as f: + password = f.read().strip() + if not password: + print(f'[{ts()}] ERROR: Gmail password file is empty — {PASS_FILE}') + return + msg = MIMEMultipart('alternative') + msg['Subject'] = f'[{UNIT_ID}] {subject}' + msg['From'] = FROM + msg['To'] = TO + msg.attach(MIMEText(html, 'html')) + s = smtplib.SMTP('smtp.gmail.com', 587) + s.starttls() + s.login(FROM, password) + s.sendmail(FROM, TO, msg.as_string()) + s.quit() + print(f'[{ts()}] Email sent: {subject}') + except Exception as e: + print(f'[{ts()}] Email error: {e}') + +def ts(): + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + +# ── System Stats ────────────────────────────────────────────────────────────── +def get_temp(): + try: + with open('/sys/class/thermal/thermal_zone0/temp') as f: + return int(f.read().strip()) / 1000.0 + except: + return None + +def get_cpu_usage(): + try: + result = subprocess.run(['top', '-bn1'], capture_output=True, text=True) + for line in result.stdout.split('\n'): + if 'Cpu(s)' in line or '%Cpu' in line: + parts = line.split(',') + idle = float([p for p in parts if 'id' in p][0].strip().split()[0]) + return round(100 - idle, 1) + except: + pass + return None + +def get_memory(): + try: + result = subprocess.run(['free', '-m'], capture_output=True, text=True) + lines = result.stdout.strip().split('\n') + parts = lines[1].split() + total, used = int(parts[1]), int(parts[2]) + return used, total, round(used / total * 100, 1) + except: + return None, None, None + +def get_disk(): + try: + result = subprocess.run(['df', '-h', '/'], capture_output=True, text=True) + parts = result.stdout.strip().split('\n')[1].split() + used, total, pct = parts[2], parts[1], int(parts[4].replace('%','')) + return used, total, pct + except: + return None, None, None + +def get_uptime(): + try: + with open('/proc/uptime') as f: + secs = float(f.read().split()[0]) + h = int(secs // 3600) + m = int((secs % 3600) // 60) + return f'{h}h {m}m' + except: + return 'unknown' + +def is_server_running(): + try: + result = subprocess.run(['pgrep', '-f', SERVER_SCRIPT], capture_output=True) + return result.returncode == 0 + except: + return False + +def restart_server(): + # Skip if update.sh or the supervisor is already handling it + if os.path.exists('/tmp/rangetrack_update.lock'): + print(f'[{ts()}] Update in progress — skipping restart.') + return + try: + phase2 = os.path.dirname(os.path.abspath(__file__)) + subprocess.run(['pkill', '-f', SERVER_SCRIPT], capture_output=True) + time.sleep(2) + subprocess.Popen( + ['python3', SERVER_SCRIPT], + cwd=phase2, + stdout=open('/home/pi/server.log', 'a'), + stderr=subprocess.STDOUT + ) + print(f'[{ts()}] Server restarted.') + except Exception as e: + print(f'[{ts()}] Failed to restart server: {e}') + +def get_local_ip(): + try: + result = subprocess.run(['hostname', '-I'], capture_output=True, text=True) + return result.stdout.strip().split()[0] + except: + return 'unknown' + +def has_internet(): + try: + subprocess.run(['ping', '-c', '1', '-W', '3', '8.8.8.8'], + capture_output=True, check=True) + return True + except: + return False + +def get_current_launch_id(): + """Try to get the first upcoming launch ID from the data cache for the mission screenshot.""" + try: + with open(DATA_CACHE) as f: + data = json.load(f) + launches = data.get('launches', []) + if launches: + return launches[0].get('id') + except: + pass + return None + +def take_screenshots(): + """Capture index, launches, and mission pages via headless Chromium. + Returns dict of {label: base64_png_string} or empty dict on failure.""" + mission_id = get_current_launch_id() + mission_url = f'http://localhost:5001/mission?id={mission_id}' if mission_id else 'http://localhost:5001/mission' + pages = [ + ('LAUNCHES', 'http://localhost:5001/launches', 60), + ('MISSION', mission_url, 60), + ] + results = {} + tmp_dir = tempfile.mkdtemp() + for label, url, timeout in pages: + out = os.path.join(tmp_dir, f'{label}.png') + try: + subprocess.run([ + 'chromium', + '--headless', + '--no-sandbox', + '--no-zygote', + '--disable-gpu', + '--disable-software-rasterizer', + '--disable-dev-shm-usage', + '--window-size=800,480', + '--virtual-time-budget=8000', + f'--screenshot={out}', + url + ], timeout=timeout, capture_output=True) + if os.path.exists(out) and os.path.getsize(out) > 10000: + # Cap at 800KB raw to avoid Gmail attachment limits + if os.path.getsize(out) > 800 * 1024: + print(f'[{ts()}] Screenshot too large ({label}), skipping') + os.remove(out) + else: + with open(out, 'rb') as f: + results[label] = base64.b64encode(f.read()).decode() + os.remove(out) + else: + print(f'[{ts()}] Screenshot too small or missing ({label}) — page may not have rendered') + try: os.remove(out) + except: pass + except Exception as e: + print(f'[{ts()}] Screenshot error ({label}): {e}') + time.sleep(2) # let chromium fully exit before next launch + try: + os.rmdir(tmp_dir) + except: + pass + return results + +def get_recent_updates(): + try: + with open(UPDATE_LOG) as f: + updates = json.load(f) + # Only return updates from the last 24 hours + cutoff = time.time() - 86400 + recent = [] + for u in updates: + try: + t = datetime.strptime(u['time'], '%Y-%m-%d %H:%M:%S').timestamp() + if t > cutoff: + recent.append(u) + except: + pass + return recent + except: + return [] + +def clear_update_log(): + try: + with open(UPDATE_LOG, 'w') as f: + json.dump([], f) + except: + pass + +def get_last_fetch_age(): + try: + with open(DATA_CACHE) as f: + data = json.load(f) + fetched_at = data.get('fetched_at', 0) + if fetched_at: + return round((time.time() - fetched_at) / 60, 1) + except: + pass + return None + +# ── Alert State ─────────────────────────────────────────────────────────────── +def load_alert_state(): + try: + with open(ALERT_STATE) as f: + return json.load(f) + except: + return {} + +def save_alert_state(state): + try: + with open(ALERT_STATE, 'w') as f: + json.dump(state, f) + except: + pass + +# ── HTML Templates ──────────────────────────────────────────────────────────── +PIXEL_FONT = "font-family: 'Courier New', monospace;" + +def bar_html(pct, color): + filled = int(pct / 100 * 20) + empty = 20 - filled + return ( + f'{"█" * filled}' + f'{"█" * empty}' + f' {pct}%' + ) + +def status_dot(ok): + return ( + f'▮ NOMINAL' if ok + else f'▮ OFFLINE' + ) + +def temp_color(t): + if t is None: return '#aaaaaa' + if t >= 80: return '#ff4422' + if t >= 65: return '#ffd93d' + return '#00e87a' + +def digest_html(temp, cpu, mem_u, mem_t, mem_pct, disk_u, disk_t, disk_pct, uptime, server, internet, los, updates, screenshots): + tc = temp_color(temp) + ts_ = ts() + cpu_ = cpu if cpu is not None else 0 + mem_ = mem_pct if mem_pct is not None else 0 + disk_ = disk_pct if disk_pct is not None else 0 + + if screenshots: + shots = ''.join( + f'
' + f'
{lbl}
' + f'' + f'
' + for lbl, b64 in screenshots.items() + ) + screenshots_section = f''' +
+ + {shots} +
''' + else: + screenshots_section = '' + + if updates: + rows = ''.join( + f'
' + f'
{u["time"]}
' + f'
{u["commit"]}
' + f'
' + for u in reversed(updates) + ) + updates_section = f''' +
+ + {rows} +
''' + else: + updates_section = '' + + return f""" + + + + + + + +
+
+
■ RANGETRACK OS
+
DAILY HEALTH REPORT // {ts_}
+
+
+ +
+
+
■ {UNIT_ID}
+
UPTIME: {uptime}
+
IP: {get_local_ip()}
+
+
{ts_}
+
+ +
+ + +
+
CPU TEMP
+
{f'{temp:.1f}°C' if temp else '—'}
+
+ +
+
CPU LOAD
+ {bar_html(int(cpu_), '#4a9ede')} +
+ +
+
MEMORY  {f'{mem_u}MB / {mem_t}MB' if mem_u else '—'}
+ {bar_html(int(mem_), '#ffd93d')} +
+ +
+
DISK    {f'{disk_u} / {disk_t}' if disk_u else '—'}
+ {bar_html(int(disk_), '#00e87a')} +
+ +
+
INTERNET
+
{status_dot(internet)}
+
+
+ +
+ +
+
SERVER
+
{status_dot(server)}
+
+
+
LAST API FETCH
+
+ {f'{los} MIN AGO' if los is not None else '— UNKNOWN'} +
+
+
+ +
+ {screenshots_section} + {updates_section} + +
+ + +""" + +def alert_html(alerts, resolved=False): + color = '#00e87a' if resolved else '#ff4422' + title = 'ALERT RESOLVED' if resolved else 'SYSTEM ALERT' + icon = '✓' if resolved else '⚠' + items = ''.join( + f'
' + f'{icon} {a}
' + for a in alerts + ) + return f""" + + + + + + + +
+
+
■ RANGETRACK OS — {title}
+
{UNIT_ID} // {ts()}
+
+
+ {items} +
+ +
+ + +""" + +# ── Digest ──────────────────────────────────────────────────────────────────── +def send_digest(): + temp = get_temp() + cpu = get_cpu_usage() + mem_u, mem_t, mem_pct = get_memory() + disk_u, disk_t, disk_pct = get_disk() + uptime = get_uptime() + server = is_server_running() + internet = has_internet() + los = get_last_fetch_age() + updates = get_recent_updates() + print(f'[{ts()}] Taking screenshots...') + screenshots = take_screenshots() + print(f'[{ts()}] Got {len(screenshots)} screenshots') + html = digest_html(temp, cpu, mem_u, mem_t, mem_pct, disk_u, disk_t, disk_pct, uptime, server, internet, los, updates, screenshots) + send_email('Daily Health Report', html) + clear_update_log() # reset after digest so updates don't stack up + +# ── Alert Check ─────────────────────────────────────────────────────────────── +ALERT_COOLDOWN = 3600 # re-alert at most once per hour per condition + +def check_alerts(): + state = load_alert_state() + now = time.time() + alerts = [] + cleared = [] + + def _fire(key, msg, restart_fn=None): + """Fire alert if condition is new or cooldown has expired.""" + is_new = not state.get(key) + last_sent = state.get(f'{key}_sent', 0) + if is_new and restart_fn: + restart_fn() + if is_new or (now - last_sent) >= ALERT_COOLDOWN: + alerts.append(msg) + state[key] = True + state[f'{key}_sent'] = now + + def _clear(key, msg): + cleared.append(msg) + state[key] = False + state.pop(f'{key}_sent', None) + + internet = has_internet() + if not internet: + _fire('no_internet', 'INTERNET LOST — Pi has no network connection.') + elif state.get('no_internet'): + _clear('no_internet', 'Internet connection restored.') + + temp = get_temp() + if temp is not None: + if temp >= TEMP_ALERT_C: + _fire('high_temp', f'HIGH TEMP — CPU at {temp:.1f}C (limit {TEMP_ALERT_C}C).') + elif state.get('high_temp'): + _clear('high_temp', f'Temperature back to normal ({temp:.1f}C).') + + server = is_server_running() + if not server: + _fire('server_down', 'SERVER CRASHED — auto-restarted server.py.', restart_fn=restart_server) + elif state.get('server_down'): + _clear('server_down', 'Server is back online.') + + los = get_last_fetch_age() + if los is not None: + if los >= LOS_ALERT_MINUTES: + _fire('los', f'LOSS OF SIGNAL — No API data for {los:.0f} minutes.') + elif state.get('los'): + _clear('los', f'API signal restored ({los:.0f} min ago).') + + _, _, disk_pct = get_disk() + if disk_pct is not None: + if disk_pct >= DISK_ALERT_PCT: + _fire('low_disk', f'LOW DISK — {disk_pct}% used (limit {DISK_ALERT_PCT}%).') + elif state.get('low_disk'): + _clear('low_disk', f'Disk space OK ({disk_pct}% used).') + + save_alert_state(state) + + if alerts: + send_email('ALERT', alert_html(alerts, resolved=False)) + if cleared: + send_email('Alert Resolved', alert_html(cleared, resolved=True)) + +# ── Main ────────────────────────────────────────────────────────────────────── +if __name__ == '__main__': + mode = sys.argv[1] if len(sys.argv) > 1 else 'digest' + if mode == 'digest': + send_digest() + elif mode == 'check': + check_alerts() + else: + print('Usage: python3 health.py [digest|check]') diff --git a/launch-timer/Phase2/notify.py b/launch-timer/Phase2/notify.py new file mode 100644 index 0000000..c7c2d28 --- /dev/null +++ b/launch-timer/Phase2/notify.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import smtplib +import sys +from email.mime.text import MIMEText + +FROM = 'rangetrack551@gmail.com' +TO = 'rangetrack551@gmail.com' +PASS_FILE = '/home/pi/.rangetrack_gmail_pass' + +def send(body): + try: + with open(PASS_FILE) as f: + password = f.read().strip() + msg = MIMEText(body) + msg['Subject'] = 'RangeTrack OS' + msg['From'] = FROM + msg['To'] = TO + s = smtplib.SMTP('smtp.gmail.com', 587) + s.starttls() + s.login(FROM, password) + s.sendmail(FROM, TO, msg.as_string()) + s.quit() + print('Notification sent.') + except Exception as e: + print(f'Notification error: {e}') + +if __name__ == '__main__': + body = ' '.join(sys.argv[1:]) if len(sys.argv) > 1 else 'RangeTrack OS updated.' + send(body) diff --git a/launch-timer/Phase2/server.py b/launch-timer/Phase2/server.py index 4c576df..95b2d27 100644 --- a/launch-timer/Phase2/server.py +++ b/launch-timer/Phase2/server.py @@ -1,90 +1,80 @@ #!/usr/bin/env python3 """ -Phase 2 Backend Server -Fetches launch and weather data, serves it to the browser frontend. -Run this file to start the app — it will open your browser automatically. +LaunchTracker2D — Backend Server +Single data source: Launch Library 2 (LL2) +Weather: Open-Meteo (free, no rate limit) + +One background thread refreshes all data every 5 minutes. +All pages read from /api/data — single source of truth. """ import threading -import webbrowser import time import requests +import json import subprocess -from datetime import datetime, timezone -from flask import Flask, jsonify, send_from_directory, request +from datetime import datetime, timezone, timedelta +from flask import Flask, jsonify, send_from_directory, request, redirect import os - import sys +import logging + if getattr(sys, 'frozen', False): BASE_DIR = os.path.dirname(sys.executable) else: BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -app = Flask(__name__, static_folder=os.path.join(BASE_DIR, 'static')) -# ── Helpers ────────────────────────────────────────────────────────────────── +app = Flask(__name__, static_folder=os.path.join(BASE_DIR, 'static')) +logging.getLogger('werkzeug').setLevel(logging.ERROR) -def _ts(): - return datetime.now().strftime("%H:%M:%S") +SETTINGS_FILE = os.path.join(BASE_DIR, 'settings.json') +LL2_BASE = 'https://ll.thespacedevs.com/2.3.0' +LL2_TIMEOUT = 15 +RELAY_URL = 'http://45.55.245.193' # DO relay — Pi fetches from here instead of LL2 directly -# ── Launch API ──────────────────────────────────────────────────────────────── +# ── Settings ────────────────────────────────────────────────────────────────── -def fetch_launches(num_launches=5): - """Fetch upcoming launches from RocketLaunch.Live, filter completed ones.""" - url = f"https://fdo.rocketlaunch.live/json/launches/next/{num_launches}" +def _load_settings(): try: - response = requests.get(url, timeout=10) - response.raise_for_status() - launches = response.json().get('result', []) - - upcoming = [] - skipped = 0 - for launch in launches: - result = launch.get('result') - status_id = launch.get('status', {}).get('id', 0) - if (result is not None and result > 0) or status_id == 3: - skipped += 1 - continue - upcoming.append(launch) - - print(f"[{_ts()}] Launches fetched: {len(upcoming)} upcoming" - + (f", {skipped} skipped" if skipped else "")) - for i, lv in enumerate(upcoming): - t0 = lv.get('t0') or lv.get('win_open') or 'TBD' - vehicle = lv.get('vehicle', {}).get('name', 'Unknown') - status = lv.get('status', {}).get('name', '?') - marker = ">>>" if i == 0 else " " - print(f" {marker} [{i+1}] {lv.get('name', 'Unknown')}") - print(f" Vehicle: {vehicle} | Status: {status} | T0: {t0}") - return upcoming - except requests.exceptions.RequestException as e: - print(f"[{_ts()}] ERROR fetching launches: {e}") - return [] - - -def get_countdown(launch_time_iso): - """Return countdown dict or 'LAUNCHED' string.""" - if not launch_time_iso: - return None + with open(SETTINGS_FILE) as f: + return json.load(f) + except Exception: + return {'brightness': 40, 'temp_unit': 'f', 'site': 'cape', 'time_format': 'local', 'unit_id': '', 'auto_dim': True} + +def _save_settings(data): try: - launch_time = datetime.fromisoformat(launch_time_iso.replace('Z', '+00:00')) - now = datetime.now(timezone.utc) - delta = launch_time - now - if delta.total_seconds() < 0: - return "LAUNCHED" - days = delta.days - hours, rem = divmod(delta.seconds, 3600) - minutes, seconds = divmod(rem, 60) - return { - 'days': days, 'hours': hours, - 'minutes': minutes, 'seconds': seconds, - 'total_seconds': delta.total_seconds() - } + with open(SETTINGS_FILE, 'w') as f: + json.dump(data, f) + return True except Exception: - return None + return False + +def _ts(): + return datetime.now().strftime('%H:%M:%S') + +# ── Location ────────────────────────────────────────────────────────────────── -# ── Weather API ─────────────────────────────────────────────────────────────── +SITE_COORDS = { + 'cape': (28.3922, -80.6077), + 'vandenberg': (34.6321, -120.6110), + 'all': (28.3922, -80.6077), +} +_location_cache = None + +def _get_location(): + global _location_cache + if _location_cache: + return _location_cache + site = _load_settings().get('site', 'cape') + coords = SITE_COORDS.get(site, SITE_COORDS['cape']) + _location_cache = coords + print(f'[{_ts()}] Location: {site} → {coords[0]}, {coords[1]}') + return coords + + +# ── Weather ─────────────────────────────────────────────────────────────────── _WIND_DIRS = ['N','NNE','NE','ENE','E','ESE','SE','SSE', 'S','SSW','SW','WSW','W','WNW','NW','NNW'] @@ -97,7 +87,6 @@ def get_countdown(launch_time_iso): 80:'Light showers', 81:'Showers', 82:'Heavy showers', 95:'Thunderstorm', 96:'Thunderstorm w/ hail', 99:'Thunderstorm w/ heavy hail', } - _WMO_CONDITION = { 0:'clear', 1:'clear', 2:'cloudy', 3:'cloudy', 45:'fog', 48:'fog', @@ -106,226 +95,996 @@ def get_countdown(launch_time_iso): 95:'thunderstorm', 96:'thunderstorm', 99:'thunderstorm', } +try: + VERSION = subprocess.check_output( + ['git', '-C', BASE_DIR, 'rev-parse', '--short', 'HEAD'], + text=True, stderr=subprocess.DEVNULL).strip() +except Exception: + VERSION = 'v2.0.0' + +_weather_cache = {'data': None, 'fetched': 0} +WEATHER_TTL = 900 # 15 min -def fetch_weather(): - """Fetch Cape Canaveral weather from Open-Meteo (free, no key, reliable).""" - ts = _ts() +def _fetch_weather(): + lat, lon = _get_location() url = ( - "https://api.open-meteo.com/v1/forecast" - "?latitude=28.3922&longitude=-80.6077" - "¤t=temperature_2m,relative_humidity_2m,precipitation," - "weather_code,cloud_cover,wind_speed_10m,wind_direction_10m" - "&temperature_unit=fahrenheit&wind_speed_unit=mph" - "&timezone=America%2FNew_York" + f'https://api.open-meteo.com/v1/forecast' + f'?latitude={lat}&longitude={lon}' + '¤t=temperature_2m,relative_humidity_2m,precipitation,' + 'weather_code,cloud_cover,wind_speed_10m,wind_direction_10m' + '&daily=sunrise,sunset' + '&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto' ) try: r = requests.get(url, timeout=10) r.raise_for_status() - c = r.json()['current'] - - wmo_code = c.get('weather_code', 0) - wind_deg = c.get('wind_direction_10m', 0) - wind_dir = _WIND_DIRS[int((wind_deg + 11.25) / 22.5) % 16] - condition = _WMO_CONDITION.get(wmo_code, 'clear') - label = _WMO_LABELS.get(wmo_code, f'Code {wmo_code}') - temp_f = round(c['temperature_2m'], 1) - - info = { + data = r.json() + c = data['current'] + daily = data.get('daily', {}) + wmo = c.get('weather_code', 0) + temp_f = round(c['temperature_2m'], 1) + wind_deg = c.get('wind_direction_10m', 0) + result = { 'temp_f': temp_f, 'temp_c': round((temp_f - 32) * 5 / 9, 1), - 'condition': condition, - 'label': label, - 'weather_code': wmo_code, + 'condition': _WMO_CONDITION.get(wmo, 'clear'), + 'label': _WMO_LABELS.get(wmo, f'Code {wmo}'), + 'weather_code': wmo, 'humidity': c.get('relative_humidity_2m', 0), 'wind_speed': round(c.get('wind_speed_10m', 0), 1), - 'wind_dir': wind_dir, + 'wind_dir': _WIND_DIRS[int((wind_deg + 11.25) / 22.5) % 16], 'precip': c.get('precipitation', 0), 'cloud_cover': c.get('cloud_cover', 0), + 'sunrise': (daily.get('sunrise') or [None])[0], + 'sunset': (daily.get('sunset') or [None])[0], } - print(f"[{ts}] Weather | {label}, {temp_f}°F, " - f"{info['wind_speed']} mph {wind_dir}, " - f"{info['cloud_cover']}% cloud → {condition}") - return info - except requests.exceptions.Timeout: - print(f"[{ts}] Weather timeout — using defaults") - except requests.exceptions.ConnectionError: - print(f"[{ts}] Weather connection error — using defaults") + print(f'[{_ts()}] Weather: {result["label"]}, {temp_f}°F, ' + f'{result["wind_speed"]} mph {result["wind_dir"]}, ' + f'{result["cloud_cover"]}% cloud') + return result except Exception as e: - print(f"[{ts}] Weather error: {e}") - return {'condition': 'clear', 'label': 'Unknown', 'temp_f': 75, - 'temp_c': 24, 'humidity': 60, 'wind_speed': 10, - 'wind_dir': 'E', 'precip': 0, 'cloud_cover': 0} + print(f'[{_ts()}] Weather error: {e}') + return { + 'condition': 'clear', 'label': 'Unknown', 'temp_f': 75, 'temp_c': 24, + 'humidity': 60, 'wind_speed': 10, 'wind_dir': 'E', 'precip': 0, 'cloud_cover': 0, + } + +_WEATHER_DEFAULT = { + 'condition': 'clear', 'label': 'Loading...', 'temp_f': 0, 'temp_c': 0, + 'humidity': 0, 'wind_speed': 0, 'wind_dir': '—', 'precip': 0, 'cloud_cover': 0, +} + +def _get_weather(): + """Returns cached weather — never blocks on a live fetch. Background thread refreshes it.""" + return _weather_cache['data'] or _WEATHER_DEFAULT + + +# ── LL2 data normalisation ──────────────────────────────────────────────────── + +def _d(val): + """Return val if it's a dict, else {}. Guards against API fields that are + sometimes None, a string, or any other non-dict type.""" + return val if isinstance(val, dict) else {} + +def _normalize_launch(r): + """Convert a raw LL2 launch object into our flat standard format.""" + rocket = _d(r.get('rocket')) + config = _d(rocket.get('configuration')) + stages = rocket.get('launcher_stage') + stages = stages if isinstance(stages, list) else [] + stage = _d(stages[0]) if stages else {} + launcher = _d(stage.get('launcher')) + landing = _d(stage.get('landing')) + landing_loc = _d(landing.get('location')) + + mission = _d(r.get('mission')) + orbit = _d(mission.get('orbit')) + m_type = _d(mission.get('type')) + + pad = _d(r.get('pad')) + pad_loc = _d(pad.get('location')) + lsp = _d(r.get('launch_service_provider')) + + programs = r.get('program') + programs = programs if isinstance(programs, list) else [] + program = programs[0].get('name', '') if programs and isinstance(programs[0], dict) else '' + + patches = r.get('mission_patches') + patches = patches if isinstance(patches, list) else [] + patch_url = patches[0].get('image_url') if patches and isinstance(patches[0], dict) else None + + prev_flight = stage.get('previous_flight') + booster_prev_mission = _d(prev_flight).get('name') if prev_flight else None + + t0 = r.get('net') or r.get('window_start') or '' + + return { + 'id': str(r.get('id', '')), + 'name': r.get('name', 'Unknown Mission'), + 'vehicle': config.get('name') or '', + 'provider': lsp.get('name', ''), + 'pad': pad.get('name', ''), + 'location': pad_loc.get('name', ''), + 'pad_lat': pad.get('latitude', ''), + 'pad_lon': pad.get('longitude', ''), + 'status': (r.get('status') or {}).get('name', 'TBD'), + 't0': t0, + 'win_open': r.get('window_start') or t0, + 'win_close': r.get('window_end') or '', + 'probability': r.get('probability'), + 'mission_type': m_type.get('name', ''), + 'mission_desc': mission.get('description', ''), + 'orbit_abbrev': orbit.get('abbrev', ''), + 'orbit_name': orbit.get('name', ''), + 'program': program, + 'booster_serial': launcher.get('serial_number') or None, + 'booster_flight': stage.get('turn_around_flight_count') or None, + 'booster_last_flight': stage.get('previous_flight_date') or None, + 'booster_prev_mission': booster_prev_mission, + 'recovery_vessel': landing_loc.get('name') or None, + 'landing_type': _d(landing.get('type')).get('abbrev') or None, + 'pad_launches': pad.get('total_launch_count'), + 'last_updated': r.get('last_updated', ''), + 'patch_url': patch_url, + } + +_TBD_NAMES = {'unknown', 'tbd', 'to be determined', 'to be confirmed', 'n/a', ''} +_DONE_STATUSES = {'launch successful', 'launch failure', 'partial failure', 'launch in flight', 'in flight'} + +def _is_valid(launch): + """Return False if this launch should be skipped (TBD vehicle, no time, past t0, or already launched).""" + vehicle = (launch.get('vehicle') or '').strip().lower() + t0 = (launch.get('t0') or '').strip() + status = (launch.get('status') or '').strip().lower() + if vehicle in _TBD_NAMES: + return False + if not t0: + return False + if any(s in status for s in _DONE_STATUSES): + return False + # Drop launches whose T-0 has passed by more than 10 minutes (API may not have updated status yet) + try: + t0_dt = datetime.fromisoformat(t0.replace('Z', '+00:00')) + if t0_dt < datetime.now(timezone.utc) - timedelta(minutes=10): + return False + except Exception: + pass + return True + +# ── Central data cache ──────────────────────────────────────────────────────── -# ── Simple in-memory cache ───────────────────────────────────────────────────── +CACHE_FILE = os.path.join(BASE_DIR, 'data_cache.json') +T0_HIST_FILE = os.path.join(BASE_DIR, 't0_history.json') +_cache_lock = threading.Lock() # guards all _data_cache mutations -_cache = { - 'launches': [], - 'launches_fetched': 0, - 'weather': None, - 'weather_fetched': 0, +def _load_t0_history(): + try: + with open(T0_HIST_FILE) as f: + return json.load(f) + except Exception: + return {} + +def _save_t0_history(hist): + try: + with open(T0_HIST_FILE, 'w') as f: + json.dump(hist, f) + except Exception: + pass + +def _apply_t0_history(launches): + """For each launch, record its first-seen T0. If T0 has slipped, attach original_t0.""" + hist = _load_t0_history() + changed = False + for l in launches: + lid = str(l.get('id', '')) + t0 = l.get('t0') + if not lid or not t0: + continue + if lid not in hist: + hist[lid] = t0 + changed = True + else: + orig = hist[lid] + if orig != t0: + l['original_t0'] = orig + # Prune IDs older than 30 days to keep file small + cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() + hist = {k: v for k, v in hist.items() if v >= cutoff} + if changed: + _save_t0_history(hist) + return launches + +_data_cache = { + 'launches': [], # normalized + filtered upcoming launches + 'year_launches': [], # raw LL2 results (for leaderboard) + 'events': [], # raw LL2 events + 'fetched_at': 0, # timestamp of last upcoming-launch fetch + 'hourly_fetched': 0, # timestamp of last year/events fetch } -LAUNCH_TTL = 300 -WEATHER_TTL = 900 +def _load_cache(): + """Load persisted cache from disk on startup so data is available immediately.""" + try: + with open(CACHE_FILE) as f: + saved = json.load(f) + all_launches = saved.get('launches', []) + valid = [l for l in all_launches if _is_valid(l)] + _data_cache['launches'] = valid + _data_cache['year_launches'] = saved.get('year_launches', []) + _data_cache['events'] = saved.get('events', []) + _data_cache['fetched_at'] = saved.get('fetched_at', 0) + _data_cache['hourly_fetched'] = saved.get('hourly_fetched', 0) + skipped = len(all_launches) - len(valid) + age = int(time.time() - _data_cache['fetched_at']) + print(f'[{_ts()}] Loaded cache from disk — {len(valid)} launches, {age}s old' + + (f', {skipped} stale filtered' if skipped else '')) + except FileNotFoundError: + print(f'[{_ts()}] No cache file yet — will fetch fresh data') + except Exception as e: + print(f'[{_ts()}] Cache load error: {e}') -def _get_launches(): - now = time.time() - if not _cache['launches'] or (now - _cache['launches_fetched']) > LAUNCH_TTL: - _cache['launches'] = fetch_launches(5) - _cache['launches_fetched'] = now - return _cache['launches'] +def _save_cache(): + """Persist current cache to disk so reboots don't lose data.""" + try: + with open(CACHE_FILE, 'w') as f: + json.dump({ + 'launches': _data_cache['launches'], + 'year_launches': _data_cache['year_launches'], + 'events': _data_cache['events'], + 'fetched_at': _data_cache['fetched_at'], + 'hourly_fetched': _data_cache['hourly_fetched'], + }, f) + except Exception as e: + print(f'[{_ts()}] Cache save error: {e}') +_load_cache() -def _get_weather(): - now = time.time() - if not _cache['weather'] or (now - _cache['weather_fetched']) > WEATHER_TTL: - _cache['weather'] = fetch_weather() - _cache['weather_fetched'] = now - return _cache['weather'] +def _fetch_upcoming(): + """Fetch upcoming launches — tries DO relay first, falls back to LL2 directly.""" + try: + relay_url = f'{RELAY_URL}/api/launches' + r = requests.get(relay_url, timeout=8) + if r.status_code == 200: + results = r.json() + if isinstance(results, list) and len(results) > 0: + launches = [] + skipped = 0 + for item in results: + norm = _normalize_launch(item) + if not _is_valid(norm): + skipped += 1 + continue + launches.append(norm) + if len(launches) >= 5: + break + print(f'[{_ts()}] Relay: {len(launches)} launches (fallback=off)') + return launches + except Exception as e: + print(f'[{_ts()}] Relay unavailable ({e}) — falling back to LL2') + try: + url = f'{LL2_BASE}/launches/upcoming/?limit=10&ordering=net&format=json&hide_recent_previous=true' + r = requests.get(url, timeout=LL2_TIMEOUT) + if r.status_code == 429: + print(f'[{_ts()}] LL2 rate limited — keeping cached launches') + return None + r.raise_for_status() + payload = r.json() + if not isinstance(payload, dict): + print(f'[{_ts()}] LL2 unexpected response: {str(payload)[:200]}') + return None + results = payload.get('results', []) + if not isinstance(results, list): + print(f'[{_ts()}] LL2 results not a list: {str(results)[:200]}') + return None + launches = [] + skipped = 0 + for item in results: + norm = _normalize_launch(item) + if not _is_valid(norm): + skipped += 1 + print(f'[{_ts()}] skip: {norm["name"]} ' + f'(vehicle="{norm["vehicle"]}", t0="{norm["t0"]}")') + continue + launches.append(norm) + if len(launches) >= 5: + break + print(f'[{_ts()}] Upcoming launches: {len(launches)} valid' + + (f', {skipped} skipped (TBD)' if skipped else '')) + for i, l in enumerate(launches): + marker = '>>>' if i == 0 else ' ' + print(f' {marker} [{i+1}] {l["name"]} | {l["vehicle"]} ' + f'| {l["status"]} | T0: {l["t0"] or "TBD"}') + return launches + except Exception as e: + print(f'[{_ts()}] Error fetching upcoming launches: {e}') + return None + +def _fetch_ll2(url, label): + """Generic LL2 GET — returns results list or None on error/rate-limit.""" + try: + r = requests.get(url, timeout=LL2_TIMEOUT) + if r.status_code == 429: + print(f'[{_ts()}] LL2 rate limited ({label})') + return None + r.raise_for_status() + payload = r.json() + results = payload.get('results', []) if isinstance(payload, dict) else [] + print(f'[{_ts()}] {label}: {len(results)}') + return results + except Exception as e: + print(f'[{_ts()}] Error fetching {label}: {e}') + return None + +def _fetch_year_launches(): + year_str = datetime.now().strftime('%Y-01-01') + return _fetch_ll2( + f'{LL2_BASE}/launches/?window_start__gte={year_str}&limit=100&ordering=window_start&format=json', + 'year launches') + +def _fetch_events(): + return _fetch_ll2(f'{LL2_BASE}/events/upcoming/?limit=5&format=json', 'events') + +_refresh_in_progress = False + +def _refresh_all(force_hourly=False): + """Refresh upcoming launches. Refresh year/events when stale or forced.""" + global _refresh_in_progress + if _refresh_in_progress: + print(f'[{_ts()}] Refresh already in progress — skipping') + return + _refresh_in_progress = True + try: + launches = _fetch_upcoming() + if launches is not None: + launches = _apply_t0_history(launches) + with _cache_lock: + _data_cache['launches'] = launches + _data_cache['fetched_at'] = time.time() + + now = time.time() + if force_hourly or (now - _data_cache.get('hourly_fetched', 0) > 3600): + time.sleep(5) + year = _fetch_year_launches() + if year is not None: + with _cache_lock: + _data_cache['year_launches'] = year + time.sleep(5) + events = _fetch_events() + if events is not None: + with _cache_lock: + _data_cache['events'] = events + with _cache_lock: + _data_cache['hourly_fetched'] = now + + _save_cache() + finally: + _refresh_in_progress = False + +def _background_thread(): + """Single background thread — refreshes all data and weather every 5 minutes. + Switches to 60-second refresh when the next launch is within 10 minutes.""" + time.sleep(2) + _refresh_all(force_hourly=True) # full fetch on startup + while True: + # Adaptive interval: 60s when next launch is within 10 min (before or after T-0) + interval = 300 + launches = _data_cache.get('launches') or [] + if launches: + try: + t0_str = (launches[0].get('t0') or '').strip() + if t0_str: + t0_dt = datetime.fromisoformat(t0_str.replace('Z', '+00:00')) + diff = (t0_dt - datetime.now(timezone.utc)).total_seconds() + if -300 <= diff <= 600: # 10 min before → 5 min after + interval = 60 + except Exception: + pass + time.sleep(interval) + _refresh_all() + # Refresh weather in background so /api/data never blocks on a network call + now = time.time() + if now - _weather_cache['fetched'] >= WEATHER_TTL: + data = _fetch_weather() + _weather_cache['data'] = data + _weather_cache['fetched'] = now + +_UNIT_ID_FILE = '/home/pi/.rangetrack_unit_id' + +def _get_unit_id(): + # Return cached ID if already resolved this session + if hasattr(_get_unit_id, '_cached'): + return _get_unit_id._cached + # Try to load persisted ID first + try: + uid = open(_UNIT_ID_FILE).read().strip() + if uid.startswith('LT-') and len(uid) > 4: + _get_unit_id._cached = uid + return uid + except Exception: + pass + # Derive from MAC — retry up to 10 times in case net isn't up yet + for _ in range(10): + for iface in ['wlan0', 'eth0', 'wlan1', 'en0']: + try: + mac = open(f'/sys/class/net/{iface}/address').read().strip() + if mac and mac != '00:00:00:00:00:00': + uid = 'LT-' + mac.replace(':', '')[-4:].upper() + try: + open(_UNIT_ID_FILE, 'w').write(uid) + except Exception: + pass + _get_unit_id._cached = uid + return uid + except Exception: + continue + import time as _t; _t.sleep(2) + uid = 'LT-UNKN' + _get_unit_id._cached = uid + return uid + +def _ping_relay(): + """Ping the DO relay every 5 minutes.""" + while True: + try: + settings = _load_settings() + unit_id = _get_unit_id() + wx = _weather_cache.get('data') or {} + requests.post(f'{RELAY_URL}/api/unit/ping', json={ + 'unit_id': unit_id, + 'version': VERSION, + 'site': settings.get('site', 'cape'), + 'condition': wx.get('condition', ''), + 'temp_f': wx.get('temp_f', 0), + }, timeout=5) + except Exception: + pass + time.sleep(300) + +def _poll_commands(): + """Poll DO relay for pending commands every 5 seconds.""" + while True: + try: + unit_id = _get_unit_id() + r = requests.get(f'{RELAY_URL}/api/unit/commands/{unit_id}', timeout=5) + for cmd in r.json(): + _execute_command(cmd.get('command', '')) + except Exception: + pass + time.sleep(5) +def _execute_command(cmd): + print(f'[{_ts()}] Remote command received: {cmd}') + try: + # Acknowledge to relay before executing (reboot won't be able to after) + try: + requests.post(f'{RELAY_URL}/api/unit/ack', + json={'unit_id': _get_unit_id(), 'command': cmd}, + timeout=5) + except Exception: + pass + # Queue on-screen notification for all pages (persisted to file to survive restart) + if cmd == 'update': + subprocess.Popen(['bash', '/home/pi/Desktop/LaunchTracker2D/launch-timer/Phase2/update.sh']) + elif cmd == 'reboot': + _notify_write('REBOOTING', 'System reboot in progress...') + subprocess.Popen(['bash', '-c', 'sleep 2 && sudo /sbin/reboot']) + except Exception as e: + print(f'[{_ts()}] Command error: {e}') + +threading.Thread(target=_background_thread, daemon=True).start() +threading.Thread(target=_ping_relay, daemon=True).start() +threading.Thread(target=_poll_commands, daemon=True).start() + + +# ── Auto brightness ─────────────────────────────────────────────────────────── + +def _backlight_path(): + """Return the first available backlight brightness path, or None.""" + import glob + for p in glob.glob('/sys/class/backlight/*/brightness'): + return p + return None -# ── Flask routes ────────────────────────────────────────────────────────────── +def _auto_brightness(): + while True: + try: + wx = _weather_cache.get('data') or {} + sunrise = wx.get('sunrise') + sunset = wx.get('sunset') + if sunrise and sunset: + now = datetime.now() + sr = datetime.fromisoformat(sunrise) + ss = datetime.fromisoformat(sunset) + DAY_MAX, NIGHT_MIN, FADE_SECS = 255, 51, 45 * 60 # NIGHT_MIN=51 ≈ 20% + after_sr = (now - sr).total_seconds() + before_ss = (ss - now).total_seconds() + if after_sr < 0 or before_ss < 0: + brightness = NIGHT_MIN + elif after_sr < FADE_SECS: + brightness = int(NIGHT_MIN + (DAY_MAX - NIGHT_MIN) * after_sr / FADE_SECS) + elif before_ss < FADE_SECS: + brightness = int(NIGHT_MIN + (DAY_MAX - NIGHT_MIN) * before_ss / FADE_SECS) + else: + brightness = DAY_MAX + try: + bp = _backlight_path() + if bp: + cur = int(open(bp).read().strip()) + if abs(cur - brightness) > 5: + open(bp, 'w').write(str(brightness)) + print(f'[{_ts()}] Auto brightness → {brightness}') + except Exception: + pass + except Exception as e: + print(f'[{_ts()}] Auto brightness error: {e}') + time.sleep(300) + +threading.Thread(target=_auto_brightness, daemon=True).start() + + +# ── Flask page routes ───────────────────────────────────────────────────────── @app.route('/') def index(): + if not os.path.exists(SETTINGS_FILE): + return redirect('/settings?setup=1') return send_from_directory(os.path.join(BASE_DIR, 'static'), 'index.html') - @app.route('/static/') def serve_static(path): - return send_from_directory(os.path.join(BASE_DIR, 'static'), path) + resp = send_from_directory(os.path.join(BASE_DIR, 'static'), path) + resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + resp.headers['Pragma'] = 'no-cache' + resp.headers['Expires'] = '0' + return resp + +@app.route('/mission') +def mission_page(): + return send_from_directory(os.path.join(BASE_DIR, 'static'), 'mission.html') + +@app.route('/launches') +def launches_page(): + return send_from_directory(os.path.join(BASE_DIR, 'static'), 'launches.html') + +@app.route('/settings') +def settings_page(): + return send_from_directory(os.path.join(BASE_DIR, 'static'), 'settings.html') + +@app.route('/wifi') +def wifi_page(): + return send_from_directory(os.path.join(BASE_DIR, 'static'), 'wifi.html') + +@app.route('/positioner') +def positioner_page(): + return send_from_directory(os.path.join(BASE_DIR, 'static'), 'asset-positioner.html') + +@app.route('/api/assets') +def api_assets(): + assets_dir = os.path.join(BASE_DIR, 'static', 'assets') + try: + files = sorted([f for f in os.listdir(assets_dir) if f.lower().endswith('.png')]) + except Exception: + files = [] + return jsonify(files) + +# ── Primary data endpoint — all pages read from here ───────────────────────── + +@app.route('/api/data') +def api_data(): + # Re-apply validity filter on cached data so status changes (e.g. "Launch in Flight") + # are reflected immediately without waiting for the next LL2 fetch + launches = [l for l in _data_cache.get('launches', []) if _is_valid(l)] + year_raw = _data_cache.get('year_launches', []) + events = _data_cache.get('events', []) + weather = _get_weather() + settings = _load_settings() + age_seconds = int(time.time() - _data_cache.get('fetched_at', time.time())) + + # Compute year stats from cached year_launches + now_dt = datetime.now(timezone.utc) + past_year = [] + for l in year_raw: + ws = l.get('window_start') or l.get('net') or '' + try: + if ws and datetime.fromisoformat(ws.replace('Z', '+00:00')) < now_dt: + past_year.append(l) + except Exception: + pass + orbital_year = len(past_year) + + # Pre-compute provider counts once instead of O(n*m) per launch + provider_counts = {} + for y in past_year: + name = (y.get('launch_service_provider') or {}).get('name', '') + if name: + provider_counts[name] = provider_counts.get(name, 0) + 1 + + enriched = [] + for launch in launches: + l = dict(launch) + provider = l.get('provider', '') + l['orbital_year'] = orbital_year + l['agency_year'] = provider_counts.get(provider, 0) + enriched.append(l) + + return jsonify({ + 'launches': enriched, + 'year_launches': year_raw, + 'events': events, + 'weather': weather, + 'settings': settings, + 'age_seconds': age_seconds, + }) + + +# ── Dev/test helper ────────────────────────────────────────────────────────── + +@app.route('/api/test-launch') +def api_test_launch(): + """Inject a fake Falcon 9 launch with T-0 = now + ?secs=N (default 60). + Prepends to /api/data response so the app treats it as the next launch.""" + secs = int(request.args.get('secs', 60)) + t0_dt = datetime.now(timezone.utc) + timedelta(seconds=secs) + t0_str = t0_dt.strftime('%Y-%m-%dT%H:%M:%SZ') + fake = { + 'id': 'test-9999', + 'name': 'TEST MISSION | TEST SAT-1', + 'vehicle': 'Falcon 9', + 'provider': 'SpaceX', + 'pad': 'SLC-40, Cape Canaveral', + 'location': 'Cape Canaveral, FL, USA', + 'pad_lat': '28.5618571', + 'pad_lon': '-80.577366', + 'status': 'Go for Launch', + 't0': t0_str, + 'win_open': t0_str, + 'win_close': '', + 'probability': 90, + 'mission_type': 'Communications', + 'mission_desc': 'A simulated test launch for development purposes.', + 'orbit_abbrev': 'LEO', + 'orbit_name': 'Low Earth Orbit', + 'program': 'TEST PROGRAM', + 'booster_serial': 'B1078', + 'booster_flight': 12, + 'booster_last_flight': '2024-11-15T00:00:00Z', + 'booster_prev_mission': 'Starlink Group 9-1', + 'recovery_vessel': 'A Shortfall of Gravitas', + 'pad_launches': 142, + 'last_updated': t0_str, + 'patch_url': None, + 'orbital_year': 0, + 'agency_year': 0, + } + weather = _get_weather() + settings = _load_settings() + return jsonify({ + 'launches': [fake], + 'year_launches': _data_cache.get('year_launches', []), + 'events': _data_cache.get('events', []), + 'weather': weather, + 'settings': settings, + 'age_seconds': 0, + '_test_mode': True, + }) + + +# ── Backward-compat shims (app.js still calls these) ───────────────────────── @app.route('/api/launches') def api_launches(): - """Return upcoming launches with countdown pre-computed.""" - launches = _get_launches() - result = [] - for lv in launches: - t0 = lv.get('t0') or lv.get('win_open') - countdown = get_countdown(t0) - result.append({ - 'id': lv.get('id'), - 'name': lv.get('name', 'Unknown Mission'), - 'vehicle': lv.get('vehicle', {}).get('name', 'Unknown'), - 'provider': lv.get('provider', {}).get('name', 'Unknown'), - 'pad': lv.get('pad', {}).get('name', 'Unknown'), - 'location': lv.get('pad', {}).get('location', {}).get('name', 'Unknown'), - 'status': lv.get('status', {}).get('name', 'TBD'), - 't0': t0, - 'win_open': lv.get('win_open'), - 'countdown': countdown, - 'result': lv.get('result'), - }) - return jsonify({'launches': result, 'fetched_at': _ts()}) - + """Thin wrapper over _data_cache — same data as /api/data.launches.""" + launches = _data_cache.get('launches', []) + age_seconds = int(time.time() - _data_cache.get('fetched_at', time.time())) + return jsonify({'launches': launches, 'age_seconds': age_seconds}) @app.route('/api/weather') def api_weather(): return jsonify(_get_weather()) +@app.route('/api/events') +def api_events(): + return jsonify(_data_cache.get('events', [])) + +@app.route('/api/launches/year') +def api_launches_year(): + return jsonify(_data_cache.get('year_launches', [])) @app.route('/api/launches/invalidate', methods=['POST']) def invalidate_launches(): - """Force a fresh launch fetch on next request (called after a launch completes).""" - _cache['launches_fetched'] = 0 - print(f"[{_ts()}] Launch cache invalidated") + """Force an immediate re-fetch on the background thread schedule.""" + _data_cache['fetched_at'] = 0 + threading.Thread(target=_refresh_all, daemon=True).start() + print(f'[{_ts()}] Launch cache invalidated — refreshing now') return jsonify({'ok': True}) -# ── WiFi routes ─────────────────────────────────────────────────────────────── +# ── Settings ────────────────────────────────────────────────────────────────── -@app.route('/wifi') -def wifi_page(): - return send_from_directory(os.path.join(BASE_DIR, 'static'), 'wifi.html') +@app.route('/api/settings', methods=['GET', 'POST']) +def api_settings(): + if request.method == 'GET': + return jsonify(_load_settings()) + settings = _load_settings() + settings.update(request.get_json() or {}) + _save_settings(settings) + global _location_cache + _location_cache = None + # Flush weather so next request reflects new site immediately + _weather_cache['data'] = None + _weather_cache['fetched'] = 0 + return jsonify({'ok': True, 'settings': settings}) + +@app.route('/api/settings/brightness', methods=['POST']) +def set_brightness(): + val = max(20, min(100, int((request.get_json() or {}).get('value', 40)))) + mapped = int(val * 2.55) + try: + bp = _backlight_path() + if not bp: + return jsonify({'ok': False, 'error': 'no backlight'}) + open(bp, 'w').write(str(mapped)) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}) + return jsonify({'ok': True}) +@app.route('/api/osk') +def api_osk(): + """Show or hide squeekboard on-screen keyboard via D-Bus (Pi 2 / Wayland).""" + show = request.args.get('show', '1') == '1' + value = 'true' if show else 'false' + try: + env = os.environ.copy() + env.setdefault('DBUS_SESSION_BUS_ADDRESS', 'unix:path=/run/user/1000/bus') + subprocess.run( + ['dbus-send', '--session', '--dest=sm.puri.OSK0', + '/sm/puri/OSK0', 'sm.puri.OSK0.SetVisible', f'boolean:{value}'], + timeout=2, env=env, capture_output=True) + except Exception as e: + print(f'[{_ts()}] OSK toggle error: {e}') + return jsonify({'ok': True, 'visible': show}) + + +@app.route('/api/settings/timezone', methods=['POST']) +def set_timezone(): + tz = (request.get_json() or {}).get('timezone', '') + if not tz: + return jsonify({'ok': False, 'error': 'no timezone'}) + try: + subprocess.run(['sudo', 'timedatectl', 'set-timezone', tz], check=True) + return jsonify({'ok': True}) + except Exception as e: + return jsonify({'ok': False, 'error': str(e)}) + + +# ── Device info ─────────────────────────────────────────────────────────────── + +@app.route('/api/device') +def api_device(): + mac = '??:??:??:??:??:??' + for iface in ['wlan0', 'eth0', 'end0', 'ens0']: + try: + m = open(f'/sys/class/net/{iface}/address').read().strip() + if m and m != '00:00:00:00:00:00': + mac = m + break + except Exception: + continue + auto_id = 'LT-' + mac.replace(':', '')[-4:].upper() + unit_id = _load_settings().get('unit_id', '').strip() or auto_id + try: + temp_raw = int(open('/sys/class/thermal/thermal_zone0/temp').read().strip()) + temp = f'{temp_raw / 1000:.1f}°C' + except Exception: + temp = '—' + try: + import subprocess as _sp + log = _sp.run(['git', '-C', os.path.dirname(os.path.abspath(__file__)), 'log', '-1', '--format=%ar'], capture_output=True, text=True) + last_update = log.stdout.strip() or '—' + except Exception: + last_update = '—' + return jsonify({'mac': mac, 'unit_id': unit_id, 'version': VERSION, 'temp': temp, 'last_update': last_update}) + + +# ── Pi notification (shown on all pages) ────────────────────────────────────── + +_NOTIFY_FILE = '/tmp/rangetrack_notify.json' +_NOTIFY_TTL = 16 # seconds — just over the 15s display time; survives page reloads but doesn't bleed into next update + +def _notify_write(title, msg): + """Write notification to file with expiry so it survives server restarts and page reloads.""" + import json as _json + entry = {'title': title, 'msg': msg, 'expires': time.time() + _NOTIFY_TTL} + try: + with open(_NOTIFY_FILE, 'w') as f: + _json.dump([entry], f) + except Exception: + pass + +@app.route('/api/notify') +def get_notify(): + import json as _json + now = time.time() + try: + with open(_NOTIFY_FILE) as f: + file_msgs = _json.load(f) + live = [m for m in file_msgs if m.get('expires', 0) > now] + if live: + return jsonify([{'title': m['title'], 'msg': m['msg']} for m in live]) + os.remove(_NOTIFY_FILE) + except Exception: + pass + return jsonify([]) + +@app.route('/api/notify-push', methods=['POST']) +def notify_push(): + data = request.get_json() or {} + if data.get('title'): + _notify_write(data['title'], data.get('msg', '')) + return jsonify({'ok': True}) + +@app.route('/api/version') +def get_version(): + return jsonify({'version': VERSION}) + +# ── Reboot ──────────────────────────────────────────────────────────────────── + +@app.route('/api/reboot', methods=['POST']) +def reboot(): + threading.Thread( + target=lambda: (time.sleep(1), subprocess.Popen(['sudo', '/sbin/reboot'])), + daemon=True).start() + return jsonify({'ok': True}) + +@app.route('/api/update', methods=['POST']) +def force_update(): + script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'update.sh') + threading.Thread( + target=lambda: subprocess.Popen(['bash', script], + stdout=open('/home/pi/update.log', 'a'), + stderr=subprocess.STDOUT), + daemon=True).start() + _notify_write('UPDATE', 'Force update triggered from Mission Control') + return jsonify({'ok': True}) + + + +# ── WiFi ────────────────────────────────────────────────────────────────────── + +def _use_nmcli(): + """True if NetworkManager is managing wifi (Pi OS Trixie+).""" + try: + r = subprocess.run(['nmcli', '-t', '-f', 'STATE', 'g'], + capture_output=True, text=True, timeout=5) + return 'connected' in r.stdout or 'disconnected' in r.stdout + except Exception: + return False + @app.route('/api/wifi/scan') def wifi_scan(): try: - # Bring interface up in case it's down - subprocess.run(['sudo', 'ifconfig', 'wlan0', 'up'], check=False) - import time - time.sleep(1) - result = subprocess.check_output(['sudo', 'iwlist', 'wlan0', 'scan'], text=True) - networks = [] - for line in result.split('\n'): - if 'ESSID:' in line: - ssid = line.split('ESSID:')[1].strip().strip('"') + if _use_nmcli(): + subprocess.run(['nmcli', 'dev', 'wifi', 'rescan'], capture_output=True, timeout=10) + result = subprocess.check_output( + ['nmcli', '-t', '-f', 'SSID', 'dev', 'wifi', 'list'], + text=True, timeout=15) + networks = [] + for line in result.strip().split('\n'): + ssid = line.strip() if ssid and ssid not in networks: networks.append(ssid) + else: + subprocess.run(['sudo', 'ifconfig', 'wlan0', 'up'], check=False) + time.sleep(1) + result = subprocess.check_output(['sudo', 'iwlist', 'wlan0', 'scan'], text=True, timeout=15) + networks = [] + for line in result.split('\n'): + if 'ESSID:' in line: + ssid = line.split('ESSID:')[1].strip().strip('"') + if ssid and ssid not in networks: + networks.append(ssid) return jsonify({'networks': networks}) except Exception as e: return jsonify({'networks': [], 'error': str(e)}) +@app.route('/api/wifi/current') +def wifi_current(): + try: + if _use_nmcli(): + result = subprocess.run( + ['nmcli', '-t', '-f', 'ACTIVE,SSID', 'dev', 'wifi'], + capture_output=True, text=True, timeout=5) + for line in result.stdout.split('\n'): + if line.startswith('yes:'): + return jsonify({'ssid': line.split(':', 1)[1].strip()}) + return jsonify({'ssid': ''}) + else: + result = subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'status'], + capture_output=True, text=True, timeout=5) + for line in result.stdout.split('\n'): + if line.startswith('ssid='): + return jsonify({'ssid': line.split('=', 1)[1].strip()}) + return jsonify({'ssid': ''}) + except Exception: + return jsonify({'ssid': ''}) @app.route('/api/wifi/connect', methods=['POST']) def wifi_connect(): - data = request.get_json() - ssid = data.get('ssid', '') + data = request.get_json() or {} + ssid = data.get('ssid', '') password = data.get('password', '') try: - # Add new network slot in memory - result = subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'add_network'], - capture_output=True, text=True) - net_id = result.stdout.strip() - print(f"[{_ts()}] WiFi: added network id={net_id}") - - # Configure it - subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'set_network', net_id, 'ssid', f'"{ssid}"'], check=True) - subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'set_network', net_id, 'psk', f'"{password}"'], check=True) - subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'select_network', net_id], check=True) - - # Wait for connection - time.sleep(8) - - # Check status - status = subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'status'], - capture_output=True, text=True) - print(f"[{_ts()}] WiFi status:\n{status.stdout}") - - connected = f'ssid={ssid}' in status.stdout and 'wpa_state=COMPLETED' in status.stdout - - if connected: - # Save to config permanently - clean_config = ( - 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n' - 'update_config=1\n' - 'country=US\n\n' - 'network={\n' - f' ssid="{ssid}"\n' - f' psk="{password}"\n' - ' key_mgmt=WPA-PSK\n' - '}\n' - ) - with open('/tmp/wpa_supplicant.conf', 'w') as f: - f.write(clean_config) - subprocess.run(['sudo', 'bash', '-c', 'cp /tmp/wpa_supplicant.conf /etc/wpa_supplicant/wpa_supplicant.conf'], check=True) - _cache['launches_fetched'] = 0 - _cache['weather_fetched'] = 0 - return jsonify({'ok': True}) + if _use_nmcli(): + # Delete any existing saved connection with this SSID first + subprocess.run(['nmcli', 'con', 'delete', ssid], + capture_output=True, timeout=5) + if password: + result = subprocess.run( + ['nmcli', 'dev', 'wifi', 'connect', ssid, 'password', password], + capture_output=True, text=True, timeout=30) + else: + result = subprocess.run( + ['nmcli', 'dev', 'wifi', 'connect', ssid], + capture_output=True, text=True, timeout=30) + connected = result.returncode == 0 and 'successfully activated' in result.stdout + if connected: + _data_cache['fetched_at'] = 0 + return jsonify({'ok': True}) + else: + err = result.stderr.strip() or result.stdout.strip() + return jsonify({'ok': False, 'error': err or 'Could not connect — wrong password?'}) else: - # Remove failed network and reconnect to old - subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'remove_network', net_id], check=True) - subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'reconfigure'], check=True) - return jsonify({'ok': False, 'error': 'Could not connect — wrong password?'}) - + result = subprocess.run( + ['sudo', 'wpa_cli', '-i', 'wlan0', 'add_network'], + capture_output=True, text=True, timeout=5) + net_id = result.stdout.strip() + if not net_id.isdigit(): + return jsonify({'ok': False, 'error': 'Failed to create network profile'}) + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'set_network', net_id, 'ssid', f'"{ssid}"'], check=True, timeout=5) + if password: + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'set_network', net_id, 'psk', f'"{password}"'], check=True, timeout=5) + else: + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'set_network', net_id, 'key_mgmt', 'NONE'], check=True, timeout=5) + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'select_network', net_id], check=True, timeout=5) + time.sleep(8) + status = subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'status'], + capture_output=True, text=True, timeout=5) + connected = (f'ssid={ssid}' in status.stdout and + 'wpa_state=COMPLETED' in status.stdout) + if connected: + if password: + net_block = f' ssid="{ssid}"\n psk="{password}"\n key_mgmt=WPA-PSK\n' + else: + net_block = f' ssid="{ssid}"\n key_mgmt=NONE\n' + clean_config = ( + 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n' + 'update_config=1\ncountry=US\n\n' + f'network={{\n{net_block}}}\n' + ) + with open('/tmp/wpa_supplicant.conf', 'w') as f: + f.write(clean_config) + subprocess.run(['sudo', 'bash', '-c', + 'cp /tmp/wpa_supplicant.conf /etc/wpa_supplicant/wpa_supplicant.conf'], + check=True, timeout=5) + _data_cache['fetched_at'] = 0 + return jsonify({'ok': True}) + else: + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'remove_network', net_id], timeout=5) + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'reconfigure'], timeout=5) + return jsonify({'ok': False, 'error': 'Could not connect — wrong password?'}) except Exception as e: - print(f"[{_ts()}] WiFi connect error: {e}") + print(f'[{_ts()}] WiFi connect error: {e}') return jsonify({'ok': False, 'error': str(e)}) # ── Entry point ─────────────────────────────────────────────────────────────── -def open_browser(): - time.sleep(1.2) - webbrowser.open('http://localhost:5001') - if __name__ == '__main__': - print(f"[{_ts()}] ══════════════════════════════════════") - print(f"[{_ts()}] Launch Countdown — Phase 2") - print(f"[{_ts()}] http://localhost:5001") - print(f"[{_ts()}] ══════════════════════════════════════") - threading.Thread(target=open_browser, daemon=True).start() - app.run(host='0.0.0.0', port=5001, debug=False) + print(f'[{_ts()}] ══════════════════════════════════════') + print(f'[{_ts()}] LaunchTracker2D — Phase 2') + print(f'[{_ts()}] http://localhost:5001') + print(f'[{_ts()}] ══════════════════════════════════════') + app.run(host='0.0.0.0', port=5001, debug=False, threaded=True) diff --git a/launch-timer/Phase2/settings.json b/launch-timer/Phase2/settings.json new file mode 100644 index 0000000..ba331e0 --- /dev/null +++ b/launch-timer/Phase2/settings.json @@ -0,0 +1 @@ +{"brightness": 89, "temp_unit": "c", "site": "cape", "time_format": "local", "unit_id": "", "timezone": "America/New_York", "auto_dim": true} \ No newline at end of file diff --git a/launch-timer/Phase2/setup.sh b/launch-timer/Phase2/setup.sh new file mode 100755 index 0000000..2bfdd52 --- /dev/null +++ b/launch-timer/Phase2/setup.sh @@ -0,0 +1,192 @@ +#!/bin/bash +# RangeTrack OS — Fresh Pi Setup Script +# Run once on a fresh Raspberry Pi OS installation. +# Usage: bash setup.sh +# +# What this does: +# 1. Installs dependencies (python3, flask, requests, chromium) +# 2. Clones the repo (or updates if already present) +# 3. Prompts for timezone +# 4. Sets up autostart (server + chromium kiosk) +# 5. Sets up wallpaper and hides taskbar +# 6. Removes boot splash +# 7. Sets up hourly auto-updater cron +# 8. Configures passwordless sudo for reboot +# 9. Installs unclutter (hides cursor) + +set -e + +REPO_URL="https://github.com/carsonmwolfe/LaunchTracker2D.git" +REPO_DIR="/home/pi/Desktop/LaunchTracker2D" +BRANCH="Phase3" +SERVER_DIR="$REPO_DIR/launch-timer/Phase2" +LOG_FILE="/home/pi/setup.log" + +log() { echo "[$(date '+%H:%M:%S')] $1" | tee -a "$LOG_FILE"; } + +log "=== RangeTrack OS Setup ===" + +# ── 1. Dependencies ──────────────────────────────────────────────────────────── +log "Installing dependencies..." +sudo apt-get update -qq +sudo apt-get install -y python3 python3-pip chromium xdotool git psmisc -qq +sudo apt-get install -y unclutter -qq 2>/dev/null || true +pip3 install flask requests --quiet --break-system-packages 2>/dev/null || pip3 install flask requests --quiet +log "Dependencies installed" + +# ── 2. Clone or update repo ──────────────────────────────────────────────────── +if [ -d "$REPO_DIR/.git" ]; then + log "Repo already exists — pulling latest..." + cd "$REPO_DIR" + git fetch origin "$BRANCH" --quiet + git reset --hard "origin/$BRANCH" +else + log "Cloning repo..." + mkdir -p /home/pi/Desktop + git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR" +fi + +chmod +x "$SERVER_DIR/update.sh" +chmod +x "$SERVER_DIR/start.sh" +log "Repo ready at $REPO_DIR" + +# ── 3. Timezone ──────────────────────────────────────────────────────────────── +echo "" +echo "Select timezone:" +echo " 1) America/New_York (Eastern)" +echo " 2) America/Chicago (Central)" +echo " 3) America/Denver (Mountain)" +echo " 4) America/Los_Angeles (Pacific)" +echo " 5) America/Phoenix (Arizona)" +echo " 6) Enter manually" +echo "" +read -rp "Choose [1-6]: " TZ_CHOICE +case "$TZ_CHOICE" in + 1) TZ_SET="America/New_York" ;; + 2) TZ_SET="America/Chicago" ;; + 3) TZ_SET="America/Denver" ;; + 4) TZ_SET="America/Los_Angeles" ;; + 5) TZ_SET="America/Phoenix" ;; + 6) read -rp "Enter timezone (e.g. Europe/London): " TZ_SET ;; + *) TZ_SET="America/New_York" ;; +esac +sudo timedatectl set-timezone "$TZ_SET" +log "Timezone set to $TZ_SET" + +# ── 4. Autostart ────────────────────────────────────────────────────────────── +log "Setting up autostart..." + +# labwc (Wayland — newer Pi OS Bookworm/Trixie) +if [ -d "/home/pi/.config/labwc" ] || command -v labwc &>/dev/null; then + mkdir -p /home/pi/.config/labwc + cat > /home/pi/.config/labwc/autostart << LABWCEOF +bash $SERVER_DIR/start.sh & +(sleep 3 && pkill -f 'lwrespawn.*wf-panel' && pkill -f 'wf-panel-pi') & +(sleep 1 && pkill -f 'gnome-keyring-daemon') & +LABWCEOF + log "labwc autostart configured" +fi + +# lxsession (X11 — older Pi OS) +for SESSION in LXDE-pi rpd-x; do + mkdir -p "/home/pi/.config/lxsession/$SESSION" + echo "@bash $SERVER_DIR/start.sh" > "/home/pi/.config/lxsession/$SESSION/autostart" +done +log "lxsession autostart configured" + +# ── 5. Wallpaper ────────────────────────────────────────────────────────────── +log "Setting wallpaper..." + +# 'default' is the profile pcmanfm reads on Pi OS Trixie (Wayland/labwc) +mkdir -p /home/pi/.config/pcmanfm/default +cat > /home/pi/.config/pcmanfm/default/desktop-items-0.conf << PCEOF +[*] +wallpaper_mode=4 +wallpaper=$SERVER_DIR/static/assets/BootLOGO.png +wallpaper_common=1 +desktop_bg=#060a10 +desktop_fg=#060a10 +desktop_shadow=#060a10 +show_documents=0 +show_trash=0 +show_mounts=0 +show_desktop=0 +PCEOF + +# rpd-labwc and LXDE-pi profiles as fallback +for SESSION in rpd-labwc LXDE-pi; do + mkdir -p "/home/pi/.config/pcmanfm/$SESSION" + cat > "/home/pi/.config/pcmanfm/$SESSION/desktop-items-0.conf" << PCEOF +[*] +wallpaper_mode=4 +wallpaper=$SERVER_DIR/static/assets/BootLOGO.png +wallpaper_common=1 +desktop_bg=#060a10 +desktop_fg=#060a10 +desktop_shadow=#060a10 +show_documents=0 +show_trash=0 +show_mounts=0 +show_desktop=0 +PCEOF +done +log "Wallpaper configured" + +# Hide desktop icons (repo folder etc) +echo "LaunchTracker2D" > /home/pi/Desktop/.hidden + +# ── Disable gnome-keyring (prevents "choose password" dialog on first boot) ─── +log "Disabling gnome-keyring..." +mkdir -p /home/pi/.config/autostart +for KR in gnome-keyring-secrets gnome-keyring-ssh gnome-keyring-pkcs11 gnome-keyring-gpg; do + cat > /home/pi/.config/autostart/${KR}.desktop << KREOF +[Desktop Entry] +Type=Application +Hidden=true +KREOF +done +log "gnome-keyring disabled" + +# ── 6. Hide taskbar (lxsession only — labwc taskbar killed via autostart) ────── +mkdir -p /home/pi/.config/lxpanel/LXDE-pi/panels +if [ ! -f /home/pi/.config/lxpanel/LXDE-pi/panels/panel ]; then + cat > /home/pi/.config/lxpanel/LXDE-pi/panels/panel << PEOF +Global { + edge=bottom + autohide=1 + heightwhenhidden=0 + height=28 +} +PEOF +fi +log "Taskbar set to auto-hide" + +# ── 7. Remove boot splash ────────────────────────────────────────────────────── +log "Removing boot splash..." +if [ -f /boot/firmware/cmdline.txt ]; then + sudo sed -i 's/ splash//g; s/splash //g' /boot/firmware/cmdline.txt + log "Splash removed from /boot/firmware/cmdline.txt" +elif [ -f /boot/cmdline.txt ]; then + sudo sed -i 's/ splash//g; s/splash //g' /boot/cmdline.txt + log "Splash removed from /boot/cmdline.txt" +fi + +# ── 8. Cron jobs ────────────────────────────────────────────────────────────── +log "Setting up cron jobs..." +( crontab -l 2>/dev/null | grep -v "update.sh" | grep -v "health.py"; \ + echo "0 * * * * bash $SERVER_DIR/update.sh >> /home/pi/update.log 2>&1"; \ + echo "0 8 * * * python3 $SERVER_DIR/health.py digest >> /home/pi/health.log 2>&1"; \ + echo "*/5 * * * * python3 $SERVER_DIR/health.py check >> /home/pi/health.log 2>&1" \ +) | crontab - +log "Cron installed — updater hourly, health digest 8am daily, health check every 5min" + +# ── 9. Passwordless sudo for reboot ─────────────────────────────────────────── +log "Configuring passwordless reboot..." +printf 'pi ALL=(ALL) NOPASSWD: /sbin/reboot\npi ALL=(ALL) NOPASSWD: /usr/bin/timedatectl\n' | sudo tee /etc/sudoers.d/rangetrack > /dev/null +log "Done" + +# ── Done ────────────────────────────────────────────────────────────────────── +log "" +log "=== Setup complete — reboot to launch ===" +echo "" +echo "All done. Run: sudo reboot" diff --git a/launch-timer/Phase2/start.sh b/launch-timer/Phase2/start.sh new file mode 100644 index 0000000..6d7d82a --- /dev/null +++ b/launch-timer/Phase2/start.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# RangeTrack OS — Startup & Supervisor +# Handles server start, health check, Chromium launch, and crash recovery. + +APP_DIR="/home/pi/Desktop/LaunchTracker2D/launch-timer/Phase2" +SERVER_LOG="/home/pi/server.log" +# Support both chromium-browser (older Pi OS) and chromium (newer) +if [ -x "/usr/bin/chromium-browser" ]; then + CHROMIUM="/usr/bin/chromium-browser" +else + CHROMIUM="/usr/bin/chromium" +fi +APP_URL="http://localhost:5001/" +BOOT_URL="file://$APP_DIR/static/boot.html" + +CHROMIUM_FLAGS="--kiosk --noerrdialogs --disable-infobars \ + --disable-features=ChromeWhatsNew --no-default-browser-check \ + --disable-background-networking --disable-session-crashed-bubble \ + --enable-virtual-keyboard --window-size=800,480 \ + --disable-notifications --disable-popup-blocking \ + --no-first-run --use-angle=gles \ + --ozone-platform=wayland \ + --password-store=basic \ + --disable-renderer-accessibility \ + --enable-wayland-ime" + +# ── Ensure DISPLAY / WAYLAND_DISPLAY are set ───────────────────────────────── +export DISPLAY=${DISPLAY:-:0} +export WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-0} + +# ── Kill any stale Chromium + singleton lock ────────────────────────────────── +pkill -f chromium 2>/dev/null; sleep 2 +rm -f /home/pi/.config/chromium/SingletonLock \ + /home/pi/.config/chromium/SingletonCookie \ + /home/pi/.config/chromium/SingletonSocket 2>/dev/null + +# ── Clear screen to dark ────────────────────────────────────────────────────── +xsetroot -solid '#060a10' 2>/dev/null || true + +# ── Kill anything already on port 5001 ─────────────────────────────────────── +fuser -k 5001/tcp 2>/dev/null || kill $(fuser 5001/tcp 2>/dev/null) 2>/dev/null || true +sleep 1 + +# ── Start server ────────────────────────────────────────────────────────────── +cd "$APP_DIR" || exit 1 +nohup python3 server.py >> "$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +# ── Launch Chromium immediately to boot.html (hides desktop while server loads) ── +"$CHROMIUM" $CHROMIUM_FLAGS "$BOOT_URL" & +CHROMIUM_PID=$! + +# ── Wait for server to be ready (max 30s) — boot.html will redirect when ready ── +for i in $(seq 1 20); do + if curl -s -o /dev/null "$APP_URL" --max-time 1 2>/dev/null; then + break + fi + sleep 1.5 +done + +# ── Supervisor loop — restart server if it crashes ─────────────────────────── +while true; do + sleep 15 + + if ! kill -0 $SERVER_PID 2>/dev/null; then + # Don't restart if update.sh is already handling it + if [ -e "/tmp/rangetrack_update.lock" ]; then + echo "[$(date '+%H:%M:%S')] Server down but update in progress — skipping restart" >> "$SERVER_LOG" + else + echo "[$(date '+%H:%M:%S')] Server crashed — restarting..." >> "$SERVER_LOG" + fuser -k 5001/tcp 2>/dev/null || kill $(fuser 5001/tcp 2>/dev/null) 2>/dev/null || true + sleep 1 + cd "$APP_DIR" + nohup python3 server.py >> "$SERVER_LOG" 2>&1 & + SERVER_PID=$! + sleep 5 + # Client detects server restart via watchdog and reloads itself + fi + else + # Update may have restarted the server — re-acquire PID so supervisor stays accurate + NEW_PID=$(fuser 5001/tcp 2>/dev/null | awk '{print $1}') + if [ -n "$NEW_PID" ] && [ "$NEW_PID" != "$SERVER_PID" ]; then + SERVER_PID=$NEW_PID + fi + fi + + # If Chromium died too, relaunch it + if ! kill -0 $CHROMIUM_PID 2>/dev/null; then + "$CHROMIUM" $CHROMIUM_FLAGS "$APP_URL" & + CHROMIUM_PID=$! + fi +done diff --git a/launch-timer/Phase2/static/.DS_Store b/launch-timer/Phase2/static/.DS_Store index c76cd3a..41aba9e 100644 Binary files a/launch-timer/Phase2/static/.DS_Store and b/launch-timer/Phase2/static/.DS_Store differ diff --git a/launch-timer/Phase2/static/asset-positioner.html b/launch-timer/Phase2/static/asset-positioner.html index f29290d..2dd5bbb 100644 --- a/launch-timer/Phase2/static/asset-positioner.html +++ b/launch-timer/Phase2/static/asset-positioner.html @@ -2,570 +2,405 @@ -Asset Positioner v2 +Rocket Positioner -

🚀 ASSET POSITIONER v2

-

Drag · Scale · Rotate · Save to app.js

+ +
+

ROCKET POSITIONER

+ Select rocket → adjust sliders → copy config +
+
- -
+ + -
- 📂 Load PNG Assets -
select multiple files at once -
- + +
+ +
click canvas to read position
+
-
- 📄 Load app.js (to enable Save) -
- - -
Assets
-
No assets loaded
- -
Transform
-
-
Click an asset to select it
-