infinicode 2.8.22 → 2.8.25

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.
@@ -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, datetime.utcnow().isoformat()))
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, datetime.utcnow().isoformat()))
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. ──
@@ -1591,6 +1661,20 @@ async def update_device(device_id: str, payload: DeviceUpdate):
1591
1661
  await db.commit()
1592
1662
  if cur.rowcount == 0:
1593
1663
  raise HTTPException(404, "Device not found")
1664
+ # Keep the legacy `robots` table's own `name` column in sync when this
1665
+ # PATCH actually changes the device name. Without this, robots.name
1666
+ # only ever gets set once (at first-creation, via INSERT OR IGNORE)
1667
+ # and silently goes stale on every subsequent rename — the dashboard
1668
+ # reads from /api/robots, not /api/devices, so it would keep showing
1669
+ # the old name forever. Only touch robots.name when a name was part
1670
+ # of this update (don't unconditionally overwrite it on unrelated
1671
+ # field updates), and only if a robots row for this id exists.
1672
+ if "name" in fields:
1673
+ await db.execute(
1674
+ "UPDATE robots SET name = ? WHERE id = ?",
1675
+ (fields["name"], device_id),
1676
+ )
1677
+ await db.commit()
1594
1678
  await broadcast("device_updated", {"device_id": device_id, "fields": list(fields.keys())})
1595
1679
  await _log_history("device", "device", device_id, "updated", "operator", f"fields={','.join(fields.keys())}")
1596
1680
  return await get_device(device_id)
@@ -1769,6 +1853,10 @@ async def enroll_device(payload: DeviceEnrollRequest):
1769
1853
  payload.name, device_id,
1770
1854
  ),
1771
1855
  )
1856
+ # OR IGNORE: only fires the first time this robots row is created.
1857
+ # PATCH /api/devices/{device_id} now propagates renames into
1858
+ # robots.name afterwards, so this is purely a first-creation
1859
+ # concern, not an ongoing sync path.
1772
1860
  await db.execute(
1773
1861
  "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
1774
1862
  (device_id, existing["name"] or payload.name or device_id, existing["character_id"]),
@@ -1805,6 +1893,10 @@ async def enroll_device(payload: DeviceEnrollRequest):
1805
1893
  new_hash, None, now, now,
1806
1894
  ),
1807
1895
  )
1896
+ # OR IGNORE: only fires the first time this robots row is created.
1897
+ # PATCH /api/devices/{device_id} now propagates renames into
1898
+ # robots.name afterwards, so this is purely a first-creation
1899
+ # concern, not an ongoing sync path.
1808
1900
  await db.execute(
1809
1901
  "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
1810
1902
  (device_id, (payload.name or f"Pi-{device_id[-4:]}").strip(), payload.character_id),
@@ -1910,6 +2002,10 @@ async def device_request_session(device_id: str,
1910
2002
 
1911
2003
  # Mirror device -> robot identity (id == device_id). Additive + idempotent:
1912
2004
  # if the robot row already exists it is left untouched.
2005
+ # OR IGNORE: only fires the first time this robots row is created.
2006
+ # PATCH /api/devices/{device_id} now propagates renames into
2007
+ # robots.name afterwards, so this is purely a first-creation
2008
+ # concern, not an ongoing sync path.
1913
2009
  await db.execute(
1914
2010
  "INSERT OR IGNORE INTO robots (id, name, character_id) VALUES (?, ?, ?)",
1915
2011
  (device_id, device["name"] or device_id, device["character_id"]),
@@ -1938,10 +2034,11 @@ async def device_request_session(device_id: str,
1938
2034
  # timeout instead of the 30s default).
1939
2035
  room_name = f"robopark-{device_id}-{ts}"
1940
2036
 
2037
+ _now_iso = datetime.utcnow().isoformat()
1941
2038
  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, datetime.utcnow().isoformat()))
2039
+ INSERT INTO sessions (id, robot_id, server_id, room_name, started_at, last_activity_at)
2040
+ VALUES (?, ?, ?, ?, ?, ?)
2041
+ """, (session_id, device_id, server["id"], room_name, _now_iso, _now_iso))
1945
2042
 
1946
2043
  # Mark the mirrored robot connecting + count the camera trigger, exactly
1947
2044
  # as the robot path does (request-session is the natural trigger point).