generated from Grigo/AndroidTemplate
Initial commit: LoraTester Android + server
This commit is contained in:
@@ -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.
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.
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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
|
||||
@@ -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> <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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user