diff --git a/README.md b/README.md index 259e570..e0d4c27 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,46 @@ python3 test_server.py --speedup 200 --start 5900 # сразу перед ра Эмулирует полный профиль полёта (подъём 5 м/с → разрыв на 30 км → спуск) в точном формате пакетов бэкенда. Подключаться на `ws://localhost:8765/`. +## Демо бок-о-бок с leaflet_svelte (через Django, как в проде) + +```bash +scripts/demo-stack.sh # Django :8000 + leaflet :5173 + дэшборд :5174 + станция +scripts/demo-stack.sh restart-flight # полёт заново: рестарт станции + чистка истории в БД +scripts/demo-stack.sh stop +``` + +Станция летит по `mock_prediction.json` из корня репозиториев (реальный +предикт) со случайным дрейфом до ±5 км. Каждый запуск/`restart-flight` +начинает полёт с нуля; после него нажми «Отключиться/Подключиться» в обоих +окнах, чтобы сбросить старые графики. Темп: `SPEEDUP=60 scripts/demo-stack.sh`. + +Скрипт также поднимает **фейковый predictor на :8080** (захардкоженный адрес +`TawhiriClient` в Django), который отдаёт тот же `mock_prediction.json` — +поэтому «Рассчитать» в leaflet возвращает ровно ту траекторию, по которой +летит станция. Порядок для оверлея прогноза в слежении: `/predict` → +«Рассчитать» → перейти на «Слежение» **кликом в навбаре** (результат прогноза +живёт в памяти SPA и не переживает перезагрузку страницы) → выбрать рабочую +область в «Сравнении с прогнозом». Если на :8080 уже работает настоящий +predictor — скрипт его не трогает и прогнозы идут через него. + +График отклонений сопоставляет точки по времени: при ускоренном полёте +(speedup > 1) времена телеметрии и прогноза расходятся — для честного +сравнения ставь дату запуска в форме «сейчас» и `SPEEDUP=1`. + +Скрипт (адаптация leaflet_svelte/scripts/run-stack.sh под новые пути) сам +создаёт спутник и токен, запускает `test_server.py --push` как наземную +станцию и печатает UUID спутника. Дальше: + +- **leaflet**: http://localhost:5173/track → логин demo/demo → вставить UUID → + «Подключиться»; +- **дэшборд**: http://localhost:5174/ → адрес + `ws://localhost:8000/api/ws/satellite//telemetry/` → «Подключиться» + (адрес запоминается в localStorage; история за прошедший полёт подтянется + по REST автоматически). + +Оба окна читают один и тот же поток из Django. Если фронты уже запущены +руками — скрипт их не трогает, просто вставь UUID в оба окна. + ## Пре-флайт аудит форматов ```bash diff --git a/scripts/demo-stack.sh b/scripts/demo-stack.sh new file mode 100755 index 0000000..90746c1 --- /dev/null +++ b/scripts/demo-stack.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Boot the side-by-side demo: one synthetic ground station feeding BOTH UIs +# through the real Django backend (production topology). +# +# test_server.py --push ──▶ Django :8000 ──▶ leaflet_svelte :5173 (/track) +# └──▶ telemetry-dashboard :5174 +# +# The station flies along mock_prediction.json (the real predict) with a +# bounded random drift, if that file exists at the repo root. +# +# Adapted from leaflet_svelte/scripts/run-stack.sh (paths updated after the +# repo move; DJANGO_ENV is NOT set so Django uses the local sqlite db). +# +# Usage: +# scripts/demo-stack.sh # start everything +# scripts/demo-stack.sh restart-flight # начать полёт заново (станция + чистка истории) +# scripts/demo-stack.sh stop # stop everything +set -euo pipefail + +ROOT=/home/anton/stratoflights +BACKEND_DIR=$ROOT/stratoflights +LEAFLET_DIR=$ROOT/leaflet_svelte +DASH_DIR=$ROOT/telemetry-dashboard +TRAJECTORY=$ROOT/mock_prediction.json +PID_DIR=/tmp/strato-demo +SPEEDUP=${SPEEDUP:-20} + +provision() { # prints "SAT TOKEN" + (cd "$BACKEND_DIR" && python3 - <<'EOF' +import os, django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'stratoflights.settings') +django.setup() +from stratoflights_api.models import Satellite, User +from rest_framework.authtoken.models import Token +u = User.objects.get(username='demo') +sat, _ = Satellite.objects.get_or_create(name='demo-balloon', user=u) +u.satellites.add(sat) +token, _ = Token.objects.get_or_create(user=u) +print(sat.id, token.key) +EOF + ) +} + +wipe_history() { + (cd "$BACKEND_DIR" && python3 - <<'EOF' +import os, django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'stratoflights.settings') +django.setup() +from stratoflights_api.models import TelemetryPacket +n, _ = TelemetryPacket.objects.filter(satellite__name='demo-balloon').delete() +print(f"история очищена: {n} пакетов") +EOF + ) +} + +kill_station() { + pkill -f 'test_server.py --push' 2>/dev/null && sleep 1 || true + rm -f "$PID_DIR/station.pid" +} + +start_station() { # $1=SAT $2=TOKEN + local traj_args=() + [[ -f "$TRAJECTORY" ]] && traj_args=(--trajectory "$TRAJECTORY") + echo "starting synthetic ground station (speedup x$SPEEDUP)" + cd "$DASH_DIR" + python3 -u test_server.py --push ws://localhost:8000 \ + --satellite "$1" --token "$2" \ + --interval 1 --speedup "$SPEEDUP" "${traj_args[@]}" \ + > /tmp/strato-demo-station.log 2>&1 & + echo $! > "$PID_DIR/station.pid" +} + +case "${1:-}" in +stop) + for f in "$PID_DIR"/*.pid; do + [[ -f "$f" ]] && kill "$(cat "$f")" 2>/dev/null && echo "stopped $(basename "$f" .pid)" + rm -f "$f" + done + exit 0 + ;; +restart-flight) + mkdir -p "$PID_DIR" + kill_station + wipe_history + read -r SAT TOKEN < <(provision) + start_station "$SAT" "$TOKEN" + echo "полёт перезапущен с нуля. Нажми Отключиться/Подключиться в обоих окнах," + echo "чтобы сбросить старые графики (история в БД уже чистая)." + exit 0 + ;; +esac + +mkdir -p "$PID_DIR" + +# --- fake predictor on :8080 (TawhiriClient's hardcoded port) ------------------ +# Serves the mock_prediction trajectory, so «Запустить прогноз» in leaflet +# returns exactly the flight the demo station is flying. +if ! ss -tlnp 2>/dev/null | grep -q ':8080'; then + echo "starting fake predictor on :8080" + FAKE_TAWHIRI_TRAJECTORY="$TRAJECTORY" \ + python3 -u "$LEAFLET_DIR/tests/e2e/fake_tawhiri.py" 8080 \ + > /tmp/strato-demo-predictor.log 2>&1 & + echo $! > "$PID_DIR/predictor.pid" +fi + +# --- Django (sqlite dev settings) -------------------------------------------- +# Restart if it's ours (pid file) so env changes (CSRF origins) apply. +if [[ -f "$PID_DIR/django.pid" ]] && kill -0 "$(cat "$PID_DIR/django.pid")" 2>/dev/null; then + kill "$(cat "$PID_DIR/django.pid")" 2>/dev/null || true + sleep 1 +fi +if ! ss -tlnp 2>/dev/null | grep -q ':8000'; then + echo "starting Django on :8000" + cd "$BACKEND_DIR" + DEBUG=True \ + ALLOWED_HOSTS=localhost,127.0.0.1 \ + CSRF_TRUSTED_ORIGINS="http://localhost:5173,http://localhost:5174,http://localhost:8000,http://127.0.0.1:5173,http://127.0.0.1:5174,http://127.0.0.1:8000" \ + python3 manage.py runserver 0.0.0.0:8000 > /tmp/strato-demo-django.log 2>&1 & + echo $! > "$PID_DIR/django.pid" + sleep 3 +else + echo "ВНИМАНИЕ: :8000 занят не нашим Django — если ловишь CSRF-ошибку, останови его и перезапусти этот скрипт" +fi + +read -r SAT TOKEN < <(provision) + +# --- synthetic ground station (push mode) ------------------------------------- +# всегда стартуем полёт с нуля: старую станцию гасим, историю чистим +kill_station +wipe_history +start_station "$SAT" "$TOKEN" + +# --- leaflet_svelte ----------------------------------------------------------- +if ! ss -tlnp 2>/dev/null | grep -q ':5173'; then + echo "starting leaflet_svelte on :5173" + cd "$LEAFLET_DIR" + VITE_USE_MOCK_API=false VITE_API_BASE_URL=/api VITE_API_PROXY_TARGET=http://localhost:8000 \ + npm run dev -- --port 5173 --strictPort > /tmp/strato-demo-leaflet.log 2>&1 & + echo $! > "$PID_DIR/leaflet.pid" +fi + +# --- telemetry-dashboard ------------------------------------------------------ +if ! ss -tlnp 2>/dev/null | grep -q ':5174'; then + echo "starting telemetry-dashboard on :5174" + cd "$DASH_DIR" + VITE_DEFAULT_WS_URL="ws://localhost:8000/api/ws/satellite/$SAT/telemetry/" \ + npm run dev -- --port 5174 --strictPort > /tmp/strato-demo-dash.log 2>&1 & + echo $! > "$PID_DIR/dash.pid" +fi + +sleep 2 +echo +echo "--- demo stack ready ---" +echo " satellite UUID: $SAT" +echo " leaflet (карта): http://localhost:5173/track (login: demo/demo, ID спутника выше)" +echo " дэшборд (приборы): http://localhost:5174/ (ws://localhost:8000/api/ws/satellite/$SAT/telemetry/)" +echo +echo "перезапуск полёта: scripts/demo-stack.sh restart-flight" +echo "logs: /tmp/strato-demo-*.log stop: scripts/demo-stack.sh stop" diff --git a/test_server.py b/test_server.py index 795e312..26ef5a6 100644 --- a/test_server.py +++ b/test_server.py @@ -11,14 +11,24 @@ Usage: --speedup compresses flight time: 20 means one wall-clock second advances the flight by 20 s (a 3 h flight replays in ~9 min). + +Push mode (--push): instead of serving, connect to the Django backend as a +ground station and push the same synthetic flight there. Both leaflet_svelte +(tracking) and this dashboard can then read the shared stream from +ws:///api/ws/satellite//telemetry/: + + python3 test_server.py --push ws://localhost:8000 \ + --satellite --token --speedup 20 """ import argparse import asyncio +import bisect import json import math import random import time +from datetime import datetime import websockets @@ -28,14 +38,51 @@ GROUND_ALT = 200.0 BASE_LAT, BASE_LON = 61.66, 129.38 START_WALL = time.time() +# Random lat/lon drift around the trajectory (metres): each packet takes a +# random step; past DRIFT_MAX_M the next step is forced back toward zero. +DRIFT_STEP_M = 150.0 +DRIFT_MAX_M = 5000.0 -def sample(flight_s: float) -> dict: +TRAJ = None # [(t_s, lat, lon, alt), ...] from --trajectory, else synthetic +_TRAJ_TIMES = [] +_drift = {"n": 0.0, "e": 0.0} + + +def load_trajectory(path): + """stratoflights/Tawhiri prediction JSON -> [(sec_from_launch, lat, lon, alt)].""" + d = json.load(open(path)) + stages = (d.get("result") or d)["prediction"] + pts = [p for s in stages for p in s["trajectory"]] + t0 = datetime.fromisoformat(pts[0]["datetime"].replace("Z", "+00:00")) + traj = [] + for p in pts: + t = (datetime.fromisoformat(p["datetime"].replace("Z", "+00:00")) - t0).total_seconds() + traj.append((t, p["latitude"], p["longitude"], p["altitude"])) + return traj + + +def traj_at(flight_s): + """Interpolated (lat, lon, alt, landed) along TRAJ.""" + if flight_s <= TRAJ[0][0]: + _, lat, lon, alt = TRAJ[0] + return lat, lon, alt, False + if flight_s >= TRAJ[-1][0]: + _, lat, lon, alt = TRAJ[-1] + return lat, lon, alt, True + i = bisect.bisect_right(_TRAJ_TIMES, flight_s) + t0, la0, lo0, al0 = TRAJ[i - 1] + t1, la1, lo1, al1 = TRAJ[i] + f = (flight_s - t0) / (t1 - t0) + return la0 + f * (la1 - la0), lo0 + f * (lo1 - lo0), al0 + f * (al1 - al0), False + + +def synth_at(flight_s): + """Fallback synthetic profile: 5 m/s ascent to 30 km, parachute descent.""" ascent_end = (BURST_ALT - GROUND_ALT) / ASCENT_MS if flight_s < ascent_end: alt = GROUND_ALT + ASCENT_MS * flight_s - vs_phase = "ascent" + landed = False else: - # parachute descent, faster in thin air t = flight_s - ascent_end alt = BURST_ALT dt, step = 0.0, 5.0 @@ -44,7 +91,26 @@ def sample(flight_s: float) -> dict: alt -= rate * step dt += step alt = max(alt, GROUND_ALT) - vs_phase = "descent" if alt > GROUND_ALT else "landed" + landed = alt <= GROUND_ALT + lat = BASE_LAT + flight_s * 1.2e-5 + lon = BASE_LON + flight_s * 6e-5 + return lat, lon, alt, landed + + +def drift_step(): + for k in ("n", "e"): + d = random.uniform(-DRIFT_STEP_M, DRIFT_STEP_M) + if abs(_drift[k]) > DRIFT_MAX_M: + d = -math.copysign(abs(d), _drift[k]) # forced step back + _drift[k] += d + + +def sample(flight_s: float) -> dict: + lat, lon, alt, landed = traj_at(flight_s) if TRAJ else synth_at(flight_s) + drift_step() + lat += _drift["n"] / 111320.0 + lon += _drift["e"] / (111320.0 * math.cos(math.radians(lat))) + vs_phase = "landed" if landed else "flight" alt_noise = alt + random.uniform(-8, 8) pressure = 101325.0 * math.exp(-alt / 8400.0) @@ -64,8 +130,8 @@ def sample(flight_s: float) -> dict: "id": "test", # flight-time timestamp so derived vertical speed is realistic under --speedup "timestamp": int(START_WALL + flight_s), - "lat": BASE_LAT + flight_s * 1.2e-5 + random.uniform(-1e-5, 1e-5), - "lon": BASE_LON + flight_s * 6e-5 + random.uniform(-1e-5, 1e-5), + "lat": round(lat, 7), + "lon": round(lon, 7), "alt": round(alt_noise, 1), "payload": { "utc": time.strftime("%H:%M:%S", utc) + ".00", @@ -102,6 +168,33 @@ async def handler(ws, interval: float, speedup: float, start_s: float = 0.0): print(f"client disconnected after {n} packets") +async def push(base_url, satellite, token, interval, speedup, start_s): + """Ground-station mode: push the synthetic flight into the Django backend.""" + base = base_url.rstrip("/").replace("http://", "ws://").replace("https://", "wss://") + url = f"{base}/api/ws/station/{satellite}/telemetry/?token={token}" + n = 0 + while True: + try: + async with websockets.connect(url) as ws: + print(f"pushing to {url.split('?')[0]}") + + async def log_errors(): + async for msg in ws: + print(f" server: {msg}") + + reader = asyncio.ensure_future(log_errors()) + while True: + flight_s = start_s + (time.time() - START_WALL) * speedup + await ws.send(json.dumps(sample(flight_s))) + n += 1 + if n % 25 == 0: + print(f" sent {n} packets, flight time {flight_s/60:.1f} min") + await asyncio.sleep(interval) + except (OSError, websockets.WebSocketException) as e: + print(f"push link down ({e}); retrying in 3 s") + await asyncio.sleep(3) + + async def main(): ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=8765) @@ -109,8 +202,27 @@ async def main(): ap.add_argument("--speedup", type=float, default=20.0, help="flight seconds per wall second") ap.add_argument("--start", type=float, default=0.0, help="flight seconds to skip at connect (5900 ≈ just before burst)") + ap.add_argument("--push", metavar="URL", help="push to Django backend instead of serving") + ap.add_argument("--satellite", help="satellite UUID (push mode)") + ap.add_argument("--token", help="DRF auth token (push mode)") + ap.add_argument("--trajectory", metavar="JSON", + help="fly along a stratoflights/Tawhiri prediction JSON " + "(с рандомным дрейфом ±5 км) вместо встроенного профиля") args = ap.parse_args() + if args.trajectory: + global TRAJ, _TRAJ_TIMES + TRAJ = load_trajectory(args.trajectory) + _TRAJ_TIMES = [p[0] for p in TRAJ] + print(f"trajectory: {len(TRAJ)} pts, {_TRAJ_TIMES[-1]/60:.0f} min, " + f"launch {TRAJ[0][1]:.5f},{TRAJ[0][2]:.5f}") + + if args.push: + if not args.satellite or not args.token: + ap.error("--push requires --satellite and --token") + await push(args.push, args.satellite, args.token, args.interval, args.speedup, args.start) + return + async with websockets.serve( lambda ws: handler(ws, args.interval, args.speedup, args.start), "localhost", args.port ):