611 lines
26 KiB
Python
611 lines
26 KiB
Python
"""
|
|
Bridge to the on-robot agent (agent/x2_agent.py) over plain TCP.
|
|
|
|
This is the bridge that drives a real X2. The dashboard server itself never
|
|
imports rclpy - it talks JSON-lines to the agent running on PC2. That keeps the
|
|
dashboard usable on any machine (including Windows) and, more importantly, keeps
|
|
the web UI up when the robot is powered off so it can say so instead of dying.
|
|
|
|
Reconnection is automatic and continuous: switch the robot off and the UI shows
|
|
the offline gate; switch it on and the dashboard reattaches on its own.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import contextlib
|
|
import json
|
|
import time
|
|
|
|
from . import netinfo, recovery, settings, x2_spec
|
|
from .bridge_base import Bridge, CommandResult
|
|
|
|
RECONNECT_MIN = 1.0
|
|
RECONNECT_MAX = 6.0
|
|
COMMAND_TIMEOUT = 12.0
|
|
|
|
# asyncio's StreamReader defaults to a 64 KiB limit per line, and readline()
|
|
# raises once a line exceeds it. Camera frames arrive base64-encoded at roughly
|
|
# 400 KB and a full ROS graph dump is over 100 KB, so the default silently tore
|
|
# the link down mid-command. 32 MiB leaves generous headroom.
|
|
STREAM_LIMIT = 32 * 1024 * 1024
|
|
|
|
|
|
class AgentBridge(Bridge):
|
|
name = "agent"
|
|
simulated = False
|
|
|
|
def __init__(self, hub, config: dict):
|
|
super().__init__(hub, config)
|
|
self._reader = None
|
|
self._writer = None
|
|
self._task: asyncio.Task | None = None
|
|
self._push_task: asyncio.Task | None = None
|
|
self._pending: dict[int, asyncio.Future] = {}
|
|
self._next_id = 1
|
|
self._connected = asyncio.Event()
|
|
self._closing = False
|
|
self._agent_info: dict = {}
|
|
# key -> (ts, bytes, content_type, flip_used)
|
|
self._frames: dict[str, tuple[float, bytes, str, bool | None]] = {}
|
|
self._attempts = 0
|
|
self._last_error = ""
|
|
self._recovering = False
|
|
|
|
# -- lifecycle ----------------------------------------------------------
|
|
|
|
@property
|
|
def host(self) -> str:
|
|
return (self.config.get("robot_host") or "").strip()
|
|
|
|
@property
|
|
def port(self) -> int:
|
|
return int(self.config.get("agent_port") or x2_spec.AGENT_PORT)
|
|
|
|
async def start(self) -> None:
|
|
if not self.host and not self.config.get("auto_discover"):
|
|
raise RuntimeError(
|
|
"No robot host configured. Set it in the Settings tab, run the "
|
|
"server with --robot <ip>, or enable auto-discovery."
|
|
)
|
|
self._closing = False
|
|
self.state.connection.transport = "agent"
|
|
self.state.connection.host = f"{self.host}:{self.port}"
|
|
self.state.connection.simulated = False
|
|
self.state.connection.online = False
|
|
|
|
self._task = asyncio.create_task(self._link_loop(), name="agent-link")
|
|
self._push_task = asyncio.create_task(self._push_loop(), name="agent-push")
|
|
|
|
# Give the first connection a moment so the UI opens attached rather
|
|
# than flashing the offline gate, but never block startup on it.
|
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
await asyncio.wait_for(self._connected.wait(), timeout=4.0)
|
|
|
|
async def stop(self) -> None:
|
|
self._closing = True
|
|
for task in (self._task, self._push_task):
|
|
if task:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await task
|
|
await self._drop_link()
|
|
self.state.connection.online = False
|
|
self.state.connection.transport = "none"
|
|
|
|
async def reconfigure(self, config: dict) -> None:
|
|
"""
|
|
Adopt new settings, and re-dial immediately if the address changed.
|
|
|
|
Without the re-dial an operator who corrects the robot address sees
|
|
nothing happen until they restart the bridge by hand, because the link
|
|
loop only reads the address when it next reconnects - and a healthy
|
|
connection never reconnects.
|
|
"""
|
|
before = (self.host, self.port)
|
|
self.config = config
|
|
after = ((config.get("robot_host") or "").strip(),
|
|
int(config.get("agent_port") or x2_spec.AGENT_PORT))
|
|
|
|
if before != after and self._writer is not None:
|
|
await self.hub.emit("info", "robot",
|
|
f"Robot address changed to {after[0]}:{after[1]} - reconnecting")
|
|
self._attempts = 0
|
|
# Dropping the socket makes _connect_once return; the link loop then
|
|
# dials the new address on its next pass.
|
|
await self._drop_link()
|
|
|
|
# -- link ---------------------------------------------------------------
|
|
|
|
async def _link_loop(self) -> None:
|
|
while not self._closing:
|
|
try:
|
|
await self._connect_once()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
self._last_error = str(exc)
|
|
finally:
|
|
await self._drop_link()
|
|
|
|
if self._closing:
|
|
return
|
|
|
|
if self.state.connection.online:
|
|
# We were up and lost it - that is worth saying out loud.
|
|
self.state.connection.online = False
|
|
self.state.connection.error = self._last_error or "link lost"
|
|
await self.hub.emit("warn", "robot",
|
|
f"Lost connection to the robot ({self._last_error or 'link closed'})")
|
|
|
|
self._attempts += 1
|
|
|
|
# After a few plain retries, try harder: the robot may have come
|
|
# back on a different address, or be up with the agent not running.
|
|
if self._attempts % 4 == 0:
|
|
await self._attempt_recovery()
|
|
|
|
delay = min(RECONNECT_MAX, RECONNECT_MIN * (1.35 ** min(self._attempts, 8)))
|
|
await asyncio.sleep(delay)
|
|
|
|
async def _attempt_recovery(self) -> None:
|
|
"""
|
|
Try to get the robot back without the operator doing anything.
|
|
|
|
Two failure modes are handled, in order of likelihood after a power
|
|
cycle: the agent is not running (robot answers SSH but not the agent
|
|
port), and the robot moved to a different DHCP address.
|
|
"""
|
|
if self._recovering:
|
|
return
|
|
self._recovering = True
|
|
try:
|
|
config = settings.load()
|
|
port = int(config.get("agent_port") or x2_spec.AGENT_PORT)
|
|
host = self.host
|
|
|
|
# 1. Is the robot there at all, just without the agent running?
|
|
if host and config.get("auto_start_agent"):
|
|
info = await netinfo.probe_host(
|
|
host, ports=[port, int(config.get("robot_ssh_port") or 22)], timeout=1.5)
|
|
open_ports = info.get("open_ports") or []
|
|
if port in open_ports:
|
|
return # agent is up; the next retry will land
|
|
if 22 in open_ports:
|
|
await self._start_agent_on(host, config, port)
|
|
return
|
|
|
|
# 2. Otherwise look for it somewhere else on this network.
|
|
if not config.get("auto_discover"):
|
|
return
|
|
|
|
self._set_recovery("Looking for the robot on this network…")
|
|
await self.hub.emit("info", "robot",
|
|
"Robot not answering - scanning the network for it")
|
|
candidates = await recovery.find_robot(port, int(config.get("robot_ssh_port") or 22))
|
|
candidates = [c for c in candidates if c["host"] != host]
|
|
|
|
running = next((c for c in candidates if c["agent"]), None)
|
|
if running:
|
|
await self._adopt(running["host"], "found the agent running there")
|
|
return
|
|
|
|
if config.get("auto_start_agent"):
|
|
for candidate in candidates:
|
|
if not candidate["ssh"]:
|
|
continue
|
|
if await self._start_agent_on(candidate["host"], config, port, adopt=True):
|
|
return
|
|
|
|
self._set_recovery("")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
self._set_recovery("")
|
|
await self.hub.emit("warn", "robot", f"Recovery attempt failed: {exc}")
|
|
finally:
|
|
self._recovering = False
|
|
|
|
async def _start_agent_on(self, host: str, config: dict, port: int,
|
|
adopt: bool = False) -> bool:
|
|
self._set_recovery(f"Robot is on at {host} but the agent is not running — starting it…")
|
|
await self.hub.emit("info", "robot",
|
|
f"{host} is reachable but the agent is down - starting it over SSH")
|
|
|
|
ok, message = await recovery.start_agent(host, config)
|
|
if not ok:
|
|
self._set_recovery(message)
|
|
await self.hub.emit("warn", "robot", message)
|
|
return False
|
|
|
|
if await recovery.wait_for_agent(host, port, timeout=45.0):
|
|
self._set_recovery("")
|
|
if adopt:
|
|
await self._adopt(host, "started the agent there")
|
|
else:
|
|
await self.hub.emit("info", "robot", "Agent started - reconnecting")
|
|
return True
|
|
|
|
self._set_recovery("Started the agent, but it has not come up yet…")
|
|
return False
|
|
|
|
async def _adopt(self, host: str, why: str) -> None:
|
|
"""Switch to a newly found address and remember it."""
|
|
settings.save({"robot_host": host})
|
|
self.config = settings.load()
|
|
self.state.connection.host = f"{host}:{self.port}"
|
|
self._attempts = 0
|
|
self._set_recovery("")
|
|
await self.hub.emit("info", "robot", f"Robot found at {host} - {why}")
|
|
await self.hub.broadcast("settings", settings.describe())
|
|
|
|
def _set_recovery(self, message: str) -> None:
|
|
"""Progress text for the offline gate, so the operator sees us working."""
|
|
self.state.custom["recovery"] = message
|
|
if message:
|
|
self.state.connection.error = message
|
|
|
|
async def _connect_once(self) -> None:
|
|
host, port = self.host, self.port
|
|
if not host:
|
|
# Nothing to dial yet - let the recovery pass find one.
|
|
self.state.connection.error = "Looking for the robot on this network…"
|
|
raise ConnectionError("no robot address yet")
|
|
try:
|
|
self._reader, self._writer = await asyncio.wait_for(
|
|
asyncio.open_connection(host, port, limit=STREAM_LIMIT), timeout=5.0)
|
|
except (asyncio.TimeoutError, OSError) as exc:
|
|
self._last_error = f"{host}:{port} unreachable"
|
|
self.state.connection.error = (
|
|
f"Cannot reach the robot agent at {host}:{port}. "
|
|
"Is the robot powered on and on this network?"
|
|
)
|
|
raise ConnectionError(self._last_error) from exc
|
|
|
|
was_offline = not self.state.connection.online
|
|
self._attempts = 0
|
|
self._last_error = ""
|
|
self.state.connection.online = True
|
|
self.state.connection.since = time.time()
|
|
self.state.connection.error = ""
|
|
self._connected.set()
|
|
|
|
if was_offline:
|
|
await self.hub.emit("info", "robot", f"Connected to robot agent at {host}:{port}")
|
|
|
|
# Re-assert anything that does not survive a reconnect.
|
|
self.state.source_registered = False
|
|
asyncio.create_task(self._announce_self())
|
|
|
|
while not self._closing:
|
|
line = await self._reader.readline()
|
|
if not line:
|
|
raise ConnectionError("agent closed the connection")
|
|
try:
|
|
message = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
self._on_message(message)
|
|
|
|
async def _announce_self(self) -> None:
|
|
"""
|
|
Tell the robot this dashboard exists as a control source.
|
|
|
|
The motion controller ignores velocity from a source it has never heard
|
|
of, so this has to happen before the joystick will do anything. It is
|
|
not a takeover: registering only makes the dashboard *eligible*, and
|
|
arbitration still hands control to the highest-priority source that is
|
|
actively sending - the handheld RC (80) and mobile app (60) both
|
|
outrank us at 30.
|
|
|
|
Done automatically rather than left to a button, because there is no
|
|
situation where an operator wants the dashboard connected but not
|
|
allowed to drive, and "register input source" means nothing to someone
|
|
who just wants to move the robot.
|
|
"""
|
|
source = x2_spec.DASHBOARD_INPUT_SOURCE
|
|
for attempt in range(3):
|
|
await asyncio.sleep(1.0 if attempt == 0 else 3.0)
|
|
if not self.state.connection.online:
|
|
return
|
|
result = await self.register_input_source(
|
|
source["name"], source["priority"], source["timeout"])
|
|
if result.ok:
|
|
return
|
|
if attempt == 2:
|
|
await self.hub.emit(
|
|
"warn", "control",
|
|
f"Could not announce the dashboard to the robot: {result.message}. "
|
|
"Driving will be refused until this succeeds - use the Control tab to retry.",
|
|
)
|
|
|
|
async def _drop_link(self) -> None:
|
|
self._connected.clear()
|
|
writer, self._writer, self._reader = self._writer, None, None
|
|
if writer is not None:
|
|
with contextlib.suppress(Exception):
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
for future in self._pending.values():
|
|
if not future.done():
|
|
future.set_exception(ConnectionError("link dropped"))
|
|
self._pending.clear()
|
|
|
|
def _on_message(self, message: dict) -> None:
|
|
kind = message.get("type")
|
|
if kind == "state":
|
|
self._apply_state(message.get("data") or {})
|
|
elif kind == "hello":
|
|
self._agent_info = message.get("data") or {}
|
|
self.state.custom["agent"] = self._agent_info
|
|
# Report the domain the agent is actually on, rather than whatever
|
|
# this machine has configured - only the agent's value is real.
|
|
try:
|
|
self.state.connection.ros_domain_id = int(
|
|
self._agent_info.get("ros_domain_id", 0))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
elif kind == "result":
|
|
future = self._pending.pop(message.get("id"), None)
|
|
if future and not future.done():
|
|
future.set_result(message)
|
|
|
|
# -- state mapping ------------------------------------------------------
|
|
|
|
def _apply_state(self, data: dict) -> None:
|
|
s = self.state
|
|
|
|
s.mode = data.get("mode") or "UNKNOWN"
|
|
spec = x2_spec.MC_MODE_BY_ID.get(s.mode)
|
|
s.mode_desc = spec["label"] if spec else s.mode
|
|
status = data.get("mode_status")
|
|
s.mode_status = x2_spec.MC_ACTION_STATUS.get(status, str(status or ""))
|
|
|
|
for field in ("battery_pct", "battery_voltage", "battery_current",
|
|
"battery_temp", "battery_cycles", "pmu_temp",
|
|
"fan_rpm", "fan_pct"):
|
|
setattr(s, field, data.get(field))
|
|
s.charging = bool(data.get("charging"))
|
|
|
|
rails = {}
|
|
for spec_rail in x2_spec.PMU_RAILS:
|
|
live = (data.get("rails") or {}).get(spec_rail["key"])
|
|
if not live:
|
|
continue
|
|
rails[spec_rail["key"]] = {
|
|
"label": spec_rail["label"],
|
|
"nominal": spec_rail["nominal"],
|
|
"voltage": live.get("voltage"),
|
|
"current": live.get("current"),
|
|
"ok": live.get("ok", True),
|
|
}
|
|
s.rails = rails
|
|
|
|
s.imu = data.get("imu") or {}
|
|
s.joints = data.get("joints") or {}
|
|
s.hand_type = data.get("hand_type") or "None"
|
|
s.hand_state = data.get("hand_state") or {}
|
|
s.touch_head = data.get("touch_head") or {}
|
|
s.velocity = data.get("velocity") or s.velocity
|
|
s.velocity_command = data.get("velocity_command") or s.velocity_command
|
|
s.odom = data.get("odom") or s.odom
|
|
s.volume = data.get("volume") if data.get("volume") is not None else s.volume
|
|
s.muted = bool(data.get("muted"))
|
|
s.emoji_id = data.get("emoji_id")
|
|
s.led = data.get("led") or s.led
|
|
s.input_source = data.get("input_source") or ""
|
|
s.topic_stats = data.get("topic_stats") or {}
|
|
|
|
s.custom["pmu_raw"] = data.get("pmu_raw") or {}
|
|
s.custom["pmu_info"] = data.get("pmu_info") or {}
|
|
s.custom["cameras"] = data.get("cameras") or {}
|
|
# Which feeds are actually subscribed on the robot right now. The
|
|
# Vision tab reads this rather than tracking its own idea of on/off,
|
|
# so a second browser sees the true state instead of "off".
|
|
s.custom["streams"] = data.get("streams") or {}
|
|
s.custom["face_status"] = data.get("face_status")
|
|
s.custom["battery_capacity_mah"] = data.get("battery_capacity_mah")
|
|
s.custom["battery_power"] = data.get("battery_power")
|
|
s.custom["agent_version"] = data.get("agent_version")
|
|
s.custom["hand_left_type"] = data.get("hand_left_type")
|
|
s.custom["hand_right_type"] = data.get("hand_right_type")
|
|
|
|
s.updated = time.time()
|
|
|
|
async def _push_loop(self) -> None:
|
|
"""Forward state to browsers and record chart series."""
|
|
hz = max(1, int(self.config.get("telemetry_hz", 10)))
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(1.0 / hz)
|
|
now = time.time()
|
|
if self.state.connection.online:
|
|
self._record(now)
|
|
await self.hub.broadcast("state", self.state.as_dict())
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
await asyncio.sleep(1.0)
|
|
|
|
def _record(self, now: float) -> None:
|
|
for key, value in (
|
|
("battery_pct", self.state.battery_pct),
|
|
("battery_voltage", self.state.battery_voltage),
|
|
("battery_current", self.state.battery_current),
|
|
("battery_temp", self.state.battery_temp),
|
|
("pmu_temp", self.state.pmu_temp),
|
|
("fan_pct", self.state.fan_pct),
|
|
("vel_forward", self.state.velocity.get("forward")),
|
|
("vel_lateral", self.state.velocity.get("lateral")),
|
|
("vel_angular", self.state.velocity.get("angular")),
|
|
):
|
|
if value is not None:
|
|
self.hub.record(key, value, now)
|
|
chest = self.state.imu.get("chest") or {}
|
|
for key, field in (("imu_roll", "roll"), ("imu_pitch", "pitch"), ("imu_yaw", "yaw")):
|
|
if field in chest:
|
|
self.hub.record(key, chest[field], now)
|
|
|
|
# -- command plumbing ---------------------------------------------------
|
|
|
|
async def _send(self, name: str, args: dict | None = None,
|
|
timeout: float = COMMAND_TIMEOUT) -> CommandResult:
|
|
if self._writer is None or not self.state.connection.online:
|
|
return CommandResult.failure(
|
|
"The robot is not connected. Power it on and wait for the dashboard to reattach."
|
|
)
|
|
|
|
message_id = self._next_id
|
|
self._next_id += 1
|
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
self._pending[message_id] = future
|
|
|
|
payload = json.dumps({"type": "cmd", "id": message_id,
|
|
"name": name, "args": args or {}}) + "\n"
|
|
try:
|
|
self._writer.write(payload.encode())
|
|
await self._writer.drain()
|
|
except Exception as exc:
|
|
self._pending.pop(message_id, None)
|
|
return CommandResult.failure(f"Send failed: {exc}")
|
|
|
|
try:
|
|
reply = await asyncio.wait_for(future, timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
self._pending.pop(message_id, None)
|
|
return CommandResult.failure(f"The robot did not answer '{name}' within {timeout:g} s")
|
|
except ConnectionError:
|
|
return CommandResult.failure("Connection to the robot dropped mid-command")
|
|
|
|
return CommandResult(bool(reply.get("ok")),
|
|
reply.get("message", ""),
|
|
reply.get("detail"))
|
|
|
|
# -- commands -----------------------------------------------------------
|
|
|
|
async def set_mode(self, mode_id: str) -> CommandResult:
|
|
result = await self._send("set_mode", {"mode": mode_id})
|
|
if result.ok:
|
|
await self.hub.emit("info", "mode", f"Mode set to {mode_id}")
|
|
return result
|
|
|
|
async def get_mode(self) -> CommandResult:
|
|
return await self._send("get_mode")
|
|
|
|
async def set_velocity(self, forward: float, lateral: float, angular: float) -> CommandResult:
|
|
return await self._send("set_velocity",
|
|
{"forward": forward, "lateral": lateral, "angular": angular},
|
|
timeout=4.0)
|
|
|
|
async def stop_motion(self) -> CommandResult:
|
|
return await self._send("stop", timeout=4.0)
|
|
|
|
async def play_preset(self, motion: int, area: int, interrupt: bool = True) -> CommandResult:
|
|
return await self._send("preset", {"motion": motion, "area": area,
|
|
"interrupt": interrupt}, timeout=15.0)
|
|
|
|
async def set_joints(self, group: str, mode: str, targets: dict,
|
|
stiffness=None, damping=None) -> CommandResult:
|
|
return await self._send("set_joints", {
|
|
"group": group, "mode": mode, "targets": targets,
|
|
"stiffness": stiffness, "damping": damping,
|
|
})
|
|
|
|
async def set_hand(self, side: str, positions: list) -> CommandResult:
|
|
return await self._send("set_hand", {"side": side, "positions": positions})
|
|
|
|
async def speak(self, text: str, priority: int = 6, interrupt: bool = False) -> CommandResult:
|
|
return await self._send("speak", {"text": text, "priority": priority,
|
|
"interrupt": interrupt}, timeout=15.0)
|
|
|
|
async def set_volume(self, volume: int) -> CommandResult:
|
|
return await self._send("set_volume", {"volume": volume})
|
|
|
|
async def set_mute(self, muted: bool) -> CommandResult:
|
|
return await self._send("set_mute", {"muted": muted})
|
|
|
|
async def play_emoji(self, emotion_id: int, mode: int = 1, priority: int = 6) -> CommandResult:
|
|
return await self._send("emoji", {"emotion_id": emotion_id, "mode": mode,
|
|
"priority": priority})
|
|
|
|
async def set_led(self, mode: int, r: int, g: int, b: int, priority: int = 6,
|
|
keep: bool = True) -> CommandResult:
|
|
return await self._send("led", {"mode": mode, "r": r, "g": g, "b": b,
|
|
"priority": priority, "keep": keep})
|
|
|
|
async def register_input_source(self, name: str, priority: int, timeout: int) -> CommandResult:
|
|
result = await self._send("register_source", {"name": name, "priority": priority,
|
|
"timeout": timeout})
|
|
if result.ok:
|
|
self.state.source_registered = True
|
|
self.state.input_source = name
|
|
return result
|
|
|
|
async def publish_raw(self, topic: str, message_type: str, payload: dict) -> CommandResult:
|
|
return await self._send("publish_raw", {"topic": topic, "type": message_type,
|
|
"fields": payload})
|
|
|
|
async def call_service(self, service: str, service_type: str, payload: dict) -> CommandResult:
|
|
return CommandResult.failure(
|
|
"Arbitrary service calls are not exposed by the agent. Use the typed commands, "
|
|
"or add a handler to agent/x2_agent.py."
|
|
)
|
|
|
|
async def list_topics(self) -> list[dict]:
|
|
return list(self.state.topic_stats.values())
|
|
|
|
async def graph(self) -> dict:
|
|
result = await self._send("graph", timeout=10.0)
|
|
return result.detail if result.ok and result.detail else {"topics": [], "services": []}
|
|
|
|
# -- on-demand streams --------------------------------------------------
|
|
|
|
async def set_stream(self, key: str, active: bool) -> CommandResult:
|
|
"""Switch a camera or the LiDAR on or off on the robot."""
|
|
result = await self._send("stream_set", {"key": key, "active": bool(active)},
|
|
timeout=8.0)
|
|
if result.ok:
|
|
# A stopped feed must not keep serving its last frame from cache.
|
|
if not active:
|
|
self._frames.pop(key, None)
|
|
await self.hub.emit("info", "vision",
|
|
f"{key} switched {'on' if active else 'off'}")
|
|
return result
|
|
|
|
async def list_streams(self) -> CommandResult:
|
|
return await self._send("stream_list", timeout=6.0)
|
|
|
|
async def lidar_points(self) -> CommandResult:
|
|
return await self._send("lidar_points", timeout=8.0)
|
|
|
|
# -- camera -------------------------------------------------------------
|
|
|
|
async def camera_frame(self, camera_key: str, stream: str = "rgb",
|
|
flip: bool | None = None) -> bytes | None:
|
|
cached = self._frames.get(camera_key)
|
|
# The agent pushes on request; a 120 ms cache stops several browser tabs
|
|
# from each pulling the same frame off the robot.
|
|
if cached and (time.time() - cached[0]) < 0.12 and cached[3] == flip:
|
|
return cached[1]
|
|
|
|
args: dict = {"key": camera_key}
|
|
if flip is not None:
|
|
args["flip"] = bool(flip)
|
|
result = await self._send("camera_frame", args, timeout=6.0)
|
|
if not result.ok or not result.detail:
|
|
return None
|
|
try:
|
|
data = base64.b64decode(result.detail["b64"])
|
|
except (KeyError, ValueError):
|
|
return None
|
|
fmt = result.detail.get("format", "jpeg")
|
|
self._frames[camera_key] = (time.time(), data,
|
|
"image/png" if fmt == "png" else "image/jpeg",
|
|
flip)
|
|
return data
|
|
|
|
def frame_content_type(self, camera_key: str = "", stream: str = "rgb") -> str:
|
|
cached = self._frames.get(camera_key)
|
|
return cached[2] if cached else "image/jpeg"
|