generated from Grigo/AndroidTemplate
Initial commit: LoraTester Android + server
This commit is contained in:
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")),
|
||||
)
|
||||
Reference in New Issue
Block a user