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
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")),
)