Initial commit: LoraTester Android + server

This commit is contained in:
2026-06-04 14:39:14 +03:00
parent 253a7d74ca
commit 81eaa95df3
26 changed files with 1898 additions and 106 deletions
+300
View File
@@ -11,6 +11,11 @@ from .elevation import fetch_elevation_m
from .models import ChatIn, TelemetryIn
from .schema import SCHEMA_VERSION, apply_migrations, check_db_ok, get_schema_version
WEB_SENDER_ID = "web"
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
PAIRED_ONLINE_SEC = 30.0
PAIRED_START_DELAY_SEC = 3.0
logger = logging.getLogger(__name__)
_HISTORY_COLUMNS = (
@@ -396,3 +401,298 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
(since, limit),
).fetchall()
return [dict(r) for r in rows]
def _is_valid_sender(device_id: str) -> bool:
d = (device_id or "").strip()
return d == WEB_SENDER_ID or is_valid_device_id(d)
def _row_to_command(row: sqlite3.Row) -> dict[str, Any]:
payload = row["payload"]
if payload:
try:
payload = json.loads(payload)
except json.JSONDecodeError:
pass
return {
"id": row["id"],
"from_device_id": row["from_device_id"],
"to_device_id": row["to_device_id"],
"kind": row["kind"],
"payload": payload,
"created_at": row["created_at"],
"delivered_at": row["delivered_at"],
}
def enqueue_command(
from_device_id: str,
to_device_id: str,
kind: str,
payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
from_id = (from_device_id or "").strip()
to_id = (to_device_id or "").strip()
kind = (kind or "").strip().lower()
if not _is_valid_sender(from_id):
raise ValueError(f"invalid from_device_id '{from_id}'")
if not is_valid_device_id(to_id):
raise ValueError(f"invalid to_device_id '{to_id}'")
if kind not in COMMAND_KINDS:
raise ValueError(f"invalid kind '{kind}', expected at|mode|stats_push")
if from_id == to_id:
raise ValueError("from and to device must differ")
ts = time.time()
payload_json = json.dumps(payload or {}, ensure_ascii=False)
with _db() as conn:
cur = conn.execute(
"""
INSERT INTO device_commands
(from_device_id, to_device_id, kind, payload, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(from_id, to_id, kind, payload_json, ts),
)
cmd_id = cur.lastrowid
return {
"ok": True,
"id": cmd_id,
"from_device_id": from_id,
"to_device_id": to_id,
"kind": kind,
"created_at": ts,
}
def poll_pending_commands(device_id: str, limit: int = 20) -> list[dict[str, Any]]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
limit = min(max(1, limit), 50)
now = time.time()
with _db() as conn:
rows = conn.execute(
"""
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
FROM device_commands
WHERE to_device_id = ? AND delivered_at IS NULL
ORDER BY created_at ASC
LIMIT ?
""",
(device_id, limit),
).fetchall()
ids = [r["id"] for r in rows]
if ids:
placeholders = ",".join("?" * len(ids))
conn.execute(
f"UPDATE device_commands SET delivered_at = ? WHERE id IN ({placeholders})",
[now, *ids],
)
return [_row_to_command(r) for r in rows]
def list_commands(
to_device_id: Optional[str] = None, limit: int = 50
) -> list[dict[str, Any]]:
limit = min(max(1, limit), 200)
with _db() as conn:
if to_device_id:
rows = conn.execute(
"""
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
FROM device_commands
WHERE to_device_id = ?
ORDER BY created_at DESC
LIMIT ?
""",
(to_device_id, limit),
).fetchall()
else:
rows = conn.execute(
"""
SELECT id, from_device_id, to_device_id, kind, payload, created_at, delivered_at
FROM device_commands
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [_row_to_command(r) for r in rows]
def _online_android_devices(within_sec: float = PAIRED_ONLINE_SEC) -> list[str]:
cutoff = time.time() - within_sec
devices = list_devices()
return [
d["device_id"]
for d in devices
if d.get("last_seen", 0) >= cutoff
]
def _row_to_paired_session(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"device_a": row["device_a"],
"device_b": row["device_b"],
"initiator": row["initiator"],
"status": row["status"],
"start_at": row["start_at"],
"track_id_a": row["track_id_a"],
"track_id_b": row["track_id_b"],
"created_at": row["created_at"],
}
def _cancel_active_paired_sessions(conn: sqlite3.Connection) -> None:
conn.execute(
"""
UPDATE paired_track_sessions
SET status = 'cancelled'
WHERE status IN ('armed', 'recording')
"""
)
def start_paired_track(
device_ids: Optional[list[str]] = None,
initiator: str = WEB_SENDER_ID,
) -> dict[str, Any]:
initiator = (initiator or WEB_SENDER_ID).strip()
if not _is_valid_sender(initiator):
raise ValueError(f"invalid initiator '{initiator}'")
if device_ids and len(device_ids) == 2:
a, b = [str(x).strip() for x in device_ids]
if not is_valid_device_id(a) or not is_valid_device_id(b):
raise ValueError("device_ids must be two valid android-* ids")
if a == b:
raise ValueError("device_ids must differ")
else:
online = _online_android_devices()
if len(online) != 2:
raise ValueError(
f"expected exactly 2 online devices, found {len(online)}"
)
a, b = sorted(online)
now = time.time()
start_at = now + PAIRED_START_DELAY_SEC
with _db() as conn:
_cancel_active_paired_sessions(conn)
cur = conn.execute(
"""
INSERT INTO paired_track_sessions
(device_a, device_b, initiator, status, start_at, created_at)
VALUES (?, ?, ?, 'armed', ?, ?)
""",
(a, b, initiator, start_at, now),
)
session_id = cur.lastrowid
return {
"ok": True,
"session": get_paired_track_session(session_id),
}
def get_active_paired_track() -> Optional[dict[str, Any]]:
with _db() as conn:
row = conn.execute(
"""
SELECT id, device_a, device_b, initiator, status, start_at,
track_id_a, track_id_b, created_at
FROM paired_track_sessions
WHERE status IN ('armed', 'recording')
ORDER BY id DESC
LIMIT 1
"""
).fetchone()
if not row:
return None
session = _row_to_paired_session(row)
now = time.time()
session["server_time"] = now
session["ready"] = session["status"] == "armed" and now >= session["start_at"]
return session
def get_paired_track_session(session_id: int) -> dict[str, Any]:
with _db() as conn:
row = conn.execute(
"""
SELECT id, device_a, device_b, initiator, status, start_at,
track_id_a, track_id_b, created_at
FROM paired_track_sessions WHERE id = ?
""",
(session_id,),
).fetchone()
if not row:
raise ValueError(f"session {session_id} not found")
session = _row_to_paired_session(row)
now = time.time()
session["server_time"] = now
session["ready"] = session["status"] == "armed" and now >= session["start_at"]
return session
def ack_paired_track(
session_id: int, device_id: str, track_id: int
) -> dict[str, Any]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
with _db() as conn:
row = conn.execute(
"""
SELECT id, device_a, device_b, status, track_id_a, track_id_b
FROM paired_track_sessions WHERE id = ?
""",
(session_id,),
).fetchone()
if not row:
raise ValueError(f"session {session_id} not found")
if row["status"] not in ("armed", "recording"):
raise ValueError(f"session {session_id} not active")
col = None
if device_id == row["device_a"]:
col = "track_id_a"
elif device_id == row["device_b"]:
col = "track_id_b"
else:
raise ValueError(f"device {device_id} not in session")
conn.execute(
f"UPDATE paired_track_sessions SET {col} = ? WHERE id = ?",
(track_id, session_id),
)
updated = conn.execute(
"""
SELECT track_id_a, track_id_b, status FROM paired_track_sessions
WHERE id = ?
""",
(session_id,),
).fetchone()
if updated["track_id_a"] and updated["track_id_b"]:
conn.execute(
"UPDATE paired_track_sessions SET status = 'recording' WHERE id = ?",
(session_id,),
)
return {"ok": True, "session": get_paired_track_session(session_id)}
def cancel_paired_track(session_id: Optional[int] = None) -> dict[str, Any]:
with _db() as conn:
if session_id is not None:
cur = conn.execute(
"""
UPDATE paired_track_sessions
SET status = 'cancelled'
WHERE id = ? AND status IN ('armed', 'recording')
""",
(session_id,),
)
if cur.rowcount == 0:
raise ValueError(f"session {session_id} not found or not active")
else:
_cancel_active_paired_sessions(conn)
return {"ok": True, "active": get_active_paired_track()}