Skip to content

Add XNCP coordinator LED support#710

Draft
TheJulianJES wants to merge 1 commit into
zigpy:devfrom
TheJulianJES:tjj/zbt2_led
Draft

Add XNCP coordinator LED support#710
TheJulianJES wants to merge 1 commit into
zigpy:devfrom
TheJulianJES:tjj/zbt2_led

Conversation

@TheJulianJES
Copy link
Copy Markdown
Contributor

@TheJulianJES TheJulianJES commented Mar 21, 2026

DRAFT.

Proposed change

This adds XNCP support for coordinators implementing LED control, like the ZBT-2.

Additional information

Please see the linked PR for notes (and shortcomings).

Related ZHA PR:

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.54%. Comparing base (0d5b1b7) to head (0b421e6).

Additional details and impacted files
@@           Coverage Diff           @@
##              dev     #710   +/-   ##
=======================================
  Coverage   99.54%   99.54%           
=======================================
  Files          61       61           
  Lines        4147     4160   +13     
=======================================
+ Hits         4128     4141   +13     
  Misses         19       19           

☔ View full report in Codecov by Sentry.
📢 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.

Copy link
Copy Markdown

@zigpy-review-bot zigpy-review-bot left a comment

Choose a reason for hiding this comment

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

Verdict: looks right end-to-end against firmware, no bellows-side blockers. The wire shape and feature-bit value both match the ZBT-2 firmware exactly; raising mostly follow-up notes since the PR is DRAFT.

Firmware verification (from NabuCasa/silabs-firmware-builder src/zigbee_ncp/extension/xncp_zbt2_extension/src/xncp_zbt2_commands.c):

  • Opcode 0x0F00 -> handle_set_led_state -- matches XncpCommandId.SET_LED_STATE_REQ.
  • The handler accepts payload lengths of 3 (8-bit/ch) or 6 (16-bit/ch, big-endian), in r/g/b order. The bellows 3-byte t.uint8_t * 3 serialization (FF 80 00 for r=255,g=128,b=0) hits the 3-byte branch and the firmware promotes each byte to the high byte of a 16-bit register before calling led_manager_set_color(LED_PRIORITY_MANUAL, color). So (255,128,0) from bellows maps to firmware color (0xFF00, 0x8000, 0x0000) -- consistent with what led_manager expects.
  • XNCP_FEATURE_LED_CONTROL = 1 << 31 in xncp_types.h matches the new FirmwareFeatures.LED_CONTROL = 1 << 31. Bit 31 round-trips cleanly through t.bitmap32 (verified: int(LED_CONTROL) == 0x80000000, serializes to 00 00 00 80, deserializes back to a single member).
  • Empty SetLedStateRsp matches the firmware handler (writes status only, no reply bytes).

Local checks: full pytest tests/ passes (425/425). Pre-commit black/flake8/ruff/isort/codespell pass on the changed files. Bellows' [tool.mypy] sets ignore_errors = true, so mypy doesn't gate.

