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 км → спуск) в
точном формате пакетов бэкенда. Подключаться на `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

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
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 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
):