Skip to content

Add the HKSV 17.99 multi-tier RTP and WebRTC stream services - #545

Merged
ikalchev merged 67 commits into
ikalchev:devfrom
RaHehl:hksv3-stream-services
Nov 10, 2026
Merged

ikalchev merged 67 commits into
ikalchev:devfrom
RaHehl:hksv3-stream-services

Conversation

@RaHehl

@RaHehl RaHehl commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Builds on #532, #543 and #544 (and so on #535 to #537). Until those are merged, the diff against dev includes them.

The HomeKit Secure Video guide adds two stream services next to the classic RTP stream management: Multi-Tier RTP Stream Management, which lets a controller pick between several video tiers, for example for H.265, and WebRTC Stream Management for remote viewing. This PR adds the protocol side of both. Cameras without the new options are unchanged.

  • pyhap/hksv3.py: TLV8 codecs for both services.
  • pyhap/camera_stream.py: the session state machine both services share (setup, start, reconfigure, end, idle timeout, session limits).
  • pyhap/camera_multi_tier.py: MultiTierController, a MultiTierDelegate protocol, and FFmpegMultiTierDelegate as a simple reference that runs one ffmpeg per session.
  • pyhap/camera_webrtc.py: WebRTCController and a WebRTCDelegate protocol for solicit offer, answer, re-offer, session updates and end. Media is up to the delegate; a WebRTC media implementation follows in a separate PR.
  • Camera options multi_tier and webrtc add the services.

Tested with unit tests (3.10, 3.11), ruff, ty, and live with current iOS/tvOS hubs: H.265 multi-tier streams play locally with the ffmpeg delegate. The WebRTC side was tested live together with the media implementation of the follow-up PR.

References:

AI use: written with the help of an AI coding assistant (Opus 5.5). I reviewed and tested the changes myself (pytest, ruff, ty, live with current iOS/tvOS hubs).

RaHehl added 30 commits November 8, 2026 18:31
Add the eight services and 27 characteristics of Apple's HomeKit Secure
Video Open Source Compatibility Guide (Developer Preview 1.0) to the
loader resources. Two of them, Contributing Sensors and Motion Enabled,
also become optional characteristics of the Motion Sensor service.

Timed write is advertised where the guide requires it. Writes to Motion
Enabled and Streaming Enabled are marked admin only with an
"AdminOnlyAccess": ["write"] catalog property. It is not part of the HAP
representation and not enforced yet.

Camera Recording Management keeps the characteristics classic recording
relies on.
Third Party Camera Active is state the camera reports, so controllers
read and subscribe to it but do not write it. Night Vision and Camera
Operating Mode Indicator are written with timed writes. The Camera
Global Operating Mode service of HKSV 17.99 uses all three. This
matches the definitions in HAP-NodeJS.
Add pyhap.hksv3 with the camera capabilities, contributing sensors and
camera zones values of the HomeKit Secure Video Open Source
Compatibility Guide.

Lists use the HAP TLV8 list encoding: one TLV per item with an empty
type 0 TLV between items of the same type. Values over 255 bytes are
split into 255-byte fragments, and the decoder joins a TLV to the one
before it only after such a full fragment, like hksv_recording and
HAP-NodeJS. Enum values a controller sends that are not known yet are
kept as ints instead of failing the write, and a zone without method or
a polygon without identifier is read like HAP-NodeJS does.

The tests check the encoder output and the decoder against hand written
vectors and a small independent parser of the list encoding.
A new "sensors" camera option takes the image sensors of the camera,
each with its real dimensions, type, intent (main or package camera) and
video encodings. With it the camera advertises them in the Camera
Capabilities service, accepts motion zones through the Camera Motion
Zones service and adds Contributing Sensors and Motion Enabled to the
motion sensor. Cameras without the option are unchanged.

set_motion_detected reports the sensors that saw the motion and reports
none while the user turned motion detection off. Integrations read the
zones from camera_zones and override motion_zones_changed and
motion_enabled_changed to react. The hooks only hear about changes, not
about a controller writing the same value again.

Zones, their Active state and Motion Enabled are kept in the driver's
accessory state like the classic recording state: restored when the
camera is rebuilt and returned to their defaults when the last pairing
is removed, calling the hooks for the values that change once all state
is reset. Both kinds of state share one save per burst of writes.

A zones write that does not decode fails and leaves the previous zones
in place. A warning is logged for sensors with fewer than the three
video encodings the guide requires and for motion reported for sensors
that are not configured.
A new "global_operating_mode" camera option adds the Camera Global
Operating Mode service with the listed optional characteristics, after
all other services so their instance IDs stay the same. The settings a
controller writes are persisted and reset for a new home; changes from
writes and resets call global_operating_mode_changed with the setting
and its value. Manually Disabled and Third Party Camera Active are
device state, set through set_manually_disabled and
set_third_party_camera_active.

