robopark 2.8.23 → 2.8.26
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/__pycache__/preview_agent.cpython-312.pyc +0 -0
- package/scheduler/main.py +116 -11
- package/scheduler/preview_agent.py +1011 -782
- package/vision/app_pi_clean.py +81 -1
package/package.json
CHANGED
|
Binary file
|
package/scheduler/main.py
CHANGED
|
@@ -298,6 +298,13 @@ async def init_db():
|
|
|
298
298
|
("sessions", "joined_at", "TEXT"),
|
|
299
299
|
("devices", "video_device", "TEXT"),
|
|
300
300
|
("devices", "audio_device", "TEXT"),
|
|
301
|
+
# Silence-based session end (see POST /api/sessions/{id}/keepalive
|
|
302
|
+
# and GET /api/sessions/{id}/status): tracks the last time some
|
|
303
|
+
# caller signaled this session was still active. Defaults to
|
|
304
|
+
# started_at at INSERT time; existing rows on an upgraded DB will
|
|
305
|
+
# have NULL here until touched, so readers should fall back to
|
|
306
|
+
# started_at (see the /status endpoint below).
|
|
307
|
+
("sessions", "last_activity_at", "TEXT"),
|
|
301
308
|
):
|
|
302
309
|
try:
|
|
303
310
|
await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
|
|
@@ -485,6 +492,12 @@ async def request_session(robot_id: str,
|
|
|
485
492
|
async with db.execute("SELECT name FROM devices WHERE id = ?", (robot_id,)) as dc:
|
|
486
493
|
device_row = await dc.fetchone()
|
|
487
494
|
if device_row:
|
|
495
|
+
# OR IGNORE means this name is only ever written on first
|
|
496
|
+
# creation of the robots row. PATCH /api/devices/{device_id}
|
|
497
|
+
# now keeps robots.name in sync on every rename going forward,
|
|
498
|
+
# so a stale robots.name here is only possible if a session
|
|
499
|
+
# request race wins before that PATCH sync runs — not an
|
|
500
|
+
# ongoing drift concern.
|
|
488
501
|
await db.execute(
|
|
489
502
|
"INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
|
|
490
503
|
(robot_id, device_row["name"]),
|
|
@@ -517,11 +530,12 @@ async def request_session(robot_id: str,
|
|
|
517
530
|
session_id = f"session_{robot_id}_{int(datetime.utcnow().timestamp())}"
|
|
518
531
|
room_name = f"robopark-{robot_id}-{int(datetime.utcnow().timestamp())}"
|
|
519
532
|
|
|
533
|
+
_now_iso = datetime.utcnow().isoformat()
|
|
520
534
|
await db.execute("""
|
|
521
|
-
INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
|
|
522
|
-
VALUES (?, ?, ?, ?, ?)
|
|
523
|
-
""", (session_id, robot_id, server["id"], room_name,
|
|
524
|
-
|
|
535
|
+
INSERT INTO sessions (id, robot_id, server_id, room_name, started_at, last_activity_at)
|
|
536
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
537
|
+
""", (session_id, robot_id, server["id"], room_name, _now_iso, _now_iso))
|
|
538
|
+
|
|
525
539
|
# Update robot status + count this as a camera trigger. request-session
|
|
526
540
|
# is the natural trigger point ("robot requests a session after scene
|
|
527
541
|
# detection"), so the trigger_count populates here with no client change.
|
|
@@ -808,10 +822,11 @@ async def create_web_client_session(req: Optional[WebClientSessionRequest] = Non
|
|
|
808
822
|
session_id = f"web_{int(datetime.utcnow().timestamp())}"
|
|
809
823
|
room_name = f"robopark_web_{int(datetime.utcnow().timestamp())}"
|
|
810
824
|
|
|
825
|
+
_now_iso = datetime.utcnow().isoformat()
|
|
811
826
|
await db.execute("""
|
|
812
|
-
INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
|
|
813
|
-
VALUES (?, ?, ?, ?, ?)
|
|
814
|
-
""", (session_id, "web-client", server["id"], room_name,
|
|
827
|
+
INSERT INTO sessions (id, robot_id, server_id, room_name, started_at, last_activity_at)
|
|
828
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
829
|
+
""", (session_id, "web-client", server["id"], room_name, _now_iso, _now_iso))
|
|
815
830
|
|
|
816
831
|
await db.commit()
|
|
817
832
|
|
|
@@ -899,6 +914,61 @@ async def session_joined(session_id: str,
|
|
|
899
914
|
await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
|
|
900
915
|
return {"status": "ok", "joined_at": now, "latency_ms": latency_ms}
|
|
901
916
|
|
|
917
|
+
@app.post("/api/sessions/{session_id}/keepalive")
|
|
918
|
+
async def session_keepalive(session_id: str,
|
|
919
|
+
authorization: Optional[str] = Header(default=None)):
|
|
920
|
+
"""Silence-based session extension signal.
|
|
921
|
+
|
|
922
|
+
INTEGRATION POINT (out of scope here — lives in a different repo): the
|
|
923
|
+
VOICE AGENT is the one process that actually knows, via VAD, whether the
|
|
924
|
+
user/agent are still talking. While a motion-triggered session's
|
|
925
|
+
conversation is active, the voice agent should call this endpoint
|
|
926
|
+
periodically (e.g. every few seconds) to bump last_activity_at. The robot
|
|
927
|
+
side (preview_agent.py) has no independent way to know if a conversation
|
|
928
|
+
is ongoing — it only knows this via what it reads back from here through
|
|
929
|
+
GET /api/sessions/{session_id}/status. If nothing ever calls this
|
|
930
|
+
endpoint, last_activity_at simply stays at started_at and the session
|
|
931
|
+
will be torn down by preview_agent.py's silence timeout shortly after it
|
|
932
|
+
starts — that's an intentionally conservative default, not a bug.
|
|
933
|
+
|
|
934
|
+
preview_agent.py additionally enforces vision_session_seconds as a hard
|
|
935
|
+
ceiling regardless of activity, so a misbehaving/stuck caller can never
|
|
936
|
+
hold the publisher forever even if it calls this endpoint continuously.
|
|
937
|
+
"""
|
|
938
|
+
if not await _authorize_fleet(authorization):
|
|
939
|
+
raise HTTPException(401, "Invalid or missing device token")
|
|
940
|
+
now = datetime.utcnow().isoformat()
|
|
941
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
942
|
+
cur = await db.execute(
|
|
943
|
+
"UPDATE sessions SET last_activity_at = ? WHERE id = ? AND ended_at IS NULL",
|
|
944
|
+
(now, session_id),
|
|
945
|
+
)
|
|
946
|
+
await db.commit()
|
|
947
|
+
if cur.rowcount == 0:
|
|
948
|
+
raise HTTPException(404, "Session not found or already ended")
|
|
949
|
+
return {"status": "ok", "last_activity_at": now}
|
|
950
|
+
|
|
951
|
+
@app.get("/api/sessions/{session_id}/status")
|
|
952
|
+
async def get_session_status(session_id: str):
|
|
953
|
+
"""Lightweight session status for polling — used by preview_agent.py to
|
|
954
|
+
decide whether a motion-triggered session is still "active" (recent
|
|
955
|
+
last_activity_at) or has gone silent and should be torn down. See
|
|
956
|
+
POST /api/sessions/{session_id}/keepalive for who updates last_activity_at."""
|
|
957
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
958
|
+
db.row_factory = aiosqlite.Row
|
|
959
|
+
async with db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)) as c:
|
|
960
|
+
session = await c.fetchone()
|
|
961
|
+
if not session:
|
|
962
|
+
raise HTTPException(404, "Session not found")
|
|
963
|
+
return {
|
|
964
|
+
"session_id": session["id"],
|
|
965
|
+
"started_at": session["started_at"],
|
|
966
|
+
"ended_at": session["ended_at"],
|
|
967
|
+
# Existing rows created before this column existed (or that predate
|
|
968
|
+
# any keepalive call) have NULL here — fall back to started_at.
|
|
969
|
+
"last_activity_at": session["last_activity_at"] or session["started_at"],
|
|
970
|
+
}
|
|
971
|
+
|
|
902
972
|
# ── Back-compat: legacy web-client session token (ported from the deployed OLD
|
|
903
973
|
# scheduler so voice_client.html / the old frontend keep working after the
|
|
904
974
|
# fork reconcile). Raw-PyJWT HS256, publish-capable "Web User" token. ──
|
|
@@ -1089,7 +1159,15 @@ async def health_checker():
|
|
|
1089
1159
|
if resp.status_code == 200:
|
|
1090
1160
|
new_status = "online"
|
|
1091
1161
|
else:
|
|
1092
|
-
|
|
1162
|
+
# A bare LiveKit server (no sidecar webhook
|
|
1163
|
+
# service in front of it, e.g. `--dev` mode
|
|
1164
|
+
# with no separate health endpoint) has no
|
|
1165
|
+
# /health route and returns 404 here even
|
|
1166
|
+
# though it's genuinely up — LiveKit's own
|
|
1167
|
+
# root path returns "OK" instead. Fall back
|
|
1168
|
+
# to that before declaring it offline.
|
|
1169
|
+
resp2 = await client.get(server["webhook_url"])
|
|
1170
|
+
new_status = "online" if resp2.status_code == 200 else "offline"
|
|
1093
1171
|
except:
|
|
1094
1172
|
new_status = "offline"
|
|
1095
1173
|
|
|
@@ -1591,6 +1669,20 @@ async def update_device(device_id: str, payload: DeviceUpdate):
|
|
|
1591
1669
|
await db.commit()
|
|
1592
1670
|
if cur.rowcount == 0:
|
|
1593
1671
|
raise HTTPException(404, "Device not found")
|
|
1672
|
+
# Keep the legacy `robots` table's own `name` column in sync when this
|
|
1673
|
+
# PATCH actually changes the device name. Without this, robots.name
|
|
1674
|
+
# only ever gets set once (at first-creation, via INSERT OR IGNORE)
|
|
1675
|
+
# and silently goes stale on every subsequent rename — the dashboard
|
|
1676
|
+
# reads from /api/robots, not /api/devices, so it would keep showing
|
|
1677
|
+
# the old name forever. Only touch robots.name when a name was part
|
|
1678
|
+
# of this update (don't unconditionally overwrite it on unrelated
|
|
1679
|
+
# field updates), and only if a robots row for this id exists.
|
|
1680
|
+
if "name" in fields:
|
|
1681
|
+
await db.execute(
|
|
1682
|
+
"UPDATE robots SET name = ? WHERE id = ?",
|
|
1683
|
+
(fields["name"], device_id),
|
|
1684
|
+
)
|
|
1685
|
+
await db.commit()
|
|
1594
1686
|
await broadcast("device_updated", {"device_id": device_id, "fields": list(fields.keys())})
|
|
1595
1687
|
await _log_history("device", "device", device_id, "updated", "operator", f"fields={','.join(fields.keys())}")
|
|
1596
1688
|
return await get_device(device_id)
|
|
@@ -1769,6 +1861,10 @@ async def enroll_device(payload: DeviceEnrollRequest):
|
|
|
1769
1861
|
payload.name, device_id,
|
|
1770
1862
|
),
|
|
1771
1863
|
)
|
|
1864
|
+
# OR IGNORE: only fires the first time this robots row is created.
|
|
1865
|
+
# PATCH /api/devices/{device_id} now propagates renames into
|
|
1866
|
+
# robots.name afterwards, so this is purely a first-creation
|
|
1867
|
+
# concern, not an ongoing sync path.
|
|
1772
1868
|
await db.execute(
|
|
1773
1869
|
"INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
|
|
1774
1870
|
(device_id, existing["name"] or payload.name or device_id, existing["character_id"]),
|
|
@@ -1805,6 +1901,10 @@ async def enroll_device(payload: DeviceEnrollRequest):
|
|
|
1805
1901
|
new_hash, None, now, now,
|
|
1806
1902
|
),
|
|
1807
1903
|
)
|
|
1904
|
+
# OR IGNORE: only fires the first time this robots row is created.
|
|
1905
|
+
# PATCH /api/devices/{device_id} now propagates renames into
|
|
1906
|
+
# robots.name afterwards, so this is purely a first-creation
|
|
1907
|
+
# concern, not an ongoing sync path.
|
|
1808
1908
|
await db.execute(
|
|
1809
1909
|
"INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
|
|
1810
1910
|
(device_id, (payload.name or f"Pi-{device_id[-4:]}").strip(), payload.character_id),
|
|
@@ -1910,6 +2010,10 @@ async def device_request_session(device_id: str,
|
|
|
1910
2010
|
|
|
1911
2011
|
# Mirror device -> robot identity (id == device_id). Additive + idempotent:
|
|
1912
2012
|
# if the robot row already exists it is left untouched.
|
|
2013
|
+
# OR IGNORE: only fires the first time this robots row is created.
|
|
2014
|
+
# PATCH /api/devices/{device_id} now propagates renames into
|
|
2015
|
+
# robots.name afterwards, so this is purely a first-creation
|
|
2016
|
+
# concern, not an ongoing sync path.
|
|
1913
2017
|
await db.execute(
|
|
1914
2018
|
"INSERT OR IGNORE INTO robots (id, name, character_id) VALUES (?, ?, ?)",
|
|
1915
2019
|
(device_id, device["name"] or device_id, device["character_id"]),
|
|
@@ -1938,10 +2042,11 @@ async def device_request_session(device_id: str,
|
|
|
1938
2042
|
# timeout instead of the 30s default).
|
|
1939
2043
|
room_name = f"robopark-{device_id}-{ts}"
|
|
1940
2044
|
|
|
2045
|
+
_now_iso = datetime.utcnow().isoformat()
|
|
1941
2046
|
await db.execute("""
|
|
1942
|
-
INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
|
|
1943
|
-
VALUES (?, ?, ?, ?, ?)
|
|
1944
|
-
""", (session_id, device_id, server["id"], room_name,
|
|
2047
|
+
INSERT INTO sessions (id, robot_id, server_id, room_name, started_at, last_activity_at)
|
|
2048
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2049
|
+
""", (session_id, device_id, server["id"], room_name, _now_iso, _now_iso))
|
|
1945
2050
|
|
|
1946
2051
|
# Mark the mirrored robot connecting + count the camera trigger, exactly
|
|
1947
2052
|
# as the robot path does (request-session is the natural trigger point).
|