Top notes (all non-blocking, mostly about the consumer-side surface):

  1. Once you un-draft the ZHA companion (zigpy/zha#715), the consumer reaches into application_controller._ezsp._xncp_features and ._ezsp.xncp_set_led_state(...) -- both private-prefixed members. Existing precedent (MEMBER_OF_ALL_GROUPS, MANUAL_SOURCE_ROUTE, RESTORE_ROUTE_TABLE) keeps those internal to bellows.zigbee.application, so this would be the first feature consumed from outside bellows. Worth exposing a public surface on ControllerApplication (e.g. set_coordinator_led_state(red, green, blue) that no-ops or raises when the feature bit is absent) so ZHA doesn't have to private-poke.
  2. The firmware also exposes a 6-byte (16-bit/ch) payload format. Not adding it now is fine, but a struct-level option (or a separate SetLedStateReq16 payload) is the only way to drive the LED at >8-bit precision later. Worth a TODO comment near the request payload definition.
  3. There's no command exposed for led_manager_clear_pattern(LED_PRIORITY_MANUAL) -- the ZHA PR body already flags this. Without it, "off" (rgb=0,0,0) holds the manual override and blocks firmware-driven patterns from re-asserting. If you intend to address this on the bellows side eventually, a CLEAR_LED_STATE_REQ opcode reservation (or a comment noting the future plan) would be friendly.
  4. The 0x0F00 jump from the contiguous 0x0000..0x0008 block is meaningful (it's the firmware's per-device extension table base, separate from the core XNCP table); a one-line comment on the enum value would save future readers from wondering about the gap.
  5. Optional: a feature-bit roundtrip assertion would catch any future migration to a narrower bitmap type accidentally truncating bit 31. The project doesn't unit-test other feature bits this way, so this is just a suggestion.

Nothing that should block landing once the companion ZHA PR is ready.

Comment thread bellows/ezsp/xncp.py
SET_ROUTE_TABLE_ENTRY_REQ = 0x0006
GET_ROUTE_TABLE_ENTRY_REQ = 0x0007
GET_TX_POWER_INFO_REQ = 0x0008
SET_LED_STATE_REQ = 0x0F00
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider a brief comment here explaining the 0x0F00 opcode jump -- in the firmware, command IDs 0x0000..0x000F are the adapter-core XNCP table and IDs 0x0F00+ are the per-device extension table (xncp_zbt2_commands[], where 0x0F00 = SET_LED_STATE, 0x0F01 = GET_ACCELEROMETER). Future readers (and you, in six months) will appreciate not having to trace the firmware to understand the gap.

Also a future-proofing thought: the firmware handler accepts both 3-byte (8-bit/ch) and 6-byte (16-bit/ch, big-endian) payload formats, but bellows only emits the 3-byte form. If 16-bit precision ever matters for ZBT-2 LED color smoothness, you'd want either a second payload class (SetLedState16Req) or a flag on this one. Not blocking; worth a TODO.

Comment thread bellows/ezsp/xncp.py
TX_POWER_INFO = 1 << 7

# The firmware exposes adapter LED control
LED_CONTROL = 1 << 31
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Confirmed against firmware: XNCP_FEATURE_LED_CONTROL = 1 << 31 in src/zigbee_ncp/extension/xncp_extension/inc/xncp_types.h. Bit 31 round-trips cleanly through t.bitmap32 -- serializes to 00 00 00 80 (LE) and deserializes back to a single-member flag. No sign-bit weirdness despite hitting the top of a signed-int32 range, since bitmap32 is unsigned-backed.

The gap between bit 7 (TX_POWER_INFO) and bit 31 (LED_CONTROL) mirrors the firmware's separation between adapter-core features (low bits, sequential) and device-specific features (top of the range). A comment along those lines on the LED_CONTROL line would document the intentional gap.

Comment thread bellows/ezsp/xncp.py
class SetLedStateReq(XncpCommandPayload):
red: t.uint8_t
green: t.uint8_t
blue: t.uint8_t
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Payload shape verified against firmware handle_set_led_state -- with payload_length == 3 it reads payload[0..2] as r/g/b and shifts each into the high byte of a 16-bit color register. t.uint8_t * 3 serializes to exactly red green blue (no padding, no length prefix), so xncp_set_led_state(red=255, green=128, blue=0) lands as wire bytes 00 0F 00 FF 80 00 (cmd-id LE, status, payload) -- which the firmware decodes back to RGB (0xFF00, 0x8000, 0x0000) for the LED driver. Correct.

The firmware also has a 6-byte branch for 16-bit-per-channel color. Out of scope for this PR, but worth a # TODO: 16-bit color uses 6-byte payload (big-endian) if you anticipate adding that later -- the 6-byte branch is big-endian per channel, which is unusual for XNCP, so future-you will want the breadcrumb.

Comment thread bellows/ezsp/__init__.py

async def xncp_set_led_state(
self, red: t.uint8_t, green: t.uint8_t, blue: t.uint8_t
) -> None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ZHA pull zigpy/zha#715 ends up calling this directly via application_controller._ezsp.xncp_set_led_state(...) and gating on application_controller._ezsp._xncp_features. That's the first time a feature bit is being consumed from outside bellows.zigbee.application -- the existing MEMBER_OF_ALL_GROUPS/MANUAL_SOURCE_ROUTE/RESTORE_ROUTE_TABLE callers all live inside bellows.

Consider exposing a public method on bellows.zigbee.application.ControllerApplication (e.g. async def set_coordinator_led_state(self, red, green, blue) -> None that checks FirmwareFeatures.LED_CONTROL in self._ezsp._xncp_features and raises a typed CoordinatorFeatureNotSupported -- or returns a sentinel -- otherwise). That gives ZHA a stable public surface to call instead of poking _ezsp directly, and you avoid the implicit-API problem the next time someone refactors EZSP._xncp_features into something else.

No-op if you prefer keeping the consumer inside bellows long-term, but the ZHA PR body explicitly raises this question, so worth answering in this PR's review.

Comment thread tests/test_xncp.py
xncp.SetLedStateReq(red=1, green=2, blue=3)
).serialize()
)
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test is fine and matches the established pattern in this file. Two optional additions worth considering for follow-up:

  1. A FirmwareFeatures.LED_CONTROL roundtrip assertion (serialize + deserialize, confirm the bit survives) -- bit 31 sits at the top of a signed-int32 range and a future migration of the underlying type could silently drop it. Verified locally that the current code is fine, but a regression test costs ~3 lines.
  2. A negative-path test for what happens when the firmware doesn't support the command -- i.e. customFrame returning an Unknown payload or a non-OK status. send_xncp_frame raises InvalidCommandError in that case (see __init__.py:752,762,767), but neither the bellows side nor the ZHA-side PR has a test pinning that behavior.

Both are nice-to-have, not project-pattern-required.

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