234 lines
8.8 KiB
Python
234 lines
8.8 KiB
Python
"""Synthetic telemetry WS server for dashboard development.
|
||
|
||
Emits packets in the exact shape the stratoflights backend broadcasts
|
||
(see stratoflights_api/consumers.py), simulating a full flight profile:
|
||
ascent at ~5 m/s to 30 km, burst, parachute descent. Point the dashboard at
|
||
ws://localhost:8765/ and press "Подключиться".
|
||
|
||
Usage:
|
||
pip install websockets
|
||
python3 test_server.py [--interval 1.0] [--speedup 20]
|
||
|
||
--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
|
||
|
||
BURST_ALT = 30000.0
|
||
ASCENT_MS = 5.0
|
||
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
|
||
|
||
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
|
||
landed = False
|
||
else:
|
||
t = flight_s - ascent_end
|
||
alt = BURST_ALT
|
||
dt, step = 0.0, 5.0
|
||
while dt < t and alt > GROUND_ALT:
|
||
rate = 5.0 + 45.0 * (alt / BURST_ALT) ** 2
|
||
alt -= rate * step
|
||
dt += step
|
||
alt = max(alt, GROUND_ALT)
|
||
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)
|
||
temp_ext = 15.0 - 6.5 * min(alt, 11000) / 1000.0 + (0.002 * max(alt - 20000, 0))
|
||
temp_int = max(temp_ext + 25.0, -15.0)
|
||
hum = max(5.0, 60.0 - alt / 400.0)
|
||
vbat = 3900 - flight_s * 0.06 + random.uniform(-5, 5)
|
||
|
||
flags = ["GPS_FIX", "GPS_TIME_VALID", "SD_OK", "MS5611_OK", "SHT45_OK", "PWR_GNSS", "PWR_SD"]
|
||
if temp_int < 0:
|
||
flags.append("HEATER1_ON")
|
||
if vbat < 3300: # firmware BATT_LOW_MV (board.h)
|
||
flags.append("LOW_BATT")
|
||
|
||
utc = time.gmtime()
|
||
return {
|
||
"id": "test",
|
||
# flight-time timestamp so derived vertical speed is realistic under --speedup
|
||
"timestamp": int(START_WALL + flight_s),
|
||
"lat": round(lat, 7),
|
||
"lon": round(lon, 7),
|
||
"alt": round(alt_noise, 1),
|
||
"payload": {
|
||
"utc": time.strftime("%H:%M:%S", utc) + ".00",
|
||
"callsign": "YKSA-1",
|
||
"uptime_s": int(flight_s) + 600,
|
||
"sats": random.randint(7, 12) if vs_phase != "landed" else random.randint(4, 8),
|
||
"vbat_mv": int(vbat),
|
||
"pressure_pa": int(pressure),
|
||
"baro_alt": round(alt_noise + random.uniform(-30, 30), 1) if alt < 25000 else None,
|
||
"baro_temp_c": round(temp_int, 2),
|
||
"humidity_pct": round(hum, 1),
|
||
"sht_temp_c": round(temp_int - 3.0, 2),
|
||
"tmp_ext_c": round(temp_ext, 2),
|
||
"flags": flags,
|
||
},
|
||
"raw_data": {},
|
||
}
|
||
|
||
|
||
async def handler(ws, interval: float, speedup: float, start_s: float = 0.0):
|
||
print(f"client connected: {ws.remote_address}")
|
||
start = time.time()
|
||
n = 0
|
||
try:
|
||
while True:
|
||
flight_s = start_s + (time.time() - start) * speedup
|
||
packet = sample(flight_s)
|
||
await ws.send(json.dumps(packet))
|
||
n += 1
|
||
if n % 25 == 0:
|
||
print(f" sent {n} packets, flight time {flight_s/60:.1f} min, alt {packet['alt']:.0f} m")
|
||
await asyncio.sleep(interval)
|
||
except websockets.ConnectionClosed:
|
||
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)
|
||
ap.add_argument("--interval", type=float, default=1.0, help="seconds between packets")
|
||
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
|
||
):
|
||
print(f"synthetic telemetry on ws://localhost:{args.port}/ (speedup x{args.speedup})")
|
||
await asyncio.Future()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|