Skip to content

feat: transaction response API for device set/get commands - #30774

Open
rusty-art wants to merge 2 commits into
Koenkk:devfrom
rusty-art:master
Open

rusty-art wants to merge 2 commits into
Koenkk:devfrom
rusty-art:master

Conversation

@rusty-art

@rusty-art rusty-art commented Jan 24, 2026

Copy link
Copy Markdown

Closes #30679

What

Adds request/response support for device commands, mirroring the existing bridge pattern (docs).

  • zigbee2mqtt/FRIENDLY_NAME/request/set → response on zigbee2mqtt/FRIENDLY_NAME/response/set
  • zigbee2mqtt/FRIENDLY_NAME/request/get → response on zigbee2mqtt/FRIENDLY_NAME/response/get
  • Existing /set and /get topics are unchanged — fully backward compatible

Response shape

/response/set — echoes requested values

Success:

{"data": {"state": "ON", "brightness": 200}, "status": "ok", "z2m_transaction": "my-id"}

Error (error is human-readable; error_details maps each failed attribute to the original error message):

{"data": {}, "status": "error", "error": "Failed to set 'brightness': Request superseded", "error_details": {"brightness": "Request superseded"}, "z2m_transaction": "my-id"}

Partial success (attributes that were accepted are still echoed in data):

{"data": {"color_temp_startup": 300}, "status": "error", "error": "Failed to set 'brightness': Request superseded", "error_details": {"brightness": "Request superseded"}, "z2m_transaction": "my-id"}

data echoes the values from the request, not from the device or cache. It confirms what was accepted, not current device state.

/response/get — status only, no data

{"data": {}, "status": "ok", "z2m_transaction": "my-id"}

GET responses deliberately omit data values. Actual device values arrive on the state topic (zigbee2mqtt/FRIENDLY_NAME), which is the authoritative source. This avoids a race condition where the state cache may not yet be updated when the response is built.

