generated from Grigo/AndroidTemplate
Initial commit: LoraTester Android + server
This commit is contained in:
+16
-1
@@ -42,7 +42,7 @@ uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
|
||||
curl http://127.0.0.1:7634/api/health
|
||||
```
|
||||
|
||||
Ожидается `"db_ok": true`, `"schema_version": 3`.
|
||||
Ожидается `"db_ok": true`, `"schema_version": 4`.
|
||||
|
||||
Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`):
|
||||
|
||||
@@ -68,6 +68,21 @@ curl http://127.0.0.1:7634/api/health
|
||||
- `GET /api/tracks?device_id=`
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo)
|
||||
|
||||
### Команды (очередь на устройство)
|
||||
|
||||
- `POST /api/commands` — `{from_device_id, to_device_id, kind, payload?}`
|
||||
`kind`: `at` (`payload.line`), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role)
|
||||
`from_device_id`: `web` или `android-xxxxxxxx`
|
||||
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||
|
||||
### Синхронный трек (два устройства)
|
||||
|
||||
- `POST /api/paired-tracks/start` — `{device_ids?: [a,b], initiator?, device_id?}` → сессия `armed`, `start_at = now+3s`
|
||||
- `GET /api/paired-tracks/active` — `{active, session?}`
|
||||
- `POST /api/paired-tracks/ack` — Android: `{session_id, device_id, track_id}`
|
||||
- `POST /api/paired-tracks/cancel` — `{session_id?}`
|
||||
|
||||
### Прочее
|
||||
|
||||
- `POST /api/chat` — `{device_id, text}`
|
||||
|
||||
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()}
|
||||
|
||||
@@ -66,6 +66,29 @@ class TrackPointsBody(BaseModel):
|
||||
points: list[TrackPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CommandBody(BaseModel):
|
||||
from_device_id: str
|
||||
to_device_id: str
|
||||
kind: str
|
||||
payload: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class PairedTrackStartBody(BaseModel):
|
||||
device_ids: Optional[list[str]] = None
|
||||
initiator: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
|
||||
|
||||
class PairedTrackAckBody(BaseModel):
|
||||
session_id: int
|
||||
device_id: str
|
||||
track_id: int
|
||||
|
||||
|
||||
class PairedTrackCancelBody(BaseModel):
|
||||
session_id: Optional[int] = None
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(
|
||||
@@ -194,6 +217,87 @@ def get_chat(since: float = 0, limit: int = Query(200, ge=1, le=500)):
|
||||
return storage.get_chat(since, limit)
|
||||
|
||||
|
||||
@app.post("/api/commands")
|
||||
def post_command(body: CommandBody):
|
||||
try:
|
||||
return storage.enqueue_command(
|
||||
body.from_device_id,
|
||||
body.to_device_id,
|
||||
body.kind,
|
||||
body.payload,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/commands/pending")
|
||||
def commands_pending(
|
||||
device_id: str = Query(...),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
_require_android(x_lora_client)
|
||||
try:
|
||||
return storage.poll_pending_commands(device_id, limit)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/commands")
|
||||
def commands_list(
|
||||
to_device_id: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
return storage.list_commands(to_device_id, limit)
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/start")
|
||||
def paired_tracks_start(
|
||||
body: PairedTrackStartBody,
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
if body.initiator:
|
||||
initiator = body.initiator
|
||||
elif body.device_id:
|
||||
initiator = body.device_id
|
||||
elif (x_lora_client or "").strip().lower() == ANDROID_CLIENT_VALUE:
|
||||
raise HTTPException(400, detail="initiator or device_id required")
|
||||
else:
|
||||
initiator = storage.WEB_SENDER_ID
|
||||
try:
|
||||
return storage.start_paired_track(body.device_ids, str(initiator))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/paired-tracks/active")
|
||||
def paired_tracks_active():
|
||||
session = storage.get_active_paired_track()
|
||||
return {"active": session is not None, "session": session}
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/ack")
|
||||
def paired_tracks_ack(
|
||||
body: PairedTrackAckBody,
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
_require_android(x_lora_client)
|
||||
try:
|
||||
return storage.ack_paired_track(
|
||||
body.session_id, body.device_id, body.track_id
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/cancel")
|
||||
def paired_tracks_cancel(body: PairedTrackCancelBody):
|
||||
try:
|
||||
return storage.cancel_paired_track(body.session_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
status = storage.db_status()
|
||||
|
||||
@@ -145,6 +145,98 @@ def get_chat():
|
||||
return jsonify(storage.get_chat(since, limit))
|
||||
|
||||
|
||||
@app.post("/api/commands")
|
||||
def post_command():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
from_id = body.get("from_device_id")
|
||||
to_id = body.get("to_device_id")
|
||||
kind = body.get("kind")
|
||||
if not from_id or not to_id or not kind:
|
||||
return jsonify({"error": "from_device_id, to_device_id, kind required"}), 400
|
||||
try:
|
||||
return jsonify(
|
||||
storage.enqueue_command(
|
||||
str(from_id), str(to_id), str(kind), body.get("payload")
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.get("/api/commands/pending")
|
||||
def commands_pending():
|
||||
if not is_android_client(request.headers):
|
||||
return jsonify({"error": "Android only"}), 403
|
||||
device_id = request.args.get("device_id")
|
||||
if not device_id:
|
||||
return jsonify({"error": "device_id required"}), 400
|
||||
limit = int(request.args.get("limit", 20))
|
||||
try:
|
||||
return jsonify(storage.poll_pending_commands(str(device_id), limit))
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.get("/api/commands")
|
||||
def commands_list():
|
||||
to_device_id = request.args.get("to_device_id")
|
||||
limit = int(request.args.get("limit", 50))
|
||||
return jsonify(storage.list_commands(to_device_id, limit))
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/start")
|
||||
def paired_tracks_start():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
initiator = body.get("initiator") or (
|
||||
body.get("device_id") if is_android_client(request.headers) else storage.WEB_SENDER_ID
|
||||
)
|
||||
device_ids = body.get("device_ids")
|
||||
try:
|
||||
return jsonify(
|
||||
storage.start_paired_track(
|
||||
device_ids if isinstance(device_ids, list) else None,
|
||||
str(initiator),
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.get("/api/paired-tracks/active")
|
||||
def paired_tracks_active():
|
||||
session = storage.get_active_paired_track()
|
||||
return jsonify({"active": session is not None, "session": session})
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/ack")
|
||||
def paired_tracks_ack():
|
||||
if not is_android_client(request.headers):
|
||||
return jsonify({"error": "Android only"}), 403
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
session_id = body.get("session_id")
|
||||
device_id = body.get("device_id")
|
||||
track_id = body.get("track_id")
|
||||
if session_id is None or not device_id or track_id is None:
|
||||
return jsonify({"error": "session_id, device_id, track_id required"}), 400
|
||||
try:
|
||||
return jsonify(
|
||||
storage.ack_paired_track(int(session_id), str(device_id), int(track_id))
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.post("/api/paired-tracks/cancel")
|
||||
def paired_tracks_cancel():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
session_id = body.get("session_id")
|
||||
try:
|
||||
sid = int(session_id) if session_id is not None else None
|
||||
return jsonify(storage.cancel_paired_track(sid))
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
status = storage.db_status()
|
||||
|
||||
Binary file not shown.
+380
-35
@@ -10,14 +10,29 @@
|
||||
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); }
|
||||
main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr auto; height: calc(100vh - 52px); }
|
||||
@media (max-width: 900px) {
|
||||
main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(200px, 1fr); }
|
||||
main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(180px, 1fr) auto; }
|
||||
}
|
||||
#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 {
|
||||
display: none; grid-column: 1 / -1; grid-row: 2;
|
||||
background: #16213e; padding: 8px 16px; border-top: 1px solid #333;
|
||||
}
|
||||
#trackTimeline.visible { display: block; }
|
||||
.timeline-bar { display: flex; flex-direction: column; gap: 6px; }
|
||||
.timeline-bar-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.timeline-bar-title { font-size: 0.85rem; font-weight: 600; }
|
||||
.timeline-bar .timeline-labels { width: 100%; margin: 0; }
|
||||
.timeline-bar #timeSlider { width: 100%; margin: 0; }
|
||||
#timelineStatsPanel {
|
||||
display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;
|
||||
}
|
||||
#timelineStatsPanel.visible { display: block; }
|
||||
#timelineStatsPanel.timeline-single #timelineStats { grid-template-columns: 1fr; }
|
||||
#timelineStatsPanel.timeline-single .timeline-col.rx { display: none; }
|
||||
#timelineStatsPanel.timeline-single #distanceNow { display: none; }
|
||||
#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; }
|
||||
@@ -49,7 +64,18 @@
|
||||
.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; }
|
||||
.track-actions { display: flex; gap: 6px; margin-top: 4px; }
|
||||
.track-actions button { flex: 1; padding: 6px; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.8rem; }
|
||||
#btnShowTracks { background: #00ff88; color: #111; }
|
||||
#btnHideTracks { background: #333; color: #eee; }
|
||||
#btnHideTracks:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.track-mode { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.track-mode button { flex: 1; padding: 4px; font-size: 0.75rem; border: 1px solid #444; background: #0a0a14; color: #ccc; border-radius: 4px; cursor: pointer; }
|
||||
.track-mode button.active { background: #e94560; color: #fff; border-color: #e94560; }
|
||||
#controlPanel input, #controlPanel select { width: 100%; padding: 4px; margin-top: 2px; border-radius: 4px; border: none; font-size: 0.8rem; }
|
||||
#controlPanel .cmd-row { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
|
||||
#controlPanel .cmd-row button { padding: 4px 8px; font-size: 0.75rem; border: none; border-radius: 4px; cursor: pointer; background: #16213e; color: #eee; }
|
||||
#pairedStatus { font-size: 0.75rem; color: #aaa; margin-top: 4px; }
|
||||
.muted { color: #aaa; font-size: 0.75rem; }
|
||||
.legend { font-size: 0.75rem; color: #ccc; }
|
||||
.legend-tx { color: #e94560; }
|
||||
@@ -93,33 +119,54 @@
|
||||
<div id="stats">Выберите устройство</div>
|
||||
<div id="history" class="muted" style="margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="panel" id="controlPanel">
|
||||
<h2>Управление</h2>
|
||||
<label class="muted">Целевое устройство</label>
|
||||
<select id="cmdTargetSelect"><option value="">—</option></select>
|
||||
<input type="text" id="cmdAtInput" placeholder="AT+SF=7 …" />
|
||||
<div class="cmd-row">
|
||||
<button type="button" id="btnCmdAt">AT</button>
|
||||
<button type="button" id="btnCmdTx">AT+TX</button>
|
||||
<button type="button" id="btnCmdRx">AT+RX</button>
|
||||
</div>
|
||||
<button type="button" id="btnPairedStart" style="width:100%;margin-top:8px;padding:6px;background:#00ff88;color:#111;border:none;border-radius:4px;font-weight:600;cursor:pointer">Старт трека (оба)</button>
|
||||
<div id="pairedStatus">—</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>Сравнение треков</h2>
|
||||
<div class="track-row">
|
||||
<label class="legend-tx">Трек TX</label>
|
||||
<select id="trackTxSelect"><option value="">—</option></select>
|
||||
<h2>Треки</h2>
|
||||
<div class="track-mode">
|
||||
<button type="button" id="btnModeSingle" class="active">Один трек</button>
|
||||
<button type="button" id="btnModeDual">Сравнение TX+RX</button>
|
||||
</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 id="trackPanelSingle">
|
||||
<div class="track-row">
|
||||
<label>Трек</label>
|
||||
<select id="trackSingleSelect"><option value="">—</option></select>
|
||||
</div>
|
||||
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
|
||||
</div>
|
||||
<div id="trackPanelDual" style="display:none">
|
||||
<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>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button type="button" id="btnShowTracks">Показать на карте</button>
|
||||
<button type="button" id="btnHideTracks" disabled>Скрыть треки</button>
|
||||
</div>
|
||||
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите трек</div>
|
||||
<div id="timelineStatsPanel">
|
||||
<h3 style="margin:0 0 6px;font-size:0.9rem">Статистика по времени</h3>
|
||||
<div id="timelineNote"></div>
|
||||
<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 tx"><h3 id="timelineCol1Label">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">
|
||||
@@ -131,6 +178,20 @@
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="trackTimeline">
|
||||
<div class="timeline-bar">
|
||||
<div class="timeline-bar-header">
|
||||
<span class="timeline-bar-title">Время теста</span>
|
||||
<button type="button" id="btnPlay" class="muted" style="padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
|
||||
</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>
|
||||
</div>
|
||||
</main>
|
||||
<div id="mapModal">
|
||||
<div id="mapModalHeader">
|
||||
@@ -163,14 +224,19 @@
|
||||
|
||||
let loadedTxTrack = null;
|
||||
let loadedRxTrack = null;
|
||||
let loadedSingleTrack = null;
|
||||
let telemetryTx = [];
|
||||
let telemetryRx = [];
|
||||
let telemetrySingle = [];
|
||||
let overlapMin = 0;
|
||||
let overlapMax = 0;
|
||||
let playTimer = null;
|
||||
let pollTimer = null;
|
||||
let pollTick = 0;
|
||||
let trackViewMode = 'single';
|
||||
let dualTracksActive = false;
|
||||
let singleTrackActive = false;
|
||||
let lastDevices = [];
|
||||
|
||||
const DEVICE_POLL_MS = 1000;
|
||||
const CHAT_POLL_MS = 2500;
|
||||
@@ -440,6 +506,36 @@
|
||||
if (ghostRx) { map.removeLayer(ghostRx); ghostRx = null; }
|
||||
}
|
||||
|
||||
function updateTrackButtons() {
|
||||
const active = dualTracksActive || singleTrackActive;
|
||||
const hideBtn = document.getElementById('btnHideTracks');
|
||||
if (hideBtn) hideBtn.disabled = !active;
|
||||
}
|
||||
|
||||
function exitTrackMode() {
|
||||
clearTrackLayers();
|
||||
dualTracksActive = false;
|
||||
singleTrackActive = false;
|
||||
loadedTxTrack = null;
|
||||
loadedRxTrack = null;
|
||||
loadedSingleTrack = null;
|
||||
telemetryTx = [];
|
||||
telemetryRx = [];
|
||||
telemetrySingle = [];
|
||||
if (playTimer) {
|
||||
clearInterval(playTimer);
|
||||
playTimer = null;
|
||||
document.getElementById('btnPlay').textContent = '▶ Play';
|
||||
}
|
||||
setTimelineVisible(false);
|
||||
if (isModalOpen() && modalMode === 'timeline') {
|
||||
closeMapModal();
|
||||
}
|
||||
document.getElementById('trackInfo').textContent =
|
||||
trackViewMode === 'dual' ? 'Выберите TX и RX треки' : 'Выберите трек';
|
||||
updateTrackButtons();
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -478,8 +574,17 @@
|
||||
return html;
|
||||
}
|
||||
|
||||
function singleTrackRange(points) {
|
||||
if (!points || !points.length) return null;
|
||||
return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' };
|
||||
}
|
||||
|
||||
function updateTimelineAt(t, opts) {
|
||||
const openModal = opts && opts.openModal;
|
||||
if (singleTrackActive && loadedSingleTrack) {
|
||||
updateTimelineAtSingle(t, openModal);
|
||||
return;
|
||||
}
|
||||
if (!loadedTxTrack || !loadedRxTrack) return;
|
||||
const txPos = positionAt(loadedTxTrack.points, t);
|
||||
const rxPos = positionAt(loadedRxTrack.points, t);
|
||||
@@ -521,12 +626,76 @@
|
||||
? formatTelemetryRow(rxTel) : '<span class="muted">нет данных</span>';
|
||||
}
|
||||
|
||||
function updateTimelineAtSingle(t, openModal) {
|
||||
const track = loadedSingleTrack;
|
||||
if (!track) return;
|
||||
const pos = positionAt(track.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);
|
||||
ghostTx = null;
|
||||
ghostRx = null;
|
||||
linkLine = null;
|
||||
if (pos) {
|
||||
const color = track.role === 'RX' ? RX_COLOR : TX_COLOR;
|
||||
ghostTx = L.circleMarker([pos.lat, pos.lon], {
|
||||
radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3
|
||||
}).addTo(map);
|
||||
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
|
||||
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
|
||||
html += formatMeta(pos.meta);
|
||||
const tel = nearestTelemetry(telemetrySingle, t);
|
||||
if (tel) html += '<br>' + formatTelemetryRow(tel);
|
||||
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
|
||||
openMapModal(html, 'timeline');
|
||||
}
|
||||
}
|
||||
const tel = nearestTelemetry(telemetrySingle, t);
|
||||
document.getElementById('statsTx').innerHTML = tel
|
||||
? formatTelemetryRow(tel) : '<span class="muted">нет данных</span>';
|
||||
}
|
||||
|
||||
function setTimelineVisible(visible) {
|
||||
document.getElementById('trackTimeline').classList.toggle('visible', visible);
|
||||
document.getElementById('timelineStatsPanel').classList.toggle('visible', visible);
|
||||
}
|
||||
|
||||
function setTimelineMode(single) {
|
||||
const statsPanel = document.getElementById('timelineStatsPanel');
|
||||
statsPanel.classList.toggle('timeline-single', single);
|
||||
}
|
||||
|
||||
function setupTimelineSingle() {
|
||||
const range = singleTrackRange(loadedSingleTrack.points);
|
||||
const note = document.getElementById('timelineNote');
|
||||
setTimelineMode(true);
|
||||
document.getElementById('timelineCol1Label').textContent =
|
||||
loadedSingleTrack.role === 'RX' ? 'RX' : 'TX';
|
||||
if (!range) {
|
||||
setTimelineVisible(false);
|
||||
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();
|
||||
note.textContent = `Трек #${loadedSingleTrack.id} · ${loadedSingleTrack.device_id || ''}`;
|
||||
setTimelineVisible(true);
|
||||
updateTimelineAtSingle(overlapMin);
|
||||
}
|
||||
|
||||
function setupTimeline() {
|
||||
setTimelineMode(false);
|
||||
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
|
||||
const panel = document.getElementById('trackTimeline');
|
||||
const note = document.getElementById('timelineNote');
|
||||
if (!range) {
|
||||
panel.classList.remove('visible');
|
||||
setTimelineVisible(false);
|
||||
return;
|
||||
}
|
||||
overlapMin = range.min;
|
||||
@@ -544,12 +713,23 @@
|
||||
} else {
|
||||
note.textContent = 'Общий интервал записи обоих треков.';
|
||||
}
|
||||
panel.classList.add('visible');
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
setTimelineVisible(true);
|
||||
updateTimelineAt(overlapMin);
|
||||
}
|
||||
|
||||
async function refreshTimelineTelemetry() {
|
||||
if (singleTrackActive && loadedSingleTrack) {
|
||||
const range = singleTrackRange(loadedSingleTrack.points);
|
||||
if (!range) return;
|
||||
const res = await fetch(
|
||||
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (res.ok) telemetrySingle = await res.json();
|
||||
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
|
||||
updateTimelineAtSingle(t);
|
||||
return;
|
||||
}
|
||||
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
|
||||
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
|
||||
if (!range) return;
|
||||
@@ -563,11 +743,20 @@
|
||||
updateTimelineAt(t);
|
||||
}
|
||||
|
||||
function trackOptionLabel(t) {
|
||||
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)}` : '';
|
||||
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
|
||||
}
|
||||
|
||||
async function loadAllTracks() {
|
||||
const txSel = document.getElementById('trackTxSelect');
|
||||
const rxSel = document.getElementById('trackRxSelect');
|
||||
const singleSel = document.getElementById('trackSingleSelect');
|
||||
const prevTx = txSel.value;
|
||||
const prevRx = rxSel.value;
|
||||
const prevSingle = singleSel.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();
|
||||
@@ -576,19 +765,67 @@
|
||||
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})`;
|
||||
opt.textContent = trackOptionLabel(t);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
};
|
||||
fill(singleSel, '— трек —');
|
||||
fill(txSel, '— TX трек —');
|
||||
fill(rxSel, '— RX трек —');
|
||||
if (prevSingle) singleSel.value = prevSingle;
|
||||
if (prevTx) txSel.value = prevTx;
|
||||
if (prevRx) rxSel.value = prevRx;
|
||||
if (!singleTrackActive && !dualTracksActive) {
|
||||
document.getElementById('trackInfo').textContent =
|
||||
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
|
||||
}
|
||||
}
|
||||
|
||||
async function showSingleTrack() {
|
||||
const id = document.getElementById('trackSingleSelect').value;
|
||||
if (!id) {
|
||||
document.getElementById('trackInfo').textContent = 'Выберите трек';
|
||||
return;
|
||||
}
|
||||
clearTrackLayers();
|
||||
dualTracksActive = false;
|
||||
singleTrackActive = false;
|
||||
loadedTxTrack = null;
|
||||
loadedRxTrack = null;
|
||||
if (playTimer) { clearInterval(playTimer); playTimer = null; }
|
||||
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
|
||||
loadedSingleTrack = await res.json();
|
||||
if (!loadedSingleTrack.role && loadedSingleTrack.points) {
|
||||
const p = loadedSingleTrack.points.find(x => x.role);
|
||||
if (p) loadedSingleTrack.role = p.role;
|
||||
}
|
||||
if (!loadedSingleTrack.points?.length) {
|
||||
document.getElementById('trackInfo').textContent = 'Пустой трек';
|
||||
return;
|
||||
}
|
||||
const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR;
|
||||
drawTrackLine(loadedSingleTrack, color, 'tx');
|
||||
const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon]));
|
||||
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50] }));
|
||||
singleTrackActive = true;
|
||||
setupTimelineSingle();
|
||||
const range = singleTrackRange(loadedSingleTrack.points);
|
||||
if (range && loadedSingleTrack.device_id) {
|
||||
const telRes = await fetch(
|
||||
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (telRes.ok) telemetrySingle = await telRes.json();
|
||||
updateTimelineAtSingle(overlapMin);
|
||||
}
|
||||
document.getElementById('trackInfo').textContent =
|
||||
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
|
||||
`Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`;
|
||||
updateTrackButtons();
|
||||
}
|
||||
|
||||
function showTracksOnMap() {
|
||||
if (trackViewMode === 'single') showSingleTrack();
|
||||
else showDualTracks();
|
||||
}
|
||||
|
||||
async function showDualTracks() {
|
||||
@@ -603,6 +840,8 @@
|
||||
return;
|
||||
}
|
||||
clearTrackLayers();
|
||||
singleTrackActive = false;
|
||||
loadedSingleTrack = null;
|
||||
if (playTimer) { clearInterval(playTimer); playTimer = null; }
|
||||
|
||||
const [txRes, rxRes] = await Promise.all([
|
||||
@@ -640,9 +879,90 @@
|
||||
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
|
||||
document.getElementById('trackInfo').textContent =
|
||||
`TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`;
|
||||
updateTrackButtons();
|
||||
}
|
||||
|
||||
document.getElementById('btnShowTracks').onclick = showDualTracks;
|
||||
document.getElementById('btnShowTracks').onclick = showTracksOnMap;
|
||||
document.getElementById('btnHideTracks').onclick = exitTrackMode;
|
||||
document.getElementById('btnModeSingle').onclick = () => {
|
||||
trackViewMode = 'single';
|
||||
document.getElementById('btnModeSingle').classList.add('active');
|
||||
document.getElementById('btnModeDual').classList.remove('active');
|
||||
document.getElementById('trackPanelSingle').style.display = '';
|
||||
document.getElementById('trackPanelDual').style.display = 'none';
|
||||
document.getElementById('trackInfo').textContent = 'Выберите трек';
|
||||
if (singleTrackActive || dualTracksActive) exitTrackMode();
|
||||
};
|
||||
document.getElementById('btnModeDual').onclick = () => {
|
||||
trackViewMode = 'dual';
|
||||
document.getElementById('btnModeDual').classList.add('active');
|
||||
document.getElementById('btnModeSingle').classList.remove('active');
|
||||
document.getElementById('trackPanelSingle').style.display = 'none';
|
||||
document.getElementById('trackPanelDual').style.display = '';
|
||||
document.getElementById('trackInfo').textContent = 'Выберите TX и RX треки';
|
||||
if (singleTrackActive || dualTracksActive) exitTrackMode();
|
||||
};
|
||||
|
||||
async function postCommand(toDeviceId, kind, payload) {
|
||||
if (!toDeviceId) {
|
||||
alert('Выберите устройство');
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/commands', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from_device_id: 'web', to_device_id: toDeviceId, kind, payload })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Ошибка команды');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btnCmdAt').onclick = () => {
|
||||
const line = document.getElementById('cmdAtInput').value.trim();
|
||||
if (!line) return;
|
||||
postCommand(document.getElementById('cmdTargetSelect').value, 'at', { line });
|
||||
};
|
||||
document.getElementById('btnCmdTx').onclick = () => {
|
||||
postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'TX' });
|
||||
};
|
||||
document.getElementById('btnCmdRx').onclick = () => {
|
||||
postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'RX' });
|
||||
};
|
||||
|
||||
async function refreshPairedStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/paired-tracks/active', { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const el = document.getElementById('pairedStatus');
|
||||
if (!data.active || !data.session) {
|
||||
el.textContent = 'Синхр. трек: нет активной сессии';
|
||||
return;
|
||||
}
|
||||
const s = data.session;
|
||||
el.textContent = `Сессия #${s.id} · ${s.status} · старт ${new Date(s.start_at * 1000).toLocaleTimeString()}`;
|
||||
} catch (e) {
|
||||
console.warn('paired status', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btnPairedStart').onclick = async () => {
|
||||
const ids = lastDevices.filter(d => d.device_id && d.device_id.startsWith('android-')).map(d => d.device_id);
|
||||
const body = ids.length === 2 ? { device_ids: ids, initiator: 'web' } : { initiator: 'web' };
|
||||
const res = await fetch('/api/paired-tracks/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Не удалось запустить');
|
||||
return;
|
||||
}
|
||||
refreshPairedStatus();
|
||||
};
|
||||
document.getElementById('timeSlider').oninput = e => {
|
||||
modalMode = 'timeline';
|
||||
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true });
|
||||
@@ -740,6 +1060,7 @@
|
||||
}
|
||||
});
|
||||
if (!mapInitialFitDone && bounds.length) fitAllMarkers(bounds);
|
||||
updateCmdTargetSelect(devices);
|
||||
if (selectedId) {
|
||||
const sel = devices.find(d => d.device_id === selectedId);
|
||||
if (sel) {
|
||||
@@ -852,13 +1173,36 @@
|
||||
console.warn('poll tracks', e);
|
||||
}
|
||||
}
|
||||
if (dualTracksActive && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
|
||||
if ((dualTracksActive || singleTrackActive) && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
|
||||
try {
|
||||
await refreshTimelineTelemetry();
|
||||
} catch (e) {
|
||||
console.warn('poll timeline telemetry', e);
|
||||
}
|
||||
}
|
||||
if (pollTick % Math.round(2000 / DEVICE_POLL_MS) === 0) {
|
||||
try {
|
||||
await refreshPairedStatus();
|
||||
} catch (e) {
|
||||
console.warn('poll paired', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateCmdTargetSelect(devices) {
|
||||
lastDevices = devices;
|
||||
const sel = document.getElementById('cmdTargetSelect');
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = '<option value="">— устройство —</option>';
|
||||
devices.forEach(d => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.device_id;
|
||||
let label = d.device_id;
|
||||
if (d.role) label += ` · ${d.role}`;
|
||||
opt.textContent = label;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (prev) sel.value = prev;
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
@@ -877,6 +1221,7 @@
|
||||
|
||||
schedulePoll();
|
||||
loadAllTracks();
|
||||
refreshPairedStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user