diff --git a/README.md b/README.md index aa8a9ef..c52c592 100644 --- a/README.md +++ b/README.md @@ -221,8 +221,8 @@ Connected BT accessories surface as their own **child devices** hanging off the | Camera | Single workspace camera (single-camera V2 models such as F1 Ultra V2). Streams live MJPEG with snapshot fallback | | Main camera | Wide-angle workspace camera (F2 family + MetalFab on V2 firmware). Streams live MJPEG with snapshot fallback | | Deep camera | Close-up / depth camera (F2 family + MetalFab on V2 firmware). Streams live MJPEG with snapshot fallback | -| Overview camera | Wide-angle workspace camera (P-family + V1-firmware dual-camera devices) | -| Close-up camera | Detail camera (P-family + V1-firmware dual-camera devices) | +| Overview camera | Wide-angle workspace camera (P-family + V1-firmware dual-camera devices); native H.264 live stream on P3 | +| Close-up camera | Detail camera (P-family + V1-firmware dual-camera devices); native H.264 live stream on P3 | | Global / Local / Side camera | M2 exposes three cameras: `far` (global / overview), `near` (local / close-up) and `side` (process-side view) | | Flame record | Snapshot of the most recent flame-detection event | diff --git a/custom_components/xtool/manifest.json b/custom_components/xtool/manifest.json index b842da8..89d779d 100644 --- a/custom_components/xtool/manifest.json +++ b/custom_components/xtool/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@thecodingdad"], "config_flow": true, "documentation": "https://github.com/thecodingdad/ha-xtool", + "dependencies": ["stream"], "integration_type": "device", "iot_class": "local_polling", "issue_tracker": "https://github.com/thecodingdad/ha-xtool/issues", diff --git a/custom_components/xtool/protocols/ws_v2/entities.py b/custom_components/xtool/protocols/ws_v2/entities.py index d548bbd..58ea90d 100644 --- a/custom_components/xtool/protocols/ws_v2/entities.py +++ b/custom_components/xtool/protocols/ws_v2/entities.py @@ -19,6 +19,7 @@ import asyncio from datetime import timedelta import logging +import shutil from typing import Any from homeassistant.components.binary_sensor import ( @@ -27,7 +28,7 @@ ) from homeassistant.components.button import ButtonEntity from aiohttp import web -from homeassistant.components.camera import Camera +from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -87,10 +88,8 @@ ), # ``task_name`` / loaded-G-code filename entity intentionally # absent — F1V2 firmware decompile shows ``fileName`` string - # in laserservice but no V2 endpoint or push surfaces it. The - # only thing ``/v1/processing/progress`` returns is - # ``{"progress": "%f"}`` (percent only). Restore once a future - # firmware / log capture confirms a real wire-source. + # in laserservice but no V2 endpoint or push surfaces it. Restore once a + # future firmware / log capture confirms a real wire-source. # ``working_mode`` diagnostic sensor removed in v2.5.4 — the # firmware ``workingMode`` field on F-series V2 carries the # ``"NORMAL"`` (stationary) / ``"HANDLE"`` (handheld) enum that @@ -1119,23 +1118,20 @@ async def _action(self) -> None: class _WSV2Camera(XtoolEntity, Camera): """V2 camera — single entity per physical lens. - Serves both the still-snapshot flow (``async_camera_image``) and - the live MJPEG preview (``handle_async_mjpeg_stream``) over the - same ``/v1/camera/snap?name=`` wire path. HA's picture-card - auto-subscribes to the streaming method when - ``_attr_is_streaming`` is ``True``, while the - ``camera.snapshot`` service still consumes the - snapshot-cached path. + Serves the still-snapshot flow (``async_camera_image``) on every + supported WS-V2 model. Models with verified live-video mappings also + expose Home Assistant's native Stream feature; unverified models keep + the snapshot-polled MJPEG fallback. Previously split into separate ``_WSV2Camera`` + ``_WSV2LiveCamera`` entities; dual-camera models then surfaced four entries on the device page. Merged in v2.5.4. - ``_attr_is_streaming`` is intentionally left ``False`` until - the live MJPEG path is fully verified — Issue #4 v2.5.4 retest - reports "Streaming" state but no frame rendered on F2 Ultra UV. - Falling back to snapshot-card keeps the still image working - while the live-stream wire shape is re-investigated. + P3 uses the verified WS-V2 ``media_stream`` channel. A localhost-only + TCP bridge exposes its Annex-B H.264 packets to Home Assistant's native + Stream integration, which provides HLS/WebRTC playback and still-frame + extraction. Other models retain the snapshot/MJPEG fallback until their + live camera names and payloads are confirmed on hardware. """ _camera_name: str = "" @@ -1158,6 +1154,135 @@ def __init__( self._attr_icon = icon self._last_snapshot: bytes | None = None self._last_snapshot_time = dt_util.utcnow() - MIN_SNAPSHOT_INTERVAL + self._live_supported = coordinator.protocol.supports_media_stream( + camera_name, + ) + self._stream_server: asyncio.AbstractServer | None = None + self._stream_clients = 0 + self._ffmpeg_path = shutil.which("ffmpeg") + # P3 is exposed as direct MJPEG because HA's native Stream/go2rtc + # path does not reliably request or timestamp its raw Annex-B feed. + # Lovelace's live camera card falls back to camera_proxy_stream, + # which calls ``handle_async_mjpeg_stream`` below. + self._attr_supported_features = CameraEntityFeature(0) + + @property + def use_stream_for_stills(self) -> bool: + """Keep snapshot requests independent from the live H.264 stream.""" + return False + + async def stream_source(self) -> str | None: + """Return a timestamped MPEG-TS source for HA's Stream worker.""" + if not self._live_supported or self._ffmpeg_path is None: + if self._live_supported and self._ffmpeg_path is None: + _LOGGER.error("xTool live camera requires the ffmpeg executable") + return None + if self._stream_server is None: + self._stream_server = await asyncio.start_server( + self._handle_stream_client, + host="127.0.0.1", + port=0, + ) + sockets = self._stream_server.sockets or [] + if not sockets: + return None + port = sockets[0].getsockname()[1] + return f"tcp://127.0.0.1:{port}" + + async def _handle_stream_client( + self, + _reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Remux one raw xTool H.264 feed into timestamped MPEG-TS.""" + self._stream_clients += 1 + self._attr_is_streaming = True + self.async_write_ha_state() + process: asyncio.subprocess.Process | None = None + feed_task: asyncio.Task[None] | None = None + try: + assert self._ffmpeg_path is not None + process = await asyncio.create_subprocess_exec( + self._ffmpeg_path, + "-hide_banner", + "-loglevel", + "error", + "-fflags", + "+genpts", + "-use_wallclock_as_timestamps", + "1", + "-f", + "h264", + "-framerate", + "25", + "-i", + "pipe:0", + "-map", + "0:v:0", + "-c:v", + "copy", + "-muxdelay", + "0", + "-muxpreload", + "0", + "-f", + "mpegts", + "pipe:1", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + async def feed_ffmpeg() -> None: + assert process is not None and process.stdin is not None + async for fragment in self.coordinator.protocol.iter_media_stream( + self._camera_name, + ): + process.stdin.write(fragment) + await process.stdin.drain() + process.stdin.close() + + feed_task = asyncio.create_task(feed_ffmpeg()) + assert process.stdout is not None + while chunk := await process.stdout.read(64 * 1024): + writer.write(chunk) + await writer.drain() + except (asyncio.CancelledError, ConnectionError, BrokenPipeError): + pass + except Exception as err: + _LOGGER.debug( + "V2 live H.264 %s ended: %s", self._camera_name, err, + ) + finally: + if feed_task is not None: + feed_task.cancel() + try: + await feed_task + except (asyncio.CancelledError, ConnectionError, BrokenPipeError): + pass + if process is not None and process.returncode is None: + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=2) + except TimeoutError: + process.kill() + await process.wait() + writer.close() + try: + await writer.wait_closed() + except (ConnectionError, BrokenPipeError): + pass + self._stream_clients = max(0, self._stream_clients - 1) + self._attr_is_streaming = self._stream_clients > 0 + self.async_write_ha_state() + + async def async_will_remove_from_hass(self) -> None: + """Close the private TCP bridge when the camera entity unloads.""" + if self._stream_server is not None: + self._stream_server.close() + await self._stream_server.wait_closed() + self._stream_server = None + await super().async_will_remove_from_hass() async def async_camera_image( self, @@ -1183,12 +1308,11 @@ async def async_camera_image( async def handle_async_mjpeg_stream( self, request: web.Request, ) -> web.StreamResponse | None: - """Multipart-MJPEG live preview at ``LIVE_FRAME_INTERVAL``. + """Serve live P3 video, with a snapshot-polled model fallback. - Substitute for the unimplemented WebRTC ``media_stream`` - path (see PROTOCOL.md). HA's Lovelace picture-card renders - the resulting ``multipart/x-mixed-replace`` stream as a - continuous video feed. + P3's raw H.264 is decoded directly to multipart JPEG so Lovelace + does not depend on HA's HLS/WebRTC timestamp handling. Models without + a verified live-media mapping continue to use the snapshot API. """ boundary = "--xtoolframe" response = web.StreamResponse( @@ -1202,6 +1326,85 @@ async def handle_async_mjpeg_stream( }, ) await response.prepare(request) + if self._live_supported and self._ffmpeg_path is not None: + process: asyncio.subprocess.Process | None = None + feed_task: asyncio.Task[None] | None = None + self._stream_clients += 1 + self._attr_is_streaming = True + self.async_write_ha_state() + try: + process = await asyncio.create_subprocess_exec( + self._ffmpeg_path, + "-hide_banner", + "-loglevel", + "error", + "-fflags", + "+genpts", + "-f", + "h264", + "-framerate", + "25", + "-i", + "pipe:0", + "-vf", + "fps=5", + "-q:v", + "5", + "-f", + "mpjpeg", + "-boundary_tag", + boundary[2:], + "pipe:1", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + async def feed_ffmpeg() -> None: + assert process is not None and process.stdin is not None + async for fragment in ( + self.coordinator.protocol.iter_media_stream( + self._camera_name, + ) + ): + process.stdin.write(fragment) + await process.stdin.drain() + process.stdin.close() + + feed_task = asyncio.create_task(feed_ffmpeg()) + assert process.stdout is not None + while chunk := await process.stdout.read(64 * 1024): + await response.write(chunk) + except ( + asyncio.CancelledError, + ConnectionError, + ConnectionResetError, + BrokenPipeError, + ): + pass + finally: + if feed_task is not None: + feed_task.cancel() + try: + await feed_task + except ( + asyncio.CancelledError, + ConnectionError, + BrokenPipeError, + ): + pass + if process is not None and process.returncode is None: + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=2) + except TimeoutError: + process.kill() + await process.wait() + self._stream_clients = max(0, self._stream_clients - 1) + self._attr_is_streaming = self._stream_clients > 0 + self.async_write_ha_state() + return response + try: while True: try: @@ -1692,9 +1895,9 @@ def build_wsv2_buttons(coordinator: XtoolCoordinator) -> list[ButtonEntity]: def build_wsv2_cameras(coordinator: XtoolCoordinator) -> list[Camera]: """Build one camera entity per ``model.camera_names`` entry. - Each entity serves both ``async_camera_image`` (still - snapshot, cached) and ``handle_async_mjpeg_stream`` (live - preview). The earlier split into snapshot + live entities + Each entity serves cached still snapshots. Verified models also expose + their native live stream; other models retain the snapshot-polled MJPEG + preview. The earlier split into snapshot + live entities surfaced four entries on dual-camera devices; collapsed in v2.5.4. Skipping when ``camera_names`` is empty avoids creating entities whose wire-shape we haven't audited. diff --git a/custom_components/xtool/protocols/ws_v2/models.py b/custom_components/xtool/protocols/ws_v2/models.py index fb55dde..b6bf4fc 100644 --- a/custom_components/xtool/protocols/ws_v2/models.py +++ b/custom_components/xtool/protocols/ws_v2/models.py @@ -396,7 +396,8 @@ has_air_assist_state=True, has_water_cooling=True, has_camera=True, - camera_names=("overview", "closeup"), # P3 retains the V1 dual-camera shape + # Snapshot API names; live media maps these to Studio's far/upside names. + camera_names=("overview", "closeup"), has_camera_exposure=True, has_digital_lock=True, has_distance_measure=True, diff --git a/custom_components/xtool/protocols/ws_v2/protocol.py b/custom_components/xtool/protocols/ws_v2/protocol.py index b490ac5..b73902c 100644 --- a/custom_components/xtool/protocols/ws_v2/protocol.py +++ b/custom_components/xtool/protocols/ws_v2/protocol.py @@ -63,7 +63,7 @@ import ssl import time import uuid -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable from datetime import datetime from typing import Any @@ -130,6 +130,20 @@ # protocol type. Payload is a binary FILE_REQUEST / FILE_DATA # packet, not a JSON envelope. WSV2_PROTOCOL_FILE_TRANSFER = 33 +# Studio's live-camera packets on ``function=media_stream``. The payload +# begins with a one-byte stream id followed by an Annex-B H.264 fragment. +WSV2_PROTOCOL_MEDIA_STREAM = 34 + +# Snapshot and live-view camera names are not identical on P3 firmware. +# ``/v1/camera/snap`` retains the P-family overview/closeup vocabulary while +# ``/v1/platform/camera/live`` uses the names from Studio's cameraMap. +_MEDIA_CAMERA_NAMES: dict[str, dict[str, str]] = { + "P3": {"overview": "far", "closeup": "upside"}, +} + +# First byte of each P3 protocol-34 payload. Both live cameras share one +# ``media_stream`` socket; the byte routes packets to the requested lens. +_MEDIA_STREAM_IDS: dict[str, int] = {"far": 0, "upside": 2} # --- Studio file-transfer constants (verified against Studio v1.7.23 # ``main.e_TJj9fA.js`` — ``Iv/Lv/Rv/zv/Bv/Vv/Wv`` blocks). ------------ @@ -525,6 +539,7 @@ class WSV2Protocol(XtoolProtocol): PATH_ALARMS = "/v1/device/alarms" PATH_PROCESSING_STATE = "/v1/processing/state" PATH_CAMERA_SNAP = "/v1/camera/snap" + PATH_CAMERA_LIVE = "/v1/platform/camera/live" PATH_UPGRADE_MODE = "/v1/device/upgrade-mode" def _apply_latest_to_state(self, state: XtoolDeviceState) -> None: @@ -544,8 +559,14 @@ def __init__(self, host: str, port: int = WSV2_PORT) -> None: self._session: aiohttp.ClientSession | None = None self._ws_instr: aiohttp.ClientWebSocketResponse | None = None self._ws_file: aiohttp.ClientWebSocketResponse | None = None + self._ws_media: aiohttp.ClientWebSocketResponse | None = None self._reader_task: asyncio.Task[None] | None = None self._heartbeat_task: asyncio.Task[None] | None = None + self._media_reader_task: asyncio.Task[None] | None = None + self._media_lock = asyncio.Lock() + self._media_subscribers: dict[ + str, set[asyncio.Queue[bytes | None]] + ] = {} self._pending: dict[int, _PendingRequest] = {} self._heartbeat_pending: asyncio.Future[dict[str, Any]] | None = None self._transaction_counter = 0 @@ -698,7 +719,11 @@ async def disconnect(self) -> None: async def _close_quiet(self) -> None: self._connected = False - for t in (self._reader_task, self._heartbeat_task): + for t in ( + self._reader_task, + self._heartbeat_task, + self._media_reader_task, + ): if t and not t.done(): t.cancel() try: @@ -707,7 +732,14 @@ async def _close_quiet(self) -> None: pass self._reader_task = None self._heartbeat_task = None - for ws in (self._ws_instr, self._ws_file): + self._media_reader_task = None + for queues in self._media_subscribers.values(): + for queue in queues: + if queue.full(): + queue.get_nowait() + queue.put_nowait(None) + self._media_subscribers.clear() + for ws in (self._ws_instr, self._ws_file, self._ws_media): if ws and not ws.closed: try: await ws.close() @@ -715,6 +747,7 @@ async def _close_quiet(self) -> None: pass self._ws_instr = None self._ws_file = None + self._ws_media = None # Resolve any outstanding requests so callers don't hang. for pending in self._pending.values(): if not pending.future.done(): @@ -823,6 +856,162 @@ async def _send_first_message(self) -> None: _skip_connect=True, ) + def supports_media_stream(self, camera_name: str) -> bool: + """Return whether this model has a live stream mapping we verified.""" + model_id = str(getattr(self._model, "model_id", "")) + return camera_name in _MEDIA_CAMERA_NAMES.get(model_id, {}) + + async def iter_media_stream(self, camera_name: str) -> AsyncIterator[bytes]: + """Yield routed Annex-B H.264 fragments from a WS-V2 camera. + + P3 exposes both lenses over one same-session ``media_stream`` socket. + Protocol-34's leading byte is 0 for ``far`` and 2 for ``upside``; + the rest is Annex-B H.264. Multiple HA stream workers subscribe to + the shared reader and receive only their camera's packets. + """ + await self.connect() + model_id = str(getattr(self._model, "model_id", "")) + live_name = _MEDIA_CAMERA_NAMES.get(model_id, {}).get(camera_name) + if live_name is None: + raise RuntimeError( + f"WS-V2 media stream is not verified for {model_id}/{camera_name}" + ) + if self._session is None or self._session.closed: + raise ConnectionError("V2 WebSocket session is not connected") + queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=12) + first_for_camera = False + async with self._media_lock: + if self._ws_media is None or self._ws_media.closed: + await self._open_media_stream() + subscribers = self._media_subscribers.setdefault(live_name, set()) + first_for_camera = not subscribers + subscribers.add(queue) + try: + if first_for_camera: + await self.request( + self.PATH_CAMERA_LIVE, + "POST", + params={"name": live_name, "action": "start"}, + ) + while (fragment := await queue.get()) is not None: + yield fragment + finally: + stop_camera = False + close_media = False + async with self._media_lock: + subscribers = self._media_subscribers.get(live_name, set()) + subscribers.discard(queue) + if not subscribers: + self._media_subscribers.pop(live_name, None) + stop_camera = True + close_media = not self._media_subscribers + if stop_camera: + try: + await self.request( + self.PATH_CAMERA_LIVE, + "POST", + params={"name": live_name, "action": "stop"}, + ) + except Exception as err: + _LOGGER.debug( + "V2 media stream %s stop failed: %s", live_name, err, + ) + if close_media: + await self._close_media_stream() + + async def _open_media_stream(self) -> None: + """Open the one shared media socket and start its routing task.""" + if self._session is None or self._session.closed: + raise ConnectionError("V2 WebSocket session is not connected") + url = ( + f"wss://{self.host}:{self._port}{WSV2_PATH}" + f"?id={_CLIENT_SESSION_ID}&function=media_stream" + ) + self._ws_media = await self._session.ws_connect( + url, + ssl=_ssl_context(), + timeout=aiohttp.ClientTimeout(total=15), + heartbeat=20.0, + max_msg_size=0, + headers={"Origin": "atomm://renderer"}, + ) + self._media_reader_task = asyncio.create_task(self._media_reader_loop()) + + async def _media_reader_loop(self) -> None: + """Route protocol-34 packets from the shared socket to subscribers.""" + ws = self._ws_media + if ws is None: + return + rx_buffer = bytearray() + try: + async for msg in ws: + if msg.type != aiohttp.WSMsgType.BINARY: + if msg.type in ( + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.ERROR, + ): + break + continue + rx_buffer.extend(msg.data) + frames, remainder = _decode_frames(bytes(rx_buffer)) + rx_buffer = bytearray(remainder) + for protocol_type, payload in frames: + if ( + protocol_type != WSV2_PROTOCOL_MEDIA_STREAM + or len(payload) <= 5 + or payload[1:5] != b"\x00\x00\x00\x01" + ): + continue + stream_id = payload[0] + live_name = next( + ( + name for name, value in _MEDIA_STREAM_IDS.items() + if value == stream_id + ), + None, + ) + # When upside is the only active camera its initial SPS + # packet may carry id 0 before subsequent packets use 2. + if ( + live_name == "far" + and "far" not in self._media_subscribers + and "upside" in self._media_subscribers + ): + live_name = "upside" + if live_name is None: + continue + for queue in tuple( + self._media_subscribers.get(live_name, ()) + ): + if queue.full(): + queue.get_nowait() + queue.put_nowait(payload[1:]) + except asyncio.CancelledError: + raise + except Exception as err: + _LOGGER.debug("V2 shared media reader ended: %s", err) + finally: + for queues in self._media_subscribers.values(): + for queue in queues: + if queue.full(): + queue.get_nowait() + queue.put_nowait(None) + + async def _close_media_stream(self) -> None: + """Close the shared socket after its last subscriber exits.""" + task = self._media_reader_task + self._media_reader_task = None + if task and not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + if self._ws_media is not None and not self._ws_media.closed: + await self._ws_media.close() + self._ws_media = None + # ── action helpers (entity write-paths) ────────────────────────── async def set_config(self, key: str, value: Any) -> dict[str, Any]: @@ -2058,18 +2247,41 @@ async def poll_state(self, state: XtoolDeviceState) -> None: if isinstance(v, (int, float)): self._latest[dst] = int(v) - # 5. Progress — only when a job is running. - if state.status in (XtoolStatus.PROCESSING, - XtoolStatus.PROCESSING_READY, - XtoolStatus.FRAMING): + # 5. Progress — only when a job is running on most models. P3's + # runtime-infos endpoint can report P_OFF throughout an active cut, + # even while /v1/processing/progress exposes the live workingTime. + # Poll P3 unconditionally so task time does not disappear behind the + # incorrect mode flag. + model_id = str(getattr(model, "model_id", "")) + if ( + model_id == "P3" + or state.status in ( + XtoolStatus.PROCESSING, + XtoolStatus.PROCESSING_READY, + XtoolStatus.FRAMING, + ) + ): try: prog = await self.request(self.PATH_PROGRESS, "GET") except Exception: prog = None + _LOGGER.debug("V2 %s raw: %s", self.PATH_PROGRESS, prog) if isinstance(prog, dict): - wt = prog.get("workingTime") or prog.get("totalTime") + # P3 firmware names elapsed job seconds ``time`` and uses + # ``total``/``value`` for percentage progress. Other WS-V2 + # models use workingTime (or, on older firmware, totalTime). + wt = ( + prog.get("workingTime") + or prog.get("time") + or prog.get("totalTime") + ) if isinstance(wt, (int, float)): - state.task_time = int(wt) + elapsed = int(wt) + state.task_time = elapsed + # _apply_latest_to_state runs after polling. Keep its + # push cache synchronized so a stale zero cannot replace + # the freshly polled P3 value before entities update. + self._latest["task_time"] = elapsed # 6. Alarms — alarm presence, slow cadence. Skip once cached as # unsupported (F1 / GS005 / HJ003 / M1Ultra / P2S / P3 / DT001 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 19273ec..fd77e2f 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1336,12 +1336,30 @@ Per-model step-1 variants (audited from each Studio bundle): | All of the above (V2) | `/v1/camera/snap` | `GET ?name=fireRecord` — captures the buffered frame from the most recent flame-detection event (when supported by the firmware build). | | F1, M1 Ultra, DT001 | _no camera-snap route in bundle_ | n/a | -#### Camera live video — `media_stream` channel + WebRTC signaling - -The third WS channel (`function=media_stream`) carries the live -camera video over **WebRTC**, not a simple WS-MJPEG stream. -Signaling rides the `/v1/signaling/*` and `/v1/platform/camera/*` -endpoints (see below). +#### Camera live video — `media_stream` channel + +The third WS channel (`function=media_stream`) carries live camera +video. Live P3 hardware and the Studio P3 bundle confirm a direct H.264 +path that does **not** require WebRTC signaling: + +1. Open `instruction` and `media_stream` sockets with the same client id. +2. Send `POST /v1/platform/camera/live` with query params + `{name:"far", action:"start"}` for the overview camera or + `{name:"upside", action:"start"}` for the second camera. +3. Read CRC-wrapped protocol-34 frames from the single shared + `media_stream` socket. Each payload is one stream-id byte followed by + Annex-B H.264 (start code `00 00 00 01`): id `0` routes `far`, and id + `2` routes `upside`. Both start requests may be active concurrently. +4. Send the matching `action:"stop"` request when the consumer exits. + +Observed on a production P3: H.264 High profile, 1280×720, approximately +10 delivered packets/frames per second. The integration exposes this to +Home Assistant's native Stream component through a localhost-only TCP +bridge; HA handles HLS/WebRTC presentation and still-frame decoding. + +The signaling endpoints below still exist in firmware and may be used by +other clients or model generations, but they are not required for the +verified P3 direct media path. **Firmware infrastructure** (`/tmp/f1v2-fw/apps/root/lib/libmk-host.so`, class `streamService` + `rtc::impl::PeerConnection`): @@ -1425,13 +1443,11 @@ The namespace mixes two concerns: | `/v1/platform/user/{parity,ping}` | various | — | varies | Account session keep-alive. | | `/v1/atomm-api/v1/device/{bind-user,dev-bind-code,register,sign,timestamp}` | various | — | varies | Atomm-namespaced bind + sign endpoints (xTool's internal cloud SDK). | -**Studio's actual usage:** zero. A `grep -c -"RTCPeerConnection\|webrtc\|signaling\|mediasoup\|iceServer"` over -every Studio `index.js` returns `0` everywhere — Studio never -opens `media_stream` in any model bundle. Live preview appears -to be exclusive to the xTool **mobile app**, which is the only -known consumer of `/v1/platform/camera/live` + the -`/v1/signaling/*` exchange. Studio bundles don't exercise it. +**Studio's actual P3 usage:** current Studio builds configure three +same-id sockets (`instruction`, `file_stream`, `media_stream`) and call +`/v1/platform/camera/live` through `cameraMediaManager.openVideoStream`. +The P3 camera map names are `far` and `upside`; these differ from the +snapshot entity labels `overview` and `closeup`. ### Push events @@ -2820,4 +2836,3 @@ hardware split: | P2 | Allwinner H3 + Linux | GD450 motion + GD330 UI + GD330 WCB | UI + cover board MCUs | | P2S | same as P2 | same | newer revision | | Bluetooth dongle | dedicated MCU | — | exposes `M9091`–`M9098` for pairing, scan, connect | -