Common fields

  • z2m_transaction — optional, echoed back if provided in request
  • error — human-readable summary, same style as the bridge API
  • error_details{attribute: original error message} for every failed attribute; original messages are passed through unchanged (e.g. herdsman's Request superseded when a newer command replaced a queued one on a sleepy device)
  • QoS of response matches request QoS
  • retain: false (responses are events, not state)
  • Ping: send {"z2m_transaction": "ping1"} (or {}) to /request/set{"data": {}, "status": "ok"}

Why z2m_transaction instead of transaction

The bridge uses transaction. We use z2m_transaction because CSM-300ZB has a real device attribute called transaction (shinasystem.js). A flat transaction field would collide.

Diff

 lib/extension/publish.ts   | +63 (regex, ParsedTopic.isRequest, response logic, per-attribute error tracking)
 lib/mqtt.ts                | +3  (QoS propagation through event system)
 lib/extension/frontend.ts  | +2  (QoS for WebSocket messages)
 lib/types/types.d.ts       | +1  (qos field on MQTTMessage event)
 package.json               | +1  (zigbee-herdsman 9.0.2 → 9.0.6 for superseded error)
 test/extensions/publish.ts | +172 (14 tests covering all response branches)
 test/controller.test.ts    | +2/-2 (update existing tests for qos field)

Test plan

  • 14 unit tests covering all response branches (set success, get status-only, errors, partial success, shared-converter keys, ping, groups, QoS, endpoint/attribute topics)
  • 834 tests pass, 0 failures
  • Biome lint clean
  • Manual testing with mains and sleepy devices

Frontend branches (windfront)

The earlier windfront PR (Nerivec/zigbee2mqtt-windfront#409) was closed as stale while this backend PR awaits review. A windfront PR will be opened once this is merged. Until then, both branches are kept rebased on current windfront main and can be used to test against this PR:

Testing together

  • Backend: this PR — publishes structured responses on <device>/response/set and <device>/response/get
  • Frontend: the transaction-response-api branch above — consumes responses to show real-time command status

@Koenkk

Koenkk commented Jan 24, 2026

Copy link
Copy Markdown
Owner

I think it's good to have a request/response like API similar to how it is implemented for the bridge (docs). For this existing function like getResponse can be re-used (and should also reduce the code to add by a lot). I propose something like: zigbee2mqtt/FRIENDLY_NAME/request/set which then sends a response to zigbee2mqtt/FRIENDLY_NAME/response/set (same as the current zigbee2mqtt/FRIENDLY_NAME/set

@rusty-art

Copy link
Copy Markdown
Author

The consistency with bridge makes a lot of sense. I've worked through the changes and have a few questions - happy to go with whatever approach you prefer.

1. Correlation field naming

Nerivec previously flagged that CSM-300ZB has a "transaction" device attribute (0-1000ms enum). If we use flat "transaction" for correlation, those users can't set their device attribute AND use request-response simultaneously - we'd have to strip it before forwarding, breaking their device config.

Options:

  • Nested: "z2m: { request_id }" (current) - entire z2m object stripped before forwarding, no collision possible
  • Flat with prefix: "transaction_id" or "transaction_request" (or more unique like z2m_id) - more similar to bridge pattern, avoids CSM-300ZB collision
  • Flat: "transaction" - matches bridge exactly but breaks device(s): CSM-300ZB users can't set the transaction attribute

Preference?

2. Response metadata

Bridge responses are minimal. For device commands, I've added fields that frontends/automation can use for better sleepy-device handling and performance monitoring:

  • "elapsed_ms" - latency monitoring (frontend can show "responded in 45ms")
  • "status: pending" - for sleepy battery devices (command queued, will deliver when device wakes)
  • "status: partial" - some attributes succeeded, some failed (per-attribute tracking)
  • "transmission_type: multicast" + "member_count" - for group commands (no per-device ACK per ZCL spec)
  • "final" - enables multi-response streaming (e.g., if a command triggers multiple state updates over time); but currently always 'true'

these can be wrapped in the z2m structure (item 1 above) or flattened like z2m_transaction_id, z2m_status, z2m_type, etc.
These fields allow frontends to provide clear feedback to users on the status of set requests and sleep-device handling.

Should we keep these, or go minimal to match bridge exactly for now (foregoing some better frontend UX)

3. Request topic (optional)

We could add {device}/request/set as an alternative to {device}/set (and similar for get) for clients that want explicit request-response semantics. This would mirror bridge exactly, but introduce extra messages and the slow/painful migration for clients to deprecate {device}/[set|get]/ and replace with {device}/request/[get|set]. Probably unnecessary, but just wanted to check if you wanted to introduce that as well.

(FYI, Ecosystem impact: I checked Home Assistant and windfront - neither will break. It seems that HA uses specific topics from discovery (not wildcards on device topics). Windfront uses WebSocket with the backend forwarding all MQTT messages. Both approaches just add new topics; existing /set and /get behavior unchanged.)

Let me know your thoughts and I'll adjust accordingly.

@rusty-art
rusty-art marked this pull request as draft January 25, 2026 02:33
@rusty-art
rusty-art force-pushed the master branch 3 times, most recently from 25d57ea to 210e118 Compare March 1, 2026 03:12
@rusty-art rusty-art changed the title feat: implement Transaction Response API feat: transaction response API for device set/get commands Mar 1, 2026

@rusty-art rusty-art left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

explanatory comments only.

Comment thread lib/extension/publish.ts
// Used by `publish.test.ts` to reload regex when changing `mqtt.base_topic`.
export const loadTopicGetSetRegex = (): void => {
topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(get|set)(?:/(.+))?$`);
topicGetSetRegex = new RegExp(`^${settings.get().mqtt.base_topic}/(?!bridge)(.+?)/(request/)?(get|set)(?:/(.+))?$`);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The (request/)? group is optional (?), so this regex still matches legacy /set and /get topics exactly as before. The new capture group shifts subsequent group indices (match[2] → type, match[3] → isRequest, match[4] → attribute). Backwards compatibility is verified by all existing tests passing unchanged, plus an explicit "Should NOT publish response for legacy /set topic" test.

Comment thread lib/extension/publish.ts
return;
}

// Extract and strip z2m_transaction before forwarding to converters

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We strip z2m_transaction from the message before forwarding to converters. This is necessary because the CSM-300ZB device (shinasystem.js) has a real device attribute called transaction — if we'd used that name, the converter would consume it as a device setting. We use z2m_transaction to avoid the collision, and strip it here so converters never see it.

Comment thread lib/extension/publish.ts
delete message.z2m_transaction;
}

// Ping: /request/ topic with empty payload after stripping z2m_transaction

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ping pattern: after stripping z2m_transaction, if the payload is empty ({}), we short-circuit with an immediate {data: {}, status: "ok"} response. This lets clients verify the bridge is responsive without generating any Zigbee traffic. Useful for health checks and connection validation.

Comment thread lib/extension/publish.ts Outdated
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
if (parsedTopic.isRequest) {
((error as Error).message?.includes("Request superseded") ? supersededKeys : failedKeys).push(originalKey);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Herdsman ≥9.0.5 throws "Request superseded" when a queued command for a sleepy device is replaced by a newer one. We distinguish this from generic failures so clients can tell the difference between "your command was replaced" vs "your command failed." Both map to status: "error" but with different error string prefixes (superseded: vs failed:).

Comment thread lib/mqtt.ts
if (!this.publishedTopics.has(topic)) {
logger.debug(() => `Received MQTT message on '${topic}' with data '${message.toString()}'`, NS);
this.eventBus.emitMQTTMessage({topic, message: message.toString()});
this.eventBus.emitMQTTMessage({topic, message: message.toString(), qos: /* v8 ignore next */ packet?.qos ?? 0});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Propagates the MQTT QoS level from the incoming packet into the event system, so the response can be published at the same QoS as the request. Previously QoS was discarded at the event boundary. The ?? 0 fallback handles edge cases where packet is undefined (e.g. WebSocket messages via frontend.ts).

await flushPromises();
expect(mockLogger.error).toHaveBeenCalledWith("Entity 'an_unknown_entity' is unknown");
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

13 tests covering all transaction response branches: success with/without z2m_transaction, converter failure, superseded error, legacy topic (no response), ping pattern, QoS matching, group commands, attribute-in-topic, endpoint-in-topic, GET response (status only, no data), partial success, and mixed superseded+failed outcomes.

@rusty-art
rusty-art marked this pull request as ready for review March 1, 2026 04:51
@rusty-art

Copy link
Copy Markdown
Author

@Koenkk, now implemented and rebased onto dev. Thanks for the guidance on the bridge pattern, it worked out really well!

Quick summary of what's in the updated PR:

  • Bridge-style topics and response shape: {device}/request/set{device}/response/set with the same {data, status, error} structure as getResponse. Legacy /set and /get are fully unchanged - no response published.

  • z2m_transaction instead of transaction: Used a prefixed name to avoid the CSM-300ZB collision that Nerivec flagged (it has a real transaction device attribute). Otherwise follows the same correlation pattern as bridge.

  • Didn't reuse getResponse directly - two small differences: we need z2m_transaction instead of transaction, and on partial success we return the succeeded attributes in data alongside the error (bridge always returns empty data on error). The response building is only ~12 lines inline in publish.ts though. Happy to refactor into a shared helper if you think that's cleaner!

  • Kept it minimal: Dropped the extra metadata (elapsed_ms, pending status, etc.) from my earlier proposal. Just data, status, error, and z2m_transaction - simple and consistent with bridge.

I've added inline comments on the key design decisions. Total diff is +187/-10 across 6 files with 13 new tests. Let me know if anything needs adjusting.

@Koenkk

Koenkk commented Mar 2, 2026

Copy link
Copy Markdown
Owner

Nice! I need to do a in-depth review, @Nerivec do you think this is also useful for the frontend? (to get better error messages when changing stuff through the exposes tab?)

@github-actions

Copy link
Copy Markdown
Contributor

This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days

@github-actions github-actions Bot added the stale Stale issues label May 14, 2026
@burmistrzak

Copy link
Copy Markdown
Contributor

Do not stale. ☝️

@github-actions github-actions Bot removed the stale Stale issues label May 15, 2026
@tbudzyn

tbudzyn commented Jun 12, 2026

Copy link
Copy Markdown

Is there something i could help out, to speed up verification of this PR?

@github-actions

Copy link
Copy Markdown
Contributor

This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days

@github-actions github-actions Bot added the stale Stale issues label Aug 22, 2026
Adds request/response support for device commands, mirroring the
existing bridge pattern. Clients send to /request/set or /request/get
and receive structured responses on /response/set or /response/get.

SET responses echo the requested values. GET responses return status
only (no data) — actual values arrive on the state topic to avoid a
race condition between convertGet() resolving and the cache update.

Includes z2m_transaction correlation, superseded command detection,
QoS propagation, and ping support.
@rusty-art

rusty-art commented Aug 23, 2026

Copy link
Copy Markdown
Author

Not stale — still keen to get this in. Rebased onto current dev (cf11b1a1) to resolve the conflict from the express-static-gzip → srvx change in frontend.ts (only the IPublishPacket import was affected). No functional changes; tsc, biome and all 831 tests pass locally.

For anyone wanting to test end-to-end, the windfront side is kept rebased on current windfront main (PR description updated with the same links):

@Koenkk happy to make any adjustments from the review whenever you get a chance.

@github-actions github-actions Bot removed the stale Stale issues label Aug 24, 2026
Comment thread test/extensions/publish.test.ts Outdated
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb_color/response/set",
stringify({data: {}, error: "superseded:brightness", status: "error", z2m_transaction: "tx3"}),

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 think we should just put a human readable message here, and next to that add e.g. error_details: {"brightness": "superseded"}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed in 63cc4f6: error is now a human-readable message and error_details maps each failed attribute to its original error message, e.g.

{"data": {}, "status": "error", "error": "Failed to set 'brightness': Request superseded", "error_details": {"brightness": "Request superseded"}}

I went with the original message as the error_details value rather than a category word like superseded, since that keeps the backend free of any string matching on herdsman error text and still lets a client detect superseded by looking at the per-attribute message. Happy to switch to a fixed category if you'd rather have that.

While testing this I also found that attributes handled by a converter that already ran for an earlier key (e.g. brightness + color_temp on a light) were dropped from the response entirely; they now inherit that call's outcome in data or error_details.

Comment thread test/extensions/publish.test.ts Outdated
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
"zigbee2mqtt/bulb_color/response/set",
stringify({data: {}, error: "failed:brightness", status: "error", z2m_transaction: "tx2"}),

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.

Suggested change
stringify({data: {}, error: "failed:brightness", status: "error", z2m_transaction: "tx2"}),
stringify({data: {}, error: "failed:brightness", status: "error", "transaction: "tx2"}),

To make it the same as the bridge API

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Happy to rename this to transaction to match the bridge. One thing I want to double-check before doing it: as @Nerivec flagged earlier (and my question 1 above), the CSM-300ZB (shinasystem) has a real device attribute called transaction, which is why I went with the z2m_transaction prefix.

If we use plain transaction, my plan would be to strip it from the payload before it reaches the converters, but only on /request/set and /request/get. Legacy /set would still pass transaction through to the device, so CSM-300ZB owners keep the attribute there; they just can't set it via the request/response topics.

Is that trade-off OK with you, or would you prefer a different name that can't collide (e.g. transaction_id)? Once the name is settled, the response building can reuse utils.getResponse as you suggested originally.

Comment thread lib/extension/publish.ts
logger.error(message);
// biome-ignore lint/style/noNonNullAssertion: always Error
logger.debug((error as Error).stack!);
if (parsedTopic.isRequest) {

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.

Would just keep the original error message here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 63cc4f6: the Request superseded string match is gone and the original error message is passed straight through, same as the bridge does in its catch block.

… responses

Review feedback from Koenkk on Koenkk#30774:

- `error` is now a human-readable message built from the original
  converter/herdsman error messages instead of the packed
  `superseded:a|failed:b` format.
- New `error_details` field maps each failed attribute to its original
  error message, so clients no longer need to parse `error`.
- Drop the string match on "Request superseded"; the original message is
  passed through unchanged, as the bridge API does.
- Keys handled by a converter that already ran for an earlier key in the
  same message (e.g. brightness + color_temp on a light) now inherit that
  call's outcome instead of silently disappearing from the response.
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.

4 participants