Skip to content

Add HKSV 17.99 camera capabilities, motion zones and global operating mode - #532

Merged
ikalchev merged 9 commits into
ikalchev:devfrom
RaHehl:hksv3-operating-mode
Nov 10, 2026
Merged

ikalchev merged 9 commits into
ikalchev:devfrom
RaHehl:hksv3-operating-mode

Conversation

@RaHehl

@RaHehl RaHehl commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Builds on the catalog from #539. All opt-in, cameras without the new options are unchanged.

  • pyhap/hksv3.py: TLV8 codecs for Camera Capabilities, Contributing Sensors and Camera Zones. Same TLV layout as HAP-NodeJS PR 1132.
  • sensors option: Camera Capabilities, Camera Motion Zones, Contributing Sensors and Motion Enabled. Without recording it adds its own Motion Sensor service. Zones and Motion Enabled are persisted like the recording state.
  • global_operating_mode option: Camera Global Operating Mode service. Together with recording there are two HomeKit Camera Active characteristics. The last write to either turns the camera on or off and the other follows, so a hub that only knows the Camera Operating Mode service still works. While streaming is turned off (camera off, Streaming Enabled off or manually disabled), SetupEndpoints is answered with an error and running live streams end.

New API: pyhap.hksv3, camera_zones, motion_zones_active, motion_enabled, streaming_allowed, set_motion_detected(sensors=), set_manually_disabled, set_third_party_camera_active, hooks motion_zones_changed, motion_enabled_changed, global_operating_mode_changed(name, value). The new hooks only fire on changes, the hub often writes the same values several times. With global_operating_mode that also goes for homekit_camera_active_changed on writes to the global characteristic. Classic writes call it every time as before.

With the default IID manager, turning on sensors for a paired camera with recording changes the IIDs of the recording services.

Tested with unit tests (3.10, 3.11) and paired with a Home hub: recording works as before, turning the camera off/on writes the global operating mode and the hooks get name and value, and the Home app does not ask for a stream while the camera is off. The hub did not touch the motion zones service, even after drawing a zone in the Home app.

References:

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

Add the eight services and 27 characteristics of Apple's HomeKit Secure
Video Open Source Compatibility Guide (Developer Preview 1.0) to the
loader resources. Two of them, Contributing Sensors and Motion Enabled,
also become optional characteristics of the Motion Sensor service.

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

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

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

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

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

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

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

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

When the service exists, its HomeKit Camera Active alone turns the
camera on or off. The value on the Camera Operating Mode service of
classic recording is then ignored, so a value left there cannot keep
the camera off. A change closes a running recording and calls
homekit_camera_active_changed like before; writing the same value again
calls no hook.
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.77451% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.39%. Comparing base (08b7456) to head (0af78d7).
⚠️ Report is 1 commits behind head on dev.

Files with missing lines Patch % Lines
pyhap/camera.py 98.06% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #532      +/-   ##
==========================================
+ Coverage   90.38%   91.39%   +1.00%     
==========================================
  Files          26       27       +1     
  Lines        3798     4170     +372     
  Branches      459      522      +63     
==========================================
+ Hits         3433     3811     +378     
+ Misses        258      252       -6     
  Partials      107      107              
Files with missing lines Coverage Δ
pyhap/hksv3.py 100.00% <100.00%> (ø)
pyhap/camera.py 91.28% <98.06%> (+4.34%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ikalchev

Copy link
Copy Markdown
Owner

@RaHehl, again, the chars/services work here is done in #539. Not closing this as I see other changes besides the definitions. LMK if I can help any further.

# Conflicts:
#	pyhap/resources/characteristics.json
@RaHehl

RaHehl commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for regenerating the catalog in #539. I merged dev, so my catalog commits are gone from the diff. Everything this PR uses is in there with the same UUIDs, including the timed write and read-only permissions of the operating mode characteristics I had fixed by hand.

@RaHehl
RaHehl marked this pull request as ready for review November 10, 2026 13:13
With the global operating mode, the last write to either HomeKit Camera
Active now decides and the other follows, so a hub that only knows the
Camera Operating Mode service can still turn the camera off. Running
live streams end once streaming is not allowed anymore: camera off,
Streaming Enabled off or manually disabled. A global_operating_mode
option that is not a list raises TypeError.
Comment thread pyhap/camera.py Outdated
Comment on lines +1004 to +1007
if not isinstance(chars, (list, tuple)):
raise ValueError(
"global_operating_mode must be a list of characteristic names"
)

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.

Hm, the method type hints require list[str], but here we want list or tuple. Update the input type hint if tuples are allowed (or maybe use Iterable?).

Also, we generally do not assert the input is of the correct type, so I would drop this if entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, it takes a Sequence[str] now and the check is gone.

Comment thread pyhap/camera.py Outdated
Comment on lines +1013 to +1014
"Not an optional Camera Global Operating Mode characteristic: "
+ ", ".join(unknown)

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.

nit: Maybe use an f-string?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment thread pyhap/camera.py Resolved
Comment thread pyhap/camera.py Outdated
objs = tlv.decode(value, from_base64=True)
session_id = UUID(bytes=objs[SETUP_TYPES["SESSION_ID"]])
if not self.streaming_allowed:
logger.warning("[%s] Streaming is turned off, refusing it", session_id)

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.

I would put that at info

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, it's info now.

Comment thread pyhap/hksv3.py Outdated
return value


def _to_uuid(data: bytes) -> UUID:

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.

there is utils.hap_type_to_uuid, can you reuse it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

hap_type_to_uuid takes a HAP type string, here the TLV carries the 16 raw bytes. I dropped _to_uuid and use UUID(bytes=...) directly, it raises ValueError for a wrong length by itself.

The option takes any sequence of names and is no longer type checked.
The service keeps its chars by name, built once in setup. A refused
stream while streaming is turned off logs at info. Zone polygon
identifiers use UUID(bytes=...) directly, it raises ValueError for a
wrong length by itself.
@ikalchev
ikalchev merged commit 9250856 into ikalchev:dev Nov 10, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants