Skip to content

Add Sonoff SNZB-06P24 presence sensor - #4907

Open
Oniums wants to merge 17 commits into
zigpy:devfrom
Oniums:dev
Open

Add Sonoff SNZB-06P24 presence sensor#4907
Oniums wants to merge 17 commits into
zigpy:devfrom
Oniums:dev

Conversation

@Oniums

@Oniums Oniums commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Proposed change

Add a quirk for the Sonoff SNZB-06P24 presence sensor.

This quirk adds support for the device's manufacturer-specific FC11 cluster and exposes the following controls and entities in ZHA:

  • Illumination offset as a configurable number entity
  • Fine-tune sensitivity as a configurable number entity
  • A button to trigger spatial learning
  • Spatial learning state as a sensor
  • Spatial learning countdown as a sensor
  • Per-zone enable switches for the supported detection zones

The quirk also implements handling for:

  • Virtual zone attributes backed by the zone enable bitmap
  • Spatial learning command remapping and incoming spatial learning payload parsing
  • Virtual attribute cache updates for zone state and spatial learning state/countdown

Additional information

This change is intended to add support for the Sonoff SNZB-06P24 in ZHA by exposing device-specific configuration and spatial learning features that are not available through the standard clusters alone.

This is not intended to be a breaking change for existing devices.

Device diagnostics

zha-01KNJTVMGFY0P6H2TCKT692JY6-SONOFF SNZB-06P24-6ced29d613a94bc061cbaaa40250d577.json

Checklist

  • The changes are tested and work correctly
  • pre-commit checks pass / the code has been formatted using Black
  • Tests have been added to verify that the new code works
  • Device diagnostics data has been attached

Copilot AI review requested due to automatic review settings April 7, 2026 02:38
@codecov

codecov Bot commented Apr 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 23.96313% with 165 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.39%. Comparing base (1c7c923) to head (6650c0b).
⚠️ Report is 42 commits behind head on dev.

Files with missing lines Patch % Lines
zhaquirks/sonoff/snzb06p24.py 23.96% 165 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #4907      +/-   ##
==========================================
- Coverage   93.07%   91.39%   -1.69%     
==========================================
  Files         401      417      +16     
  Lines       13306    14543    +1237     
==========================================
+ Hits        12385    13291     +906     
- Misses        921     1252     +331     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new ZHA quirk implementation for the Sonoff SNZB-06P24 presence sensor, introducing support for the device’s manufacturer-specific 0xFC11 cluster and exposing configuration/learning-related entities in Home Assistant.

Changes:

  • Introduces SonoffSNZB06P24FC11Cluster with custom command handling, payload parsing, and virtual zone attributes backed by a bitmap.
  • Registers a v2 QuirkBuilder to expose numbers, a command button, sensors, and per-zone enable switches.

Comment on lines +35 to +42
class SpatialLearningUiState(t.enum8):
"""Spatial learning UI state enum."""

IDLE = 0x00
START = 0x01
COUNTDOWN = 0x02
TIMEOUT = 0x03

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

Enum members in SpatialLearningUiState use ALL_CAPS (IDLE/START/COUNTDOWN/TIMEOUT) while other enums in this repo (including SpatialLearningState just above) consistently use PascalCase. This inconsistency makes the enum harder to use and is likely to leak into entity state strings. Rename these members to PascalCase and update all references accordingly.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +9 to +12
from zigpy.quirks.v2.homeassistant import EntityPlatform, EntityType, UnitOfTime
import zigpy.types as t
from zigpy.zcl import foundation

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

OccupancySensing is imported but never used in this module. Please remove the unused import to keep linting clean.

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +103
)
self._update_attribute(ATTR_SONOFF_SPATIAL_COUNTDOWN, 0)