streaming_allowed combines HomeKit Camera Active, Streaming Enabled and
Manually Disabled. While it is False, the camera answers SetupEndpoints
with an error and does not start streams. Cameras without the option are
unchanged.

When the service exists, its HomeKit Camera Active alone turns the
camera on or off. The value on the Camera Operating Mode service of
classic recording is then ignored, so a value left there cannot keep
the camera off. A change closes a running recording and calls
homekit_camera_active_changed like before; writing the same value again
calls no hook.
GET /characteristics returned the stored value of every characteristic,
including write-only ones such as Identify or a camera key. The HAP spec
only allows reads of characteristics with the paired read (pr)
permission and answers everything else with -70405. HAP-NodeJS does the
same.

The getter is not called for such characteristics. The /accessories
database already omitted their value; this adds tests for it.
Any characteristic could be subscribed to, whether or not it has the
notify (ev) permission. A controller could then receive the values that
another controller wrote to a write-only characteristic. Per the HAP
spec, (un)subscribing a characteristic without ev now answers -70406
and leaves the subscriptions unchanged, the same as HAP-NodeJS. A value
in the same request entry is not written.

Some tests subscribed to Version, which has no ev permission. They now
use CurrentDoorState.
A value written to a characteristic without the pw permission still
reached its setter. Answer it with -70404 like HAP-NodeJS instead, without
calling the characteristic, service or accessory setters. A subscription
in the same entry and the other characteristics of the request are still
handled.
A write carrying a pid that was never prepared on this connection, was
already used or has outlived its TTL was still applied, and its event
subscriptions were changed. Only characteristics without a value got
-70410.

The HAP specification says such a request must be ignored and answered
with -70410. Do that for every characteristic in the request, as
HAP-NodeJS does. The TTL is now measured with the monotonic clock, so a
wall clock jump, e.g. from NTP right after boot, can't expire a timed
write.
No behaviour change. The next commit fills in the response after
awaiting async setters, which needs the result building in the driver
and the HTTP response filling in the handler to work on their own.
A write with "r": true got the setter's return value back for any
characteristic. The HAP spec ties write responses to the write response
(wr) permission, and HAP-NodeJS ignores the request for other
characteristics. Do the same.

When a write response is sent, it becomes the characteristic's value
instead of the written one, which HAP-NodeJS does as well. The sent value
is the stored one, after the usual format checks. A response the
characteristic can't hold is still sent and the written value is kept.

The write response tests are now parametrized and also cover a wr
characteristic without paired read: neither the written value nor the
response can be read back.
The HAP specification requires a plain write to a characteristic with
the timed write ("tw") permission to be refused with -70410. HAP-NodeJS
does this.

A Home hub has been seen writing HomeKit Camera Active without a timed
write, and the HomeKit Secure Video guide gives Streaming Enabled and
Motion Enabled tw as well. Other controllers may not support timed
writes at all. So the check is opt-in: with enforce_timed_writes=True
the driver refuses such a write, otherwise it applies it and only logs
at debug level.
A setter that is a coroutine function was called but never awaited. With
"r": true the coroutine was passed to the JSON encoder, which raised
TypeError, so the whole write failed with a 500.

Writes from a controller now wait for coroutine setters before they are
answered, through the existing delayed response. Accessory and service
setters still run first. A setter still running after the response
timeout is cancelled and its characteristic answers -70408; the other
characteristics keep their results. A setter that raises answers -70402,
and a closed connection cancels the setters. The return value becomes
the write response and the stored value under the same rules as for
sync setters.

The public set_characteristics() keeps its signature. Called directly,
it schedules coroutine setters and answers at once. Only coroutines are
awaited; a sync setter that returns a task or future works as before.
A write response of a characteristic that also has pr was stored as its
value, so any paired controller could read it with GET /characteristics
or /accessories. Keep it per connection instead: the connection that
wrote reads its last response until it closes. Other connections read
the characteristic's own value, which the write leaves as it was, also
when the setter raises or returns nothing. Any new write of a connection
drops its former response. Characteristics without pr keep the response
as the value.
The writing connection reads an async setter's response back, other
connections don't, the request stays private while the setter runs, and
a response that completes as the connection closes is not kept.
# Conflicts:
#	pyhap/resources/characteristics.json
An accessory that keeps per-controller state, such as camera streams a
controller set up, has no way to learn that the controller went away.

The driver now calls Accessory.client_disconnected() from
connection_lost, and a bridge passes it on to each bridged accessory.
An accessory that raises is logged and does not stop the others or the
rest of the connection cleanup.
The controller address of a Setup Endpoints write is now accepted only as
the canonical IP of the announced version, without an IPv6 scope ID.
Any other address, a missing address, port or SRTP field, a version or
port field of the wrong length or an empty crypto suite answers with an
error and sets up no session. set_endpoints() also returns that error
answer.

The default start_stream also builds its argument list by formatting
each argument of the command template, instead of splitting the
formatted command.
A controller writes Setup Endpoints and reads the answer back. The answer
carries the session keys, so it now stays with the connection that wrote,
like a write response.

A characteristic with read_back_response keeps what its setter returns
for the writing connection, until it writes again or closes. Other
connections and /accessories read the characteristic's own value, and
the written request is not left as that value either. The same now holds
for any write to a readable write response characteristic, with or
without r.

set_endpoints() returns its answer instead of setting the value.
# Conflicts:
#	pyhap/accessory_driver.py
#	pyhap/camera.py
#	tests/test_accessory_driver.py
#	tests/test_camera.py
The Camera Multi-Tier RTP Stream Management service of the HomeKit Secure
Video 17.99 guide advertises video and audio stream tiers, sets up a
session with Setup Endpoints and starts and ends streams through RTP
Streaming Control.

Add the video and audio stream tiers and their Supported Video/Audio
Stream Tiers values, the decoder for RTP Streaming Control writes and the
encoder for its read back status, the Setup Endpoints request and answer,
and tiers_from_sensor() to offer the video encodings of a sensor as
tiers. Unknown commands are kept as plain ints, like the other enums of
the module.

Tiers are frozen. A video tier also carries the peak bit rate of its
encoding, which is not part of the TLV. The payload types 99 and 110 are
constants, so the RTP tiers, the WebRTC tiers and a WebRTC offer agree.

check_audio_tier() and check_video_tiers() hold the tiers against the
guide: 20 ms mono Opus, at least one video encoding, known qualities,
Highest only with High and a peak not below the average. A gap of more
than 10 % of the peak and peaks above the guide's bit rate table only
warn, the guide recommends them. A frame between two rows of the table
takes the larger one, and a frame above 4K warns.

The module docstring now says the API is experimental.
RaHehl and others added 28 commits November 10, 2026 14:33
A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.
An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.
- A start keeps the setup timer from firing and restores the controller
  SSRCs if it fails.
- A reoffer answer may only carry an SFrame key for a session with SFrame.
- The ffmpeg delegate sends audio from a free port instead of the
  controller's.
- Delegates are told how sessions stay alive: keep_alive for multi-tier,
  session.end() on media failure for WebRTC.
- Client addresses are typed, the shared stream helpers are internal, and
  a session of another connection is logged at debug level.
# Conflicts:
#	pyhap/characteristic.py
A write asks for a response only with "r": true.
A subclass that still sets the Setup Endpoints answer as the value made
it readable for every controller. Whatever the setter of a read back
characteristic leaves in the value now goes to the writer's store and
the former value comes back. A plain write to another write response
characteristic stays readable for everyone, as in ikalchev#535. The read back
test now checks the answer's status instead of the session ID.
…None

An IPv6 address in another notation, e.g. upper case or without ::, is
accepted in its canonical form instead of being refused. Only a scope ID,
the wrong IP version or an invalid address is refused. set_endpoints
sets the refusal as the value and returns nothing, as before.
It set a new default executor on the shared test loop and shut it down
when the driver stopped, so later tests on the same xdist worker failed
with "cannot schedule new futures after shutdown". The driver now makes
its own loop and executor, as a sync start does.
# Conflicts:
#	pyhap/accessory_driver.py
#	tests/test_accessory_driver.py
# Conflicts:
#	pyhap/accessory_driver.py
#	tests/test_camera.py
# Conflicts:
#	pyhap/camera.py
#	pyhap/hksv3.py
#	tests/test_camera.py
#	tests/test_camera_operating_mode.py
#	tests/test_hksv3.py
# Conflicts:
#	pyhap/camera.py
#	tests/test_camera.py
…connection

# Conflicts:
#	pyhap/camera.py
#	tests/test_accessory_driver.py
#	tests/test_camera.py
# Conflicts:
#	pyhap/camera.py
#	tests/test_camera.py
A subclass that wraps set_endpoints, calls super() and returns nothing
got no answer once set_endpoints only returned it. set_endpoints sets
the answer, or a refusal, as the value again and returns it. The driver
keeps what a read back setter leaves as the value for the writer only
and puts the shared value back. Neither the former value of a refused
write nor the request a setter left alone counts as an answer. An async
setter must return the answer, a value it sets itself is shared.

Correct the write response docstring for a write without r, type the
Setup Endpoints helpers, document read_back_response and share the
Setup Endpoints request of the camera tests.
@ikalchev
ikalchev merged commit 8f82314 into ikalchev:dev Nov 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants