Test server

This commit is contained in:
Anatoly Antonov 2026-07-04 04:18:36 +09:00
parent 43c120df4c
commit 1e2a276126
3 changed files with 317 additions and 6 deletions

View file

@ -47,6 +47,46 @@ python3 test_server.py --speedup 200 --start 5900 # сразу перед ра
Эмулирует полный профиль полёта (подъём 5 м/с → разрыв на 30 км → спуск) в Эмулирует полный профиль полёта (подъём 5 м/с → разрыв на 30 км → спуск) в
точном формате пакетов бэкенда. Подключаться на `ws://localhost:8765/`. точном формате пакетов бэкенда. Подключаться на `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/<UUID>/telemetry/` → «Подключиться»
(адрес запоминается в localStorage; история за прошедший полёт подтянется
по REST автоматически).
Оба окна читают один и тот же поток из Django. Если фронты уже запущены
руками — скрипт их не трогает, просто вставь UUID в оба окна.
## Пре-флайт аудит форматов ## Пре-флайт аудит форматов
```bash ```bash

159
scripts/demo-stack.sh Executable file
View file

@ -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"

View file

@ -11,14 +11,24 @@ Usage:
--speedup compresses flight time: 20 means one wall-clock second advances the --speedup compresses flight time: 20 means one wall-clock second advances the
flight by 20 s (a 3 h flight replays in ~9 min). 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://<host>/api/ws/satellite/<uuid>/telemetry/:
python3 test_server.py --push ws://localhost:8000 \
--satellite <uuid> --token <drf-token> --speedup 20
""" """
import argparse import argparse
import asyncio import asyncio
import bisect
import json import json
import math import math
import random import random
import time import time
from datetime import datetime
import websockets import websockets
@ -28,14 +38,51 @@ GROUND_ALT = 200.0
BASE_LAT, BASE_LON = 61.66, 129.38 BASE_LAT, BASE_LON = 61.66, 129.38
START_WALL = time.time() 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 ascent_end = (BURST_ALT - GROUND_ALT) / ASCENT_MS
if flight_s < ascent_end: if flight_s < ascent_end:
alt = GROUND_ALT + ASCENT_MS * flight_s alt = GROUND_ALT + ASCENT_MS * flight_s
vs_phase = "ascent" landed = False
else: else:
# parachute descent, faster in thin air
t = flight_s - ascent_end t = flight_s - ascent_end
alt = BURST_ALT alt = BURST_ALT
dt, step = 0.0, 5.0 dt, step = 0.0, 5.0
@ -44,7 +91,26 @@ def sample(flight_s: float) -> dict:
alt -= rate * step alt -= rate * step
dt += step dt += step
alt = max(alt, GROUND_ALT) 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) alt_noise = alt + random.uniform(-8, 8)
pressure = 101325.0 * math.exp(-alt / 8400.0) pressure = 101325.0 * math.exp(-alt / 8400.0)
@ -64,8 +130,8 @@ def sample(flight_s: float) -> dict:
"id": "test", "id": "test",
# flight-time timestamp so derived vertical speed is realistic under --speedup # flight-time timestamp so derived vertical speed is realistic under --speedup
"timestamp": int(START_WALL + flight_s), "timestamp": int(START_WALL + flight_s),
"lat": BASE_LAT + flight_s * 1.2e-5 + random.uniform(-1e-5, 1e-5), "lat": round(lat, 7),
"lon": BASE_LON + flight_s * 6e-5 + random.uniform(-1e-5, 1e-5), "lon": round(lon, 7),
"alt": round(alt_noise, 1), "alt": round(alt_noise, 1),
"payload": { "payload": {
"utc": time.strftime("%H:%M:%S", utc) + ".00", "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") 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(): async def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8765) 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("--speedup", type=float, default=20.0, help="flight seconds per wall second")
ap.add_argument("--start", type=float, default=0.0, ap.add_argument("--start", type=float, default=0.0,
help="flight seconds to skip at connect (5900 ≈ just before burst)") 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() 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( async with websockets.serve(
lambda ws: handler(ws, args.interval, args.speedup, args.start), "localhost", args.port lambda ws: handler(ws, args.interval, args.speedup, args.start), "localhost", args.port
): ):