robopark 3.3.27 → 3.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scheduler/main.py +105 -6
package/package.json
CHANGED
package/scheduler/main.py
CHANGED
|
@@ -18,11 +18,11 @@ import uuid
|
|
|
18
18
|
from datetime import datetime, timedelta
|
|
19
19
|
from html import escape as html_escape
|
|
20
20
|
from pathlib import Path
|
|
21
|
-
from typing import Any, Optional, List
|
|
21
|
+
from typing import Any, Dict, Optional, List
|
|
22
22
|
from urllib.parse import quote, urlparse
|
|
23
23
|
from contextlib import asynccontextmanager
|
|
24
24
|
|
|
25
|
-
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query, Header, Request
|
|
25
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query, Header, Request, Body
|
|
26
26
|
from fastapi.middleware.cors import CORSMiddleware
|
|
27
27
|
from fastapi.staticfiles import StaticFiles
|
|
28
28
|
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
|
|
@@ -1963,12 +1963,41 @@ async def _claim_character_device(
|
|
|
1963
1963
|
"""INSERT INTO robots (id, name, character_id, status, last_heartbeat)
|
|
1964
1964
|
VALUES (?, ?, ?, 'online', ?)
|
|
1965
1965
|
ON CONFLICT(id) DO UPDATE SET
|
|
1966
|
+
name = excluded.name,
|
|
1966
1967
|
character_id = excluded.character_id,
|
|
1967
1968
|
status = 'online',
|
|
1968
1969
|
last_heartbeat = excluded.last_heartbeat""",
|
|
1969
1970
|
(device_id, label, character_id, stamp),
|
|
1970
1971
|
)
|
|
1972
|
+
# Retire every OTHER auto-claim for this character.
|
|
1973
|
+
#
|
|
1974
|
+
# The claim key is the join link, so each regenerated share link mints a
|
|
1975
|
+
# fresh claim device for the same character and nothing ever retired the
|
|
1976
|
+
# old ones. Three live claim_* rows for one character meant three
|
|
1977
|
+
# robopark-<device> rooms: the publisher joined the newest while the camera
|
|
1978
|
+
# grid was still watching an older twin, which is why a feed visible on the
|
|
1979
|
+
# call page showed as "waiting for camera track" everywhere else. Only
|
|
1980
|
+
# auto-claimed rows are touched -- enrolled hardware is never retired.
|
|
1981
|
+
async with db.execute(
|
|
1982
|
+
"SELECT id FROM devices WHERE lower(character_id) = lower(?) AND id != ? "
|
|
1983
|
+
"AND notes LIKE 'auto-claimed via join link%'",
|
|
1984
|
+
(character_id or "", device_id),
|
|
1985
|
+
) as cursor:
|
|
1986
|
+
twins = [str(row[0]) for row in await cursor.fetchall()]
|
|
1987
|
+
for twin in twins:
|
|
1988
|
+
await db.execute(
|
|
1989
|
+
"UPDATE sessions SET ended_at = ?, end_reason = ? WHERE robot_id = ? AND ended_at IS NULL",
|
|
1990
|
+
(stamp, "claim_superseded", twin),
|
|
1991
|
+
)
|
|
1992
|
+
await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (twin,))
|
|
1993
|
+
await db.execute(
|
|
1994
|
+
"UPDATE robots SET status = 'idle', current_session_id = NULL, "
|
|
1995
|
+
"connected_server_id = NULL WHERE id = ?",
|
|
1996
|
+
(twin,),
|
|
1997
|
+
)
|
|
1971
1998
|
await db.commit()
|
|
1999
|
+
for twin in twins:
|
|
2000
|
+
await broadcast("device_status_changed", {"device_id": twin, "status": "offline"})
|
|
1972
2001
|
# No MJPEG ref: a claimed device publishes its own browser camera rather than
|
|
1973
2002
|
# exposing a robot-side vision server, so the page falls back to local video.
|
|
1974
2003
|
return device_id, None
|
|
@@ -2356,6 +2385,70 @@ async def session_joined(session_id: str,
|
|
|
2356
2385
|
await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
|
|
2357
2386
|
return {"status": "ok", "joined_at": now, "latency_ms": latency_ms}
|
|
2358
2387
|
|
|
2388
|
+
@app.post("/api/sessions/{session_id}/heartbeat")
|
|
2389
|
+
async def session_heartbeat(session_id: str,
|
|
2390
|
+
payload: Optional[Dict[str, Any]] = Body(default=None),
|
|
2391
|
+
authorization: Optional[str] = Header(default=None),
|
|
2392
|
+
session_token: Optional[str] = Header(default=None, alias="X-RoboPark-Session-Token")):
|
|
2393
|
+
"""Liveness beat from the browser join page, every 30s for the whole call.
|
|
2394
|
+
|
|
2395
|
+
This route is the one the public join page has always called. It did not
|
|
2396
|
+
exist, so every beat 404'd and nothing refreshed liveness for a browser
|
|
2397
|
+
call. Three minutes in, _reap_robot_session_if_stale saw last_activity_at
|
|
2398
|
+
still equal to started_at, ended the session as 'stale_activity' and set
|
|
2399
|
+
robots.status='idle'/current_session_id=NULL; the device then fell out of
|
|
2400
|
+
health_checker's live-session exclusion and flipped offline 90s later.
|
|
2401
|
+
The call itself stayed up the whole time — only every operator view lost
|
|
2402
|
+
it, which is why robots vanished from the park map and the camera grid
|
|
2403
|
+
dropped feeds partway through a working call.
|
|
2404
|
+
|
|
2405
|
+
Bumps all three clocks the operator views read: the session's activity,
|
|
2406
|
+
the device heartbeat and the robot heartbeat.
|
|
2407
|
+
"""
|
|
2408
|
+
if not await _session_event_authorized(session_id, session_token, authorization, None):
|
|
2409
|
+
raise HTTPException(401, "Invalid or missing session token")
|
|
2410
|
+
now = datetime.utcnow().isoformat()
|
|
2411
|
+
source = str((payload or {}).get("source") or "")[:40]
|
|
2412
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2413
|
+
db.row_factory = aiosqlite.Row
|
|
2414
|
+
async with db.execute(
|
|
2415
|
+
"SELECT robot_id, ended_at FROM sessions WHERE id = ?", (session_id,)
|
|
2416
|
+
) as cursor:
|
|
2417
|
+
session = await cursor.fetchone()
|
|
2418
|
+
if not session:
|
|
2419
|
+
raise HTTPException(404, "Session not found")
|
|
2420
|
+
if session["ended_at"]:
|
|
2421
|
+
# Do not resurrect a closed session; tell the page so it can stop.
|
|
2422
|
+
return {"status": "ended", "ended_at": session["ended_at"]}
|
|
2423
|
+
await db.execute(
|
|
2424
|
+
"UPDATE sessions SET last_activity_at = ? WHERE id = ? AND ended_at IS NULL",
|
|
2425
|
+
(now, session_id),
|
|
2426
|
+
)
|
|
2427
|
+
robot_id = session["robot_id"]
|
|
2428
|
+
was_offline = False
|
|
2429
|
+
if robot_id:
|
|
2430
|
+
async with db.execute(
|
|
2431
|
+
"SELECT status FROM devices WHERE id = ?", (robot_id,)
|
|
2432
|
+
) as cursor:
|
|
2433
|
+
device = await cursor.fetchone()
|
|
2434
|
+
was_offline = bool(device) and device["status"] != "online"
|
|
2435
|
+
await db.execute(
|
|
2436
|
+
"UPDATE devices SET status = 'online', last_heartbeat = ? WHERE id = ?",
|
|
2437
|
+
(now, robot_id),
|
|
2438
|
+
)
|
|
2439
|
+
# Re-point the robot at this session as well. A beat that arrives
|
|
2440
|
+
# after the reaper already released the pointer has to restore it,
|
|
2441
|
+
# otherwise the session stays live but invisible until it ends.
|
|
2442
|
+
await db.execute(
|
|
2443
|
+
"UPDATE robots SET status = 'running', current_session_id = ?, last_heartbeat = ? "
|
|
2444
|
+
"WHERE id = ?",
|
|
2445
|
+
(session_id, now, robot_id),
|
|
2446
|
+
)
|
|
2447
|
+
await db.commit()
|
|
2448
|
+
if robot_id and was_offline:
|
|
2449
|
+
await broadcast("device_status_changed", {"device_id": robot_id, "status": "online"})
|
|
2450
|
+
return {"status": "ok", "last_activity_at": now, "source": source}
|
|
2451
|
+
|
|
2359
2452
|
@app.post("/api/sessions/{session_id}/keepalive")
|
|
2360
2453
|
async def session_keepalive(session_id: str,
|
|
2361
2454
|
authorization: Optional[str] = Header(default=None),
|
|
@@ -3528,11 +3621,17 @@ def _lk_public_url(url: str, request: Optional[Request] = None) -> str:
|
|
|
3528
3621
|
return url # the caller IS local; localhost is correct for them
|
|
3529
3622
|
|
|
3530
3623
|
proto = (request.headers.get("x-forwarded-proto") or request.url.scheme or "").lower()
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3624
|
+
if proto == "https":
|
|
3625
|
+
# ws:// from an https:// page is mixed content and is blocked, so a
|
|
3626
|
+
# TLS-fronted caller must get wss. But LiveKit's own port serves
|
|
3627
|
+
# plain ws — pointing wss at it just fails the TLS handshake, which
|
|
3628
|
+
# is what this fallback did on its first outing. TLS comes from a
|
|
3629
|
+
# separate front door (`tailscale serve --https=3443`), and the
|
|
3630
|
+
# control center already assumes 3443, so use that port.
|
|
3631
|
+
tls_port = (os.getenv("ROBOPARK_LIVEKIT_TLS_PORT", "").strip() or "3443")
|
|
3632
|
+
return f"wss://{caller}:{tls_port}"
|
|
3534
3633
|
port = f":{parsed.port}" if parsed.port else ""
|
|
3535
|
-
return f"
|
|
3634
|
+
return f"ws://{caller}{port}"
|
|
3536
3635
|
except Exception:
|
|
3537
3636
|
return url
|
|
3538
3637
|
|