infinicode 2.8.54 → 2.8.56

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.
@@ -1881,10 +1881,13 @@ async def robot_stream(robot_id: str,
1881
1881
  if not await get_production_mode():
1882
1882
  return {"active": False, "reason": "production_mode_off"}
1883
1883
 
1884
- async with aiosqlite.connect(DB_PATH) as db:
1885
- db.row_factory = aiosqlite.Row
1886
- async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as c:
1887
- robot = await c.fetchone()
1884
+ async with aiosqlite.connect(DB_PATH) as db:
1885
+ db.row_factory = aiosqlite.Row
1886
+ resolved_id = await _resolve_robot_reference(db, robot_id)
1887
+ if not resolved_id:
1888
+ raise HTTPException(404, "Robot not found")
1889
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (resolved_id,)) as c:
1890
+ robot = await c.fetchone()
1888
1891
  if not robot:
1889
1892
  raise HTTPException(404, "Robot not found")
1890
1893
  if not robot["current_session_id"]:
@@ -1909,7 +1912,7 @@ async def robot_stream(robot_id: str,
1909
1912
  except ImportError:
1910
1913
  raise HTTPException(503, "livekit-api not installed on scheduler")
1911
1914
 
1912
- identity = f"viewer:{robot_id}:{int(datetime.utcnow().timestamp())}"
1915
+ identity = f"viewer:{resolved_id}:{int(datetime.utcnow().timestamp())}"
1913
1916
  grants = VideoGrants(
1914
1917
  room=room_name,
1915
1918
  room_join=True,
@@ -1947,7 +1950,7 @@ async def robot_stream(robot_id: str,
1947
1950
  PREVIEW_TTL_SECONDS = 45 # preview auto-expires this long after the last keepalive
1948
1951
  DEFAULT_VISION_PORT = 5000 # RoboVisionAI_PI's default Flask port (/video_feed, /api/motion/*)
1949
1952
 
1950
- async def _mirror_device_to_robot(db, device_id: str):
1953
+ async def _mirror_device_to_robot(db, device_id: str):
1951
1954
  """Ensure a `robots` row exists for an enrolled device, so id-keyed routes
1952
1955
  (/stream, /preview/*) work even before the device's first real voice
1953
1956
  session (device_request_session normally creates this mirror, but only on
@@ -1965,8 +1968,30 @@ async def _mirror_device_to_robot(db, device_id: str):
1965
1968
  (device_id, device["name"] or device_id, device["character_id"]),
1966
1969
  )
1967
1970
  await db.commit()
1968
- async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
1969
- return await c.fetchone()
1971
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (device_id,)) as c:
1972
+ return await c.fetchone()
1973
+
1974
+ async def _resolve_robot_reference(db, robot_ref: str) -> Optional[str]:
1975
+ """Resolve a display name to its active enrolled device id when needed."""
1976
+ async with db.execute("SELECT id FROM devices WHERE id = ?", (robot_ref,)) as c:
1977
+ row = await c.fetchone()
1978
+ if row:
1979
+ return row["id"]
1980
+ async with db.execute(
1981
+ """SELECT id FROM devices WHERE lower(name) = lower(?)
1982
+ ORDER BY CASE lower(status) WHEN 'online' THEN 0 WHEN 'running' THEN 1 ELSE 2 END,
1983
+ last_heartbeat DESC, enrolled_at DESC LIMIT 1""",
1984
+ (robot_ref,),
1985
+ ) as c:
1986
+ row = await c.fetchone()
1987
+ if row:
1988
+ return row["id"]
1989
+ async with db.execute(
1990
+ "SELECT id FROM robots WHERE id = ? OR lower(name) = lower(?) LIMIT 1",
1991
+ (robot_ref, robot_ref),
1992
+ ) as c:
1993
+ row = await c.fetchone()
1994
+ return row["id"] if row else None
1970
1995
 
1971
1996
  async def _pick_preview_server(db, prefer_id: Optional[str] = None):
1972
1997
  """Return a LiveKit server row for a preview: a preferred one if still present,
@@ -2010,15 +2035,18 @@ def _mint_lk(server, room, identity, name, publish: bool, ttl_min: int):
2010
2035
  )
2011
2036
 
2012
2037
  @app.post("/api/robots/{robot_id}/preview/start")
2013
- async def preview_start(robot_id: str):
2038
+ async def preview_start(robot_id: str):
2014
2039
  """Operator dashboard: begin an on-demand live camera preview for a robot.
2015
2040
  Returns a SUBSCRIBE-only viewer token + the preview room."""
2016
- if not await get_production_mode():
2017
- return {"active": False, "reason": "production_mode_off"}
2018
- async with aiosqlite.connect(DB_PATH) as db:
2019
- db.row_factory = aiosqlite.Row
2020
- if not await _mirror_device_to_robot(db, robot_id):
2021
- raise HTTPException(404, "Robot not found")
2041
+ if not await get_production_mode():
2042
+ return {"active": False, "reason": "production_mode_off"}
2043
+ async with aiosqlite.connect(DB_PATH) as db:
2044
+ db.row_factory = aiosqlite.Row
2045
+ robot_id = await _resolve_robot_reference(db, robot_id)
2046
+ if not robot_id:
2047
+ raise HTTPException(404, "Robot not found")
2048
+ if not await _mirror_device_to_robot(db, robot_id):
2049
+ raise HTTPException(404, "Robot not found")
2022
2050
  async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
2023
2051
  existing = await c.fetchone()
2024
2052
  server = await _pick_preview_server(db, existing["server_id"] if existing else None)
@@ -2043,11 +2071,15 @@ async def preview_start(robot_id: str):
2043
2071
  "identity": identity, "expires_in": PREVIEW_TTL_SECONDS}
2044
2072
 
2045
2073
  @app.post("/api/robots/{robot_id}/preview/keepalive")
2046
- async def preview_keepalive(robot_id: str):
2047
- """Dashboard pings while the operator is watching, to keep the preview alive."""
2048
- expires = (datetime.utcnow() + timedelta(seconds=PREVIEW_TTL_SECONDS)).isoformat()
2049
- async with aiosqlite.connect(DB_PATH) as db:
2050
- cur = await db.execute("UPDATE previews SET expires_at = ? WHERE robot_id = ?", (expires, robot_id))
2074
+ async def preview_keepalive(robot_id: str):
2075
+ """Dashboard pings while the operator is watching, to keep the preview alive."""
2076
+ expires = (datetime.utcnow() + timedelta(seconds=PREVIEW_TTL_SECONDS)).isoformat()
2077
+ async with aiosqlite.connect(DB_PATH) as db:
2078
+ db.row_factory = aiosqlite.Row
2079
+ robot_id = await _resolve_robot_reference(db, robot_id)
2080
+ if not robot_id:
2081
+ return {"active": False}
2082
+ cur = await db.execute("UPDATE previews SET expires_at = ? WHERE robot_id = ?", (expires, robot_id))
2051
2083
  await db.commit()
2052
2084
  if cur.rowcount == 0:
2053
2085
  return {"active": False}
@@ -2055,10 +2087,14 @@ async def preview_keepalive(robot_id: str):
2055
2087
  return {"active": True, "expires_in": PREVIEW_TTL_SECONDS}
2056
2088
 
2057
2089
  @app.post("/api/robots/{robot_id}/preview/stop")
2058
- async def preview_stop(robot_id: str):
2059
- """Dashboard closed / operator stopped watching -> end the preview now."""
2060
- async with aiosqlite.connect(DB_PATH) as db:
2061
- await db.execute("DELETE FROM previews WHERE robot_id = ?", (robot_id,))
2090
+ async def preview_stop(robot_id: str):
2091
+ """Dashboard closed / operator stopped watching -> end the preview now."""
2092
+ async with aiosqlite.connect(DB_PATH) as db:
2093
+ db.row_factory = aiosqlite.Row
2094
+ robot_id = await _resolve_robot_reference(db, robot_id)
2095
+ if not robot_id:
2096
+ return {"stopped": False}
2097
+ await db.execute("DELETE FROM previews WHERE robot_id = ?", (robot_id,))
2062
2098
  await db.commit()
2063
2099
  await _log_history("preview", "robot", robot_id, "stop", "operator")
2064
2100
  return {"stopped": True}
@@ -2068,11 +2104,14 @@ async def preview_agent(robot_id: str, authorization: Optional[str] = Header(def
2068
2104
  """The ROBOT polls this. While an operator preview is active it returns a
2069
2105
  PUBLISH token + room so the Pi opens its cam and joins; otherwise
2070
2106
  {active:false} so the Pi stops publishing. Enrolled-device auth."""
2071
- if not await _authorize_fleet(authorization):
2072
- raise HTTPException(401, "Invalid or missing device token")
2073
- async with aiosqlite.connect(DB_PATH) as db:
2074
- db.row_factory = aiosqlite.Row
2075
- async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
2107
+ if not await _authorize_fleet(authorization):
2108
+ raise HTTPException(401, "Invalid or missing device token")
2109
+ async with aiosqlite.connect(DB_PATH) as db:
2110
+ db.row_factory = aiosqlite.Row
2111
+ robot_id = await _resolve_robot_reference(db, robot_id)
2112
+ if not robot_id:
2113
+ return {"active": False}
2114
+ async with db.execute("SELECT * FROM previews WHERE robot_id = ?", (robot_id,)) as c:
2076
2115
  row = await c.fetchone()
2077
2116
  if not _preview_active(row):
2078
2117
  return {"active": False}
@@ -302,19 +302,27 @@ def _get_lan_ip() -> Optional[str]:
302
302
  return None
303
303
 
304
304
 
305
- async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
306
- try:
307
- async with httpx.AsyncClient() as client:
308
- response = await client.post(
309
- f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
310
- json={"status": "online", "ip": _get_lan_ip(), "device_inventory": _get_device_inventory()},
311
- headers={"Authorization": f"Bearer {token}"},
312
- timeout=10.0,
313
- )
314
- response.raise_for_status()
315
- return bool(response.json().get("production_mode", False))
316
- except Exception as e:
317
- logger.debug(f"heartbeat failed: {e}")
305
+ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
306
+ try:
307
+ inventory = _get_device_inventory()
308
+ async with httpx.AsyncClient() as client:
309
+ response = await client.post(
310
+ f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
311
+ json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory},
312
+ headers={"Authorization": f"Bearer {token}"},
313
+ timeout=10.0,
314
+ )
315
+ response.raise_for_status()
316
+ logger.info(
317
+ "heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
318
+ inventory.get("source", "local"),
319
+ len(inventory.get("video", [])),
320
+ len(inventory.get("audio_input", [])),
321
+ len(inventory.get("audio_output", [])),
322
+ )
323
+ return bool(response.json().get("production_mode", False))
324
+ except Exception as e:
325
+ logger.warning(f"heartbeat failed: {e}")
318
326
  return None
319
327
 
320
328