Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
2e12ebc
Add HKSV 17.99 camera services and characteristics to the catalog
RaHehl Nov 8, 2026
207041c
Fix the camera operating mode characteristic permissions
RaHehl Nov 8, 2026
f83c74c
Add TLV8 codecs for the HKSV 17.99 camera values
RaHehl Nov 8, 2026
576f6e7
Add HKSV 17.99 camera capabilities and motion zones
RaHehl Nov 8, 2026
3dea2d5
Add the HKSV 17.99 camera global operating mode
RaHehl Nov 8, 2026
787a525
Return -70405 when reading a characteristic without paired read
RaHehl Nov 8, 2026
071f96d
Answer -70406 for event subscriptions without notify permission
RaHehl Nov 8, 2026
3fd5a1b
Return -70404 when writing a characteristic without paired write
RaHehl Nov 8, 2026
93d17d3
Ignore timed writes with an unknown or expired pid
RaHehl Nov 8, 2026
e09ecbd
Split building the characteristics write response into helpers
RaHehl Nov 8, 2026
1ef0721
Only send write responses for characteristics with wr permission
RaHehl Nov 8, 2026
9ef52f7
Check the timed write permission on writes
RaHehl Nov 8, 2026
3082bba
Await async characteristic setters before answering the write
RaHehl Nov 8, 2026
f946d1f
Only let the writing connection read back a write response
RaHehl Nov 8, 2026
aeaafaf
Merge branch 'enforce-permissions' into enforce-timed-write
RaHehl Nov 8, 2026
9b2c4d0
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 8, 2026
c4c842b
Test write response privacy with async setters
RaHehl Nov 8, 2026
ab604de
Merge remote-tracking branch 'upstream/dev' into enforce-permissions
RaHehl Nov 10, 2026
8f298c6
Drop the redundant bool() around the write response check
RaHehl Nov 10, 2026
ff12fb6
Type the write response helpers with the HAP JSON value types
RaHehl Nov 10, 2026
c9ed715
Explain why a private write puts the former value back
RaHehl Nov 10, 2026
93abcd8
Merge branch 'enforce-permissions' into enforce-timed-write
RaHehl Nov 10, 2026
f76d810
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
f3b5ce5
Merge remote-tracking branch 'upstream/dev' into hksv3-operating-mode
RaHehl Nov 10, 2026
3bc3d7a
Tell accessories when a controller's connection closes
RaHehl Nov 9, 2026
c7e554d
Accept only a canonical IP address in Setup Endpoints
RaHehl Nov 9, 2026
af8d2c0
Keep the Setup Endpoints answer per connection
RaHehl Nov 9, 2026
9b47781
Merge branch 'hksv3-operating-mode' into hksv3-stream-services
RaHehl Nov 10, 2026
64a1d86
Merge branch 'camera-connection-hooks' into hksv3-stream-services
RaHehl Nov 10, 2026
3def9b4
Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management
RaHehl Nov 8, 2026
e7e0de4
Add TLV8 codecs for the HKSV 17.99 WebRTC stream management
RaHehl Nov 8, 2026
e445c4a
Add the session state machine of the HKSV 17.99 stream services
RaHehl Nov 9, 2026
4e2f705
Add the HKSV 17.99 multi-tier RTP and WebRTC stream services
RaHehl Nov 9, 2026
f46b75f
Set up, start and end HKSV 17.99 multi-tier RTP sessions
RaHehl Nov 9, 2026
42d549f
Stream HKSV 17.99 multi-tier sessions with ffmpeg
RaHehl Nov 9, 2026
d2190a9
Solicit, answer and end HKSV 17.99 WebRTC sessions
RaHehl Nov 9, 2026
c8e3fc4
Renegotiate HKSV 17.99 WebRTC sessions and update their keys
RaHehl Nov 9, 2026
dd42ed5
Keep reading ffmpeg's output in chunks so status lines can't block it
RaHehl Nov 10, 2026
2e00188
Don't tell subscribers the request of a write with r
RaHehl Nov 10, 2026
0aa3907
Merge branch 'enforce-permissions' into enforce-timed-write
RaHehl Nov 10, 2026
d130cfb
Type the timed write pid and ignore a pid that is not an int
RaHehl Nov 10, 2026
4fcf261
Bound the SFrame keys and SDP m-line indexes controllers send
RaHehl Nov 10, 2026
2374093
Tighten multi-tier and WebRTC session handling
RaHehl Nov 10, 2026
81746e4
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
3a593d7
Name the fields of a pending async write and keep write_response a bool
RaHehl Nov 10, 2026
08b3f51
Merge branch 'async-setters' into setup-endpoints-per-connection
RaHehl Nov 10, 2026
1b10be9
Keep read back answers private, also when the setter sets them
RaHehl Nov 10, 2026
58a0356
Test a closed connection without an accessory
RaHehl Nov 10, 2026
8a0c11e
Normalize Setup Endpoints addresses and keep set_endpoints returning …
RaHehl Nov 10, 2026
770db17
Run test_start_from_sync on the driver's own loop
RaHehl Nov 10, 2026
83b3e66
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
3bb7ca1
Merge branch 'async-setters' into setup-endpoints-per-connection
RaHehl Nov 10, 2026
142ce33
Merge remote-tracking branch 'upstream/dev' into enforce-timed-write
RaHehl Nov 10, 2026
312dd18
Merge branch 'enforce-timed-write' into async-setters
RaHehl Nov 10, 2026
4bec70d
Merge branch 'async-setters' into setup-endpoints-per-connection
RaHehl Nov 10, 2026
f16828d
Merge remote-tracking branch 'upstream/dev' into camera-connection-hooks
RaHehl Nov 10, 2026
18cbcbb
Answer a Setup Endpoints refused while streaming is off only to the w…
RaHehl Nov 10, 2026
82f878a
Merge branch 'setup-endpoints-per-connection' into hksv3-stream-services
RaHehl Nov 10, 2026
2579461
Merge branch 'camera-connection-hooks' into hksv3-stream-services
RaHehl Nov 10, 2026
c2e659a
Test that a refused Setup Endpoints reaches only the writer
RaHehl Nov 10, 2026
874e697
Decode the stream service UUIDs with UUID(bytes=...) like the zones
RaHehl Nov 10, 2026
d1a82f9
Merge remote-tracking branch 'upstream/dev' into setup-endpoints-per-…
RaHehl Nov 10, 2026
54d0a0d
Merge remote-tracking branch 'upstream/dev' into setup-endpoints-per-…
RaHehl Nov 10, 2026
29cc0eb
Merge branch 'setup-endpoints-per-connection' into hksv3-stream-services
RaHehl Nov 10, 2026
b28c8c8
Keep setting the Setup Endpoints answer as the value
RaHehl Nov 10, 2026
a9af038
Merge branch 'setup-endpoints-per-connection' into hksv3-stream-services
RaHehl Nov 10, 2026
550c319
Merge branch 'dev' into hksv3-stream-services
ikalchev Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions pyhap/accessory_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from concurrent.futures import ThreadPoolExecutor
import hashlib
import inspect
import ipaddress
import logging
import os
import re
Expand Down Expand Up @@ -1123,6 +1124,30 @@ def derive_session_key(
return None
return connection.derive_key(salt, info)

def local_address(
self, client_addr: tuple[str, int] | None, ipv6: bool
) -> str | None:
"""The accessory's address on a controller's connection.

Returns None without such a connection or if its address is not of
the asked IP version. An IPv4 address mapped into IPv6 counts as
IPv4, a scope ID is dropped.
"""
connection = (
self.http_server.connections.get(client_addr) if client_addr else None
)
transport = connection.transport if connection else None
sockname = transport.get_extra_info("sockname") if transport else None
if not sockname:
return None
try:
address = ipaddress.ip_address(str(sockname[0]).split("%", 1)[0])
except ValueError:
return None
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
address = address.ipv4_mapped
return str(address) if address.version == (6 if ipv6 else 4) else None

def set_characteristics(
self,
chars_query: dict[str, Any],
Expand Down
194 changes: 189 additions & 5 deletions pyhap/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"""

import asyncio
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
from collections.abc import AsyncGenerator, Callable, Iterable, Mapping, Sequence
import contextlib
import functools
import hashlib
Expand All @@ -26,7 +26,8 @@
import os
import struct
import sys
from typing import Any
from types import MappingProxyType
from typing import TYPE_CHECKING, Any
from uuid import UUID

from pyhap import (
Expand All @@ -45,6 +46,10 @@
from pyhap.service import Service
from pyhap.util import base64_to_bytes, byte_bool, callback, to_base64_str

if TYPE_CHECKING:
from pyhap.camera_multi_tier import MultiTierController
from pyhap.camera_webrtc import WebRTCController

if sys.version_info >= (3, 11):
from asyncio import timeout as async_timeout
else:
Expand Down Expand Up @@ -625,6 +630,23 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None:
at least three video encodings. With ``recording``, turning it
on for a paired camera changes the instance IDs of the recording
services with the default IID manager.
- multi_tier - :class:`pyhap.camera_multi_tier.MultiTierConfig`, or a
dict of its fields. Developer Preview of HomeKit Secure Video
17.99, experimental. Adds a Camera Multi-Tier RTP Stream
Management service per sensor, offering the sensor's video
encodings as H.265 tiers. Needs ``webrtc``, ``sensors``,
``global_operating_mode``, ``recording``, ``video`` and
``audio``: iOS 27 shows a camera without the classic RTP stream
management service as not responding. Its services come before
the RTP stream management services, which then get a new
``unique_id``; for a paired camera this changes instance IDs.
- webrtc - :class:`pyhap.camera_webrtc.WebRTCConfig`, or a dict of
its fields. Developer Preview of HomeKit Secure Video 17.99,
experimental. Adds a Camera WebRTC Stream Management service per
sensor, linked to its multi-tier service. Controllers view
remotely through it: one that streams through the multi-tier
service does not fall back to the RTP stream management service.
Needs ``multi_tier`` and the options that it needs.

:type options: ``dict``
"""
Expand Down Expand Up @@ -689,11 +711,20 @@ def __init__(self, options: dict[str, Any], *args: Any, **kwargs: Any) -> None:
self._motion_zones_service: Service | None = None
self._contributing_sensors_char: Characteristic | None = None
self._motion_enabled_char: Characteristic | None = None
self._multi_tier: dict[UUID, MultiTierController] = {}
self._webrtc: dict[UUID, WebRTCController] = {}
self._stream_status_scheduled = False
if options.get("multi_tier") is not None or options.get("webrtc") is not None:
self._setup_stream_controllers(options)
if options.get("video"):
self._setup_stream_management(options)
# Classic HomeKit Secure Video recording (HDS + fragmented MP4).
if options.get("recording"):
self._setup_recording_management(options)
transport = self.get_service("DataStreamTransportManagement")
if transport is not None:
for controller in self._stream_controllers():
controller.service.add_linked_service(transport)
if self._sensors:
self._setup_camera_capabilities()
# Last, so the instance IDs of the other services stay the same.
Expand All @@ -718,8 +749,11 @@ def _create_stream_management(
self, stream_idx: int, options: dict[str, Any]
) -> Service:
"""Create a stream management service."""
# A new unique_id lets a persistent IID manager place the service
# after the multi-tier ones, which come first.
unique_id = stream_idx if not self._multi_tier else f"rtp-{stream_idx}"
management = self.add_preload_service(
"CameraRTPStreamManagement", unique_id=stream_idx, chars=["Active"]
"CameraRTPStreamManagement", unique_id=unique_id, chars=["Active"]
)
# HAP-NodeJS exposes an Active (0x0B0) characteristic on every stream
# management service (value 1 = active); iOS' camera validation expects
Expand Down Expand Up @@ -755,6 +789,142 @@ def _create_stream_management(
setup_endpoints.read_back_response = True
return management

def _setup_stream_controllers(self, options: dict[str, Any]) -> None:
"""Add the multi-tier and WebRTC controllers of each sensor (17.99)."""
# Here, not at the top: these modules import this one.
from pyhap.camera_multi_tier import ( # noqa: PLC0415
MultiTierConfig,
MultiTierController,
)
from pyhap.camera_webrtc import WebRTCConfig, WebRTCController # noqa: PLC0415

if options.get("multi_tier") is None or options.get("webrtc") is None:
raise ValueError(
"multi_tier and webrtc need each other, without WebRTC "
"controllers cannot view remotely"
)
if (
not options.get("video")
or not options.get("audio")
or options.get("stream_count", 1) < 1
):
raise ValueError(
"multi_tier and webrtc need the video and audio options, iOS 27 "
"shows a camera without the classic RTP stream management "
"service as not responding"
)
if (
not self._sensors
or options.get("global_operating_mode") is None
or not options.get("recording")
):
raise ValueError(
"multi_tier and webrtc need the sensors, global_operating_mode "
"and recording options"
)
multi_tier = MultiTierConfig.from_option(options["multi_tier"])
webrtc = WebRTCConfig.from_option(options["webrtc"])
for sensor in self._sensors:
for warning in hksv3.check_video_tiers(sensor):
logger.warning("Sensor %s: %s", sensor.sensor_uuid, warning)
controller = MultiTierController(self, sensor, multi_tier)
self._multi_tier[sensor.sensor_uuid] = controller
self._webrtc[sensor.sensor_uuid] = WebRTCController(
self, controller, webrtc
)
for kind, controllers in (
("multi_tier", self._multi_tier),
("webrtc", self._webrtc),
):
for sensor_uuid, controller in controllers.items():
self._persist_hksv3_char(
controller.streaming_enabled,
f"{kind}.{sensor_uuid}.streaming_enabled",
self._handle_stream_enabled,
)

def _stream_controllers(
self,
) -> "list[MultiTierController | WebRTCController]":
return [*self._multi_tier.values(), *self._webrtc.values()]

@property
def multi_tier_controllers(self) -> "Mapping[UUID, MultiTierController]":
"""The multi-tier RTP controller of each sensor (17.99), by sensor UUID."""
return MappingProxyType(self._multi_tier)

@property
def webrtc_controllers(self) -> "Mapping[UUID, WebRTCController]":
"""The WebRTC controller of each sensor (17.99), by sensor UUID."""
return MappingProxyType(self._webrtc)

def _handle_stream_enabled(self, value: Any) -> None:
self._schedule_stream_status()

def _schedule_stream_status(self) -> None:
"""Update the stream controllers once the current write is done.

A controller writes several of the values they depend on in one
request, e.g. HomeKit Camera Active and Streaming Enabled.
"""
if self._stream_status_scheduled or not self._multi_tier:
return
self._stream_status_scheduled = True
self.driver.loop.call_soon(self._update_stream_status)

def _update_stream_status(self) -> None:
"""Let each stream controller derive whether it may stream now.

Ends the sessions that may no longer stream.
"""
self._stream_status_scheduled = False
for controller in self._stream_controllers():
controller.update_status()

def stream_address_for(
self, client_addr: tuple[str, int] | None, is_ipv6: bool
) -> str | None:
"""The address to stream from to a controller, in the given IP version.

The accessory's address on the controller's connection, else the
``address`` option if it has that version.
"""
address = self.driver.local_address(client_addr, is_ipv6)
if address is not None:
return address
if (self.stream_address_isv6 == b"\x01") == is_ipv6:
return str(self.stream_address)
return None

async def end_session(self, session_id: UUID) -> bool:
"""End a multi-tier or WebRTC session (17.99) on the camera's side.

E.g. when its media source stopped. Ends the sessions with that ID of
every sensor and service and waits until their delegates released
them. Returns False if there is no such session.
"""
ended = await asyncio.gather(
*(
controller.end_session(session_id)
for controller in self._stream_controllers()
)
)
return any(ended)

async def end_all_sessions(self) -> None:
"""End every multi-tier and WebRTC session (17.99) and wait for them."""
await asyncio.gather(
*(
controller.end_all_sessions()
for controller in self._stream_controllers()
)
)

def client_disconnected(self, client_addr: tuple[str, int]) -> None:
"""End the multi-tier and WebRTC sessions of the connection."""
for controller in self._stream_controllers():
controller.client_disconnected(client_addr)

def _default_recording_configs(
self, options: dict[str, Any]
) -> tuple[
Expand Down Expand Up @@ -1160,6 +1330,7 @@ def set_manually_disabled(self, disabled: bool) -> None:
if char is not None:
char.set_value(bool(disabled))
self._end_streams_if_not_allowed()
self._update_stream_status()

def set_third_party_camera_active(self, active: bool) -> None:
"""Report whether the camera is on for apps outside HomeKit.
Expand All @@ -1176,6 +1347,7 @@ def _global_operating_mode_char(self, name: str) -> Characteristic | None:
def _handle_global_operating_mode(self, name: str, value: Any) -> None:
if name == "StreamingEnabled":
self._end_streams_if_not_allowed()
self._schedule_stream_status()
self.global_operating_mode_changed(name, value)

def _end_streams_if_not_allowed(self) -> None:
Expand Down Expand Up @@ -1316,6 +1488,8 @@ def _restore_hksv3_state(self) -> None:
if self.aid is not None:
self._load_hksv3_state(self.aid)
self._hksv3_seen = {char: char.value for char in self._hksv3_defaults}
# Restored values bypass the handlers.
self._update_stream_status()

def _load_hksv3_state(self, aid: int) -> None:
stored = self.driver.accessory_state(aid).get(HKSV3_STATE)
Expand Down Expand Up @@ -1398,6 +1572,7 @@ def reset_accessory_state(self) -> None:
self._schedule_recording_save()
for handler, value in handlers:
handler(value)
self._update_stream_status()

def set_motion_detected(
self, detected: bool, sensors: Iterable[UUID] | None = None
Expand Down Expand Up @@ -1624,6 +1799,7 @@ def _handle_homekit_camera_active(self, value: Any) -> None:
if not active and self._recording_stream is not None:
self._recording_stream.close(hds_recording.RecordingReason.NOT_ALLOWED)
self._end_streams_if_not_allowed()
self._schedule_stream_status()
self.homekit_camera_active_changed(active)

def homekit_camera_active_changed(self, active: bool) -> None:
Expand Down Expand Up @@ -1931,9 +2107,17 @@ async def stop_recording_transport(self) -> None:
await listener.stop()

async def stop(self) -> None:
"""Stop all streaming sessions and the HDS listener."""
"""Stop all streaming sessions and the HDS listener.

The HKSV 17.99 stream services stay closed. Create a new accessory to
stream on them again.
"""
await asyncio.gather(
*(self.stop_stream(session_info) for session_info in self.sessions.values())
*(
self.stop_stream(session_info)
for session_info in self.sessions.values()
),
*(controller.close() for controller in self._stream_controllers()),
)
await self.stop_recording_transport()

Expand Down
Loading
Loading