def request(

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

_last_spatial_start_ts_ms is written in multiple places but never read. Either remove it or use it to derive UI state/countdown behavior (otherwise it’s dead state that can confuse future maintenance).

Copilot uses AI. Check for mistakes.
Comment on lines +225 to +268
"""Intercept read_attributes to handle virtual zone switches."""
virtual_attrs = set()
real_attributes = []

requested_ids = set()

for a in attributes:
aid = a
if isinstance(a, str):
if a in self.attributes_by_name:
aid = self.attributes_by_name[a].id

requested_ids.add(aid)
if isinstance(aid, int) and 0x1000 <= aid <= 0x1007:
virtual_attrs.add(aid)
else:
real_attributes.append(a)

if not virtual_attrs:
return await super().read_attributes(
attributes, allow_cache, only_cache, manufacturer
)

# Ensure we read the bitmap if we need it for virtual attributes
if ATTR_SONOFF_ZONE_ENABLE not in requested_ids:
real_attributes.append(ATTR_SONOFF_ZONE_ENABLE)

success, failure = await super().read_attributes(
real_attributes, allow_cache, only_cache, manufacturer
)

if ATTR_SONOFF_ZONE_ENABLE in success:
bitmap_val = success[ATTR_SONOFF_ZONE_ENABLE]
for v_id in virtual_attrs:
idx = v_id - 0x1000
if idx == 0:
val = bool(bitmap_val & 0x03)
else:
val = bool(bitmap_val & (1 << idx))
success[v_id] = t.Bool(val)
self._update_attribute(v_id, t.Bool(val))

if ATTR_SONOFF_ZONE_ENABLE in failure:
err = failure[ATTR_SONOFF_ZONE_ENABLE]

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

read_attributes() builds success/failure entries for virtual zone attributes using attribute IDs (success[v_id] = ...). Zigpy generally returns results keyed by the same form the caller requested (name vs id). If a caller requests e.g. "zone_0_enable", this implementation will return the value under 0x1000 instead of the name. Preserve and return results keyed by the original requested attribute identifiers.

Copilot uses AI. Check for mistakes.
Comment on lines +285 to +288
"""Intercept write_attributes to handle virtual zone switches."""

virtual_updates = {}
real_attributes = {}

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

write_attributes() overrides the base method but does not accept **kwargs (and its attributes type omits foundation.ZCLAttributeDef). Callers in this codebase often pass extra kwargs (e.g. disable_default_response) and may pass attribute defs; this override can raise TypeError. Update the signature to accept/forward **kwargs and include foundation.ZCLAttributeDef in the accepted key types to match zigpy/repo patterns.

Copilot uses AI. Check for mistakes.
Comment on lines +342 to +349
self._update_attribute(0x1001, t.Bool(state))
else:
self._update_attribute(0x1000 + idx, t.Bool(state))

# Perform the actual write
res = await super().write_attributes(real_attributes, manufacturer)

# Normalize response records.

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

This method updates the virtual zone attributes in the local cache before performing the bitmap write. If the device write fails, the cache/UI will temporarily show the wrong state (and you don’t roll it back). Update the cache only after confirming a SUCCESS status for the bitmap write, or revert the optimistic updates on failure.

Copilot uses AI. Check for mistakes.
Comment on lines +351 to +383
if res and isinstance(res[0], list):
records = res[0]
else:
records = res
elif hasattr(res, "status_records"):
records = res.status_records
else:
records = [res]

# Check if the bitmap write was successful
bitmap_status = foundation.Status.SUCCESS

for record in records:
if isinstance(record, foundation.WriteAttributesStatusRecord):
if record.attrid == ATTR_SONOFF_ZONE_ENABLE:
bitmap_status = record.status
break

# Generate records for virtual updates
if virtual_updates:
for idx in virtual_updates:
v_attr_id = 0x1000 + idx
# If bitmap write failed, report failure for virtual attrs too
records.append(
foundation.WriteAttributesStatusRecord(bitmap_status, v_attr_id)
)

return [records]

def _update_attribute(self, attrid, value):
"""Update attribute value in cache and push to listeners."""
super()._update_attribute(attrid, value)

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

The response “normalization” here can drop data: if super().write_attributes(...) returns multiple response chunks (list of lists), this code only keeps/returns the first chunk (records = res[0]) and discards the rest. Preserve the original response structure and append virtual status records to the corresponding response list without losing other records.

Copilot uses AI. Check for mistakes.
Comment on lines +415 to +424
mode="slider",
translation_key="fine_tune_sensitivity",
fallback_name="Fine-tune Sensitivity",
)
# Spatial Learning Button
# Cmd 0x04, SubCmd=0 (Start), Sequence=timestamp_ms
.command_button(
command_name="start_learning_now",
cluster_id=SonoffSNZB06P24FC11Cluster.cluster_id,
command_args=(),

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

fallback_name should be sentence case (per repo conventions). "Fine-tune Sensitivity" should be "Fine-tune sensitivity" to avoid inconsistent entity naming in Home Assistant.

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +224

if (
hdr.frame_control.frame_type == foundation.FrameType.CLUSTER_COMMAND
and hdr.command_id == CMD_SPATIAL_LEARNING
):
self._handle_spatial_learning_payload(bytes(payload))
return hdr, []

return super().deserialize(hdr.serialize() + payload)

def _handle_spatial_learning_payload(self, payload: bytes) -> None:
"""Handle incoming spatial learning command payloads."""
if not payload:
return

cmd_type = payload[0]

if cmd_type == 0x01 and len(payload) >= 17:
start_ts = int.from_bytes(payload[1:9], "little")
end_ts = int.from_bytes(payload[9:17], "little")
self._last_spatial_start_ts_ms = start_ts

duration_ms = max(0, end_ts - start_ts)
duration_sec = int(duration_ms // 1000)

self._set_spatial_ui_state(SpatialLearningUiState.COUNTDOWN)
self._start_spatial_countdown(duration_sec)
self._start_spatial_timeout()
elif cmd_type == 0x02 and len(payload) >= 11:
self._stop_spatial_tasks()
self._set_spatial_countdown(0)
self._set_spatial_ui_state(SpatialLearningUiState.IDLE)

def _set_spatial_ui_state(self, state: SpatialLearningUiState) -> None:
self._update_attribute(ATTR_SONOFF_SPATIAL_UI_STATE, state)

def _set_spatial_countdown(self, seconds: int) -> None:
self._update_attribute(ATTR_SONOFF_SPATIAL_COUNTDOWN, max(0, int(seconds)))

def _stop_spatial_tasks(self) -> None:
if self._spatial_countdown_task and not self._spatial_countdown_task.done():
self._spatial_countdown_task.cancel()
if self._spatial_timeout_task and not self._spatial_timeout_task.done():
self._spatial_timeout_task.cancel()

def _start_spatial_countdown(self, seconds: int) -> None:
if self._spatial_countdown_task and not self._spatial_countdown_task.done():
self._spatial_countdown_task.cancel()
self._spatial_countdown_task = asyncio.create_task(
self._spatial_countdown_worker(seconds)
)

async def _spatial_countdown_worker(self, seconds: int) -> None:
remaining = max(0, int(seconds))
self._set_spatial_countdown(remaining)
while remaining > 0:
await asyncio.sleep(1)
remaining -= 1
self._set_spatial_countdown(remaining)

def _start_spatial_timeout(self) -> None:
if self._spatial_timeout_task and not self._spatial_timeout_task.done():
self._spatial_timeout_task.cancel()
self._spatial_timeout_task = asyncio.create_task(self._spatial_timeout_worker())

async def _spatial_timeout_worker(self) -> None:
await asyncio.sleep(60)
if self._spatial_countdown_task and not self._spatial_countdown_task.done():
self._spatial_countdown_task.cancel()
self._set_spatial_countdown(0)
self._set_spatial_ui_state(SpatialLearningUiState.TIMEOUT)
await asyncio.sleep(5)
self._set_spatial_ui_state(SpatialLearningUiState.IDLE)

async def read_attributes(
self, attributes, allow_cache=False, only_cache=False, manufacturer=None
):

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

This quirk introduces substantial custom logic (custom deserialize() parsing, virtual attributes backed by a bitmap, and custom read/write interception). There should be dedicated unit tests (similar to existing Sonoff quirk tests in tests/test_sonoff.py) covering: virtual zone read/write behavior (including failed writes), and spatial learning payload handling updating the UI state/countdown.

Copilot generated this review using guidance from repository custom instructions.
@TheJulianJES TheJulianJES added manufacturer This request was made by the device's manufacturer priority: medium This should be addressed or looked at soon labels May 8, 2026
Copilot AI review requested due to automatic review settings May 27, 2026 03:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

Comment thread zhaquirks/sonoff/snzb09p.py Outdated
@@ -0,0 +1,385 @@
"""Sonoff SNZB-09P - Zigbee alarm sensor."""

from typing import Any
Comment thread zhaquirks/sonoff/snzb09p.py Outdated
Comment on lines +36 to +38
ALARM_STATE_OFF = 0x00
ALARM_STATE_MANUAL = 0x01
ALARM_STATE_SCENE = 0x02
Comment thread zhaquirks/sonoff/snzb09p.py Outdated
Comment on lines +250 to +262
SUBCMD_START_MANUAL_ALARM if alarm_switch_state else SUBCMD_STOP_ALARM
)

try:
await self.command(
CMD_SOUND_AND_LIGHT_ALARM_SETTINGS,
command_arg,
manufacturer=manufacturer or SONOFF_MANUFACTURER_CODE,
)
except Exception:
records.append(
foundation.WriteAttributesStatusRecord(
foundation.Status.FAILURE,
Comment on lines +29 to +32
Idle = 0x00
Learning = 0x01
Success = 0x02
Failure = 0x03
timestamp_ms = int(time.time() * 1000)
args = (0, timestamp_ms)
command_id = CMD_SPATIAL_LEARNING
manufacturer = 0x1286
self._handle_spatial_learning_payload(bytes(payload))
return hdr, []

return super().deserialize(hdr.serialize() + payload)
Comment on lines +272 to +276
# Remove bitmap if not requested (by ID or name)
if ATTR_SONOFF_ZONE_ENABLE not in requested_ids:
# Also check if user requested it by name "zone_enable"
# But we only track IDs in requested_ids.
# If 0x2016 isn't in requested_ids, we pop it.
Comment on lines +338 to +344
# Optimistically update virtual attributes in cache
for idx, state in virtual_updates.items():
if idx == 0:
self._update_attribute(0x1000, t.Bool(state))
self._update_attribute(0x1001, t.Bool(state))
else:
self._update_attribute(0x1000 + idx, t.Bool(state))
@yanhao666

Copy link
Copy Markdown

Love this presence sensor. When will it be merged, please? @TheJulianJES

@hagaygo

hagaygo commented Jun 9, 2026

Copy link
Copy Markdown

Tried the quirk localy and it seems to work just fine.

@hagaygo

hagaygo commented Jul 17, 2026

Copy link
Copy Markdown

Lately i was getting a warning on ha logs

Command 'spatial_learning' has an incorrect direction, please remove the `direction` kwarg
Command 'start_learning_now' has an incorrect direction, please remove the `direction` kwarg

I think it started after upgrading to 2026.7.1

Tried some code tweaks using some llm

added

from zigpy.zcl.foundation import Direction

And changed to

# Define the server commands to send "Start Spatial Learning"
    server_commands = {
        CMD_SPATIAL_LEARNING: (
            "spatial_learning",
            (t.uint8_t, t.uint64_t),
            Direction.Server_to_Client,
        ),
        CMD_START_LEARNING_NOW: (
            "start_learning_now",
            (),
            Direction.Server_to_Client,
        ),
    }

    # Define client commands for incoming spatial learning reports
    client_commands = {
        CMD_SPATIAL_LEARNING: (
            "spatial_learning",
            (t.uint8_t, t.uint64_t),
            Direction.Client_to_Server,
        ),
    }

No warning after this change , not sure its the correct one...

@zigpy-review-bot zigpy-review-bot changed the title Add a quirk for the Sonoff SNZB-06P24 presence sensor. Add Sonoff SNZB-06P24 presence sensor Jul 18, 2026
@zigpy-review-bot zigpy-review-bot added the bot: needs changes PR needs changes per LLM label Jul 18, 2026
@zigpy-review-bot zigpy-review-bot added bot: 2.0 migration needed PR needs ZHA/quirks 2.0.0 migration per LLM new quirk Adds support for a new device labels Jul 18, 2026
Migrate the spatial learning commands to BaseCommandDefs and
ZCLCommandDef so zigpy derives the correct server and client directions.

This removes the incorrect direction warnings without changing command
IDs, names, or payload schemas.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot: needs changes PR needs changes per LLM bot: 2.0 migration needed PR needs ZHA/quirks 2.0.0 migration per LLM manufacturer This request was made by the device's manufacturer new quirk Adds support for a new device priority: medium This should be addressed or looked at soon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants