Skip to content

Enforce the pr, pw, ev and wr permissions - #535

Merged
ikalchev merged 10 commits into
ikalchev:devfrom
RaHehl:enforce-permissions
Nov 10, 2026
Merged

ikalchev merged 10 commits into
ikalchev:devfrom
RaHehl:enforce-permissions

Conversation

@RaHehl

@RaHehl RaHehl commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

GET /characteristics returned the value of every characteristic, including write-only ones like Identify. With Camera Key from the HomeKit Secure Video guide (#531), any paired controller could read a key another controller wrote. Without pr a read now answers -70405 and the getter is not called. The /accessories database already left these values out; there are tests for it now.

Subscribing without ev answers -70406 instead of subscribing, and writing a value without pw answers -70404 instead of calling the setter. A 207 now also lists the subscriptions that went through.

A write response ("r": true) is only sent for characteristics with wr. Without pr the response becomes the stored value. With pr, only that connection can read it back until it closes, and its next write drops it. Other controllers and /accessories keep reading the characteristic's own value, which the write leaves as it was, also when the setter fails or answers nothing, and ev subscribers are not notified of the request. Such writes can carry SDP and stream keys. HAP-NodeJS does these checks too, but lets every controller read the last write response. Where our catalog's pr/pw/ev flags differ from HAP-NodeJS, ours are the more permissive ones, so for the catalog characteristics nothing iOS did before gets refused.

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

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 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.
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.72131% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.45%. Comparing base (08b7456) to head (2e00188).

Files with missing lines Patch % Lines
pyhap/accessory_driver.py 96.61% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #535      +/-   ##
==========================================
+ Coverage   90.38%   90.45%   +0.06%     
==========================================
  Files          26       26              
  Lines        3798     3844      +46     
  Branches      459      469      +10     
==========================================
+ Hits         3433     3477      +44     
- Misses        258      260       +2     
  Partials      107      107              
Files with missing lines Coverage Δ
pyhap/characteristic.py 97.85% <100.00%> (+<0.01%) ⬆️
pyhap/hap_handler.py 85.84% <ø> (ø)
pyhap/accessory_driver.py 92.81% <96.61%> (+0.24%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

@ikalchev ikalchev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome! Some minor comments and questions from my side.

Comment thread pyhap/accessory_driver.py Resolved
Comment thread pyhap/accessory_driver.py Resolved
Comment thread pyhap/accessory_driver.py Resolved
Comment thread pyhap/accessory_driver.py Resolved
Comment thread pyhap/accessory_driver.py Resolved
@RaHehl

RaHehl commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All five points are addressed, and dev is merged in.

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.
RaHehl added a commit to RaHehl/HAP-python that referenced this pull request Nov 10, 2026
ikalchev#535 adds client_addr as the second argument of get_characteristics,
so a positional client_uuid could end up as the address once both are
in. Also note why privileges are checked before availability.
@ikalchev
ikalchev merged commit 0e85dcc into ikalchev:dev Nov 10, 2026
7 checks passed
RaHehl added a commit to RaHehl/HAP-python that referenced this pull request Nov 10, 2026
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.
ikalchev pushed a commit that referenced this pull request Nov 10, 2026
* Add HKSV 17.99 camera services and characteristics to the catalog

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.

* Fix the camera operating mode characteristic permissions

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.

* Enforce admin-only characteristic access

The catalog can mark a characteristic with "AdminOnlyAccess" (Motion
Enabled and Streaming Enabled list "write"), but nothing checked it.

Pass the controller of the session from the HAP handler to the driver
and refuse a write, or a read when the list contains "read", with
-70401 unless the controller is a paired admin. A request without a
known controller is refused too. This follows adminOnlyAccess in
HAP-NodeJS. Characteristics without the property are not affected.

* Pass client_uuid by keyword only

#535 adds client_addr as the second argument of get_characteristics,
so a positional client_uuid could end up as the address once both are
in. Also note why privileges are checked before availability.

* Keep only the admin-only test in the loader tests

The catalog tests came with the old catalog commits and are not about
admin-only access.

* Enforce AdminOnlyAccess for writes only

* Refuse an explicit null from a regular controller before any setter

Also say in the catalog generator that the driver enforces admin-only
writes.
ikalchev added a commit that referenced this pull request Nov 10, 2026
* Return -70405 when reading a characteristic without paired read

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.

* Answer -70406 for event subscriptions without notify permission

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.

* Return -70404 when writing a characteristic without paired write

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.

* Ignore timed writes with an unknown or expired pid

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.

* Split building the characteristics write response into helpers

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.

* Only send write responses for characteristics with wr permission

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.

* Check the timed write permission on writes

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.

* Await async characteristic setters before answering the write

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.

* Only let the writing connection read back a write response

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.

* Test write response privacy with async setters

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.

* Drop the redundant bool() around the write response check

* Type the write response helpers with the HAP JSON value types

* Explain why a private write puts the former value back

* Keep the Setup Endpoints answer per connection

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.

* Don't tell subscribers the request of a write with r

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.

* Type the timed write pid and ignore a pid that is not an int

A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.

* Name the fields of a pending async write and keep write_response a bool

A write asks for a response only with "r": true.

* Keep read back answers private, also when the setter sets them

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 #535. The read back
test now checks the answer's status instead of the session ID.

* Run test_start_from_sync on the driver's own loop

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.

* Answer a Setup Endpoints refused while streaming is off only to the writer

* Keep setting the Setup Endpoints answer as the value

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.

* Use incomaptible-method-override instead of invalid-

ty was upgraded today

---------

Co-authored-by: Ivan Kalchev <25887324+ikalchev@users.noreply.github.com>
ikalchev added a commit that referenced this pull request Nov 10, 2026
* Add HKSV 17.99 camera services and characteristics to the catalog

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.

* Fix the camera operating mode characteristic permissions

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 TLV8 codecs for the HKSV 17.99 camera values

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.

* Add HKSV 17.99 camera capabilities and motion zones

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.

* Add the HKSV 17.99 camera global operating mode

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.

* Return -70405 when reading a characteristic without paired read

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.

* Answer -70406 for event subscriptions without notify permission

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.

* Return -70404 when writing a characteristic without paired write

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.

* Ignore timed writes with an unknown or expired pid

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.

* Split building the characteristics write response into helpers

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.

* Only send write responses for characteristics with wr permission

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.

* Check the timed write permission on writes

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.

* Await async characteristic setters before answering the write

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.

* Only let the writing connection read back a write response

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.

* Test write response privacy with async setters

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.

* Drop the redundant bool() around the write response check

* Type the write response helpers with the HAP JSON value types

* Explain why a private write puts the former value back

* Tell accessories when a controller's connection closes

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.

* Accept only a canonical IP address in Setup Endpoints

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.

* Keep the Setup Endpoints answer per connection

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.

* Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management

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.

* Add TLV8 codecs for the HKSV 17.99 WebRTC stream management

The Camera WebRTC Stream Management service of the HomeKit Secure Video
17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through
write response characteristics: the accessory offers, the controller
answers, renegotiates, updates SFrame keys and ends the session.

Add the offer options, ICE candidates and SFrame key data, the requests
and responses of each characteristic and their status enums. Each type
encodes and decodes, so tests can play the controller.

WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend
returns. With IceCandidate and SFrameKey they are the types the HAP side
and the media share, all frozen. The offer's SDP carries the ICE
candidates, so the Solicit Offer answer lists none, as in the Scrypted
HomeKit plugin. generate_sframe_key() makes a key to send with.

An answer or reoffer with an SDP above 32 KiB, a write with more than
64 ICE candidates or one above 1 KiB, and an Update Session with more
than 16 keys to add or to remove do not decode, so the media side never
sees one.

* Add the session state machine of the HKSV 17.99 stream services

The multi-tier RTP and WebRTC stream services of the HomeKit Secure
Video 17.99 guide keep sessions that a controller sets up and the camera
streams through a delegate. Both need the same lifecycle, so it lives in
one place, pyhap.camera_stream, which the services in the next changes
build on.

A session goes from new to prepared or solicited, through answering for
WebRTC, to started or active, and ends from any state. Only the
connection that set it up may control it, and no two sessions of a
controller share an ID. A controller has a limit of sessions in all and
per connection.

A delegate hook runs as a task the session owns. The HAP request waits
for it until one deadline, hook_timeout after the write, which must leave
a second of the 9 second HAP response timeout. A hook that is still
running then is cancelled and its session ends. Each session the
delegate saw is released exactly once, in the background and after its
cancelled hooks finished, or after hook_timeout if one ignores its
cancellation. So a timeout never waits for a second hook inside the
request. Timers re-check the state before they end a session.

A delegate fails a request with StreamError(status) and ends a session
on its own with session.end(reason). It keeps its state by session
object, since a new session may reuse the ID of one still being
released. The integrator ends sessions with end_session() and
end_all_sessions(). close() ends everything, waits for the background
tasks and takes no new session afterwards.

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

A controller that streams through the Camera Multi-Tier RTP Stream
Management service of the HomeKit Secure Video 17.99 guide does not fall
back to the RTP stream management service for remote viewing, which
needs the Camera WebRTC Stream Management service. So both come together
or not at all.

The new multi_tier and webrtc options add a MultiTierController and a
WebRTCController per sensor, in the spirit of the SecureVideoController
of HAP-NodeJS #1132. Camera composes them. The options take a
MultiTierConfig and a WebRTCConfig, or dicts of their fields. Unknown
keys raise. Both need the sensors, global_operating_mode, recording,
video and audio options. iOS 27 shows a camera without the classic RTP
stream management service as not responding. The services link to each
other and to the data stream transport.

The multi-tier service offers the sensor's video encodings as H.265
tiers and exactly one Opus tier, with the payload types 99 and 110. The
WebRTC service offers the same tiers and picks the one a remote offer
advertises, by default the Medium one. The tiers are checked against
the guide. Status Active follows the global operating mode and the
service's Streaming Enabled, which survives a restart, and the sessions
of a service end when it may no longer stream.

Each service has at least the sessions the guide requires, five and six.
max_sessions_per_connection limits one connection: three by default for
multi-tier, and all of them for WebRTC, since remote viewers share the
home hub's connection.

The media comes from typed delegates, MultiTierDelegate and
WebRTCDelegate, which release a session in handle_end(). The sessions
follow in the next changes. Camera ends the sessions of a closed
connection, stops them with the camera and has end_session() and
end_all_sessions() for the integrator.

* Set up, start and end HKSV 17.99 multi-tier RTP sessions

A controller sets up a multi-tier session with Setup Endpoints and
starts, changes or ends its stream with RTP Streaming Control.

Setup Endpoints:
- Only the canonical address of the announced IP version is accepted,
  and only AES_CM_128_HMAC_SHA1_80 keys. A write that does not decode,
  and one while streaming is not allowed, without a delegate or for a
  session of another connection, answers an error. Beyond max_sessions,
  or max_sessions_per_connection for the writing connection, it is busy.
- The session is registered before the delegate prepares it, so a closed
  connection or streaming turned off ends it too. A failed or slow
  prepare answers an error and the delegate still releases the session.
- The answer has the accessory's address on the controller's
  connection, in the controller's IP version, as in HAP-NodeJS #1132.
  The delegate may change it. The driver's new local_address() finds it.
  Only the writing connection reads the answer back.
- Setting a session up again releases the former one first. If that
  release fails or overruns the deadline, streaming turned off, or
  another write set the same ID up or took the room meanwhile, it
  answers an error or busy.

RTP Streaming Control:
- Only the connection that set the session up controls it.
- A start picks advertised tiers and calls handle_start(session,
  video_tier, audio_tier), the audio tier is None for video only. A
  start of a started session calls handle_reconfigure(), which keeps the
  SSRCs and SRTP keys of the running stream.
- An end releases the session once.

A session that is not started within setup_timeout (30 s) ends. A
started session ends after idle_timeout (30 s) unless the delegate calls
session.keep_alive(). idle_timeout=None turns that off.
open_rtcp_monitor() does that for the controller's RTCP, as
homebridge-camera-ffmpeg does: it binds a UDP port, announces it as the
accessory's video port and keeps the session alive for each datagram
from the controller's address. A former session with the same ID keeps
nothing alive.

* Stream HKSV 17.99 multi-tier sessions with ffmpeg

FFmpegMultiTierDelegate streams each session with its own ffmpeg
process. Pass it as the delegate of the multi_tier option, with the
input arguments, and audio=False for an input without audio.

The command is an argument list, ffmpeg, the input arguments and the
outputs, which no shell sees and nothing splits. It copies the video
and encodes 16 kHz mono Opus in 20 ms frames, which ffmpeg sends with
the 48 kHz RTP clock, with the session's SSRCs, keys and the payload
types 99 and 110. A start without an audio tier has no audio output.

A tier change keeps the process as it is, without adapting the stream:
a copied stream cannot change its tier, and a new process with the same
SSRC and SRTP key would repeat the keystream.

ffmpeg does not report the controller's RTCP. So the delegate opens an
RTCP monitor in prepare, announces its port as the video port and lets
ffmpeg send video from a free port, with its RTCP to the controller's
video port. The controller's RTCP keeps the session alive, without it the
session ends after idle_timeout. The session also ends when ffmpeg exits
on its own.

The SRTP keys are not logged, and URLs in the logged command and in
ffmpeg's output keep only their scheme, host and port.

* Solicit, answer and end HKSV 17.99 WebRTC sessions

A controller views remotely through the Camera WebRTC Stream Management
service: it solicits an offer, provides its answer and ends the session
with WebRTC Streaming Control (guide chapter 5).

- Solicit Offer: no offer while the camera is off (privacy mode), while
  streaming is not allowed, without a delegate, beyond max_sessions or
  beyond max_sessions_per_connection for the writing connection. The
  delegate gets the session and the tiers the offer advertises. SFrame
  is used unless the controller turned it off explicitly. The Scrypted
  HomeKit plugin found remote viewing needs it when the option is left
  out. The offer must carry an SFrame key exactly then. The SDP carries
  the ICE candidates, the answer lists none.
- Provide Answer: only from the soliciting connection and only once. A
  second answer while the first is applied is busy, one after it an
  error. An answer above the SDP or candidate limit does not decode and
  answers an error before the delegate sees it. Afterwards the session
  counts in Number of Active Sessions.
- A session without an applied answer ends after answer_timeout (30 s).
  The timer stops only once the answer applied and checks the state
  again when it fires.
- A failed or slow offer or answer ends the session. The delegate's
  handle_end() releases every session it saw exactly once, in the
  background, so a timeout never waits for it inside the HAP response
  time.

The answers are write responses. A test through the driver checks that
only the writing connection reads them back.

* Renegotiate HKSV 17.99 WebRTC sessions and update their keys

An active WebRTC session can be renegotiated with WebRTC Reoffer: the
controller offers, the delegate's handle_reoffer() answers, optionally
with a new SFrame key. WebRTC Update Session hands the delegate the
SFrame keys the controller adds and the key IDs it removes.

Both need an active session of the writing connection. A request while
another hook of the session runs answers busy. A failure answers its
status and keeps the session, a hook that takes too long ends it. A
reoffer above the SDP limit answers an error before the delegate sees
it.

* Keep reading ffmpeg's output in chunks so status lines can't block it

ffmpeg ends its status line with a carriage return. Reading stderr line by
line failed after 64 KiB without a newline, nobody drained the pipe any
more and ffmpeg blocked. The delegate now starts ffmpeg without banner and
status, reads its output in chunks and splits it on both line ends. A
failing reader no longer keeps the watcher from ending the session.

* Don't tell subscribers the request of a write with r

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.

* Type the timed write pid and ignore a pid that is not an int

A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.

* Bound the SFrame keys and SDP m-line indexes controllers send

An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.

* Tighten multi-tier and WebRTC session handling

- 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.

* Name the fields of a pending async write and keep write_response a bool

A write asks for a response only with "r": true.

* Keep read back answers private, also when the setter sets them

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 #535. The read back
test now checks the answer's status instead of the session ID.

* Test a closed connection without an accessory

* Normalize Setup Endpoints addresses and keep set_endpoints returning 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.

* Run test_start_from_sync on the driver's own loop

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.

* Answer a Setup Endpoints refused while streaming is off only to the writer

* Test that a refused Setup Endpoints reaches only the writer

* Decode the stream service UUIDs with UUID(bytes=...) like the zones

* Keep setting the Setup Endpoints answer as the value

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.

---------

Co-authored-by: Ivan Kalchev <25887324+ikalchev@users.noreply.github.com>
ikalchev pushed a commit that referenced this pull request Nov 11, 2026
* Add HKSV 17.99 camera services and characteristics to the catalog

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.

* Fix the camera operating mode characteristic permissions

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 TLV8 codecs for the HKSV 17.99 camera values

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.

* Add HKSV 17.99 camera capabilities and motion zones

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.

* Add the HKSV 17.99 camera global operating mode

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.

* Return -70405 when reading a characteristic without paired read

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.

* Answer -70406 for event subscriptions without notify permission

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.

* Return -70404 when writing a characteristic without paired write

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.

* Ignore timed writes with an unknown or expired pid

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.

* Split building the characteristics write response into helpers

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.

* Only send write responses for characteristics with wr permission

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.

* Check the timed write permission on writes

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.

* Await async characteristic setters before answering the write

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.

* Only let the writing connection read back a write response

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.

* Test write response privacy with async setters

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.

* Drop the redundant bool() around the write response check

* Type the write response helpers with the HAP JSON value types

* Explain why a private write puts the former value back

* Tell accessories when a controller's connection closes

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.

* Accept only a canonical IP address in Setup Endpoints

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.

* Keep the Setup Endpoints answer per connection

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.

* Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management

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.

* Add TLV8 codecs for the HKSV 17.99 WebRTC stream management

The Camera WebRTC Stream Management service of the HomeKit Secure Video
17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through
write response characteristics: the accessory offers, the controller
answers, renegotiates, updates SFrame keys and ends the session.

Add the offer options, ICE candidates and SFrame key data, the requests
and responses of each characteristic and their status enums. Each type
encodes and decodes, so tests can play the controller.

WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend
returns. With IceCandidate and SFrameKey they are the types the HAP side
and the media share, all frozen. The offer's SDP carries the ICE
candidates, so the Solicit Offer answer lists none, as in the Scrypted
HomeKit plugin. generate_sframe_key() makes a key to send with.

An answer or reoffer with an SDP above 32 KiB, a write with more than
64 ICE candidates or one above 1 KiB, and an Update Session with more
than 16 keys to add or to remove do not decode, so the media side never
sees one.

* Add the session state machine of the HKSV 17.99 stream services

The multi-tier RTP and WebRTC stream services of the HomeKit Secure
Video 17.99 guide keep sessions that a controller sets up and the camera
streams through a delegate. Both need the same lifecycle, so it lives in
one place, pyhap.camera_stream, which the services in the next changes
build on.

A session goes from new to prepared or solicited, through answering for
WebRTC, to started or active, and ends from any state. Only the
connection that set it up may control it, and no two sessions of a
controller share an ID. A controller has a limit of sessions in all and
per connection.

A delegate hook runs as a task the session owns. The HAP request waits
for it until one deadline, hook_timeout after the write, which must leave
a second of the 9 second HAP response timeout. A hook that is still
running then is cancelled and its session ends. Each session the
delegate saw is released exactly once, in the background and after its
cancelled hooks finished, or after hook_timeout if one ignores its
cancellation. So a timeout never waits for a second hook inside the
request. Timers re-check the state before they end a session.

A delegate fails a request with StreamError(status) and ends a session
on its own with session.end(reason). It keeps its state by session
object, since a new session may reuse the ID of one still being
released. The integrator ends sessions with end_session() and
end_all_sessions(). close() ends everything, waits for the background
tasks and takes no new session afterwards.

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

A controller that streams through the Camera Multi-Tier RTP Stream
Management service of the HomeKit Secure Video 17.99 guide does not fall
back to the RTP stream management service for remote viewing, which
needs the Camera WebRTC Stream Management service. So both come together
or not at all.

The new multi_tier and webrtc options add a MultiTierController and a
WebRTCController per sensor, in the spirit of the SecureVideoController
of HAP-NodeJS #1132. Camera composes them. The options take a
MultiTierConfig and a WebRTCConfig, or dicts of their fields. Unknown
keys raise. Both need the sensors, global_operating_mode, recording,
video and audio options. iOS 27 shows a camera without the classic RTP
stream management service as not responding. The services link to each
other and to the data stream transport.

The multi-tier service offers the sensor's video encodings as H.265
tiers and exactly one Opus tier, with the payload types 99 and 110. The
WebRTC service offers the same tiers and picks the one a remote offer
advertises, by default the Medium one. The tiers are checked against
the guide. Status Active follows the global operating mode and the
service's Streaming Enabled, which survives a restart, and the sessions
of a service end when it may no longer stream.

Each service has at least the sessions the guide requires, five and six.
max_sessions_per_connection limits one connection: three by default for
multi-tier, and all of them for WebRTC, since remote viewers share the
home hub's connection.

The media comes from typed delegates, MultiTierDelegate and
WebRTCDelegate, which release a session in handle_end(). The sessions
follow in the next changes. Camera ends the sessions of a closed
connection, stops them with the camera and has end_session() and
end_all_sessions() for the integrator.

* Set up, start and end HKSV 17.99 multi-tier RTP sessions

A controller sets up a multi-tier session with Setup Endpoints and
starts, changes or ends its stream with RTP Streaming Control.

Setup Endpoints:
- Only the canonical address of the announced IP version is accepted,
  and only AES_CM_128_HMAC_SHA1_80 keys. A write that does not decode,
  and one while streaming is not allowed, without a delegate or for a
  session of another connection, answers an error. Beyond max_sessions,
  or max_sessions_per_connection for the writing connection, it is busy.
- The session is registered before the delegate prepares it, so a closed
  connection or streaming turned off ends it too. A failed or slow
  prepare answers an error and the delegate still releases the session.
- The answer has the accessory's address on the controller's
  connection, in the controller's IP version, as in HAP-NodeJS #1132.
  The delegate may change it. The driver's new local_address() finds it.
  Only the writing connection reads the answer back.
- Setting a session up again releases the former one first. If that
  release fails or overruns the deadline, streaming turned off, or
  another write set the same ID up or took the room meanwhile, it
  answers an error or busy.

RTP Streaming Control:
- Only the connection that set the session up controls it.
- A start picks advertised tiers and calls handle_start(session,
  video_tier, audio_tier), the audio tier is None for video only. A
  start of a started session calls handle_reconfigure(), which keeps the
  SSRCs and SRTP keys of the running stream.
- An end releases the session once.

A session that is not started within setup_timeout (30 s) ends. A
started session ends after idle_timeout (30 s) unless the delegate calls
session.keep_alive(). idle_timeout=None turns that off.
open_rtcp_monitor() does that for the controller's RTCP, as
homebridge-camera-ffmpeg does: it binds a UDP port, announces it as the
accessory's video port and keeps the session alive for each datagram
from the controller's address. A former session with the same ID keeps
nothing alive.

* Stream HKSV 17.99 multi-tier sessions with ffmpeg

FFmpegMultiTierDelegate streams each session with its own ffmpeg
process. Pass it as the delegate of the multi_tier option, with the
input arguments, and audio=False for an input without audio.

The command is an argument list, ffmpeg, the input arguments and the
outputs, which no shell sees and nothing splits. It copies the video
and encodes 16 kHz mono Opus in 20 ms frames, which ffmpeg sends with
the 48 kHz RTP clock, with the session's SSRCs, keys and the payload
types 99 and 110. A start without an audio tier has no audio output.

A tier change keeps the process as it is, without adapting the stream:
a copied stream cannot change its tier, and a new process with the same
SSRC and SRTP key would repeat the keystream.

ffmpeg does not report the controller's RTCP. So the delegate opens an
RTCP monitor in prepare, announces its port as the video port and lets
ffmpeg send video from a free port, with its RTCP to the controller's
video port. The controller's RTCP keeps the session alive, without it the
session ends after idle_timeout. The session also ends when ffmpeg exits
on its own.

The SRTP keys are not logged, and URLs in the logged command and in
ffmpeg's output keep only their scheme, host and port.

* Solicit, answer and end HKSV 17.99 WebRTC sessions

A controller views remotely through the Camera WebRTC Stream Management
service: it solicits an offer, provides its answer and ends the session
with WebRTC Streaming Control (guide chapter 5).

- Solicit Offer: no offer while the camera is off (privacy mode), while
  streaming is not allowed, without a delegate, beyond max_sessions or
  beyond max_sessions_per_connection for the writing connection. The
  delegate gets the session and the tiers the offer advertises. SFrame
  is used unless the controller turned it off explicitly. The Scrypted
  HomeKit plugin found remote viewing needs it when the option is left
  out. The offer must carry an SFrame key exactly then. The SDP carries
  the ICE candidates, the answer lists none.
- Provide Answer: only from the soliciting connection and only once. A
  second answer while the first is applied is busy, one after it an
  error. An answer above the SDP or candidate limit does not decode and
  answers an error before the delegate sees it. Afterwards the session
  counts in Number of Active Sessions.
- A session without an applied answer ends after answer_timeout (30 s).
  The timer stops only once the answer applied and checks the state
  again when it fires.
- A failed or slow offer or answer ends the session. The delegate's
  handle_end() releases every session it saw exactly once, in the
  background, so a timeout never waits for it inside the HAP response
  time.

The answers are write responses. A test through the driver checks that
only the writing connection reads them back.

* Renegotiate HKSV 17.99 WebRTC sessions and update their keys

An active WebRTC session can be renegotiated with WebRTC Reoffer: the
controller offers, the delegate's handle_reoffer() answers, optionally
with a new SFrame key. WebRTC Update Session hands the delegate the
SFrame keys the controller adds and the key IDs it removes.

Both need an active session of the writing connection. A request while
another hook of the session runs answers busy. A failure answers its
status and keeps the session, a hook that takes too long ends it. A
reoffer above the SDP limit answers an error before the delegate sees
it.

* Keep reading ffmpeg's output in chunks so status lines can't block it

ffmpeg ends its status line with a carriage return. Reading stderr line by
line failed after 64 KiB without a newline, nobody drained the pipe any
more and ffmpeg blocked. The delegate now starts ffmpeg without banner and
status, reads its output in chunks and splits it on both line ends. A
failing reader no longer keeps the watcher from ending the session.

* Don't tell subscribers the request of a write with r

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.

* Type the timed write pid and ignore a pid that is not an int

A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.

* Bound the SFrame keys and SDP m-line indexes controllers send

An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.

* Tighten multi-tier and WebRTC session handling

- 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.

* Name the fields of a pending async write and keep write_response a bool

A write asks for a response only with "r": true.

* Keep read back answers private, also when the setter sets them

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 #535. The read back
test now checks the answer's status instead of the session ID.

* Test a closed connection without an accessory

* Normalize Setup Endpoints addresses and keep set_endpoints returning 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.

* Run test_start_from_sync on the driver's own loop

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.

* Answer a Setup Endpoints refused while streaming is off only to the writer

* Test that a refused Setup Endpoints reaches only the writer

* Decode the stream service UUIDs with UUID(bytes=...) like the zones

* Keep setting the Setup Endpoints answer as the value

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.

* Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management

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.

* Add TLV8 codecs for the HKSV 17.99 WebRTC stream management

The Camera WebRTC Stream Management service of the HomeKit Secure Video
17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through
write response characteristics: the accessory offers, the controller
answers, renegotiates, updates SFrame keys and ends the session.

Add the offer options, ICE candidates and SFrame key data, the requests
and responses of each characteristic and their status enums. Each type
encodes and decodes, so tests can play the controller.

WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend
returns. With IceCandidate and SFrameKey they are the types the HAP side
and the media share, all frozen. The offer's SDP carries the ICE
candidates, so the Solicit Offer answer lists none, as in the Scrypted
HomeKit plugin. generate_sframe_key() makes a key to send with.

An answer or reoffer with an SDP above 32 KiB, a write with more than
64 ICE candidates or one above 1 KiB, and an Update Session with more
than 16 keys to add or to remove do not decode, so the media side never
sees one.

* Bound the SFrame keys and SDP m-line indexes controllers send

An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.

* Decode the stream service UUIDs with UUID(bytes=...) like the zones

* Leave the ffmpeg reference delegate out of the stream services

FFmpegMultiTierDelegate, RtcpMonitor and open_rtcp_monitor are one way to
send the media, not part of the HAP side. The controllers only need a
MultiTierDelegate, so the reference delegate, its URL redaction and its
tests leave this change.

* Keep the public API of the stream services small

Camera.end_session() and Camera.end_all_sessions() go, a delegate ends a
session with StreamSession.end() and the controllers keep their own. The
tier encoders, remote_tier() and MultiTierController.keep_alive() become
private, MultiTierSession.keep_alive() stays. The helpers the stream
services share lose their underscore instead of being imported across
modules, and both services name their status enum _STATUS.

* Keep sessions counted until released and stream requests private

A session ended before it started, by a disconnect or by a timer was
dropped from the limits at once, while its release still ran. Many set
ups that end before they start could so run more releases than the caps
allow. Ended sessions now count until their release finished.

The WebRTC control points and RTP Streaming Control did not read their
answer back per connection: a write without r left the request, with
ICE passwords, SFrame keys or SSRCs, as the value every connection reads.
They now keep it like Setup Endpoints.

A hook that cancels itself fails and ends its session when a failure
should. The exception of a hook whose session ended meanwhile is
retrieved. A release that ignores its cancellation no longer holds the
release or Camera.stop(): it is given up after hook_timeout, and close()
gives up on tasks after three hook timeouts. A stopped camera answers
Setup Endpoints with Error instead of Busy. The multi-tier default of
three sessions per connection is documented, and the WebRTC delegate
docs say what a closed hub connection ends.

* Reject SSRCs above 32 bits and match guide bit rates by pixel count

* Answer End once a failed release finished and check delegate results

* Hold frames above 4K to the guide's 4K bit rates and test the largest SSRC

* Check the address a prepare hook sets against the session's family

* Answer Error when the reoffer hook returns no WebRTCReofferAnswer

* Warn about release errors raised after the release timed out

* Reject scoped and IPv4-mapped accessory addresses
ikalchev added a commit that referenced this pull request Nov 11, 2026
* Add HKSV 17.99 camera services and characteristics to the catalog

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.

* Fix the camera operating mode characteristic permissions

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 TLV8 codecs for the HKSV 17.99 camera values

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.

* Add HKSV 17.99 camera capabilities and motion zones

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.

* Add the HKSV 17.99 camera global operating mode

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.

* Don't fail stop_stream when ffmpeg already exited

terminate() raises ProcessLookupError on an exited process, which surfaced as an
unretrieved task exception. Ignore it and still reap the process; same for kill().

* Return -70405 when reading a characteristic without paired read

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.

* Answer -70406 for event subscriptions without notify permission

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.

* Return -70404 when writing a characteristic without paired write

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.

* Ignore timed writes with an unknown or expired pid

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.

* Split building the characteristics write response into helpers

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.

* Only send write responses for characteristics with wr permission

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.

* Check the timed write permission on writes

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.

* Fix tlv.encode for values of 510, 765, ... bytes

The last fragment was sliced with data[-0:], which duplicated the whole value
and produced undecodable output. Only emit a trailing fragment when bytes remain.

* Await async characteristic setters before answering the write

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.

* Only let the writing connection read back a write response

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.

* Test write response privacy with async setters

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.

* Drop the redundant bool() around the write response check

* Type the write response helpers with the HAP JSON value types

* Explain why a private write puts the former value back

* Require Python 3.11

Python 3.10 reached its end of life in October 2026. asyncio.timeout
replaces the async_timeout backport.

* Tell accessories when a controller's connection closes

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.

* Accept only a canonical IP address in Setup Endpoints

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.

* Keep the Setup Endpoints answer per connection

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.

* Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management

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.

* Add TLV8 codecs for the HKSV 17.99 WebRTC stream management

The Camera WebRTC Stream Management service of the HomeKit Secure Video
17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through
write response characteristics: the accessory offers, the controller
answers, renegotiates, updates SFrame keys and ends the session.

Add the offer options, ICE candidates and SFrame key data, the requests
and responses of each characteristic and their status enums. Each type
encodes and decodes, so tests can play the controller.

WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend
returns. With IceCandidate and SFrameKey they are the types the HAP side
and the media share, all frozen. The offer's SDP carries the ICE
candidates, so the Solicit Offer answer lists none, as in the Scrypted
HomeKit plugin. generate_sframe_key() makes a key to send with.

An answer or reoffer with an SDP above 32 KiB, a write with more than
64 ICE candidates or one above 1 KiB, and an Update Session with more
than 16 keys to add or to remove do not decode, so the media side never
sees one.

* Add the session state machine of the HKSV 17.99 stream services

The multi-tier RTP and WebRTC stream services of the HomeKit Secure
Video 17.99 guide keep sessions that a controller sets up and the camera
streams through a delegate. Both need the same lifecycle, so it lives in
one place, pyhap.camera_stream, which the services in the next changes
build on.

A session goes from new to prepared or solicited, through answering for
WebRTC, to started or active, and ends from any state. Only the
connection that set it up may control it, and no two sessions of a
controller share an ID. A controller has a limit of sessions in all and
per connection.

A delegate hook runs as a task the session owns. The HAP request waits
for it until one deadline, hook_timeout after the write, which must leave
a second of the 9 second HAP response timeout. A hook that is still
running then is cancelled and its session ends. Each session the
delegate saw is released exactly once, in the background and after its
cancelled hooks finished, or after hook_timeout if one ignores its
cancellation. So a timeout never waits for a second hook inside the
request. Timers re-check the state before they end a session.

A delegate fails a request with StreamError(status) and ends a session
on its own with session.end(reason). It keeps its state by session
object, since a new session may reuse the ID of one still being
released. The integrator ends sessions with end_session() and
end_all_sessions(). close() ends everything, waits for the background
tasks and takes no new session afterwards.

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

A controller that streams through the Camera Multi-Tier RTP Stream
Management service of the HomeKit Secure Video 17.99 guide does not fall
back to the RTP stream management service for remote viewing, which
needs the Camera WebRTC Stream Management service. So both come together
or not at all.

The new multi_tier and webrtc options add a MultiTierController and a
WebRTCController per sensor, in the spirit of the SecureVideoController
of HAP-NodeJS #1132. Camera composes them. The options take a
MultiTierConfig and a WebRTCConfig, or dicts of their fields. Unknown
keys raise. Both need the sensors, global_operating_mode, recording,
video and audio options. iOS 27 shows a camera without the classic RTP
stream management service as not responding. The services link to each
other and to the data stream transport.

The multi-tier service offers the sensor's video encodings as H.265
tiers and exactly one Opus tier, with the payload types 99 and 110. The
WebRTC service offers the same tiers and picks the one a remote offer
advertises, by default the Medium one. The tiers are checked against
the guide. Status Active follows the global operating mode and the
service's Streaming Enabled, which survives a restart, and the sessions
of a service end when it may no longer stream.

Each service has at least the sessions the guide requires, five and six.
max_sessions_per_connection limits one connection: three by default for
multi-tier, and all of them for WebRTC, since remote viewers share the
home hub's connection.

The media comes from typed delegates, MultiTierDelegate and
WebRTCDelegate, which release a session in handle_end(). The sessions
follow in the next changes. Camera ends the sessions of a closed
connection, stops them with the camera and has end_session() and
end_all_sessions() for the integrator.

* Set up, start and end HKSV 17.99 multi-tier RTP sessions

A controller sets up a multi-tier session with Setup Endpoints and
starts, changes or ends its stream with RTP Streaming Control.

Setup Endpoints:
- Only the canonical address of the announced IP version is accepted,
  and only AES_CM_128_HMAC_SHA1_80 keys. A write that does not decode,
  and one while streaming is not allowed, without a delegate or for a
  session of another connection, answers an error. Beyond max_sessions,
  or max_sessions_per_connection for the writing connection, it is busy.
- The session is registered before the delegate prepares it, so a closed
  connection or streaming turned off ends it too. A failed or slow
  prepare answers an error and the delegate still releases the session.
- The answer has the accessory's address on the controller's
  connection, in the controller's IP version, as in HAP-NodeJS #1132.
  The delegate may change it. The driver's new local_address() finds it.
  Only the writing connection reads the answer back.
- Setting a session up again releases the former one first. If that
  release fails or overruns the deadline, streaming turned off, or
  another write set the same ID up or took the room meanwhile, it
  answers an error or busy.

RTP Streaming Control:
- Only the connection that set the session up controls it.
- A start picks advertised tiers and calls handle_start(session,
  video_tier, audio_tier), the audio tier is None for video only. A
  start of a started session calls handle_reconfigure(), which keeps the
  SSRCs and SRTP keys of the running stream.
- An end releases the session once.

A session that is not started within setup_timeout (30 s) ends. A
started session ends after idle_timeout (30 s) unless the delegate calls
session.keep_alive(). idle_timeout=None turns that off.
open_rtcp_monitor() does that for the controller's RTCP, as
homebridge-camera-ffmpeg does: it binds a UDP port, announces it as the
accessory's video port and keeps the session alive for each datagram
from the controller's address. A former session with the same ID keeps
nothing alive.

* Stream HKSV 17.99 multi-tier sessions with ffmpeg

FFmpegMultiTierDelegate streams each session with its own ffmpeg
process. Pass it as the delegate of the multi_tier option, with the
input arguments, and audio=False for an input without audio.

The command is an argument list, ffmpeg, the input arguments and the
outputs, which no shell sees and nothing splits. It copies the video
and encodes 16 kHz mono Opus in 20 ms frames, which ffmpeg sends with
the 48 kHz RTP clock, with the session's SSRCs, keys and the payload
types 99 and 110. A start without an audio tier has no audio output.

A tier change keeps the process as it is, without adapting the stream:
a copied stream cannot change its tier, and a new process with the same
SSRC and SRTP key would repeat the keystream.

ffmpeg does not report the controller's RTCP. So the delegate opens an
RTCP monitor in prepare, announces its port as the video port and lets
ffmpeg send video from a free port, with its RTCP to the controller's
video port. The controller's RTCP keeps the session alive, without it the
session ends after idle_timeout. The session also ends when ffmpeg exits
on its own.

The SRTP keys are not logged, and URLs in the logged command and in
ffmpeg's output keep only their scheme, host and port.

* Solicit, answer and end HKSV 17.99 WebRTC sessions

A controller views remotely through the Camera WebRTC Stream Management
service: it solicits an offer, provides its answer and ends the session
with WebRTC Streaming Control (guide chapter 5).

- Solicit Offer: no offer while the camera is off (privacy mode), while
  streaming is not allowed, without a delegate, beyond max_sessions or
  beyond max_sessions_per_connection for the writing connection. The
  delegate gets the session and the tiers the offer advertises. SFrame
  is used unless the controller turned it off explicitly. The Scrypted
  HomeKit plugin found remote viewing needs it when the option is left
  out. The offer must carry an SFrame key exactly then. The SDP carries
  the ICE candidates, the answer lists none.
- Provide Answer: only from the soliciting connection and only once. A
  second answer while the first is applied is busy, one after it an
  error. An answer above the SDP or candidate limit does not decode and
  answers an error before the delegate sees it. Afterwards the session
  counts in Number of Active Sessions.
- A session without an applied answer ends after answer_timeout (30 s).
  The timer stops only once the answer applied and checks the state
  again when it fires.
- A failed or slow offer or answer ends the session. The delegate's
  handle_end() releases every session it saw exactly once, in the
  background, so a timeout never waits for it inside the HAP response
  time.

The answers are write responses. A test through the driver checks that
only the writing connection reads them back.

* Renegotiate HKSV 17.99 WebRTC sessions and update their keys

An active WebRTC session can be renegotiated with WebRTC Reoffer: the
controller offers, the delegate's handle_reoffer() answers, optionally
with a new SFrame key. WebRTC Update Session hands the delegate the
SFrame keys the controller adds and the key IDs it removes.

Both need an active session of the writing connection. A request while
another hook of the session runs answers busy. A failure answers its
status and keeps the session, a hook that takes too long ends it. A
reoffer above the SDP limit answers an error before the delegate sees
it.

* Make pylibsrtp a dependency

Talkback, multi-tier streams and the media package need SRTP; the
Talkback extra stays as an empty alias for existing installs.

* Add RTP, RTCP, H.265 RTP and SDP helpers for camera media

The building blocks of pyhap.media: RTP and RTCP parsing and building
(RFC 3550, RFC 4585), an SRTP output stream, H.265 RTP depacketizing and
packetizing (RFC 7798) and the SDP and codec parameters of HomeKit
camera streams.

* Add a media source that reassembles a camera channel once

A MediaSource binds the RTP input ports of one camera channel and
reassembles its H.265 once for every session of the channel.

* Share one source and pull per camera channel

SourcePool leases the source of a channel to each session; all sessions
of a channel share one source and one pull. FFmpegSourceProvider is a
reference provider that pulls each channel with one ffmpeg, restarts it
with a backoff and redacts URLs in its log.

* Run media sources in a worker process

MediaWorker keeps the per-packet media work off the caller's event loop:
one process for all cameras, length-prefixed JSON frames over pipes,
forwarded logs and proxies with the async API of the in-process objects.

* Keep reading ffmpeg's output in chunks so status lines can't block it

ffmpeg ends its status line with a carriage return. Reading stderr line by
line failed after 64 KiB without a newline, nobody drained the pipe any
more and ffmpeg blocked. The delegate now starts ffmpeg without banner and
status, reads its output in chunks and splits it on both line ends. A
failing reader no longer keeps the watcher from ending the session.

* Test that stop_stream kills ffmpeg after the timeout

* Don't tell subscribers the request of a write with r

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.

* Type the timed write pid and ignore a pid that is not an int

A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.

* Bound the SFrame keys and SDP m-line indexes controllers send

An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.

* Tighten multi-tier and WebRTC session handling

- 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.

* Name the fields of a pending async write and keep write_response a bool

A write asks for a response only with "r": true.

* Keep the WebRTC SDP out of the media core

Only the WebRTC sender needs SDP, so sdp.py and its tests come with it. The
RTP clock rates and payload types move to rtp.py, which the source and the
senders share. The test helpers are named after the media package.

* Keep read back answers private, also when the setter sets them

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 #535. The read back
test now checks the answer's status instead of the session ID.

* Harden the media core from the review

- A failed first ffmpeg start reaches on_error too.
- The worker drops log records while the parent reads slowly and gives up
  on a parent that stopped reading, instead of buffering without bound.
- A BYE without sources is parsed (RFC 3550 6.6) and does not end the
  compound packet.
- Inserted parameter sets go after an access unit delimiter.
- Closing the pool waits for a lease being opened.
- pylibsrtp gets a lower bound, talkback_available() is deprecated.
- Tests for the pool, a crashed worker and the bounded worker output.

* Test a closed connection without an accessory

* Normalize Setup Endpoints addresses and keep set_endpoints returning 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.

* Run test_start_from_sync on the driver's own loop

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.

* Name Python 3.11 as the minimum in the install guide

* Answer a Setup Endpoints refused while streaming is off only to the writer

* Test that a refused Setup Endpoints reaches only the writer

* Decode the stream service UUIDs with UUID(bytes=...) like the zones

* Keep setting the Setup Endpoints answer as the value

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.

* Run the media worker in a thread on request

MediaWorker(in_thread=True) runs the same worker server in a thread of
the caller's process, with its own event loop, over a socket pair
instead of a child process. The protocol and the proxies stay the same.
Loggers are shared in that mode, so nothing is forwarded and their
levels are left alone. The process stays the default.

* Run the media worker in a thread by default, relay a worker process's logs from stderr

The worker thread shares the loggers, so it needs no relay. A worker
process now talks over a socket pair like the thread and writes its log
records as JSON lines to stderr; the parent logs them with its own
loggers. The log frames in the protocol, their drop buffer and moving
stdout aside are gone, and a crashing interpreter's traceback now shows
up in the parent's log.

* Let a worker process log to its own file

The process writes its records to pyhap-media-<pid>.log in log_dir,
rotated by size, and takes its stderr along, so a crash leaves its
traceback there. The parent no longer reads anything from the child; it
only removes log files that were not written for a week when it starts
a worker process.

---------

Co-authored-by: Ivan Kalchev <25887324+ikalchev@users.noreply.github.com>
ikalchev added a commit that referenced this pull request Nov 11, 2026
* Add HKSV 17.99 camera services and characteristics to the catalog

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.

* Fix the camera operating mode characteristic permissions

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 TLV8 codecs for the HKSV 17.99 camera values

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.

* Add HKSV 17.99 camera capabilities and motion zones

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.

* Add the HKSV 17.99 camera global operating mode

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.

* Don't fail stop_stream when ffmpeg already exited

terminate() raises ProcessLookupError on an exited process, which surfaced as an
unretrieved task exception. Ignore it and still reap the process; same for kill().

* Return -70405 when reading a characteristic without paired read

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.

* Answer -70406 for event subscriptions without notify permission

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.

* Return -70404 when writing a characteristic without paired write

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.

* Ignore timed writes with an unknown or expired pid

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.

* Split building the characteristics write response into helpers

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.

* Only send write responses for characteristics with wr permission

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.

* Check the timed write permission on writes

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.

* Fix tlv.encode for values of 510, 765, ... bytes

The last fragment was sliced with data[-0:], which duplicated the whole value
and produced undecodable output. Only emit a trailing fragment when bytes remain.

* Await async characteristic setters before answering the write

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.

* Only let the writing connection read back a write response

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.

* Test write response privacy with async setters

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.

* Drop the redundant bool() around the write response check

* Type the write response helpers with the HAP JSON value types

* Explain why a private write puts the former value back

* Require Python 3.11

Python 3.10 reached its end of life in October 2026. asyncio.timeout
replaces the async_timeout backport.

* Tell accessories when a controller's connection closes

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.

* Accept only a canonical IP address in Setup Endpoints

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.

* Keep the Setup Endpoints answer per connection

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.

* Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management

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.

* Add TLV8 codecs for the HKSV 17.99 WebRTC stream management

The Camera WebRTC Stream Management service of the HomeKit Secure Video
17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through
write response characteristics: the accessory offers, the controller
answers, renegotiates, updates SFrame keys and ends the session.

Add the offer options, ICE candidates and SFrame key data, the requests
and responses of each characteristic and their status enums. Each type
encodes and decodes, so tests can play the controller.

WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend
returns. With IceCandidate and SFrameKey they are the types the HAP side
and the media share, all frozen. The offer's SDP carries the ICE
candidates, so the Solicit Offer answer lists none, as in the Scrypted
HomeKit plugin. generate_sframe_key() makes a key to send with.

An answer or reoffer with an SDP above 32 KiB, a write with more than
64 ICE candidates or one above 1 KiB, and an Update Session with more
than 16 keys to add or to remove do not decode, so the media side never
sees one.

* Add the session state machine of the HKSV 17.99 stream services

The multi-tier RTP and WebRTC stream services of the HomeKit Secure
Video 17.99 guide keep sessions that a controller sets up and the camera
streams through a delegate. Both need the same lifecycle, so it lives in
one place, pyhap.camera_stream, which the services in the next changes
build on.

A session goes from new to prepared or solicited, through answering for
WebRTC, to started or active, and ends from any state. Only the
connection that set it up may control it, and no two sessions of a
controller share an ID. A controller has a limit of sessions in all and
per connection.

A delegate hook runs as a task the session owns. The HAP request waits
for it until one deadline, hook_timeout after the write, which must leave
a second of the 9 second HAP response timeout. A hook that is still
running then is cancelled and its session ends. Each session the
delegate saw is released exactly once, in the background and after its
cancelled hooks finished, or after hook_timeout if one ignores its
cancellation. So a timeout never waits for a second hook inside the
request. Timers re-check the state before they end a session.

A delegate fails a request with StreamError(status) and ends a session
on its own with session.end(reason). It keeps its state by session
object, since a new session may reuse the ID of one still being
released. The integrator ends sessions with end_session() and
end_all_sessions(). close() ends everything, waits for the background
tasks and takes no new session afterwards.

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

A controller that streams through the Camera Multi-Tier RTP Stream
Management service of the HomeKit Secure Video 17.99 guide does not fall
back to the RTP stream management service for remote viewing, which
needs the Camera WebRTC Stream Management service. So both come together
or not at all.

The new multi_tier and webrtc options add a MultiTierController and a
WebRTCController per sensor, in the spirit of the SecureVideoController
of HAP-NodeJS #1132. Camera composes them. The options take a
MultiTierConfig and a WebRTCConfig, or dicts of their fields. Unknown
keys raise. Both need the sensors, global_operating_mode, recording,
video and audio options. iOS 27 shows a camera without the classic RTP
stream management service as not responding. The services link to each
other and to the data stream transport.

The multi-tier service offers the sensor's video encodings as H.265
tiers and exactly one Opus tier, with the payload types 99 and 110. The
WebRTC service offers the same tiers and picks the one a remote offer
advertises, by default the Medium one. The tiers are checked against
the guide. Status Active follows the global operating mode and the
service's Streaming Enabled, which survives a restart, and the sessions
of a service end when it may no longer stream.

Each service has at least the sessions the guide requires, five and six.
max_sessions_per_connection limits one connection: three by default for
multi-tier, and all of them for WebRTC, since remote viewers share the
home hub's connection.

The media comes from typed delegates, MultiTierDelegate and
WebRTCDelegate, which release a session in handle_end(). The sessions
follow in the next changes. Camera ends the sessions of a closed
connection, stops them with the camera and has end_session() and
end_all_sessions() for the integrator.

* Set up, start and end HKSV 17.99 multi-tier RTP sessions

A controller sets up a multi-tier session with Setup Endpoints and
starts, changes or ends its stream with RTP Streaming Control.

Setup Endpoints:
- Only the canonical address of the announced IP version is accepted,
  and only AES_CM_128_HMAC_SHA1_80 keys. A write that does not decode,
  and one while streaming is not allowed, without a delegate or for a
  session of another connection, answers an error. Beyond max_sessions,
  or max_sessions_per_connection for the writing connection, it is busy.
- The session is registered before the delegate prepares it, so a closed
  connection or streaming turned off ends it too. A failed or slow
  prepare answers an error and the delegate still releases the session.
- The answer has the accessory's address on the controller's
  connection, in the controller's IP version, as in HAP-NodeJS #1132.
  The delegate may change it. The driver's new local_address() finds it.
  Only the writing connection reads the answer back.
- Setting a session up again releases the former one first. If that
  release fails or overruns the deadline, streaming turned off, or
  another write set the same ID up or took the room meanwhile, it
  answers an error or busy.

RTP Streaming Control:
- Only the connection that set the session up controls it.
- A start picks advertised tiers and calls handle_start(session,
  video_tier, audio_tier), the audio tier is None for video only. A
  start of a started session calls handle_reconfigure(), which keeps the
  SSRCs and SRTP keys of the running stream.
- An end releases the session once.

A session that is not started within setup_timeout (30 s) ends. A
started session ends after idle_timeout (30 s) unless the delegate calls
session.keep_alive(). idle_timeout=None turns that off.
open_rtcp_monitor() does that for the controller's RTCP, as
homebridge-camera-ffmpeg does: it binds a UDP port, announces it as the
accessory's video port and keeps the session alive for each datagram
from the controller's address. A former session with the same ID keeps
nothing alive.

* Stream HKSV 17.99 multi-tier sessions with ffmpeg

FFmpegMultiTierDelegate streams each session with its own ffmpeg
process. Pass it as the delegate of the multi_tier option, with the
input arguments, and audio=False for an input without audio.

The command is an argument list, ffmpeg, the input arguments and the
outputs, which no shell sees and nothing splits. It copies the video
and encodes 16 kHz mono Opus in 20 ms frames, which ffmpeg sends with
the 48 kHz RTP clock, with the session's SSRCs, keys and the payload
types 99 and 110. A start without an audio tier has no audio output.

A tier change keeps the process as it is, without adapting the stream:
a copied stream cannot change its tier, and a new process with the same
SSRC and SRTP key would repeat the keystream.

ffmpeg does not report the controller's RTCP. So the delegate opens an
RTCP monitor in prepare, announces its port as the video port and lets
ffmpeg send video from a free port, with its RTCP to the controller's
video port. The controller's RTCP keeps the session alive, without it the
session ends after idle_timeout. The session also ends when ffmpeg exits
on its own.

The SRTP keys are not logged, and URLs in the logged command and in
ffmpeg's output keep only their scheme, host and port.

* Solicit, answer and end HKSV 17.99 WebRTC sessions

A controller views remotely through the Camera WebRTC Stream Management
service: it solicits an offer, provides its answer and ends the session
with WebRTC Streaming Control (guide chapter 5).

- Solicit Offer: no offer while the camera is off (privacy mode), while
  streaming is not allowed, without a delegate, beyond max_sessions or
  beyond max_sessions_per_connection for the writing connection. The
  delegate gets the session and the tiers the offer advertises. SFrame
  is used unless the controller turned it off explicitly. The Scrypted
  HomeKit plugin found remote viewing needs it when the option is left
  out. The offer must carry an SFrame key exactly then. The SDP carries
  the ICE candidates, the answer lists none.
- Provide Answer: only from the soliciting connection and only once. A
  second answer while the first is applied is busy, one after it an
  error. An answer above the SDP or candidate limit does not decode and
  answers an error before the delegate sees it. Afterwards the session
  counts in Number of Active Sessions.
- A session without an applied answer ends after answer_timeout (30 s).
  The timer stops only once the answer applied and checks the state
  again when it fires.
- A failed or slow offer or answer ends the session. The delegate's
  handle_end() releases every session it saw exactly once, in the
  background, so a timeout never waits for it inside the HAP response
  time.

The answers are write responses. A test through the driver checks that
only the writing connection reads them back.

* Renegotiate HKSV 17.99 WebRTC sessions and update their keys

An active WebRTC session can be renegotiated with WebRTC Reoffer: the
controller offers, the delegate's handle_reoffer() answers, optionally
with a new SFrame key. WebRTC Update Session hands the delegate the
SFrame keys the controller adds and the key IDs it removes.

Both need an active session of the writing connection. A request while
another hook of the session runs answers busy. A failure answers its
status and keeps the session, a hook that takes too long ends it. A
reoffer above the SDP limit answers an error before the delegate sees
it.

* Make pylibsrtp a dependency

Talkback, multi-tier streams and the media package need SRTP; the
Talkback extra stays as an empty alias for existing installs.

* Add RTP, RTCP, H.265 RTP and SDP helpers for camera media

The building blocks of pyhap.media: RTP and RTCP parsing and building
(RFC 3550, RFC 4585), an SRTP output stream, H.265 RTP depacketizing and
packetizing (RFC 7798) and the SDP and codec parameters of HomeKit
camera streams.

* Add a media source that reassembles a camera channel once

A MediaSource binds the RTP input ports of one camera channel and
reassembles its H.265 once for every session of the channel.

* Share one source and pull per camera channel

SourcePool leases the source of a channel to each session; all sessions
of a channel share one source and one pull. FFmpegSourceProvider is a
reference provider that pulls each channel with one ffmpeg, restarts it
with a backoff and redacts URLs in its log.

* Add an SRTP sender for multi-tier sessions

MultiTierSender sends one camera channel from a MediaSource to one
controller as SRTP on the ports announced in Setup Endpoints. It counts
only authenticated RTCP as a sign of life, answers keyframe requests and
switches tiers without a gap. It runs in process or in the media worker;
the pool creates it before the controller picks a tier.

* Run media sources in a worker process

MediaWorker keeps the per-packet media work off the caller's event loop:
one process for all cameras, length-prefixed JSON frames over pipes,
forwarded logs and proxies with the async API of the in-process objects.

* Add a multi-tier delegate that streams from the source pool

MultiTierMediaDelegate runs the multi-tier sessions of a camera with
MultiTierSenders on leased channel sources, so all local sessions of a
channel share one camera connection. FFmpegMultiTierDelegate stays as the
simple reference with an ffmpeg per session.

* Keep reading ffmpeg's output in chunks so status lines can't block it

ffmpeg ends its status line with a carriage return. Reading stderr line by
line failed after 64 KiB without a newline, nobody drained the pipe any
more and ffmpeg blocked. The delegate now starts ffmpeg without banner and
status, reads its output in chunks and splits it on both line ends. A
failing reader no longer keeps the watcher from ending the session.

* Test that stop_stream kills ffmpeg after the timeout

* Don't tell subscribers the request of a write with r

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.

* Type the timed write pid and ignore a pid that is not an int

A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.

* Bound the SFrame keys and SDP m-line indexes controllers send

An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.

* Tighten multi-tier and WebRTC session handling

- 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.

* Name the fields of a pending async write and keep write_response a bool

A write asks for a response only with "r": true.

* Keep the WebRTC SDP out of the media core

Only the WebRTC sender needs SDP, so sdp.py and its tests come with it. The
RTP clock rates and payload types move to rtp.py, which the source and the
senders share. The test helpers are named after the media package.

* Keep read back answers private, also when the setter sets them

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 #535. The read back
test now checks the answer's status instead of the session ID.

* Harden the media core from the review

- A failed first ffmpeg start reaches on_error too.
- The worker drops log records while the parent reads slowly and gives up
  on a parent that stopped reading, instead of buffering without bound.
- A BYE without sources is parsed (RFC 3550 6.6) and does not end the
  compound packet.
- Inserted parameter sets go after an access unit delimiter.
- Closing the pool waits for a lease being opened.
- pylibsrtp gets a lower bound, talkback_available() is deprecated.
- Tests for the pool, a crashed worker and the bounded worker output.

* Test a closed connection without an accessory

* Normalize Setup Endpoints addresses and keep set_endpoints returning 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.

* Make multi-tier tier switches and keep-alive exact

- A switch rebases the RTP time and asks the new channel for a keyframe
  through the new lease, whatever the rate limit; a failed switch keeps
  the old lease.
- A session starts, and continues after a switch, at an IRAP picture
  with its parameter sets.
- RTCP and SRTP that carry the accessory's own SSRC, e.g. reflected
  packets, do not keep a session alive.
- Send errors from asyncio are logged at debug level, the ports close
  even if a task fails to stop, and sender_options are checked when the
  delegate is made.
- The test helpers for a synthetic camera are named after the media
  package and record keyframe requests.

* Run test_start_from_sync on the driver's own loop

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.

* Name Python 3.11 as the minimum in the install guide

* Answer a Setup Endpoints refused while streaming is off only to the writer

* Test that a refused Setup Endpoints reaches only the writer

* Decode the stream service UUIDs with UUID(bytes=...) like the zones

* Keep setting the Setup Endpoints answer as the value

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.

* Run the media worker in a thread on request

MediaWorker(in_thread=True) runs the same worker server in a thread of
the caller's process, with its own event loop, over a socket pair
instead of a child process. The protocol and the proxies stay the same.
Loggers are shared in that mode, so nothing is forwarded and their
levels are left alone. The process stays the default.

* Run the media worker in a thread by default, relay a worker process's logs from stderr

The worker thread shares the loggers, so it needs no relay. A worker
process now talks over a socket pair like the thread and writes its log
records as JSON lines to stderr; the parent logs them with its own
loggers. The log frames in the protocol, their drop buffer and moving
stdout aside are gone, and a crashing interpreter's traceback now shows
up in the parent's log.

* Let a worker process log to its own file

The process writes its records to pyhap-media-<pid>.log in log_dir,
rotated by size, and takes its stderr along, so a crash leaves its
traceback there. The parent no longer reads anything from the child; it
only removes log files that were not written for a week when it starts
a worker process.

---------

Co-authored-by: Ivan Kalchev <25887324+ikalchev@users.noreply.github.com>
ikalchev added a commit that referenced this pull request Nov 12, 2026
* Add HKSV 17.99 camera services and characteristics to the catalog

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.

* Fix the camera operating mode characteristic permissions

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 TLV8 codecs for the HKSV 17.99 camera values

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.

* Add HKSV 17.99 camera capabilities and motion zones

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.

* Add the HKSV 17.99 camera global operating mode

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.

* Don't fail stop_stream when ffmpeg already exited

terminate() raises ProcessLookupError on an exited process, which surfaced as an
unretrieved task exception. Ignore it and still reap the process; same for kill().

* Return -70405 when reading a characteristic without paired read

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.

* Answer -70406 for event subscriptions without notify permission

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.

* Return -70404 when writing a characteristic without paired write

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.

* Ignore timed writes with an unknown or expired pid

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.

* Split building the characteristics write response into helpers

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.

* Only send write responses for characteristics with wr permission

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.

* Check the timed write permission on writes

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.

* Fix tlv.encode for values of 510, 765, ... bytes

The last fragment was sliced with data[-0:], which duplicated the whole value
and produced undecodable output. Only emit a trailing fragment when bytes remain.

* Await async characteristic setters before answering the write

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.

* Only let the writing connection read back a write response

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.

* Test write response privacy with async setters

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.

* Drop the redundant bool() around the write response check

* Type the write response helpers with the HAP JSON value types

* Explain why a private write puts the former value back

* Require Python 3.11

Python 3.10 reached its end of life in October 2026. asyncio.timeout
replaces the async_timeout backport.

* Tell accessories when a controller's connection closes

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.

* Accept only a canonical IP address in Setup Endpoints

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.

* Keep the Setup Endpoints answer per connection

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.

* Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management

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.

* Add TLV8 codecs for the HKSV 17.99 WebRTC stream management

The Camera WebRTC Stream Management service of the HomeKit Secure Video
17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through
write response characteristics: the accessory offers, the controller
answers, renegotiates, updates SFrame keys and ends the session.

Add the offer options, ICE candidates and SFrame key data, the requests
and responses of each characteristic and their status enums. Each type
encodes and decodes, so tests can play the controller.

WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend
returns. With IceCandidate and SFrameKey they are the types the HAP side
and the media share, all frozen. The offer's SDP carries the ICE
candidates, so the Solicit Offer answer lists none, as in the Scrypted
HomeKit plugin. generate_sframe_key() makes a key to send with.

An answer or reoffer with an SDP above 32 KiB, a write with more than
64 ICE candidates or one above 1 KiB, and an Update Session with more
than 16 keys to add or to remove do not decode, so the media side never
sees one.

* Add the session state machine of the HKSV 17.99 stream services

The multi-tier RTP and WebRTC stream services of the HomeKit Secure
Video 17.99 guide keep sessions that a controller sets up and the camera
streams through a delegate. Both need the same lifecycle, so it lives in
one place, pyhap.camera_stream, which the services in the next changes
build on.

A session goes from new to prepared or solicited, through answering for
WebRTC, to started or active, and ends from any state. Only the
connection that set it up may control it, and no two sessions of a
controller share an ID. A controller has a limit of sessions in all and
per connection.

A delegate hook runs as a task the session owns. The HAP request waits
for it until one deadline, hook_timeout after the write, which must leave
a second of the 9 second HAP response timeout. A hook that is still
running then is cancelled and its session ends. Each session the
delegate saw is released exactly once, in the background and after its
cancelled hooks finished, or after hook_timeout if one ignores its
cancellation. So a timeout never waits for a second hook inside the
request. Timers re-check the state before they end a session.

A delegate fails a request with StreamError(status) and ends a session
on its own with session.end(reason). It keeps its state by session
object, since a new session may reuse the ID of one still being
released. The integrator ends sessions with end_session() and
end_all_sessions(). close() ends everything, waits for the background
tasks and takes no new session afterwards.

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

A controller that streams through the Camera Multi-Tier RTP Stream
Management service of the HomeKit Secure Video 17.99 guide does not fall
back to the RTP stream management service for remote viewing, which
needs the Camera WebRTC Stream Management service. So both come together
or not at all.

The new multi_tier and webrtc options add a MultiTierController and a
WebRTCController per sensor, in the spirit of the SecureVideoController
of HAP-NodeJS #1132. Camera composes them. The options take a
MultiTierConfig and a WebRTCConfig, or dicts of their fields. Unknown
keys raise. Both need the sensors, global_operating_mode, recording,
video and audio options. iOS 27 shows a camera without the classic RTP
stream management service as not responding. The services link to each
other and to the data stream transport.

The multi-tier service offers the sensor's video encodings as H.265
tiers and exactly one Opus tier, with the payload types 99 and 110. The
WebRTC service offers the same tiers and picks the one a remote offer
advertises, by default the Medium one. The tiers are checked against
the guide. Status Active follows the global operating mode and the
service's Streaming Enabled, which survives a restart, and the sessions
of a service end when it may no longer stream.

Each service has at least the sessions the guide requires, five and six.
max_sessions_per_connection limits one connection: three by default for
multi-tier, and all of them for WebRTC, since remote viewers share the
home hub's connection.

The media comes from typed delegates, MultiTierDelegate and
WebRTCDelegate, which release a session in handle_end(). The sessions
follow in the next changes. Camera ends the sessions of a closed
connection, stops them with the camera and has end_session() and
end_all_sessions() for the integrator.

* Set up, start and end HKSV 17.99 multi-tier RTP sessions

A controller sets up a multi-tier session with Setup Endpoints and
starts, changes or ends its stream with RTP Streaming Control.

Setup Endpoints:
- Only the canonical address of the announced IP version is accepted,
  and only AES_CM_128_HMAC_SHA1_80 keys. A write that does not decode,
  and one while streaming is not allowed, without a delegate or for a
  session of another connection, answers an error. Beyond max_sessions,
  or max_sessions_per_connection for the writing connection, it is busy.
- The session is registered before the delegate prepares it, so a closed
  connection or streaming turned off ends it too. A failed or slow
  prepare answers an error and the delegate still releases the session.
- The answer has the accessory's address on the controller's
  connection, in the controller's IP version, as in HAP-NodeJS #1132.
  The delegate may change it. The driver's new local_address() finds it.
  Only the writing connection reads the answer back.
- Setting a session up again releases the former one first. If that
  release fails or overruns the deadline, streaming turned off, or
  another write set the same ID up or took the room meanwhile, it
  answers an error or busy.

RTP Streaming Control:
- Only the connection that set the session up controls it.
- A start picks advertised tiers and calls handle_start(session,
  video_tier, audio_tier), the audio tier is None for video only. A
  start of a started session calls handle_reconfigure(), which keeps the
  SSRCs and SRTP keys of the running stream.
- An end releases the session once.

A session that is not started within setup_timeout (30 s) ends. A
started session ends after idle_timeout (30 s) unless the delegate calls
session.keep_alive(). idle_timeout=None turns that off.
open_rtcp_monitor() does that for the controller's RTCP, as
homebridge-camera-ffmpeg does: it binds a UDP port, announces it as the
accessory's video port and keeps the session alive for each datagram
from the controller's address. A former session with the same ID keeps
nothing alive.

* Stream HKSV 17.99 multi-tier sessions with ffmpeg

FFmpegMultiTierDelegate streams each session with its own ffmpeg
process. Pass it as the delegate of the multi_tier option, with the
input arguments, and audio=False for an input without audio.

The command is an argument list, ffmpeg, the input arguments and the
outputs, which no shell sees and nothing splits. It copies the video
and encodes 16 kHz mono Opus in 20 ms frames, which ffmpeg sends with
the 48 kHz RTP clock, with the session's SSRCs, keys and the payload
types 99 and 110. A start without an audio tier has no audio output.

A tier change keeps the process as it is, without adapting the stream:
a copied stream cannot change its tier, and a new process with the same
SSRC and SRTP key would repeat the keystream.

ffmpeg does not report the controller's RTCP. So the delegate opens an
RTCP monitor in prepare, announces its port as the video port and lets
ffmpeg send video from a free port, with its RTCP to the controller's
video port. The controller's RTCP keeps the session alive, without it the
session ends after idle_timeout. The session also ends when ffmpeg exits
on its own.

The SRTP keys are not logged, and URLs in the logged command and in
ffmpeg's output keep only their scheme, host and port.

* Solicit, answer and end HKSV 17.99 WebRTC sessions

A controller views remotely through the Camera WebRTC Stream Management
service: it solicits an offer, provides its answer and ends the session
with WebRTC Streaming Control (guide chapter 5).

- Solicit Offer: no offer while the camera is off (privacy mode), while
  streaming is not allowed, without a delegate, beyond max_sessions or
  beyond max_sessions_per_connection for the writing connection. The
  delegate gets the session and the tiers the offer advertises. SFrame
  is used unless the controller turned it off explicitly. The Scrypted
  HomeKit plugin found remote viewing needs it when the option is left
  out. The offer must carry an SFrame key exactly then. The SDP carries
  the ICE candidates, the answer lists none.
- Provide Answer: only from the soliciting connection and only once. A
  second answer while the first is applied is busy, one after it an
  error. An answer above the SDP or candidate limit does not decode and
  answers an error before the delegate sees it. Afterwards the session
  counts in Number of Active Sessions.
- A session without an applied answer ends after answer_timeout (30 s).
  The timer stops only once the answer applied and checks the state
  again when it fires.
- A failed or slow offer or answer ends the session. The delegate's
  handle_end() releases every session it saw exactly once, in the
  background, so a timeout never waits for it inside the HAP response
  time.

The answers are write responses. A test through the driver checks that
only the writing connection reads them back.

* Renegotiate HKSV 17.99 WebRTC sessions and update their keys

An active WebRTC session can be renegotiated with WebRTC Reoffer: the
controller offers, the delegate's handle_reoffer() answers, optionally
with a new SFrame key. WebRTC Update Session hands the delegate the
SFrame keys the controller adds and the key IDs it removes.

Both need an active session of the writing connection. A request while
another hook of the session runs answers busy. A failure answers its
status and keeps the session, a hook that takes too long ends it. A
reoffer above the SDP limit answers an error before the delegate sees
it.

* Make pylibsrtp a dependency

Talkback, multi-tier streams and the media package need SRTP; the
Talkback extra stays as an empty alias for existing installs.

* Add RTP, RTCP, H.265 RTP and SDP helpers for camera media

The building blocks of pyhap.media: RTP and RTCP parsing and building
(RFC 3550, RFC 4585), an SRTP output stream, H.265 RTP depacketizing and
packetizing (RFC 7798) and the SDP and codec parameters of HomeKit
camera streams.

* Add a media source that reassembles a camera channel once

A MediaSource binds the RTP input ports of one camera channel and
reassembles its H.265 once for every session of the channel.

* Share one source and pull per camera channel

SourcePool leases the source of a channel to each session; all sessions
of a channel share one source and one pull. FFmpegSourceProvider is a
reference provider that pulls each channel with one ffmpeg, restarts it
with a backoff and redacts URLs in its log.

* Add an SRTP sender for multi-tier sessions

MultiTierSender sends one camera channel from a MediaSource to one
controller as SRTP on the ports announced in Setup Endpoints. It counts
only authenticated RTCP as a sign of life, answers keyframe requests and
switches tiers without a gap. It runs in process or in the media worker;
the pool creates it before the controller picks a tier.

* Run media sources in a worker process

MediaWorker keeps the per-packet media work off the caller's event loop:
one process for all cameras, length-prefixed JSON frames over pipes,
forwarded logs and proxies with the async API of the in-process objects.

* Record HKSV clips from a media source with a prebuffer

A Recorder keeps a ring buffer of the channel's last access units,
aligned to IRAP pictures and bounded in size, and on start feeds them and
the live stream as MPEG-TS to a transcoder whose fragmented MP4 goes to
the hub. It runs in process or in the media worker; a lease opens it on
its channel's source.

* Add a multi-tier delegate that streams from the source pool

MultiTierMediaDelegate runs the multi-tier sessions of a camera with
MultiTierSenders on leased channel sources, so all local sessions of a
channel share one camera connection. FFmpegMultiTierDelegate stays as the
simple reference with an ffmpeg per session.

* Record classic HKSV clips through a delegate of the camera

The recording option takes a delegate that makes the recordings: the
camera arms it while the hub has recording on and the camera is on,
advertises its prebuffer and records with it. RecordingMediaDelegate
leases the recording channel from the pool, by default only for a
recording, with a prebuffer for as long as it is armed.

Observed: hubs do not upload HDS recordings of cameras that also offer
multi-tier streams, so the camera warns about that combination.

* Keep reading ffmpeg's output in chunks so status lines can't block it

ffmpeg ends its status line with a carriage return. Reading stderr line by
line failed after 64 KiB without a newline, nobody drained the pipe any
more and ffmpeg blocked. The delegate now starts ffmpeg without banner and
status, reads its output in chunks and splits it on both line ends. A
failing reader no longer keeps the watcher from ending the session.

* Test that stop_stream kills ffmpeg after the timeout

* Don't tell subscribers the request of a write with r

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.

* Type the timed write pid and ignore a pid that is not an int

A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.

* Bound the SFrame keys and SDP m-line indexes controllers send

An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.

* Tighten multi-tier and WebRTC session handling

- 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.

* Name the fields of a pending async write and keep write_response a bool

A write asks for a response only with "r": true.

* Keep the WebRTC SDP out of the media core

Only the WebRTC sender needs SDP, so sdp.py and its tests come with it. The
RTP clock rates and payload types move to rtp.py, which the source and the
senders share. The test helpers are named after the media package.

* Keep read back answers private, also when the setter sets them

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 #535. The read back
test now checks the answer's status instead of the session ID.

* Harden the media core from the review

- A failed first ffmpeg start reaches on_error too.
- The worker drops log records while the parent reads slowly and gives up
  on a parent that stopped reading, instead of buffering without bound.
- A BYE without sources is parsed (RFC 3550 6.6) and does not end the
  compound packet.
- Inserted parameter sets go after an access unit delimiter.
- Closing the pool waits for a lease being opened.
- pylibsrtp gets a lower bound, talkback_available() is deprecated.
- Tests for the pool, a crashed worker and the bounded worker output.

* Test a closed connection without an accessory

* Normalize Setup Endpoints addresses and keep set_endpoints returning 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.

* Make multi-tier tier switches and keep-alive exact

- A switch rebases the RTP time and asks the new channel for a keyframe
  through the new lease, whatever the rate limit; a failed switch keeps
  the old lease.
- A session starts, and continues after a switch, at an IRAP picture
  with its parameter sets.
- RTCP and SRTP that carry the accessory's own SSRC, e.g. reflected
  packets, do not keep a session alive.
- Send errors from asyncio are logged at debug level, the ports close
  even if a task fails to stop, and sender_options are checked when the
  delegate is made.
- The test helpers for a synthetic camera are named after the media
  package and record keyframe requests.

* Keep a recording delegate buffering through failures

- The camera retries arming its recording delegate with a growing delay
  while recording is wanted, e.g. after the camera was offline at start.
- An armed delegate buffers again when its recorder ends on its own,
  e.g. after the media worker exited.
- A failed transcode names the last line ffmpeg wrote, and a failing
  output handler ends the recording instead of leaving it open.
- A worker recording counts as running before its start is answered, so
  an early end is not lost.
- An access unit's own delimiter is moved to the front instead of
  doubled.
- The "recording_delegate" warning is gone, the option never existed
  upstream.

* Run test_start_from_sync on the driver's own loop

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.

* Name Python 3.11 as the minimum in the install guide

* Answer a Setup Endpoints refused while streaming is off only to the writer

* Test that a refused Setup Endpoints reaches only the writer

* Decode the stream service UUIDs with UUID(bytes=...) like the zones

* Keep setting the Setup Endpoints answer as the value

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.

* Run the media worker in a thread on request

MediaWorker(in_thread=True) runs the same worker server in a thread of
the caller's process, with its own event loop, over a socket pair
instead of a child process. The protocol and the proxies stay the same.
Loggers are shared in that mode, so nothing is forwarded and their
levels are left alone. The process stays the default.

* Run the media worker in a thread by default, relay a worker process's logs from stderr

The worker thread shares the loggers, so it needs no relay. A worker
process now talks over a socket pair like the thread and writes its log
records as JSON lines to stderr; the parent logs them with its own
loggers. The log frames in the protocol, their drop buffer and moving
stdout aside are gone, and a crashing interpreter's traceback now shows
up in the parent's log.

* Let a worker process log to its own file

The process writes its records to pyhap-media-<pid>.log in log_dir,
rotated by size, and takes its stderr along, so a crash leaves its
traceback there. The parent no longer reads anything from the child; it
only removes log files that were not written for a week when it starts
a worker process.

* Let any channel provider name its lease_channel tier parameter

* Document the multi-tier media delegate

* Document the recording media delegate

* Start recordings only at an IRAP picture with its parameter sets

A source that joins in the middle of an access unit can deliver an IRAP
picture without VPS, SPS and PPS. The recorder took it as the start, so the
first GOP of a recording could not be decoded. Seen in CI on Linux, where the
worker's first packets of a pull raced the source.

---------

Co-authored-by: Ivan Kalchev <25887324+ikalchev@users.noreply.github.com>
ikalchev added a commit that referenced this pull request Nov 12, 2026
* Add HKSV 17.99 camera services and characteristics to the catalog

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.

* Fix the camera operating mode characteristic permissions

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 TLV8 codecs for the HKSV 17.99 camera values

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.

* Add HKSV 17.99 camera capabilities and motion zones

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.

* Add the HKSV 17.99 camera global operating mode

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.

* Don't fail stop_stream when ffmpeg already exited

terminate() raises ProcessLookupError on an exited process, which surfaced as an
unretrieved task exception. Ignore it and still reap the process; same for kill().

* Return -70405 when reading a characteristic without paired read

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.

* Answer -70406 for event subscriptions without notify permission

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.

* Return -70404 when writing a characteristic without paired write

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.

* Ignore timed writes with an unknown or expired pid

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.

* Split building the characteristics write response into helpers

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.

* Only send write responses for characteristics with wr permission

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.

* Check the timed write permission on writes

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.

* Fix tlv.encode for values of 510, 765, ... bytes

The last fragment was sliced with data[-0:], which duplicated the whole value
and produced undecodable output. Only emit a trailing fragment when bytes remain.

* Await async characteristic setters before answering the write

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.

* Only let the writing connection read back a write response

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.

* Test write response privacy with async setters

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.

* Drop the redundant bool() around the write response check

* Type the write response helpers with the HAP JSON value types

* Explain why a private write puts the former value back

* Require Python 3.11

Python 3.10 reached its end of life in October 2026. asyncio.timeout
replaces the async_timeout backport.

* Tell accessories when a controller's connection closes

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.

* Accept only a canonical IP address in Setup Endpoints

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.

* Keep the Setup Endpoints answer per connection

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.

* Add TLV8 codecs for the HKSV 17.99 multi-tier RTP stream management

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.

* Add TLV8 codecs for the HKSV 17.99 WebRTC stream management

The Camera WebRTC Stream Management service of the HomeKit Secure Video
17.99 guide (sections 3.7 and 4.17 to 4.22) negotiates a session through
write response characteristics: the accessory offers, the controller
answers, renegotiates, updates SFrame keys and ends the session.

Add the offer options, ICE candidates and SFrame key data, the requests
and responses of each characteristic and their status enums. Each type
encodes and decodes, so tests can play the controller.

WebRTCOffer and WebRTCReofferAnswer are what a WebRTC media backend
returns. With IceCandidate and SFrameKey they are the types the HAP side
and the media share, all frozen. The offer's SDP carries the ICE
candidates, so the Solicit Offer answer lists none, as in the Scrypted
HomeKit plugin. generate_sframe_key() makes a key to send with.

An answer or reoffer with an SDP above 32 KiB, a write with more than
64 ICE candidates or one above 1 KiB, and an Update Session with more
than 16 keys to add or to remove do not decode, so the media side never
sees one.

* Add the session state machine of the HKSV 17.99 stream services

The multi-tier RTP and WebRTC stream services of the HomeKit Secure
Video 17.99 guide keep sessions that a controller sets up and the camera
streams through a delegate. Both need the same lifecycle, so it lives in
one place, pyhap.camera_stream, which the services in the next changes
build on.

A session goes from new to prepared or solicited, through answering for
WebRTC, to started or active, and ends from any state. Only the
connection that set it up may control it, and no two sessions of a
controller share an ID. A controller has a limit of sessions in all and
per connection.

A delegate hook runs as a task the session owns. The HAP request waits
for it until one deadline, hook_timeout after the write, which must leave
a second of the 9 second HAP response timeout. A hook that is still
running then is cancelled and its session ends. Each session the
delegate saw is released exactly once, in the background and after its
cancelled hooks finished, or after hook_timeout if one ignores its
cancellation. So a timeout never waits for a second hook inside the
request. Timers re-check the state before they end a session.

A delegate fails a request with StreamError(status) and ends a session
on its own with session.end(reason). It keeps its state by session
object, since a new session may reuse the ID of one still being
released. The integrator ends sessions with end_session() and
end_all_sessions(). close() ends everything, waits for the background
tasks and takes no new session afterwards.

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

A controller that streams through the Camera Multi-Tier RTP Stream
Management service of the HomeKit Secure Video 17.99 guide does not fall
back to the RTP stream management service for remote viewing, which
needs the Camera WebRTC Stream Management service. So both come together
or not at all.

The new multi_tier and webrtc options add a MultiTierController and a
WebRTCController per sensor, in the spirit of the SecureVideoController
of HAP-NodeJS #1132. Camera composes them. The options take a
MultiTierConfig and a WebRTCConfig, or dicts of their fields. Unknown
keys raise. Both need the sensors, global_operating_mode, recording,
video and audio options. iOS 27 shows a camera without the classic RTP
stream management service as not responding. The services link to each
other and to the data stream transport.

The multi-tier service offers the sensor's video encodings as H.265
tiers and exactly one Opus tier, with the payload types 99 and 110. The
WebRTC service offers the same tiers and picks the one a remote offer
advertises, by default the Medium one. The tiers are checked against
the guide. Status Active follows the global operating mode and the
service's Streaming Enabled, which survives a restart, and the sessions
of a service end when it may no longer stream.

Each service has at least the sessions the guide requires, five and six.
max_sessions_per_connection limits one connection: three by default for
multi-tier, and all of them for WebRTC, since remote viewers share the
home hub's connection.

The media comes from typed delegates, MultiTierDelegate and
WebRTCDelegate, which release a session in handle_end(). The sessions
follow in the next changes. Camera ends the sessions of a closed
connection, stops them with the camera and has end_session() and
end_all_sessions() for the integrator.

* Set up, start and end HKSV 17.99 multi-tier RTP sessions

A controller sets up a multi-tier session with Setup Endpoints and
starts, changes or ends its stream with RTP Streaming Control.

Setup Endpoints:
- Only the canonical address of the announced IP version is accepted,
  and only AES_CM_128_HMAC_SHA1_80 keys. A write that does not decode,
  and one while streaming is not allowed, without a delegate or for a
  session of another connection, answers an error. Beyond max_sessions,
  or max_sessions_per_connection for the writing connection, it is busy.
- The session is registered before the delegate prepares it, so a closed
  connection or streaming turned off ends it too. A failed or slow
  prepare answers an error and the delegate still releases the session.
- The answer has the accessory's address on the controller's
  connection, in the controller's IP version, as in HAP-NodeJS #1132.
  The delegate may change it. The driver's new local_address() finds it.
  Only the writing connection reads the answer back.
- Setting a session up again releases the former one first. If that
  release fails or overruns the deadline, streaming turned off, or
  another write set the same ID up or took the room meanwhile, it
  answers an error or busy.

RTP Streaming Control:
- Only the connection that set the session up controls it.
- A start picks advertised tiers and calls handle_start(session,
  video_tier, audio_tier), the audio tier is None for video only. A
  start of a started session calls handle_reconfigure(), which keeps the
  SSRCs and SRTP keys of the running stream.
- An end releases the session once.

A session that is not started within setup_timeout (30 s) ends. A
started session ends after idle_timeout (30 s) unless the delegate calls
session.keep_alive(). idle_timeout=None turns that off.
open_rtcp_monitor() does that for the controller's RTCP, as
homebridge-camera-ffmpeg does: it binds a UDP port, announces it as the
accessory's video port and keeps the session alive for each datagram
from the controller's address. A former session with the same ID keeps
nothing alive.

* Stream HKSV 17.99 multi-tier sessions with ffmpeg

FFmpegMultiTierDelegate streams each session with its own ffmpeg
process. Pass it as the delegate of the multi_tier option, with the
input arguments, and audio=False for an input without audio.

The command is an argument list, ffmpeg, the input arguments and the
outputs, which no shell sees and nothing splits. It copies the video
and encodes 16 kHz mono Opus in 20 ms frames, which ffmpeg sends with
the 48 kHz RTP clock, with the session's SSRCs, keys and the payload
types 99 and 110. A start without an audio tier has no audio output.

A tier change keeps the process as it is, without adapting the stream:
a copied stream cannot change its tier, and a new process with the same
SSRC and SRTP key would repeat the keystream.

ffmpeg does not report the controller's RTCP. So the delegate opens an
RTCP monitor in prepare, announces its port as the video port and lets
ffmpeg send video from a free port, with its RTCP to the controller's
video port. The controller's RTCP keeps the session alive, without it the
session ends after idle_timeout. The session also ends when ffmpeg exits
on its own.

The SRTP keys are not logged, and URLs in the logged command and in
ffmpeg's output keep only their scheme, host and port.

* Solicit, answer and end HKSV 17.99 WebRTC sessions

A controller views remotely through the Camera WebRTC Stream Management
service: it solicits an offer, provides its answer and ends the session
with WebRTC Streaming Control (guide chapter 5).

- Solicit Offer: no offer while the camera is off (privacy mode), while
  streaming is not allowed, without a delegate, beyond max_sessions or
  beyond max_sessions_per_connection for the writing connection. The
  delegate gets the session and the tiers the offer advertises. SFrame
  is used unless the controller turned it off explicitly. The Scrypted
  HomeKit plugin found remote viewing needs it when the option is left
  out. The offer must carry an SFrame key exactly then. The SDP carries
  the ICE candidates, the answer lists none.
- Provide Answer: only from the soliciting connection and only once. A
  second answer while the first is applied is busy, one after it an
  error. An answer above the SDP or candidate limit does not decode and
  answers an error before the delegate sees it. Afterwards the session
  counts in Number of Active Sessions.
- A session without an applied answer ends after answer_timeout (30 s).
  The timer stops only once the answer applied and checks the state
  again when it fires.
- A failed or slow offer or answer ends the session. The delegate's
  handle_end() releases every session it saw exactly once, in the
  background, so a timeout never waits for it inside the HAP response
  time.

The answers are write responses. A test through the driver checks that
only the writing connection reads them back.

* Renegotiate HKSV 17.99 WebRTC sessions and update their keys

An active WebRTC session can be renegotiated with WebRTC Reoffer: the
controller offers, the delegate's handle_reoffer() answers, optionally
with a new SFrame key. WebRTC Update Session hands the delegate the
SFrame keys the controller adds and the key IDs it removes.

Both need an active session of the writing connection. A request while
another hook of the session runs answers busy. A failure answers its
status and keeps the session, a hook that takes too long ends it. A
reoffer above the SDP limit answers an error before the delegate sees
it.

* Make pylibsrtp a dependency

Talkback, multi-tier streams and the media package need SRTP; the
Talkback extra stays as an empty alias for existing installs.

* Add RTP, RTCP, H.265 RTP and SDP helpers for camera media

The building blocks of pyhap.media: RTP and RTCP parsing and building
(RFC 3550, RFC 4585), an SRTP output stream, H.265 RTP depacketizing and
packetizing (RFC 7798) and the SDP and codec parameters of HomeKit
camera streams.

* Add a media source that reassembles a camera channel once

A MediaSource binds the RTP input ports of one camera channel and
reassembles its H.265 once for every session of the channel.

* Share one source and pull per camera channel

SourcePool leases the source of a channel to each session; all sessions
of a channel share one source and one pull. FFmpegSourceProvider is a
reference provider that pulls each channel with one ffmpeg, restarts it
with a backoff and redacts URLs in its log.

* Add an SRTP sender for multi-tier sessions

MultiTierSender sends one camera channel from a MediaSource to one
controller as SRTP on the ports announced in Setup Endpoints. It counts
only authenticated RTCP as a sign of life, answers keyframe requests and
switches tiers without a gap. It runs in process or in the media worker;
the pool creates it before the controller picks a tier.

* Run media sources in a worker process

MediaWorker keeps the per-packet media work off the caller's event loop:
one process for all cameras, length-prefixed JSON frames over pipes,
forwarded logs and proxies with the async API of the in-process objects.

* Record HKSV clips from a media source with a prebuffer

A Recorder keeps a ring buffer of the channel's last access units,
aligned to IRAP pictures and bounded in size, and on start feeds them and
the live stream as MPEG-TS to a transcoder whose fragmented MP4 goes to
the hub. It runs in process or in the media worker; a lease opens it on
its channel's source.

* Add a multi-tier delegate that streams from the source pool

MultiTierMediaDelegate runs the multi-tier sessions of a camera with
MultiTierSenders on leased channel sources, so all local sessions of a
channel share one camera connection. FFmpegMultiTierDelegate stays as the
simple reference with an ffmpeg per session.

* Record classic HKSV clips through a delegate of the camera

The recording option takes a delegate that makes the recordings: the
camera arms it while the hub has recording on and the camera is on,
advertises its prebuffer and records with it. RecordingMediaDelegate
leases the recording channel from the pool, by default only for a
recording, with a prebuffer for as long as it is armed.

Observed: hubs do not upload HDS recordings of cameras that also offer
multi-tier streams, so the camera warns about that combination.

* Add SFrame media encryption for HomeKit WebRTC streams

SFrame (RFC 9605) with the HomeKit cipher suites, its RTP packetization
and the payload budget the multi-tier sender shares. Tested with the RFC
vectors and with vectors from a known working HomeKit WebRTC
implementation.

* Add a small STUN and ICE agent with host candidates

Remote live view runs over an Apple relay: the accessory is the
controlling agent, checks its host candidates against the remote ones,
nominates the first pair that succeeds and keeps consent (RFC 7675).
STUN messages are checked for MESSAGE-INTEGRITY and FINGERPRINT. aioice
is only a test dependency for an interop test.

* Add a WebRTC sender with a DTLS-SRTP transport

WebRTCSender offers a BUNDLEd, rtcp-muxed session on its own ICE agent,
runs DTLS-SRTP with pyOpenSSL and sends the channel's H.265 and Opus
with SFrame, paced, with talkback audio back. It runs in process or in
the media worker. pyOpenSSL and ifaddr become dependencies; the WebRTC
extra is empty.

* Add a WebRTC delegate that streams from the source pool

WebRTCMediaDelegate runs the WebRTC sessions of a camera with
WebRTCSenders on leased channel sources, so remote and local sessions of
a channel share one camera connection.

* Keep reading ffmpeg's output in chunks so status lines can't block it

ffmpeg ends its status line with a carriage return. Reading stderr line by
line failed after 64 KiB without a newline, nobody drained the pipe any
more and ffmpeg blocked. The delegate now starts ffmpeg without banner and
status, reads its output in chunks and splits it on both line ends. A
failing reader no longer keeps the watcher from ending the session.

* Test that stop_stream kills ffmpeg after the timeout

* Don't tell subscribers the request of a write with r

client_update_value takes notify=False for a write response request on
a readable characteristic, so ev subscribers don't get a request that
only the writer may read, e.g. a WiFi configuration with a key.
HAPValue moves to pyhap.characteristic next to the formats.

* Type the timed write pid and ignore a pid that is not an int

A pid of another type, e.g. a list, raised a TypeError instead of
answering -70410.

* Bound the SFrame keys and SDP m-line indexes controllers send

An SFrame key must have 16 to 64 bytes, and an m-line index decodes only
up to the 16 bits it encodes with.

* Tighten multi-tier and WebRTC session handling

- 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.

* Name the fields of a pending async write and keep write_response a bool

A write asks for a response only with "r": true.

* Keep the WebRTC SDP out of the media core

Only the WebRTC sender needs SDP, so sdp.py and its tests come with it. The
RTP clock rates and payload types move to rtp.py, which the source and the
senders share. The test helpers are named after the media package.

* Keep read back answers private, also when the setter sets them

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 #535. The read back
test now checks the answer's status instead of the session ID.

* Harden the media core from the review

- A failed first ffmpeg start reaches on_error too.
- The worker drops log records while the parent reads slowly and gives up
  on a parent that stopped reading, instead of buffering without bound.
- A BYE without sources is parsed (RFC 3550 6.6) and does not end the
  compound packet.
- Inserted parameter sets go after an access unit delimiter.
- Closing the pool waits for a lease being opened.
- pylibsrtp gets a lower bound, talkback_available() is deprecated.
- Tests for the pool, a crashed worker and the bounded worker output.

* Test a closed connection without an accessory

* Normalize Setup Endpoints addresses and keep set_endpoints returning 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.

* Make multi-tier tier switches and keep-alive exact

- A switch rebases the RTP time and asks the new channel for a keyframe
  through the new lease, whatever the rate limit; a failed switch keeps
  the old lease.
- A session starts, and continues after a switch, at an IRAP picture
  with its parameter sets.
- RTCP and SRTP that carry the accessory's own SSRC, e.g. reflected
  packets, do not keep a session alive.
- Send errors from asyncio are logged at debug level, the ports close
  even if a task fails to stop, and sender_options are checked when the
  delegate is made.
- The test helpers for a synthetic camera are named after the media
  package and record keyframe requests.

* Keep a recording delegate buffering through failures

- The camera retries arming its recording delegate with a growing delay
  while recording is wanted, e.g. after the camera was offline at start.
- An armed delegate buffers again when its recorder ends on its own,
  e.g. after the media worker exited.
- A failed transcode names the last line ffmpeg wrote, and a failing
  output handler ends the recording instead of leaving it open.
- A worker recording counts as running before its start is answered, so
  an early end is not lost.
- An access unit's own delimiter is moved to the front instead of
  doubled.
- The "recording_delegate" warning is gone, the option never existed
  upstream.

* Run test_start_from_sync on the driver's own loop

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.

* Tighten the WebRTC transport and align its names

- STUN ignores attributes after MESSAGE-INTEGRITY other than FINGERPRINT
  (RFC 8489 14.5) instead of dropping the message.
- At most four mDNS candidates are resolved, in parallel, and an address
  they resolve to must be usable unicast, not loopback.
- DTLS needs version 1.2 and refuses renegotiation.
- Audio and retransmissions waiting on a blocked path are bounded.
- Tests: a nomination from a new source after the selection moves no
  media, unusable fingerprints are refused, replayed SRTP is dropped.
- Named like the other stages while unreleased: webrtc_delegate.py,
  WebRTCMediaSender, create_webrtc_sender.
- The Scrypted vector generator is not shipped, the vector file names
  its source; module docs list the recorders again.

* Name Python 3.11 as the minimum in the install guide

* Answer a Setup Endpoints refused while streaming is off only to the writer

* Test that a refused Setup Endpoints reaches only the writer

* Decode the stream service UUIDs with UUID(bytes=...) like the zones

* Keep setting the Setup Endpoints answer as the value

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.

* Run the media worker in a thread on request

MediaWorker(in_thread=True) runs the same worker server in a thread of
the caller's process, with its own event loop, over a socket pair
instead of a child process. The protocol and the proxies stay the same.
Loggers are shared in that mode, so nothing is forwarded and their
levels are left alone. The process stays the default.

* Run the media worker in a thread by default, relay a worker process's logs from stderr

The worker thread shares the loggers, so it needs no relay. A worker
process now talks over a socket pair like the thread and writes its log
records as JSON lines to stderr; the parent logs them with its own
loggers. The log frames in the protocol, their drop buffer and moving
stdout aside are gone, and a crashing interpreter's traceback now shows
up in the parent's log.

* Let a worker process log to its own file

The process writes its records to pyhap-media-<pid>.log in log_dir,
rotated by size, and takes its stderr along, so a crash leaves its
traceback there. The parent no longer reads anything from the child; it
only removes log files that were not written for a week when it starts
a worker process.

* Let any channel provider name its lease_channel tier parameter

* Document the multi-tier media delegate

* Document the recording media delegate

* Document the WebRTC media delegate

* Start recordings only at an IRAP picture with its parameter sets

A source that joins in the middle of an access unit can deliver an IRAP
picture without VPS, SPS and PPS. The recorder took it as the start, so the
first GOP of a recording could not be decoded. Seen in CI on Linux, where the
worker's first packets of a pull raced the source.

* Make the WebRTC dependencies an optional extra

pyOpenSSL and ifaddr move to a WebRTC extra. Without it, the WebRTC
media delegate raises an ImportError naming the extra when it is
created.

---------

Co-authored-by: Ivan Kalchev <25887324+ikalchev@users.noreply.github.com>
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