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.
+41
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
SCHEMA_VERSION = 4
|
||||
|
||||
|
||||
def table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||
@@ -121,6 +121,44 @@ def apply_migrations(conn: sqlite3.Connection) -> list[str]:
|
||||
)
|
||||
log.append("CREATE track_points")
|
||||
|
||||
if not table_exists(conn, "device_commands"):
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE device_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_device_id TEXT NOT NULL,
|
||||
to_device_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
delivered_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_commands_to_pending
|
||||
ON device_commands(to_device_id, delivered_at, created_at);
|
||||
"""
|
||||
)
|
||||
log.append("CREATE device_commands")
|
||||
|
||||
if not table_exists(conn, "paired_track_sessions"):
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE paired_track_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_a TEXT NOT NULL,
|
||||
device_b TEXT NOT NULL,
|
||||
initiator TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
start_at REAL NOT NULL,
|
||||
track_id_a INTEGER,
|
||||
track_id_b INTEGER,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_status
|
||||
ON paired_track_sessions(status, created_at DESC);
|
||||
"""
|
||||
)
|
||||
log.append("CREATE paired_track_sessions")
|
||||
|
||||
set_schema_version(conn, SCHEMA_VERSION)
|
||||
log.append(f"schema_version={SCHEMA_VERSION}")
|
||||
return log
|
||||
@@ -132,6 +170,8 @@ def check_db_ok(conn: sqlite3.Connection) -> bool:
|
||||
("telemetry", "meta"),
|
||||
("tracks", None),
|
||||
("track_points", "elevation_m"),
|
||||
("device_commands", None),
|
||||
("paired_track_sessions", None),
|
||||
]
|
||||
for table, col in required:
|
||||
if not table_exists(conn, table):
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user