Initial commit: LoraTester Android + server

This commit is contained in:
2026-06-04 13:05:21 +03:00
commit 83d0353754
124 changed files with 7892 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
# LoraTester Server
Единый HTTP-сервер для телеметрии LoRa, GPS устройств, истории статистики, треков и чата.
## Быстрый старт
```bash
cd server
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
python flask_app.py
```
Откройте http://localhost:7634
## Переменные окружения
| Переменная | По умолчанию |
|------------|----------------|
| `LORATESTER_HOST` | `0.0.0.0` |
| `LORATESTER_PORT` | `7634` |
| `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
## Деплой (grigowashere.ru:7634)
```bash
cd /srv/storage/disk2/services/LoraTester
pip install -r requirements.txt
# один путь БД для всех воркеров:
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
```
После обновления кода **обязательно перезапустите** сервис. При старте выполняются миграции SQLite (`devices`, `telemetry.meta`, таблицы `tracks`).
Проверка:
```bash
curl http://127.0.0.1:7634/api/health
```
Ожидается `"db_ok": true`, `"schema_version": 3`.
Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`):
1. Остановить сервис
2. `cp loratester.db loratester.db.bak`
3. Удалить `loratester.db` (или оставить бэкап и дать миграциям дописать колонки после рестарта с новым кодом)
4. Запустить снова — `init_db()` создаст полную схему
## API
### Телеметрия (только Android, заголовок `X-Lora-Client: android`)
- `POST /api/telemetry``{device_id, lat?, lon?, rssi?, meta?, fields?, role?, ts?}`
- `GET /api/devices` — последнее состояние устройств
- `GET /api/telemetry?device_id=&limit=&since=&until=&role=` — история (без `raw_frame`)
- `GET /api/stats/history?device_id=` — то же, alias
### Треки (запись с Android)
- `POST /api/tracks/start``{device_id}``{track_id}`
- `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
- `POST /api/tracks/{id}/finish`
- `GET /api/tracks?device_id=`
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo)
### Прочее
- `POST /api/chat``{device_id, text}`
- `GET /api/chat?since=0`
- `GET /api/health``{ok, db_ok, schema_version, database_path}`
## FastAPI (прод)
```bash
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
```
Flask (`flask_app.py`) — тот же API для локальной разработки.
## Тесты
```bash
cd server
pip install httpx pytest
python -m pytest tests/ -v
```
## Android
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
"""Client identification for API endpoints."""
ANDROID_CLIENT_HEADER = "X-Lora-Client"
ANDROID_CLIENT_VALUE = "android"
def is_android_client(headers) -> bool:
value = headers.get(ANDROID_CLIENT_HEADER) or headers.get(
ANDROID_CLIENT_HEADER.lower()
)
return (value or "").strip().lower() == ANDROID_CLIENT_VALUE
+11
View File
@@ -0,0 +1,11 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATABASE_PATH = os.environ.get(
"LORATESTER_DB", str(BASE_DIR / "loratester.db")
)
HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
+10
View File
@@ -0,0 +1,10 @@
"""Device ID rules — must match the Android app (SettingsRepository)."""
import re
# android- + 8 hex chars from UUID
ANDROID_DEVICE_ID = re.compile(r"^android-[0-9a-f]{8}$", re.IGNORECASE)
def is_valid_device_id(device_id: str) -> bool:
return bool(ANDROID_DEVICE_ID.match((device_id or "").strip()))
+40
View File
@@ -0,0 +1,40 @@
"""Terrain elevation via Open-Meteo (cached per coordinate)."""
from __future__ import annotations
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
_CACHE: dict[tuple[float, float], Optional[float]] = {}
_TIMEOUT = 3.0
def _cache_key(lat: float, lon: float) -> tuple[float, float]:
return (round(lat, 4), round(lon, 4))
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
key = _cache_key(lat, lon)
if key in _CACHE:
return _CACHE[key]
try:
with httpx.Client(timeout=_TIMEOUT) as client:
r = client.get(
"https://api.open-meteo.com/v1/elevation",
params={"latitude": lat, "longitude": lon},
)
r.raise_for_status()
data = r.json()
elevations = data.get("elevation") or []
if elevations:
val = float(elevations[0])
_CACHE[key] = val
return val
except Exception as e:
logger.warning("open-meteo elevation failed for %s,%s: %s", lat, lon, e)
_CACHE[key] = None
return None
+23
View File
@@ -0,0 +1,23 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class TelemetryIn:
device_id: str
lat: Optional[float] = None
lon: Optional[float] = None
rssi: Optional[float] = None
range_m: Optional[float] = None
raw_frame: Optional[str] = None
meta: Optional[str] = None
role: Optional[str] = None
ts: Optional[float] = None
source: str = "android"
@dataclass
class ChatIn:
device_id: str
text: str
ts: Optional[float] = None
+141
View File
@@ -0,0 +1,141 @@
"""SQLite schema creation and migrations."""
from __future__ import annotations
import sqlite3
SCHEMA_VERSION = 3
def table_exists(conn: sqlite3.Connection, name: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(name,),
).fetchone()
return row is not None
def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
if not table_exists(conn, table):
return False
cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(c[1] == column for c in cols)
def ensure_column(
conn: sqlite3.Connection, table: str, column: str, ddl: str, log: list[str]
) -> None:
if not column_exists(conn, table, column):
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
log.append(f"ALTER {table} ADD {column}")
def get_schema_version(conn: sqlite3.Connection) -> int:
if not table_exists(conn, "schema_version"):
return 0
row = conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone()
return int(row[0]) if row else 0
def set_schema_version(conn: sqlite3.Connection, version: int) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
"""
)
conn.execute("DELETE FROM schema_version")
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
def apply_migrations(conn: sqlite3.Connection) -> list[str]:
log: list[str] = []
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
label TEXT,
last_seen REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
lat REAL,
lon REAL,
rssi REAL,
range_m REAL,
raw_frame TEXT,
ts REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_telemetry_device_ts
ON telemetry(device_id, ts DESC);
CREATE TABLE IF NOT EXISTS chat (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
text TEXT NOT NULL,
ts REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_ts ON chat(ts);
"""
)
log.append("ensure base tables")
ensure_column(conn, "telemetry", "source", "TEXT", log)
ensure_column(conn, "telemetry", "meta", "TEXT", log)
ensure_column(conn, "telemetry", "role", "TEXT", log)
if not table_exists(conn, "tracks"):
conn.executescript(
"""
CREATE TABLE tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
started_at REAL NOT NULL,
ended_at REAL,
label TEXT
);
CREATE INDEX IF NOT EXISTS idx_tracks_device ON tracks(device_id, started_at DESC);
"""
)
log.append("CREATE tracks")
if not table_exists(conn, "track_points"):
conn.executescript(
"""
CREATE TABLE track_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER NOT NULL,
ts REAL NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
altitude_gps REAL,
elevation_m REAL,
rssi REAL,
role TEXT,
meta TEXT,
FOREIGN KEY (track_id) REFERENCES tracks(id)
);
CREATE INDEX IF NOT EXISTS idx_track_points_track_ts
ON track_points(track_id, ts);
"""
)
log.append("CREATE track_points")
set_schema_version(conn, SCHEMA_VERSION)
log.append(f"schema_version={SCHEMA_VERSION}")
return log
def check_db_ok(conn: sqlite3.Connection) -> bool:
required = [
("devices", None),
("telemetry", "meta"),
("tracks", None),
("track_points", "elevation_m"),
]
for table, col in required:
if not table_exists(conn, table):
return False
if col and not column_exists(conn, table, col):
return False
return True
+398
View File
@@ -0,0 +1,398 @@
import json
import logging
import sqlite3
import time
from contextlib import contextmanager
from typing import Any, Optional
from .config import DATABASE_PATH, TELEMETRY_LIMIT, TRACK_POINTS_LIMIT
from .devices import is_valid_device_id
from .elevation import fetch_elevation_m
from .models import ChatIn, TelemetryIn
from .schema import SCHEMA_VERSION, apply_migrations, check_db_ok, get_schema_version
logger = logging.getLogger(__name__)
_HISTORY_COLUMNS = (
"id, device_id, lat, lon, rssi, range_m, meta, role, ts, source"
)
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(DATABASE_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
@contextmanager
def _db():
conn = _connect()
try:
yield conn
conn.commit()
finally:
conn.close()
def init_db() -> None:
with _db() as conn:
log = apply_migrations(conn)
for line in log:
logger.info("db migrate: %s", line)
cleanup_invalid_devices()
def db_status() -> dict[str, Any]:
with _db() as conn:
return {
"db_ok": check_db_ok(conn),
"schema_version": get_schema_version(conn),
"expected_version": SCHEMA_VERSION,
"database_path": DATABASE_PATH,
}
def cleanup_invalid_devices() -> int:
with _db() as conn:
cur_t = conn.execute(
"DELETE FROM telemetry WHERE device_id NOT GLOB 'android-????????'"
)
cur_d = conn.execute(
"DELETE FROM devices WHERE device_id NOT GLOB 'android-????????'"
)
return cur_t.rowcount + cur_d.rowcount
def _sanitize_coords(
lat: Optional[float], lon: Optional[float]
) -> tuple[Optional[float], Optional[float]]:
if lat is None or lon is None:
return lat, lon
if abs(lat) < 1e-5 and abs(lon) < 1e-5:
return None, None
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
return None, None
return lat, lon
def record_telemetry(data: TelemetryIn) -> dict[str, Any]:
if not is_valid_device_id(data.device_id):
raise ValueError(
f"invalid device_id '{data.device_id}', expected android-xxxxxxxx (8 hex)"
)
ts = data.ts if data.ts is not None else time.time()
lat, lon = _sanitize_coords(data.lat, data.lon)
with _db() as conn:
conn.execute(
"""
INSERT INTO devices (device_id, label, last_seen)
VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET last_seen = excluded.last_seen
""",
(data.device_id, data.device_id, ts),
)
conn.execute(
"""
INSERT INTO telemetry
(device_id, lat, lon, rssi, range_m, raw_frame, meta, role, ts, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
data.device_id,
lat,
lon,
data.rssi,
data.range_m,
data.raw_frame,
data.meta,
data.role,
ts,
data.source,
),
)
_trim_telemetry(conn, data.device_id)
return {"ok": True, "device_id": data.device_id, "ts": ts}
def _trim_telemetry(conn: sqlite3.Connection, device_id: str) -> None:
conn.execute(
"""
DELETE FROM telemetry
WHERE device_id = ? AND id NOT IN (
SELECT id FROM telemetry
WHERE device_id = ?
ORDER BY ts DESC
LIMIT ?
)
""",
(device_id, device_id, TELEMETRY_LIMIT),
)
def list_devices() -> list[dict[str, Any]]:
with _db() as conn:
rows = conn.execute(
"""
SELECT d.device_id, d.last_seen,
t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source
FROM devices d
INNER JOIN telemetry t ON t.id = (
SELECT id FROM telemetry
WHERE device_id = d.device_id AND source = 'android'
ORDER BY ts DESC LIMIT 1
)
WHERE d.device_id GLOB 'android-????????'
ORDER BY d.last_seen DESC
"""
).fetchall()
devices = [_row_to_device(r) for r in rows]
return [d for d in devices if not _is_null_island(d)]
def _is_null_island(device: dict[str, Any]) -> bool:
lat, lon = device.get("lat"), device.get("lon")
if lat is None or lon is None:
return False
return abs(lat) < 1e-5 and abs(lon) < 1e-5
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
return {
"device_id": row["device_id"],
"last_seen": row["last_seen"],
"lat": row["lat"],
"lon": row["lon"],
"rssi": row["rssi"],
"range_m": row["range_m"],
"raw_frame": row["raw_frame"],
"meta": row["meta"] if "meta" in row.keys() else None,
"role": row["role"] if "role" in row.keys() else None,
"ts": row["ts"],
"source": row["source"] if "source" in row.keys() else None,
}
def _row_to_history(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"device_id": row["device_id"],
"lat": row["lat"],
"lon": row["lon"],
"rssi": row["rssi"],
"range_m": row["range_m"],
"meta": row["meta"],
"role": row["role"],
"ts": row["ts"],
"source": row["source"],
}
def get_telemetry(
device_id: Optional[str] = None,
limit: int = 100,
since: Optional[float] = None,
until: Optional[float] = None,
role: Optional[str] = None,
) -> list[dict[str, Any]]:
limit = min(max(1, limit), 500)
clauses: list[str] = []
params: list[Any] = []
if device_id:
clauses.append("device_id = ?")
params.append(device_id)
if since is not None:
clauses.append("ts >= ?")
params.append(since)
if until is not None:
clauses.append("ts <= ?")
params.append(until)
if role:
clauses.append("role = ?")
params.append(role)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
sql = (
f"SELECT {_HISTORY_COLUMNS} FROM telemetry{where} "
f"ORDER BY ts DESC LIMIT ?"
)
params.append(limit)
with _db() as conn:
rows = conn.execute(sql, params).fetchall()
return [_row_to_history(r) for r in rows]
def start_track(device_id: str, label: Optional[str] = None) -> dict[str, Any]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
ts = time.time()
with _db() as conn:
cur = conn.execute(
"""
INSERT INTO tracks (device_id, started_at, label)
VALUES (?, ?, ?)
""",
(device_id, ts, label),
)
track_id = cur.lastrowid
return {"ok": True, "track_id": track_id, "device_id": device_id, "started_at": ts}
def add_track_points(track_id: int, points: list[dict[str, Any]]) -> dict[str, Any]:
if not points:
return {"ok": True, "added": 0}
with _db() as conn:
track = conn.execute(
"SELECT id, device_id, ended_at FROM tracks WHERE id = ?",
(track_id,),
).fetchone()
if not track:
raise ValueError(f"track {track_id} not found")
if track["ended_at"] is not None:
raise ValueError(f"track {track_id} already finished")
count = conn.execute(
"SELECT COUNT(*) FROM track_points WHERE track_id = ?",
(track_id,),
).fetchone()[0]
added = 0
for p in points:
if count + added >= TRACK_POINTS_LIMIT:
break
lat = float(p["lat"])
lon = float(p["lon"])
ts = float(p.get("ts") or time.time())
elev = fetch_elevation_m(lat, lon)
meta = p.get("meta")
if meta is not None and not isinstance(meta, str):
meta = json.dumps(meta, ensure_ascii=False)
conn.execute(
"""
INSERT INTO track_points
(track_id, ts, lat, lon, altitude_gps, elevation_m,
rssi, role, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
track_id,
ts,
lat,
lon,
p.get("altitude_gps"),
elev,
p.get("rssi"),
p.get("role"),
meta,
),
)
added += 1
return {"ok": True, "added": added, "track_id": track_id}
def finish_track(track_id: int) -> dict[str, Any]:
ts = time.time()
with _db() as conn:
cur = conn.execute(
"UPDATE tracks SET ended_at = ? WHERE id = ? AND ended_at IS NULL",
(ts, track_id),
)
if cur.rowcount == 0:
raise ValueError(f"track {track_id} not found or already finished")
count = conn.execute(
"SELECT COUNT(*) FROM track_points WHERE track_id = ?",
(track_id,),
).fetchone()[0]
return {"ok": True, "track_id": track_id, "ended_at": ts, "point_count": count}
def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
limit = min(max(1, limit), 200)
with _db() as conn:
role_sub = """
(SELECT p.role FROM track_points p
WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != ''
ORDER BY p.ts DESC LIMIT 1)
"""
if device_id:
rows = conn.execute(
f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role
FROM tracks t
WHERE t.device_id = ?
ORDER BY t.started_at DESC
LIMIT ?
""",
(device_id, limit),
).fetchall()
else:
rows = conn.execute(
f"""
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
{role_sub} AS role
FROM tracks t
ORDER BY t.started_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def get_track(track_id: int) -> dict[str, Any]:
with _db() as conn:
track = conn.execute(
"""
SELECT id, device_id, started_at, ended_at, label FROM tracks WHERE id = ?
""",
(track_id,),
).fetchone()
if not track:
raise ValueError(f"track {track_id} not found")
points = conn.execute(
"""
SELECT ts, lat, lon, altitude_gps, elevation_m, rssi, role, meta
FROM track_points
WHERE track_id = ?
ORDER BY ts ASC
""",
(track_id,),
).fetchall()
result = dict(track)
result["points"] = [dict(p) for p in points]
return result
def add_chat(data: ChatIn) -> dict[str, Any]:
ts = data.ts if data.ts is not None else time.time()
with _db() as conn:
cur = conn.execute(
"INSERT INTO chat (device_id, text, ts) VALUES (?, ?, ?)",
(data.device_id, data.text, ts),
)
msg_id = cur.lastrowid
return {
"id": msg_id,
"device_id": data.device_id,
"text": data.text,
"ts": ts,
}
def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
limit = min(max(1, limit), 500)
with _db() as conn:
rows = conn.execute(
"""
SELECT id, device_id, text, ts FROM chat
WHERE ts > ?
ORDER BY ts ASC LIMIT ?
""",
(since, limit),
).fetchall()
return [dict(r) for r in rows]
+61
View File
@@ -0,0 +1,61 @@
"""Normalize Android telemetry POST body into TelemetryIn."""
from __future__ import annotations
import json
from typing import Any, Optional
from .models import TelemetryIn
def _float_or_none(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def merge_meta(body: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
"""Build meta JSON string and role from meta / fields keys."""
meta = body.get("meta")
fields = body.get("fields")
role = body.get("role")
if isinstance(meta, dict):
mobj = meta
meta = None
elif isinstance(meta, str) and meta.strip():
try:
mobj = json.loads(meta)
except json.JSONDecodeError:
mobj = {}
else:
mobj = {}
if isinstance(fields, dict):
mobj["fields"] = fields
if mobj:
if role is None and mobj.get("role"):
role = str(mobj["role"])
return json.dumps(mobj, ensure_ascii=False), role
if isinstance(meta, str):
return meta, role
return None, role
def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
meta, role = merge_meta(body)
return TelemetryIn(
device_id=str(body["device_id"]),
lat=_float_or_none(body.get("lat")),
lon=_float_or_none(body.get("lon")),
rssi=_float_or_none(body.get("rssi")),
range_m=_float_or_none(body.get("range_m")),
raw_frame=body.get("raw_frame"),
meta=meta,
role=role,
ts=_float_or_none(body.get("ts")),
)
+206
View File
@@ -0,0 +1,206 @@
"""Alternate LoraTester entry point — same API as Flask."""
import time
from pathlib import Path
from typing import Any, Optional
from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from core.auth import ANDROID_CLIENT_HEADER, ANDROID_CLIENT_VALUE
from core.config import HOST, PORT
from core.models import ChatIn, TelemetryIn
from core import storage
from core.telemetry_body import telemetry_from_body
STATIC_DIR = Path(__file__).resolve().parent / "static"
app = FastAPI(title="LoraTester")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
storage.init_db()
class TelemetryBody(BaseModel):
device_id: str
lat: Optional[float] = None
lon: Optional[float] = None
rssi: Optional[float] = None
range_m: Optional[float] = None
raw_frame: Optional[str] = None
meta: Optional[Any] = None
fields: Optional[dict[str, Any]] = None
role: Optional[str] = None
ts: Optional[float] = None
class ChatBody(BaseModel):
device_id: str
text: str
ts: Optional[float] = None
class TrackStartBody(BaseModel):
device_id: str
label: Optional[str] = None
class TrackPoint(BaseModel):
ts: Optional[float] = None
lat: float
lon: float
altitude_gps: Optional[float] = None
rssi: Optional[float] = None
role: Optional[str] = None
meta: Optional[Any] = None
class TrackPointsBody(BaseModel):
points: list[TrackPoint] = Field(default_factory=list)
@app.get("/")
def index():
return FileResponse(
STATIC_DIR / "index.html",
headers={"Cache-Control": "no-store"},
)
@app.post("/api/telemetry")
def post_telemetry(
body: TelemetryBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
if (x_lora_client or "").strip().lower() != ANDROID_CLIENT_VALUE:
raise HTTPException(
403,
detail=f"telemetry only from Android app (header {ANDROID_CLIENT_HEADER})",
)
try:
data = telemetry_from_body(body.model_dump(exclude_none=True))
return storage.record_telemetry(data)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/devices")
def get_devices():
return storage.list_devices()
@app.get("/api/telemetry")
def get_telemetry_history(
device_id: Optional[str] = None,
limit: int = Query(100, ge=1, le=500),
since: Optional[float] = None,
until: Optional[float] = None,
role: Optional[str] = None,
):
return storage.get_telemetry(device_id, limit, since, until, role)
@app.get("/api/stats/history")
def get_stats_history(
device_id: str = Query(...),
limit: int = Query(50, ge=1, le=500),
since: Optional[float] = None,
until: Optional[float] = None,
role: Optional[str] = None,
):
return storage.get_telemetry(device_id, limit, since, until, role)
@app.post("/api/tracks/start")
def tracks_start(
body: TrackStartBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
return storage.start_track(body.device_id, body.label)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.post("/api/tracks/{track_id}/points")
def tracks_points(
track_id: int,
body: TrackPointsBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
points = [p.model_dump(exclude_none=True) for p in body.points]
return storage.add_track_points(track_id, points)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.post("/api/tracks/{track_id}/finish")
def tracks_finish(
track_id: int,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
return storage.finish_track(track_id)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/tracks")
def tracks_list(
device_id: Optional[str] = None,
limit: int = Query(50, ge=1, le=200),
):
return storage.list_tracks(device_id, limit)
@app.get("/api/tracks/{track_id}")
def tracks_get(track_id: int):
try:
return storage.get_track(track_id)
except ValueError as e:
raise HTTPException(404, detail=str(e)) from e
def _require_android(x_lora_client: Optional[str]) -> None:
if (x_lora_client or "").strip().lower() != ANDROID_CLIENT_VALUE:
raise HTTPException(
403,
detail=f"Android header {ANDROID_CLIENT_HEADER} required",
)
@app.post("/api/chat")
def post_chat(body: ChatBody):
text = body.text.strip()
if not text:
raise HTTPException(400, "text required")
data = ChatIn(device_id=body.device_id, text=text, ts=body.ts)
return storage.add_chat(data)
@app.get("/api/chat")
def get_chat(since: float = 0, limit: int = Query(200, ge=1, le=500)):
return storage.get_chat(since, limit)
@app.get("/api/health")
def health():
status = storage.db_status()
return {"ok": status["db_ok"], "ts": time.time(), **status}
if __name__ == "__main__":
import uvicorn
uvicorn.run("fastapi_app:app", host=HOST, port=PORT, reload=True)
+164
View File
@@ -0,0 +1,164 @@
"""Primary LoraTester server: REST API + web UI."""
import time
from pathlib import Path
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
from core.auth import ANDROID_CLIENT_HEADER, is_android_client
from core.config import HOST, PORT
from core.models import ChatIn, TelemetryIn
from core import storage
from core.telemetry_body import telemetry_from_body
STATIC_DIR = Path(__file__).resolve().parent / "static"
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static")
CORS(app)
storage.init_db()
@app.route("/")
def index():
resp = send_from_directory(STATIC_DIR, "index.html")
resp.headers["Cache-Control"] = "no-store"
return resp
@app.post("/api/telemetry")
def post_telemetry():
if not is_android_client(request.headers):
return jsonify({
"error": "telemetry only from Android app",
"hint": f"send header {ANDROID_CLIENT_HEADER}: android",
}), 403
body = request.get_json(force=True, silent=True) or {}
device_id = body.get("device_id")
if not device_id:
return jsonify({"error": "device_id required"}), 400
try:
data = telemetry_from_body({**body, "device_id": device_id})
return jsonify(storage.record_telemetry(data))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.get("/api/devices")
def get_devices():
return jsonify(storage.list_devices())
@app.get("/api/telemetry")
def get_telemetry_history():
device_id = request.args.get("device_id")
limit = int(request.args.get("limit", 100))
since = _float_or_none(request.args.get("since"))
until = _float_or_none(request.args.get("until"))
role = request.args.get("role")
return jsonify(storage.get_telemetry(device_id, limit, since, until, role))
@app.get("/api/stats/history")
def get_stats_history():
device_id = request.args.get("device_id")
if not device_id:
return jsonify({"error": "device_id required"}), 400
limit = int(request.args.get("limit", 50))
since = _float_or_none(request.args.get("since"))
until = _float_or_none(request.args.get("until"))
role = request.args.get("role")
return jsonify(storage.get_telemetry(device_id, limit, since, until, role))
@app.post("/api/tracks/start")
def tracks_start():
if not is_android_client(request.headers):
return jsonify({"error": "Android only"}), 403
body = request.get_json(force=True, silent=True) or {}
device_id = body.get("device_id")
if not device_id:
return jsonify({"error": "device_id required"}), 400
try:
return jsonify(storage.start_track(str(device_id), body.get("label")))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.post("/api/tracks/<int:track_id>/points")
def tracks_points(track_id: int):
if not is_android_client(request.headers):
return jsonify({"error": "Android only"}), 403
body = request.get_json(force=True, silent=True) or {}
points = body.get("points") or []
try:
return jsonify(storage.add_track_points(track_id, points))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.post("/api/tracks/<int:track_id>/finish")
def tracks_finish(track_id: int):
if not is_android_client(request.headers):
return jsonify({"error": "Android only"}), 403
try:
return jsonify(storage.finish_track(track_id))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.get("/api/tracks")
def tracks_list():
device_id = request.args.get("device_id")
limit = int(request.args.get("limit", 50))
return jsonify(storage.list_tracks(device_id, limit))
@app.get("/api/tracks/<int:track_id>")
def tracks_get(track_id: int):
try:
return jsonify(storage.get_track(track_id))
except ValueError as e:
return jsonify({"error": str(e)}), 404
@app.post("/api/chat")
def post_chat():
body = request.get_json(force=True, silent=True) or {}
device_id = body.get("device_id")
text = (body.get("text") or "").strip()
if not device_id or not text:
return jsonify({"error": "device_id and text required"}), 400
data = ChatIn(
device_id=str(device_id),
text=text,
ts=_float_or_none(body.get("ts")),
)
return jsonify(storage.add_chat(data))
@app.get("/api/chat")
def get_chat():
since = float(request.args.get("since", 0))
limit = int(request.args.get("limit", 200))
return jsonify(storage.get_chat(since, limit))
@app.get("/api/health")
def health():
status = storage.db_status()
return jsonify({"ok": status["db_ok"], "ts": time.time(), **status})
def _float_or_none(value):
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
if __name__ == "__main__":
app.run(host=HOST, port=PORT, debug=True)
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
flask>=3.0.0
flask-cors>=4.0.0
fastapi>=0.110.0
uvicorn>=0.27.0
pydantic>=2.0.0
gunicorn>=21.0.0
httpx>=0.27.0
+882
View File
@@ -0,0 +1,882 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LoraTester</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; }
header { padding: 12px 16px; background: #16213e; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
header h1 { margin: 0; font-size: 1.2rem; flex: 1; }
main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr; height: calc(100vh - 52px); }
@media (max-width: 900px) {
main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(200px, 1fr); }
}
#mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; }
#trackTimeline { display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333; }
#trackTimeline.visible { display: block; }
#timelineNote { font-size: 0.75rem; color: #aaa; margin: 4px 0 8px; }
#timeSlider { width: 100%; margin: 6px 0; }
.timeline-labels { display: flex; justify-content: space-between; font-size: 0.75rem; color: #aaa; }
#timelineStats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; font-size: 0.8rem; }
.timeline-col { background: #0a0a14; padding: 8px; border-radius: 4px; }
.timeline-col h3 { margin: 0 0 6px; font-size: 0.85rem; }
.timeline-col.tx h3 { color: #e94560; }
.timeline-col.rx h3 { color: #4fc3f7; }
#distanceNow { font-size: 0.9rem; margin-top: 4px; color: #00ff88; }
aside {
grid-column: 2; grid-row: 1;
overflow: auto; padding: 12px; border-left: 1px solid #333;
display: flex; flex-direction: column; gap: 12px;
}
@media (max-width: 900px) {
aside { grid-column: 1; grid-row: 2; border-left: none; border-top: 1px solid #333; }
}
.panel { background: #0f3460; border-radius: 8px; padding: 10px; }
.panel h2 { margin: 0 0 8px; font-size: 0.95rem; }
#deviceList { list-style: none; padding: 0; margin: 0; max-height: 140px; overflow: auto; }
#deviceList li { padding: 6px 8px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; }
#deviceList li:hover, #deviceList li.active { background: #e94560; }
#stats { font-size: 0.85rem; line-height: 1.5; }
#history { font-size: 0.75rem; max-height: 100px; overflow: auto; }
#chatLog { height: 140px; overflow: auto; font-size: 0.8rem; background: #0a0a14; padding: 8px; border-radius: 4px; }
#chatForm { display: flex; gap: 6px; margin-top: 6px; }
#chatForm input { flex: 1; padding: 6px; border: none; border-radius: 4px; }
#chatForm button { padding: 6px 12px; background: #e94560; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.track-row { margin-bottom: 6px; }
.track-row label { font-size: 0.75rem; color: #aaa; display: block; margin-bottom: 2px; }
.track-row select { width: 100%; padding: 4px; }
#btnShowTracks { width: 100%; padding: 6px; margin-top: 4px; background: #00ff88; color: #111; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
.muted { color: #aaa; font-size: 0.75rem; }
.legend { font-size: 0.75rem; color: #ccc; }
.legend-tx { color: #e94560; }
.legend-rx { color: #4fc3f7; }
#mapModal {
display: none; position: fixed; z-index: 2000;
min-width: 260px; max-width: 360px; max-height: 70vh; overflow: auto;
background: #0f3460; border: 1px solid #444; border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
#mapModal.open { display: block; }
#mapModalHeader {
padding: 8px 10px; background: #16213e; cursor: move;
display: flex; justify-content: space-between; align-items: center;
user-select: none; border-radius: 8px 8px 0 0;
}
#mapModalHeader span { font-size: 0.85rem; font-weight: 600; }
#mapModalClose { background: none; border: none; color: #eee; font-size: 1.2rem; cursor: pointer; padding: 0 4px; }
#mapModalBody { padding: 10px; font-size: 0.85rem; line-height: 1.45; }
</style>
</head>
<body>
<header>
<h1>LoraTester</h1>
<span class="muted" id="status">загрузка…</span>
<span class="muted" id="pollStatus" title="Автообновление">⟳ 1 с</span>
<span class="legend"><span class="legend-tx">● TX</span> &nbsp; <span class="legend-rx">● RX</span></span>
<input type="text" id="webDeviceId" placeholder="ник в чате" style="padding:6px;border-radius:4px;border:none;max-width:160px" />
</header>
<main>
<div id="mapWrap">
<div id="map"></div>
</div>
<aside>
<div class="panel">
<h2>Устройства</h2>
<ul id="deviceList"></ul>
</div>
<div class="panel">
<h2>Статистика</h2>
<div id="stats">Выберите устройство</div>
<div id="history" class="muted" style="margin-top:8px"></div>
</div>
<div class="panel">
<h2>Сравнение треков</h2>
<div class="track-row">
<label class="legend-tx">Трек TX</label>
<select id="trackTxSelect"><option value=""></option></select>
</div>
<div class="track-row">
<label class="legend-rx">Трек RX</label>
<select id="trackRxSelect"><option value=""></option></select>
</div>
<button type="button" id="btnShowTracks">Показать на карте</button>
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите TX и RX треки</div>
<div id="trackTimeline">
<h3 style="margin:0 0 6px;font-size:0.9rem">Время теста</h3>
<div id="timelineNote"></div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
<div id="distanceNow">Расстояние GPS: —</div>
<div id="timelineStats">
<div class="timeline-col tx"><h3>TX</h3><div id="statsTx"></div></div>
<div class="timeline-col rx"><h3>RX</h3><div id="statsRx"></div></div>
</div>
<button type="button" id="btnPlay" class="muted" style="margin-top:6px;padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</div>
</div>
<div class="panel" style="flex:1;display:flex;flex-direction:column">
<h2>Чат</h2>
<div id="chatLog"></div>
<form id="chatForm">
<input type="text" id="chatInput" placeholder="Сообщение…" autocomplete="off" />
<button type="submit"></button>
</form>
</div>
</aside>
</main>
<div id="mapModal">
<div id="mapModalHeader">
<span>Детали</span>
<button type="button" id="mapModalClose" aria-label="Закрыть">×</button>
</div>
<div id="mapModalBody"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map('map').setView([55.75, 37.62], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
const markers = {};
let selectedId = null;
let chatSince = 0;
let mapInitialFitDone = false;
let userMovedMap = false;
let programmaticMove = false;
let trackTxLayer = null;
let trackRxLayer = null;
let trackTxMarkers = [];
let trackRxMarkers = [];
let linkLine = null;
let ghostTx = null;
let ghostRx = null;
let loadedTxTrack = null;
let loadedRxTrack = null;
let telemetryTx = [];
let telemetryRx = [];
let overlapMin = 0;
let overlapMax = 0;
let playTimer = null;
let pollTimer = null;
let pollTick = 0;
let dualTracksActive = false;
const DEVICE_POLL_MS = 1000;
const CHAT_POLL_MS = 2500;
const TRACKS_POLL_MS = 10000;
const TELEMETRY_POLL_MS = 2000;
const TX_COLOR = '#e94560';
const RX_COLOR = '#4fc3f7';
map.on('zoomend moveend', () => {
if (!programmaticMove) userMovedMap = true;
});
function isNullIsland(lat, lon) {
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
}
function roleColor(role) {
return role === 'RX' ? RX_COLOR : TX_COLOR;
}
function roleLabel(role) {
if (role === 'TX') return 'Передатчик (TX)';
if (role === 'RX') return 'Приёмник (RX)';
return role || '—';
}
function roleFromDevice(d) {
if (d.role) return d.role;
try {
if (d.meta) return JSON.parse(d.meta).role;
} catch (e) {}
return null;
}
function makeRoleIcon(role) {
const color = roleColor(role);
return L.divIcon({
className: '',
html: `<div style="width:14px;height:14px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 4px #000"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7]
});
}
function haversineM(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function setMapViewProgrammatically(fn) {
programmaticMove = true;
fn();
setTimeout(() => { programmaticMove = false; }, 0);
}
function fitAllMarkers(bounds) {
if (!bounds.length || userMovedMap) return;
if (bounds.length === 1) {
setMapViewProgrammatically(() => map.setView(bounds[0], 13));
} else {
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }));
}
mapInitialFitDone = true;
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function formatMeta(meta) {
if (!meta) return '';
try {
const m = typeof meta === 'string' ? JSON.parse(meta) : meta;
const lines = [];
const skip = new Set(['send', 'receive']);
const shown = new Set();
const addLine = (label, value) => {
if (value == null || value === '') return;
const key = label.toLowerCase();
if (shown.has(key)) return;
shown.add(key);
lines.push(`${escapeHtml(label)}: ${escapeHtml(String(value))}`);
};
if (m.fields && typeof m.fields === 'object') {
for (const [k, v] of Object.entries(m.fields)) {
if (skip.has(k.toLowerCase())) continue;
addLine(k, v);
}
}
addLine('Роль', m.role ? roleLabel(m.role) : null);
addLine('Кадр', m.frame);
addLine('Мощность TX', m.power_dbm != null ? `${m.power_dbm} dBm` : null);
addLine('RSSI', m.rssi_dbm != null ? `${m.rssi_dbm} dBm` : null);
addLine('SNR', m.snr_db != null ? `${m.snr_db} dB` : null);
addLine('Частота', m.frequency_hz ? `${(m.frequency_hz/1e6).toFixed(3)} MHz` : null);
addLine('SF', m.spreading_factor);
addLine('BW', m.bandwidth_khz != null ? `${m.bandwidth_khz} kHz` : null);
addLine('Пакет', m.packet);
addLine('Payload', m.payload);
addLine('On Air', m.on_air_ms != null ? `${m.on_air_ms} ms` : null);
addLine('TX Speed', m.tx_pkt_per_s != null ? `${m.tx_pkt_per_s} pkt/s` : null);
addLine('RX Speed', m.rx_pkt_per_s != null ? `${m.rx_pkt_per_s} pkt/s` : null);
addLine('PER', m.per_percent != null ? `${m.per_percent} %` : null);
return lines.length ? lines.join('<br>') : '';
} catch (e) {
return '';
}
}
function formatTelemetryRow(r) {
let html = formatMeta(r.meta);
html += `RSSI: ${r.rssi ?? '—'} dBm<br>`;
html += `Range: ${r.range_m ?? '—'} m<br>`;
if (r.lat != null && r.lon != null && !isNullIsland(r.lat, r.lon)) {
html += `GPS: ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}`;
}
return html;
}
/* --- Draggable modal --- */
const mapModal = document.getElementById('mapModal');
const mapModalBody = document.getElementById('mapModalBody');
const mapModalHeader = document.getElementById('mapModalHeader');
let modalDrag = null;
/** null | 'device' | 'timeline' */
let modalMode = null;
function isModalOpen() {
return mapModal.classList.contains('open');
}
function loadModalPosition() {
try {
const x = sessionStorage.getItem('modalX');
const y = sessionStorage.getItem('modalY');
if (x != null && y != null) {
mapModal.style.left = x + 'px';
mapModal.style.top = y + 'px';
} else {
mapModal.style.left = '24px';
mapModal.style.top = '80px';
}
} catch (e) {
mapModal.style.left = '24px';
mapModal.style.top = '80px';
}
}
function openMapModal(html, mode) {
if (mode) modalMode = mode;
mapModalBody.innerHTML = html;
mapModal.classList.add('open');
loadModalPosition();
}
function syncModalHtml(html) {
if (!isModalOpen()) return;
mapModalBody.innerHTML = html;
}
function closeMapModal() {
mapModal.classList.remove('open');
modalMode = null;
}
document.getElementById('mapModalClose').onclick = closeMapModal;
mapModalHeader.addEventListener('pointerdown', e => {
if (e.target.id === 'mapModalClose') return;
modalDrag = {
startX: e.clientX,
startY: e.clientY,
left: mapModal.offsetLeft,
top: mapModal.offsetTop
};
mapModalHeader.setPointerCapture(e.pointerId);
});
mapModalHeader.addEventListener('pointermove', e => {
if (!modalDrag) return;
const left = modalDrag.left + (e.clientX - modalDrag.startX);
const top = modalDrag.top + (e.clientY - modalDrag.startY);
mapModal.style.left = Math.max(0, left) + 'px';
mapModal.style.top = Math.max(0, top) + 'px';
});
mapModalHeader.addEventListener('pointerup', e => {
if (!modalDrag) return;
try {
sessionStorage.setItem('modalX', String(mapModal.offsetLeft));
sessionStorage.setItem('modalY', String(mapModal.offsetTop));
} catch (err) {}
modalDrag = null;
mapModalHeader.releasePointerCapture(e.pointerId);
});
/* --- Track helpers --- */
function positionAt(points, t) {
if (!points || !points.length) return null;
const first = points[0];
const last = points[points.length - 1];
if (t <= first.ts) {
return { lat: first.lat, lon: first.lon, meta: first.meta, rssi: first.rssi };
}
if (t >= last.ts) {
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
}
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
if (t >= a.ts && t <= b.ts) {
const f = (t - a.ts) / (b.ts - a.ts);
return {
lat: a.lat + (b.lat - a.lat) * f,
lon: a.lon + (b.lon - a.lon) * f,
meta: t - a.ts < b.ts - t ? a.meta : b.meta,
rssi: t - a.ts < b.ts - t ? a.rssi : b.rssi
};
}
}
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
}
function overlapRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
const min = Math.max(txPts[0].ts, rxPts[0].ts);
const max = Math.min(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
if (min >= max) return null;
return { min, max, mode: 'overlap' };
}
/** Timeline range: prefer overlap; else full union of both tracks. */
function timelineRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
const overlap = overlapRange(txPts, rxPts);
if (overlap) return overlap;
const min = Math.min(txPts[0].ts, rxPts[0].ts);
const max = Math.max(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
if (min >= max) return null;
return { min, max, mode: 'union' };
}
function nearestTelemetry(rows, t) {
if (!rows.length) return null;
let best = rows[0];
let bestD = Math.abs(best.ts - t);
for (const r of rows) {
const d = Math.abs(r.ts - t);
if (d < bestD) { best = r; bestD = d; }
}
return best;
}
function clearTrackLayers() {
if (trackTxLayer) { map.removeLayer(trackTxLayer); trackTxLayer = null; }
if (trackRxLayer) { map.removeLayer(trackRxLayer); trackRxLayer = null; }
trackTxMarkers.forEach(m => map.removeLayer(m));
trackRxMarkers.forEach(m => map.removeLayer(m));
trackTxMarkers = [];
trackRxMarkers = [];
if (linkLine) { map.removeLayer(linkLine); linkLine = null; }
if (ghostTx) { map.removeLayer(ghostTx); ghostTx = null; }
if (ghostRx) { map.removeLayer(ghostRx); ghostRx = null; }
}
function drawTrackLine(track, color, store) {
const latlngs = track.points.map(p => [p.lat, p.lon]);
const layer = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map);
if (store === 'tx') trackTxLayer = layer;
else trackRxLayer = layer;
const markerList = store === 'tx' ? trackTxMarkers : trackRxMarkers;
track.points.forEach(p => {
const m = L.circleMarker([p.lat, p.lon], { radius: 3, color, fillColor: color, fillOpacity: 0.8 });
m.addTo(map);
m.on('click', () => {
const rel = Math.max(0, Math.min(Math.round(p.ts - overlapMin), parseInt(document.getElementById('timeSlider').max, 10)));
document.getElementById('timeSlider').value = rel;
modalMode = 'timeline';
updateTimelineAt(overlapMin + rel, { openModal: true });
});
markerList.push(m);
});
}
function buildTimelineModalHtml(t, txPos, rxPos) {
if (!txPos || !rxPos) return '';
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`;
html += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`;
html += formatMeta(txPos.meta);
html += `<br><span class="legend-rx">RX</span> ${rxPos.lat.toFixed(5)}, ${rxPos.lon.toFixed(5)}<br>`;
html += formatMeta(rxPos.meta);
const txTel = nearestTelemetry(telemetryTx, t);
const rxTel = nearestTelemetry(telemetryRx, t);
if (txTel || rxTel) {
html += '<br><br>';
if (txTel) html += `<span class="legend-tx">TX stats</span><br>${formatTelemetryRow(txTel)}<br>`;
if (rxTel) html += `<span class="legend-rx">RX stats</span><br>${formatTelemetryRow(rxTel)}`;
}
return html;
}
function updateTimelineAt(t, opts) {
const openModal = opts && opts.openModal;
if (!loadedTxTrack || !loadedRxTrack) return;
const txPos = positionAt(loadedTxTrack.points, t);
const rxPos = positionAt(loadedRxTrack.points, t);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx);
if (linkLine) map.removeLayer(linkLine);
if (txPos) {
ghostTx = L.circleMarker([txPos.lat, txPos.lon], {
radius: 10, color: TX_COLOR, fillColor: TX_COLOR, fillOpacity: 0.9, weight: 3
}).addTo(map);
}
if (rxPos) {
ghostRx = L.circleMarker([rxPos.lat, rxPos.lon], {
radius: 10, color: RX_COLOR, fillColor: RX_COLOR, fillOpacity: 0.9, weight: 3
}).addTo(map);
}
if (txPos && rxPos) {
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
document.getElementById('distanceNow').textContent =
`Расстояние GPS: ${dist.toFixed(0)} m`;
linkLine = L.polyline(
[[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]],
{ color: '#00ff88', weight: 3, dashArray: '6,6' }
).addTo(map);
const modalHtml = buildTimelineModalHtml(t, txPos, rxPos);
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(modalHtml, 'timeline');
}
}
const txTel = nearestTelemetry(telemetryTx, t);
const rxTel = nearestTelemetry(telemetryRx, t);
document.getElementById('statsTx').innerHTML = txTel
? formatTelemetryRow(txTel) : '<span class="muted">нет данных</span>';
document.getElementById('statsRx').innerHTML = rxTel
? formatTelemetryRow(rxTel) : '<span class="muted">нет данных</span>';
}
function setupTimeline() {
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
const panel = document.getElementById('trackTimeline');
const note = document.getElementById('timelineNote');
if (!range) {
panel.classList.remove('visible');
return;
}
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(1, Math.round(overlapMax - overlapMin));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = span;
slider.value = 0;
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
if (range.mode === 'union') {
note.textContent =
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
} else {
note.textContent = 'Общий интервал записи обоих треков.';
}
panel.classList.add('visible');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
updateTimelineAt(overlapMin);
}
async function refreshTimelineTelemetry() {
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) return;
const [telTx, telRx] = await Promise.all([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
updateTimelineAt(t);
}
async function loadAllTracks() {
const txSel = document.getElementById('trackTxSelect');
const rxSel = document.getElementById('trackRxSelect');
const prevTx = txSel.value;
const prevRx = rxSel.value;
const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' });
if (!res.ok) throw new Error('tracks ' + res.status);
const tracks = await res.json();
const fill = (sel, hint) => {
sel.innerHTML = `<option value="">${hint}</option>`;
tracks.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : '';
opt.textContent = `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
sel.appendChild(opt);
});
};
fill(txSel, '— TX трек —');
fill(rxSel, '— RX трек —');
if (prevTx) txSel.value = prevTx;
if (prevRx) rxSel.value = prevRx;
document.getElementById('trackInfo').textContent =
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
}
async function showDualTracks() {
const txId = document.getElementById('trackTxSelect').value;
const rxId = document.getElementById('trackRxSelect').value;
if (!txId || !rxId) {
document.getElementById('trackInfo').textContent = 'Выберите оба трека';
return;
}
if (txId === rxId) {
document.getElementById('trackInfo').textContent = 'Выберите разные треки';
return;
}
clearTrackLayers();
if (playTimer) { clearInterval(playTimer); playTimer = null; }
const [txRes, rxRes] = await Promise.all([
fetch(`/api/tracks/${txId}`),
fetch(`/api/tracks/${rxId}`)
]);
loadedTxTrack = await txRes.json();
loadedRxTrack = await rxRes.json();
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
}
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx');
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx');
const bounds = L.latLngBounds([]);
[...loadedTxTrack.points, ...loadedRxTrack.points].forEach(p => bounds.extend([p.lat, p.lon]));
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50] }));
dualTracksActive = true;
setupTimeline();
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (range) {
const [telTx, telRx] = await Promise.all([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
updateTimelineAt(t);
}
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
document.getElementById('trackInfo').textContent =
`TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`;
}
document.getElementById('btnShowTracks').onclick = showDualTracks;
document.getElementById('timeSlider').oninput = e => {
modalMode = 'timeline';
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true });
};
document.getElementById('btnPlay').onclick = () => {
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
return;
}
const slider = document.getElementById('timeSlider');
document.getElementById('btnPlay').textContent = '⏸ Pause';
playTimer = setInterval(() => {
let v = parseInt(slider.value, 10) + 1;
if (v > parseInt(slider.max, 10)) v = 0;
slider.value = v;
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
}, 1000);
};
function buildDeviceStatsHtml(d) {
let html = formatMeta(d.meta);
html += `<b>${escapeHtml(d.device_id)}</b><br>Сигнал: ${d.rssi ?? '—'} dBm<br>Range: ${d.range_m ?? '—'} m<br>`;
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`;
Object.keys(markers).forEach(id => {
if (id !== d.device_id && markers[id].getLatLng) {
const o = markers[id].getLatLng();
html += `<br>До ${escapeHtml(id)}: ${haversineM(d.lat, d.lon, o.lat, o.lng).toFixed(0)} m (GPS)`;
}
});
} else {
html += `GPS: —<br>`;
}
return html;
}
function updateStatsPanel(d, openModal) {
const html = buildDeviceStatsHtml(d);
document.getElementById('stats').innerHTML = html;
if (openModal) {
openMapModal(html, 'device');
} else if (isModalOpen() && modalMode === 'device' && selectedId === d.device_id) {
syncModalHtml(html);
}
}
async function fetchDevices() {
const res = await fetch('/api/devices', { cache: 'no-store' });
if (!res.ok) throw new Error('devices ' + res.status);
const devices = await res.json();
let tx = 0, rx = 0;
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
document.getElementById('status').textContent =
`${devices.length} устр. · TX:${tx} RX:${rx} · ${new Date().toLocaleTimeString()}`;
const list = document.getElementById('deviceList');
list.innerHTML = '';
const bounds = [];
const seen = new Set();
devices.forEach(d => {
const li = document.createElement('li');
let label = d.device_id;
if (d.role) label += ` · ${d.role}`;
if (d.rssi != null) label += ` · ${d.rssi} dBm`;
try {
if (d.meta) {
const m = JSON.parse(d.meta);
if (!d.role && m.role) label += ` · ${m.role}`;
if (m.packet != null) label += ` #${m.packet}`;
}
} catch (e) {}
li.textContent = label;
li.className = d.device_id === selectedId ? 'active' : '';
li.onclick = () => selectDevice(d);
list.appendChild(li);
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
seen.add(d.device_id);
bounds.push([d.lat, d.lon]);
const role = roleFromDevice(d);
if (!markers[d.device_id]) {
markers[d.device_id] = L.marker([d.lat, d.lon], { icon: makeRoleIcon(role) }).addTo(map);
} else {
markers[d.device_id].setLatLng([d.lat, d.lon]);
markers[d.device_id].setIcon(makeRoleIcon(role));
}
markers[d.device_id].off('click');
markers[d.device_id].on('click', () => selectDevice(d));
}
});
Object.keys(markers).forEach(id => {
if (!seen.has(id)) {
map.removeLayer(markers[id]);
delete markers[id];
}
});
if (!mapInitialFitDone && bounds.length) fitAllMarkers(bounds);
if (selectedId) {
const sel = devices.find(d => d.device_id === selectedId);
if (sel) {
updateStatsPanel(sel, false);
loadTelemetryHistory(sel.device_id);
}
}
return devices;
}
function selectDevice(d) {
selectedId = d.device_id;
document.querySelectorAll('#deviceList li').forEach(li => {
li.classList.toggle('active', li.textContent.startsWith(d.device_id));
});
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
setMapViewProgrammatically(() => {
map.setView([d.lat, d.lon], Math.max(map.getZoom(), 13));
});
}
updateStatsPanel(d, true);
loadTelemetryHistory(d.device_id);
}
async function loadTelemetryHistory(deviceId) {
const el = document.getElementById('history');
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(deviceId)}&limit=30`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const rows = await res.json();
if (!rows.length) {
el.innerHTML = '<b>История</b>: пуста';
return;
}
let html = '<b>История</b><ul style="margin:4px 0;padding-left:16px">';
rows.forEach(r => {
const role = r.role ? ` ${r.role}` : '';
let pkt = '';
try {
if (r.meta) {
const m = JSON.parse(r.meta);
if (m.packet != null) pkt = ` #${m.packet}`;
}
} catch (e) {}
html += `<li>${new Date(r.ts * 1000).toLocaleTimeString()}${role}${pkt} ${r.rssi ?? '—'} dBm</li>`;
});
el.innerHTML = html + '</ul>';
}
async function pollChat() {
const res = await fetch(`/api/chat?since=${chatSince}`, { cache: 'no-store' });
if (!res.ok) throw new Error('chat ' + res.status);
const msgs = await res.json();
const log = document.getElementById('chatLog');
msgs.forEach(m => {
chatSince = Math.max(chatSince, m.ts);
const div = document.createElement('div');
div.innerHTML = `<span class="muted">${new Date(m.ts*1000).toLocaleTimeString()}</span> <b>${escapeHtml(m.device_id)}</b>: ${escapeHtml(m.text)}`;
log.appendChild(div);
});
if (msgs.length) log.scrollTop = log.scrollHeight;
}
document.getElementById('chatForm').onsubmit = async e => {
e.preventDefault();
const text = document.getElementById('chatInput').value.trim();
const device_id = document.getElementById('webDeviceId').value.trim() || 'web';
if (!text) return;
await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id, text })
});
document.getElementById('chatInput').value = '';
pollChat();
};
function setPollStatus(ok, detail) {
const el = document.getElementById('pollStatus');
if (!el) return;
const time = new Date().toLocaleTimeString();
el.textContent = ok ? `${time}` : `${time}`;
el.title = detail || (ok ? 'Данные обновляются автоматически' : 'Ошибка опроса');
el.style.color = ok ? '#aaa' : '#e94560';
}
async function pollOnce() {
if (document.hidden) return;
try {
await fetchDevices();
setPollStatus(true);
} catch (e) {
console.warn('poll devices', e);
setPollStatus(false, String(e.message || e));
}
pollTick++;
if (pollTick % Math.round(CHAT_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await pollChat();
} catch (e) {
console.warn('poll chat', e);
}
}
if (pollTick % Math.round(TRACKS_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await loadAllTracks();
} catch (e) {
console.warn('poll tracks', e);
}
}
if (dualTracksActive && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await refreshTimelineTelemetry();
} catch (e) {
console.warn('poll timeline telemetry', e);
}
}
}
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
await pollOnce();
schedulePoll();
}, DEVICE_POLL_MS);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
pollOnce();
}
});
schedulePoll();
loadAllTracks();
</script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+68
View File
@@ -0,0 +1,68 @@
import sqlite3
import tempfile
from pathlib import Path
import pytest
from core import storage
from core.schema import SCHEMA_VERSION, apply_migrations, check_db_ok, column_exists
@pytest.fixture
def temp_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
path = str(Path(tmp) / "test.db")
monkeypatch.setattr(storage, "DATABASE_PATH", path)
yield path
def test_fresh_db_has_all_tables(temp_db):
storage.init_db()
status = storage.db_status()
assert status["db_ok"] is True
assert status["schema_version"] == SCHEMA_VERSION
def test_old_telemetry_without_meta_gets_migrated(temp_db):
conn = sqlite3.connect(temp_db)
conn.execute(
"""
CREATE TABLE telemetry (
id INTEGER PRIMARY KEY,
device_id TEXT,
lat REAL, lon REAL, rssi REAL, range_m REAL,
raw_frame TEXT, ts REAL
)
"""
)
conn.commit()
conn.close()
storage.init_db()
conn = sqlite3.connect(temp_db)
assert column_exists(conn, "telemetry", "meta")
assert check_db_ok(conn)
conn.close()
def test_tracks_crud(temp_db):
storage.init_db()
start = storage.start_track("android-12345678")
tid = start["track_id"]
storage.add_track_points(
tid,
[
{
"ts": 1.0,
"lat": 55.75,
"lon": 37.62,
"rssi": -70.0,
"role": "RX",
"meta": '{"packet":1}',
}
],
)
fin = storage.finish_track(tid)
assert fin["point_count"] == 1
track = storage.get_track(tid)
assert len(track["points"]) == 1