diff --git a/dimos/core/transport.py b/dimos/core/transport.py index 2ecdb48645..a7c76db35f 100644 --- a/dimos/core/transport.py +++ b/dimos/core/transport.py @@ -36,6 +36,7 @@ from dimos.protocol.pubsub.impl.rospubsub import DimosROS, ROSTopic from dimos.protocol.pubsub.impl.shmpubsub import BytesSharedMemory, PickleSharedMemory from dimos.protocol.pubsub.impl.webrtc.providers.broker import BrokerConfig +from dimos.protocol.pubsub.impl.webrtc.providers.livekit_broker import LiveKitBrokerConfig from dimos.protocol.pubsub.impl.webrtc.providers.spec import ProviderConfig from dimos.protocol.pubsub.impl.webrtc.webrtcpubsub import WebRTCPubSub from dimos.utils.logging_config import setup_logger @@ -445,6 +446,21 @@ class CloudflareTransport(WebRTCTransport[M]): _config_cls = BrokerConfig +class LiveKitTransport(WebRTCTransport[M]): + """WebRTC DataChannels via the hosted teleop broker + LiveKit SFU. + + Drop-in alternative to :class:`CloudflareTransport`; config kwargs flow into + :class:`LiveKitBrokerConfig` (unset fields fall back to ``TELEOP_*`` env). + + unitree_go2_livekit = unitree_go2_basic.transports({ + ("cmd_vel", Twist): LiveKitTransport("cmd_unreliable", TwistStamped), + ("color_image", Image): LiveKitVideoTransport(), + }) + """ + + _config_cls = LiveKitBrokerConfig + + class WebRTCVideoTransport(Transport[Any]): """Robot camera → remote viewer as a WebRTC video track (provider-agnostic). @@ -496,4 +512,10 @@ class CloudflareVideoTransport(WebRTCVideoTransport): _config_cls = BrokerConfig +class LiveKitVideoTransport(WebRTCVideoTransport): + """Camera → teleop web client via the hosted broker + LiveKit (see WebRTCVideoTransport).""" + + _config_cls = LiveKitBrokerConfig + + class ZenohTransport(PubSubTransport[T]): ... diff --git a/dimos/protocol/pubsub/impl/webrtc/providers/livekit_broker.py b/dimos/protocol/pubsub/impl/webrtc/providers/livekit_broker.py new file mode 100644 index 0000000000..5abfe8ce13 --- /dev/null +++ b/dimos/protocol/pubsub/impl/webrtc/providers/livekit_broker.py @@ -0,0 +1,348 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Broker-mediated LiveKit provider (hosted teleop). + +The robot asks the ``dimensional-teleop`` broker for a LiveKit room + JWT +(``POST /api/v1/sessions {transport:"livekit"}`` → ``{url, token, room}``), +then connects straight to the LiveKit SFU. Unlike the Cloudflare ``broker.py`` +path there is no SDP relay, no SCTP-id juggling, and no heartbeat-driven +channel lifecycle: LiveKit data is bidirectional and topic-addressed, so a +single room carries every channel. + +Topics (kept identical to the Cloudflare path so the typed-fingerprint demux at +the transport layer is unchanged): + cmd_unreliable operator → robot commands (lossy) + state_reliable operator → robot control plane (reliable) + state_reliable_back robot → operator telemetry (reliable) + +Video: ``set_video_frame()`` pushes camera frames into a sendonly LiveKit track +(published lazily on the first frame) — typically via ``LiveKitVideoTransport`` +bound to a blueprint's Image stream. + +Env vars (fallback when config fields are unset): + TELEOP_BROKER_URL — default https://teleop.dimensionalos.com + TELEOP_API_KEY — robot API key (dtk_live_*); broker derives identity + TELEOP_ROBOT_ID — optional robot identifier override + TELEOP_ROBOT_NAME — human-readable robot name +""" + +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import Callable +import contextlib +from dataclasses import dataclass +import importlib.util +import os +from typing import TYPE_CHECKING, Any + +from dimos.protocol.pubsub.impl.webrtc.providers.spec import ( + AsyncProviderBase, + ProviderConfig, +) +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +# find_spec instead of importing: the livekit rtc SDK pulls native libs and +# core.transport imports this module everywhere. Imported lazily on start(). +LIVEKIT_AVAILABLE = ( + importlib.util.find_spec("livekit") is not None + and importlib.util.find_spec("httpx") is not None +) + +if TYPE_CHECKING: + import httpx + from livekit import rtc + + from dimos.msgs.sensor_msgs.Image import Image + + +@dataclass(frozen=True) +class LiveKitBrokerConfig(ProviderConfig): + """Hosted teleop over LiveKit. Credentials default from TELEOP_* env.""" + + broker_url: str | None = None + api_key: str | None = None + robot_id: str | None = None + robot_name: str | None = None + heartbeat_hz: float = 1.0 + + def _create(self) -> LiveKitBrokerProvider: + return LiveKitBrokerProvider(self) + + +def _image_to_rgba(img: Image) -> tuple[int, int, bytes]: + """Pack a dimos Image into (width, height, RGBA bytes) for a LiveKit frame.""" + import numpy as np + + from dimos.msgs.sensor_msgs.Image import ImageFormat + + arr = img.data + if arr.dtype == np.uint16: + arr = (arr >> 8).astype(np.uint8) # scale 16-bit (e.g. GRAY16) to 8-bit, not truncate + elif arr.dtype != np.uint8: + arr = arr.astype(np.uint8) + h, w = arr.shape[:2] + fmt = img.format + if fmt == ImageFormat.RGBA: + rgba = arr + elif fmt == ImageFormat.BGRA: + rgba = arr[..., [2, 1, 0, 3]] + elif fmt == ImageFormat.RGB: + rgba = np.dstack([arr, np.full((h, w), 255, np.uint8)]) + elif fmt in (ImageFormat.GRAY, ImageFormat.GRAY16): + g = arr if arr.ndim == 2 else arr[..., 0] + rgba = np.dstack([g, g, g, np.full((h, w), 255, np.uint8)]) + else: # BGR and anything else: treat as BGR + rgba = np.dstack([arr[..., 2], arr[..., 1], arr[..., 0], np.full((h, w), 255, np.uint8)]) + return w, h, np.ascontiguousarray(rgba).tobytes() + + +class _VideoPublisher: + """Lazily-published sendonly LiveKit video track fed from an Image stream. + + Frames arrive from the producer thread; the source/track are created and the + track published on the first frame (so dimensions come from real data), all + marshalled onto the provider's loop thread where the room lives. + """ + + def __init__(self) -> None: + self._room: rtc.Room | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._source: rtc.VideoSource | None = None + self._publish_task: asyncio.Task[None] | None = None + + def bind(self, room: rtc.Room, loop: asyncio.AbstractEventLoop) -> None: + self._room = room + self._loop = loop + + def reset(self) -> None: + """Drop per-session state so a later bind() (reconnect) re-publishes the + track on the new room. Called from the provider's _disconnect().""" + self._room = None + self._loop = None + self._source = None + self._publish_task = None + + def set_latest(self, img: Image) -> None: + loop = self._loop + if loop is None or not loop.is_running(): + return # not connected yet; pre-connect frames are dropped + try: + w, h, buf = _image_to_rgba(img) + except Exception: + logger.debug("video: frame conversion failed", exc_info=True) + return + loop.call_soon_threadsafe(self._capture, w, h, buf) + + def _capture(self, w: int, h: int, buf: bytes) -> None: + from livekit import rtc + + if self._source is None: + self._source = rtc.VideoSource(w, h) + self._publish_task = asyncio.ensure_future(self._publish()) + frame = rtc.VideoFrame(w, h, rtc.VideoBufferType.RGBA, buf) + self._source.capture_frame(frame) + + async def _publish(self) -> None: + from livekit import rtc + + assert self._room is not None and self._source is not None + try: + track = rtc.LocalVideoTrack.create_video_track("camera", self._source) + opts = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA) + await self._room.local_participant.publish_track(track, opts) + except Exception: + # Clear _source so the next captured frame retries publish, instead + # of feeding frames forever into a never-published source. + logger.warning("LiveKit video track publish failed; will retry", exc_info=True) + self._source = None + self._publish_task = None + return + logger.info("LiveKit video track published") + + +class LiveKitBrokerProvider(AsyncProviderBase): + """Bidirectional broker-mediated LiveKit provider. + + Inbound (operator → robot): ``cmd_unreliable`` + ``state_reliable``, + delivered to subscribers by topic. Outbound (robot → operator): + ``publish()`` on any topic (LiveKit is bidirectional); ``cmd_unreliable`` + rides the lossy channel, everything else reliable. Together with + ``LiveKitTransport`` / ``LiveKitVideoTransport`` this is the LiveKit analog + of ``BrokerProvider``. + """ + + LOSSY_TOPICS = ("cmd_unreliable",) + + def __init__(self, config: LiveKitBrokerConfig | None = None) -> None: + if not LIVEKIT_AVAILABLE: + raise RuntimeError("livekit and httpx required: pip install dimos[livekit]") + super().__init__() + config = config or LiveKitBrokerConfig() + self._broker_url = ( + config.broker_url + or os.environ.get("TELEOP_BROKER_URL", "https://teleop.dimensionalos.com") + ).rstrip("/") + self._api_key = config.api_key or os.environ.get("TELEOP_API_KEY", "") + self._robot_id = config.robot_id or os.environ.get("TELEOP_ROBOT_ID", "") + self._robot_name = config.robot_name or os.environ.get("TELEOP_ROBOT_NAME", "robot") + if not self._api_key: + raise RuntimeError( + "TELEOP_API_KEY or LiveKitBrokerConfig.api_key required " + "(create one in the teleop dashboard: New Key)" + ) + self._config = config + + self._http: httpx.AsyncClient | None = None + self._room: rtc.Room | None = None + self.session_id: str | None = None + self.room: str | None = None + self._hb_task: asyncio.Task[None] | None = None + self._video = _VideoPublisher() + # topic → subscriber callbacks. Guarded by self._lock (from the base). + self._callbacks: dict[str, list[Callable[[bytes, str], None]]] = defaultdict(list) + + @property + def _headers(self) -> dict[str, str]: + return {"X-Robot-API-Key": self._api_key, "Content-Type": "application/json"} + + # ─── Connect / Disconnect (loop thread) ────────────────────────── + + async def _connect(self) -> None: + import httpx + from livekit import rtc + + self._http = httpx.AsyncClient(timeout=30.0) + r = await self._http.post( + f"{self._broker_url}/api/v1/sessions", + headers=self._headers, + json={ + "transport": "livekit", + "robot_name": self._robot_name, + **({"robot_id": self._robot_id} if self._robot_id else {}), + }, + ) + if r.status_code not in (200, 201): + raise RuntimeError(f"Broker session create failed: {r.status_code} {r.text[:500]}") + data = r.json() + self.session_id = data["session_id"] + self.room = data.get("room") + url, token = data["url"], data["token"] + + self._room = rtc.Room() + + @self._room.on("data_received") # type: ignore[untyped-decorator] + def _on_data(packet: Any) -> None: + self._dispatch(packet) + + await self._room.connect(url, token) + self._video.bind(self._room, asyncio.get_running_loop()) + logger.info( + "LiveKit broker provider connected: session=%s room=%s robot=%s", + self.session_id, + data.get("room"), + self._robot_id or "(derived from API key)", + ) + self._hb_task = asyncio.get_running_loop().create_task(self._heartbeat_loop()) + + async def _disconnect(self) -> None: + if self._hb_task is not None: + self._hb_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._hb_task + self._hb_task = None + if self._http and self.session_id: + with contextlib.suppress(Exception): # best-effort deregistration + await self._http.delete( + f"{self._broker_url}/api/v1/sessions/{self.session_id}", + headers=self._headers, + ) + if self._room is not None: + with contextlib.suppress(Exception): + await self._room.disconnect() + self._room = None + self._video.reset() # clear per-session video state so a restart re-publishes + if self._http: + await self._http.aclose() + self._http = None + self.session_id = None + + # ─── Heartbeat (loop thread; metrics/liveness only) ────────────── + + async def _heartbeat_loop(self) -> None: + interval = 1.0 / max(self._config.heartbeat_hz, 0.1) + while True: + try: + if self._http is not None and self.session_id is not None: + await self._http.post( + f"{self._broker_url}/api/v1/sessions/{self.session_id}/heartbeat", + headers=self._headers, + json={}, + ) + except Exception: + logger.warning("LiveKit heartbeat failed", exc_info=True) + await asyncio.sleep(interval) + + # ─── Dispatch (loop thread) ────────────────────────────────────── + + def _dispatch(self, packet: Any) -> None: + topic = getattr(packet, "topic", "") or "" + payload = getattr(packet, "data", b"") + if isinstance(payload, (bytearray, memoryview)): + payload = bytes(payload) + with self._lock: + callbacks = list(self._callbacks.get(topic, ())) + for cb in callbacks: + try: + cb(payload, topic) + except Exception: + logger.exception("LiveKit subscriber callback error") + + # ─── Public API (Provider) ─────────────────────────────────────── + + def publish(self, topic: str, data: bytes) -> None: + """Robot → operator on any topic (LiveKit is bidirectional). Messages + drop while no room/operator is connected — normal pubsub behaviour.""" + if isinstance(data, (bytearray, memoryview)): + data = bytes(data) + reliable = topic not in self.LOSSY_TOPICS + with self._lock: + if not self._started or self._loop is None or self._room is None: + return + coro = self._room.local_participant.publish_data(data, reliable=reliable, topic=topic) + asyncio.run_coroutine_threadsafe(coro, self._loop) + + def set_video_frame(self, img: Image) -> None: + """Robot → operator video: publish the latest camera frame (thread-safe).""" + self._video.set_latest(img) + + def subscribe(self, topic: str, callback: Callable[[bytes, str], None]) -> Callable[[], None]: + if not self.is_connected: + self.start() + with self._lock: + self._callbacks[topic].append(callback) + + def _unsub() -> None: + with self._lock: + with contextlib.suppress(ValueError): + self._callbacks[topic].remove(callback) + + return _unsub + + +__all__ = ["LiveKitBrokerConfig", "LiveKitBrokerProvider"] diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 5dcb481f60..23a1f949dc 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -73,6 +73,7 @@ "openarm-planner-coordinator": "dimos.robot.manipulators.openarm.blueprints:openarm_planner_coordinator", "path-planner-eval": "dimos.navigation.nav_3d.evaluator.blueprints:path_planner_eval", "teleop-hosted-go2": "dimos.teleop.quest_hosted.blueprints:teleop_hosted_go2", + "teleop-hosted-go2-livekit": "dimos.teleop.quest_hosted.blueprints:teleop_hosted_go2_livekit", "teleop-hosted-go2-transport": "dimos.teleop.quest_hosted.blueprints:teleop_hosted_go2_transport", "teleop-hosted-xarm7": "dimos.teleop.quest_hosted.blueprints:teleop_hosted_xarm7", "teleop-phone": "dimos.teleop.phone.blueprints:teleop_phone", diff --git a/dimos/robot/test_all_blueprints.py b/dimos/robot/test_all_blueprints.py index a2c44a31bb..760be3e335 100644 --- a/dimos/robot/test_all_blueprints.py +++ b/dimos/robot/test_all_blueprints.py @@ -51,6 +51,7 @@ "coordinator-xarm7", "dual-xarm6-planner", "teleop-hosted-go2", + "teleop-hosted-go2-livekit", "teleop-hosted-go2-transport", "teleop-hosted-xarm7", "teleop-quest-dual", diff --git a/dimos/teleop/quest_hosted/blueprints.py b/dimos/teleop/quest_hosted/blueprints.py index 9efd7da001..768b45da15 100644 --- a/dimos/teleop/quest_hosted/blueprints.py +++ b/dimos/teleop/quest_hosted/blueprints.py @@ -19,7 +19,13 @@ from dimos.constants import STATE_DIR from dimos.control.blueprints.teleop import coordinator_teleop_xarm7 from dimos.core.coordination.blueprints import autoconnect -from dimos.core.transport import CloudflareTransport, CloudflareVideoTransport, LCMTransport +from dimos.core.transport import ( + CloudflareTransport, + CloudflareVideoTransport, + LCMTransport, + LiveKitTransport, + LiveKitVideoTransport, +) from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Twist import Twist from dimos.msgs.geometry_msgs.TwistStamped import TwistStamped @@ -78,6 +84,20 @@ ).global_config(viewer="none") +# Same transport-swap as above, over the hosted broker's LiveKit backend instead +# of Cloudflare. The robot picks the backend purely by which transport class the +# blueprint wires; the broker mints a LiveKit room+token from the same dtk_live_* +# key (see dimensional-teleop docs/livekit-spec.md). +# +# Run: TELEOP_API_KEY=dtk_live_... dimos run teleop-hosted-go2-livekit +teleop_hosted_go2_livekit = unitree_go2_basic.transports( + { + ("cmd_vel", Twist): LiveKitTransport("cmd_unreliable", TwistStamped), + ("color_image", Image): LiveKitVideoTransport(), + } +).global_config(viewer="none") + + HOSTED_RECORDINGS_DIR = STATE_DIR / "hosted_teleop" / "recordings" @@ -103,6 +123,7 @@ class HostedTeleopRecorder(TeleopRecorder): "HostedTeleopRecorder", "HostedTeleopRecorderConfig", "teleop_hosted_go2", + "teleop_hosted_go2_livekit", "teleop_hosted_go2_transport", "teleop_hosted_xarm7", ] diff --git a/pyproject.toml b/pyproject.toml index 2749469a2f..48c83e1d56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -329,6 +329,12 @@ webrtc = [ "httpx>=0.27.0", ] +livekit = [ + # WebRTC pubsub over the hosted broker's LiveKit backend (robot rtc client). + "livekit>=1.0.0", + "httpx>=0.27.0", +] + base = [ "dimos[agents,web,perception,visualization]", ] @@ -533,6 +539,8 @@ module = [ "faster_whisper", "geometry_msgs.*", "lazy_loader", + "livekit", + "livekit.*", "mcap", "mcap.*", "mujoco", diff --git a/uv.lock b/uv.lock index 16adff1c99..07d11a7c47 100644 --- a/uv.lock +++ b/uv.lock @@ -2040,6 +2040,10 @@ dds = [ drone = [ { name = "pymavlink" }, ] +livekit = [ + { name = "httpx" }, + { name = "livekit" }, +] manipulation = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "drake", version = "1.45.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, @@ -2371,6 +2375,7 @@ requires-dist = [ { name = "gdown", marker = "extra == 'misc'", specifier = ">=5.2.2" }, { name = "googlemaps", marker = "extra == 'misc'", specifier = ">=4.10.0" }, { name = "gtsam-extended", marker = "extra == 'mapping'", specifier = ">=4.3a1.post1" }, + { name = "httpx", marker = "extra == 'livekit'", specifier = ">=0.27.0" }, { name = "httpx", marker = "extra == 'webrtc'", specifier = ">=0.27.0" }, { name = "hydra-core", marker = "extra == 'perception'", specifier = ">=1.3.0" }, { name = "ipykernel", marker = "extra == 'misc'" }, @@ -2386,6 +2391,7 @@ requires-dist = [ { name = "lap", marker = "extra == 'perception'", specifier = ">=0.5.12" }, { name = "lark", marker = "extra == 'misc'" }, { name = "lazy-loader" }, + { name = "livekit", marker = "extra == 'livekit'", specifier = ">=1.0.0" }, { name = "llvmlite", specifier = ">=0.42.0" }, { name = "lz4", specifier = ">=4.4.5" }, { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, @@ -2463,7 +2469,7 @@ requires-dist = [ { name = "xformers", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = ">=0.0.20" }, { name = "yapf", marker = "extra == 'misc'", specifier = "==0.40.2" }, ] -provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "cpu", "cuda", "psql", "sim", "mapping", "drone", "dds", "webrtc", "base", "apriltag", "all"] +provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "cpu", "cuda", "psql", "sim", "mapping", "drone", "dds", "webrtc", "livekit", "base", "apriltag", "all"] [package.metadata.requires-dev] autofix = [{ name = "ruff", specifier = "==0.14.3" }] @@ -4925,6 +4931,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, ] +[[package]] +name = "livekit" +version = "1.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "protobuf" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/11/a8f7af0d9a0a1e705c98a16942f8ec70865a8a08280e3ad53a3026388d36/livekit-1.1.10.tar.gz", hash = "sha256:202101c49a1fbc1d771d5dfb884c77f42c01dafc411d12224b992fac78a843cb", size = 355789, upload-time = "2026-06-04T20:23:19.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/b0/51b2dc800ff2201da35ea87be1e25d370f4973e214e96ddf09563cd977a8/livekit-1.1.10-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:495b23988673bf6571fd0faaf621416e973fa1d302b1acccd479e0461cac924d", size = 10103039, upload-time = "2026-06-04T20:23:09.411Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7b/d7e1af04915402f55d6aa2d063273d053f9da0799ea9ec0a691fc96003cf/livekit-1.1.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7bd85dda5c8b11458b3447cbb02630af7bb267d424c65c89d2e6d736905147d1", size = 8933264, upload-time = "2026-06-04T20:23:11.527Z" }, + { url = "https://files.pythonhosted.org/packages/86/78/5ee7513df2ef124e5f75659bbe477253dd214a3089003e34644a37f80462/livekit-1.1.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:b05299fa0a4c98d8d57f0013367adad70a84dd5fc9c46e4a7f3df5352c81b6c1", size = 9938610, upload-time = "2026-06-04T20:23:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/86/86/16364e82b7363ad43e6651e46dfccf88f18fe46897c2123fe4badbf1f067/livekit-1.1.10-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:29f58fe30dc181c45b0a9c929beaacacde0b2253fa6e597547508d5269e9012b", size = 11321977, upload-time = "2026-06-04T20:23:15.632Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/f50036fffbff113f8de6bd05194dc6df0a5f2028f058dcf69073f774a464/livekit-1.1.10-py3-none-win_amd64.whl", hash = "sha256:13ac0c8498e0e5bc41292526966fd6ad86baa66e1915778b128cf7e953b107fc", size = 10675561, upload-time = "2026-06-04T20:23:17.857Z" }, +] + [[package]] name = "llvmlite" version = "0.46.0" @@ -10723,6 +10749,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, ] +[[package]] +name = "types-protobuf" +version = "7.34.1.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/59/e2b13b499d15e6720150c4b1a8d91e31fcacf716b432397475b3151ff7e4/types_protobuf-7.34.1.20260518.tar.gz", hash = "sha256:28cfaded25889cb83ebfb63cfb0a43628f0b6f3785767bec17287dc6468795f2", size = 68936, upload-time = "2026-05-18T06:01:47.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/1f/ec5caf72c2e3b688ca3927e0979a04ddad19e1afc4bf1c199bd743e0f419/types_protobuf-7.34.1.20260518-py3-none-any.whl", hash = "sha256:a0a5337413347166439c0e07cbc26c6164d091401c6f01b1dfd8cdb966c4dd8f", size = 85992, upload-time = "2026-05-18T06:01:45.696Z" }, +] + [[package]] name = "types-psycopg2" version = "2.9.21.20251012"