Skip to content

[matter] Bridge dimmer fixes - #21402

Open
digitaldan wants to merge 3 commits into
openhab:mainfrom
digitaldan:matter-bridge-dimmer-off-level
Open

[matter] Bridge dimmer fixes#21402
digitaldan wants to merge 3 commits into
openhab:mainfrom
digitaldan:matter-bridge-dimmer-off-level

Conversation

@digitaldan

Copy link
Copy Markdown
Contributor

This fixes a long standing issue where we would not report correctly on Dimmable lights who ramp up and down when changed (dim to off)

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes Matter bridge dimmer ramping and cluster feature configuration.

Changes:

  • Tracks and reports dimmer levels during ramps.
  • Adds level, undefined-state, and activation tests.
  • Enables required Matter Lighting and OnOff features.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
DimmableLightDevice.java Revises level and OnOff synchronization.
DimmableLightDeviceTest.java Expands dimmer behavior tests.
DimmableDeviceType.ts Enables required dimmable-light features.
ColorDeviceType.ts Enables required color-light features.
OnOffLightDeviceType.ts Restores the Lighting feature.
OnOffPlugInDeviceType.ts Restores the Lighting feature.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@wborn wborn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Additional AI review:

The cluster feature restoration and ramp-state handling otherwise look consistent with matter.js 0.17.4, and the current build/static-analysis results are clean. One Level Control edge case still needs to be addressed before merge: ExecuteIfOff allows a without-OnOff level command while the endpoint is off, but the new unconditional Java level handling turns the openHAB dimmer on.

if (lastOnOffState == OnOffType.ON) {
updateLevel(ValueUtils.levelToPercent(((Double) data).intValue()));
}
updateLevel(ValueUtils.levelToPercent(((Double) data).intValue()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Enabling LevelControl.Feature.OnOff handles the normal off-state case, but ExecuteIfOff is still an exception. In matter.js 0.17.4, the Options semantics explicitly allow a without-OnOff command to execute while OnOff=false when ExecuteIfOff is set, and #optionsAllowExecution implements exactly that. A regular MoveToLevel still calls moveToLevelLogic(..., false, ...), so the OnOff state is not coupled.

CustomLevelControlServer forwards that level to openHAB before the base logic, and this unconditional updateLevel() turns a nonzero level into a PercentType command for the dimmer, turning the physical light on even though this is a without-OnOff command. Please preserve the off state for the ExecuteIfOff path while still allowing MoveToLevelWithOnOff to turn the device on.

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.

Co-pilot found this line and responded that same, i will repeat what i commented there

#optionsAllowExecution (LevelControlServer.ts:596) already enforces this with LevelControl.Feature.OnOff

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I double-checked this, as I also saw the Copilot comment while reviewing. I think we may be looking at two slightly different cases here.

#optionsAllowExecution() does handle the normal case where ExecuteIfOff is false. The edge case the AI is pointing to is when ExecuteIfOff is true. In that case #optionsAllowExecution allows a regular MoveToLevel to run while the light is off. MoveToLevel then calls moveToLevelLogic(..., false, ...), so the OnOff state should remain unchanged. This also seems to match the documented ExecuteIfOff semantics.

The concern is that CustomLevelControlServer forwards the new level to openHAB regardless of withOnOff, so that distinction may get lost on the bridge side.

Perhaps a regression test can be added for this specific case to settle the question and make sure the implementation keeps working as expected?

@digitaldan digitaldan Aug 16, 2026

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.

Fair point, the bridge (javascript side) is the one part of the binding that lacks test coverage, so i may need to open another PR for that (but i think thats a good idea in any case). What i can do however, is modify this to work the same way as the LevelControlServer, which really should of been done in the first place as that will cover color lights as well. I'll have that up shortly.

@lsiepel lsiepel added the bug An unexpected problem or unintended behavior of an add-on label Aug 15, 2026
@robnielsen

Copy link
Copy Markdown
Contributor

Here's another AI review...


Thanks for working on this! Adding the missing cluster features (OnOff.Feature.Lighting, LevelControl.Feature.Lighting, LevelControl.Feature.OnOff) to the specialized device endpoints is a great fix for the behavior coupling issue in matter.js.

Here are a few findings and suggestions to review:


1. OnOffType.ON state updates overwrite remembered brightness to 100%

In DimmableLightDevice.java:

@Override
public void updateState(Item item, State state) {
    PercentType brightness = state instanceof HSBType hsb ? hsb.getBrightness() : state.as(PercentType.class);
    if (brightness == null) {
        return;
    }
    List<AttributeState> states = lightStates(brightness);
    ...

In openHAB Core, OnOffType.ON.as(PercentType.class) returns PercentType.HUNDRED (100%). Consequently, in lightStates(PercentType brightness):

boolean on = brightness.intValue() > 0;
if (on) {
    lastLevel = Math.max(MIN_LEVEL, ValueUtils.percentToLevel(brightness));
}

When openHAB updates the dimmer state with OnOffType.ON (e.g. from an ON command/rule), lastLevel is overwritten to 254 (100%), discarding whatever prior brightness level was stored.

Also note that in DimmableLightDeviceTest.java:

@Test
void testOnAfterOffReportsFullBrightness() {
    // Going off must not clobber the remembered level, so a later ON returns to the previous brightness.
    dimmerDevice.updateState(dimmerItem, new PercentType(50));
    dimmerDevice.updateState(dimmerItem, PercentType.ZERO);
    Mockito.clearInvocations(client);
    dimmerDevice.updateState(dimmerItem, OnOffType.ON);

    List<AttributeState> expectedStates = List.of(new AttributeState("onOff", "onOff", true),
            new AttributeState("levelControl", "currentLevel", 254));
    verify(client).setEndpointStates(any(), eq(expectedStates));
}

The test comment states that going off should not clobber the remembered level and a subsequent ON should restore previous brightness (50%), but the assertion expects 254 (100%). Differentiating between discrete OnOffType and PercentType in updateState would preserve lastLevel when receiving ON.


2. Handling MoveToLevel vs MoveToLevelWithOnOff when endpoint is OFF

In CustomLevelControlServer.ts:

override async moveToLevelLogic(
    level: number,
    transitionTime: number | null,
    withOnOff: boolean,
    options: LevelControl.Options = {},
) {
    this.env
        .get(DeviceFunctions)
        .sendAttributeChangedEvent(this.endpoint.id, "levelControl", "currentLevel", level);
    ...
    return super.moveToLevelLogic(level, transitionTime, withOnOff, options);
}

CustomLevelControlServer emits the currentLevel event eagerly for all moveToLevelLogic calls regardless of withOnOff.

In DimmableLightDevice.java, removing the lastOnOffState == OnOffType.ON guard causes openHAB to immediately receive and send a PercentType command to the DimmerItem. In openHAB, sending a non-zero PercentType command turns the physical light on. If a controller sends a MoveToLevel (without OnOff) command to an OFF light, this turns the physical light on before Matter cluster logic evaluates the command.

Consider passing the withOnOff context or ensuring level commands aren't dispatched to turn on the physical light when a command without OnOff is received while off.


3. OnOffPlugInDeviceType feature flags

In OnOffPlugInDeviceType.ts:

OnOffPlugInUnitDevice.with(...this.baseClusterServers, CustomOnOffServer.with(OnOff.Feature.Lighting))

Per Matter Device Library Specification (§5.1 On/Off Plug-in Unit), plug-in units are general On/Off endpoints and do not require the Lighting feature. Adding OnOff.Feature.Lighting may cause controllers (Apple Home, Google Home, Alexa) to classify the plug strictly as a light fixture rather than a generic plug/appliance.


4. Alignment with ColorDevice.java

ColorDevice.java also bridges LevelControl and OnOff clusters. Consider sharing the MIN_LEVEL / ramp deduplication logic across both classes so color lights benefit from the same improvements.

@digitaldan

Copy link
Copy Markdown
Contributor Author

@robnielsen please don't just fire off random AI generated reviews on PR's that are not your own. Half of that review is just plain wrong, the rest nit picky, with one point being something @wborn already mentioned in his review. Its not helpful, and is just wasting the time we have to volunteer here.

@robnielsen

Copy link
Copy Markdown
Contributor

@robnielsen please don't just fire off random AI generated reviews on PR's that are not your own. Half of that review is just plain wrong, the rest nit picky, with one point being something @wborn already mentioned in his review. Its not helpful, and is just wasting the time we have to volunteer here.

@digitaldan. If its wrong, then respond and explain why. BTW, I don't appreciate the tone of your comments, including the comments here.

@digitaldan

Copy link
Copy Markdown
Contributor Author

@robnielsen if i was harsh i apologize, but i spent a very, very long time creating this binding, and while i welcome contributions, i am extremely skeptical of completely AI generate PR's where the authors are not reviewing the changes, or have a understanding of how the underlying code works, that last PR changed a shared utility class use by many others, which should of been a big red flag when reviewing (i'm surprised your ai did not flag it).

It costs little of ones time to ask AI to generate code or a review and push that, its costs significantly more for someone to have to read that, understand it, and validate its claims. its not fair in my opinion to push this burden on others without that person also investing in understanding what they are asking, knowing how the code works, reviewing what they are posting. I will not spend time defending something you spent little to no time generating.

@digitaldan

digitaldan commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

@robnielsen you are right , i was too harsh on that last review upon re-reading, i apologize for that, you were trying to help and i was rude in my response. Thats a lesson for me to relax a little bit around here and be more civil.

@robnielsen

Copy link
Copy Markdown
Contributor

@digitaldan, thank you for apology. If you are interested, I can give you a more detailed response. I should of originally asked antigravity to look at the comments on the PR.

@lsiepel
lsiepel requested a review from wborn August 16, 2026 20:40

@wborn wborn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The current HEAD still has the ExecuteIfOff issue discussed above. Since an update for that is already planned, there is no need to duplicate the inline comment here.

There is also one behavior worth preserving when updating this: an OnOffType.ON should not overwrite the previously remembered dimmer level with 100%. The previous implementation kept lastLevel and reported that level again when turning on. The new generic state.as(PercentType.class) handling converts OnOffType.ON to 100%, so lastLevel becomes 254.

The test testOnAfterOffReportsFullBrightness() currently appears inconsistent with its comment: the comment says that turning the light back on should return to the previous brightness, but after starting at 50% the test expects currentLevel = 254.

The Matter feature changes otherwise look correct, and CI/static analysis are clean.

This review was AI-assisted.

@robnielsen

Copy link
Copy Markdown
Contributor

Feel free to ignore if you want...


@wborn's review regarding the OnOffType.ON level reset (and the testOnAfterOffReportsFullBrightness inconsistency) as well as the MoveToLevel off-state handling matches what my previous comment found.

The only additional finding from that review not mentioned is in OnOffPlugInDeviceType.ts. Per the Matter Device Library spec (§5.1 On/Off Plug-in Unit), plug-in units are generic endpoints rather than lighting devices, so adding OnOff.Feature.Lighting there may cause controllers (Apple Home, Google Home, Alexa) to classify general smart plugs strictly as light fixtures.

@wborn

wborn commented Aug 17, 2026

Copy link
Copy Markdown
Member

Yes it should just have said that it is worth looking into and why as a normal maintainer would do instead of presenting it as a new finding. I will try to make it a bit smarter. It's being trained on the job and still has a bit of learning to do. 😉

@robnielsen

robnielsen commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

I built and am running the latest version locally and tried with Google Home. The dimmer is at 100%

Screenshot_20260818-131252

Then I slide the slider to 0% and get an error:
Screenshot_20260818-131337
Then the dimmer turns on to 1%:
Screenshot_20260818-131343

Also, if I set the value to 1% it becomes 2%. 2% or higher stays at correct value.

Here are logs for 100% -> 0% -> 1%:

2026-08-18 13:38:13.590 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: New exchange « @1:f3f3a5fe•3ea5⇵dc42 protocol: 1 peerSess: 3ea5 SAT: 4s SAI: 300ms SII: 500ms maxTrans: 5 MRP
2026-08-18 13:38:13.592 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/InvokeRequest id: @1:f3f3a5fe•3ea5⇵dc42✉05804868 type: 0x1/0x8 reqAck size: 40 payload: 15280028013602153700240024240108240204183501240001340124020024030018181824ff0c18
2026-08-18 13:38:13.595 [TRACE] [ternal.client.MatterWebsocketService] - InteractionServer: Invoke « @1:f3f3a5fe•3ea5⇵dc42 invokes: 36.levelControl.moveToLevelWithOnOff
2026-08-18 13:38:13.598 [TRACE] [ternal.client.MatterWebsocketService] - ProtocolService: Invoke « oh-bridge.aggregator.sRobOfficeLights.levelControl.moveToLevelWithOnOff @1:f3f3a5fe•3ea5⇵dc42✉05804868 level: 1 transitionTime: null optionsMask: { executeIfOff: false, coupleColorTempToLevel: false } optionsOverride: { executeIfOff: false, coupleColorTempToLevel: false }
2026-08-18 13:38:13.603 [TRACE] [ternal.client.MatterWebsocketService] - matter: Sending event: {"type":"event","message":{"type":"bridgeEvent","data":{"type":"attributeChanged","data":{"endpointId":"sRobOfficeLights","clusterName":"levelControl","attributeName":"currentLevel","data":1}}}}
2026-08-18 13:38:13.604 [DEBUG] [r.internal.bridge.MatterBridgeClient] - onWebSocketText {"type":"event","message":{"type":"bridgeEvent","data":{"type":"attributeChanged","data":{"endpointId":"sRobOfficeLights","clusterName":"levelControl","attributeName":"currentLevel","data":1}}}}
2026-08-18 13:38:13.606 [DEBUG] [r.internal.bridge.MatterBridgeClient] - bridgeEvent message {"type":"attributeChanged","data":{"endpointId":"sRobOfficeLights","clusterName":"levelControl","attributeName":"currentLevel","data":1}}
2026-08-18 13:38:13.611 [DEBUG] [l.bridge.devices.DimmableLightDevice] - sRobOfficeLights state changed from 100 to 1
2026-08-18 13:38:13.613 [DEBUG] [r.internal.bridge.MatterBridgeClient] - sendMessage: {"id":"10f909f0-e3de-41b5-a9e9-d15822d5e339","namespace":"bridge","function":"setEndpointStates","args":["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":3}]]}
2026-08-18 13:38:13.620 [TRACE] [ternal.client.MatterWebsocketService] - Controller: Received request: {"id":"10f909f0-e3de-41b5-a9e9-d15822d5e339","namespace":"bridge","function":"setEndpointStates","args":["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":3}]]}
2026-08-18 13:38:13.621 [TRACE] [ternal.client.MatterWebsocketService] - BridgeController: Executing function bridge.setEndpointStates(["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":3}]])
2026-08-18 13:38:13.623 [TRACE] [ternal.client.MatterWebsocketService] - GenericDevice: Updating states: {"onOff":{"onOff":true},"levelControl":{"currentLevel":3}}
2026-08-18 13:38:13.632 [TRACE] [ternal.client.MatterWebsocketService] - Transition: oh-bridge.aggregator.sRobOfficeLights#LevelControlBaseServer: Set currentLevel to 1
2026-08-18 13:38:13.634 [TRACE] [ternal.client.MatterWebsocketService] - matter: Sending response: {"type":"response","message":{"type":"resultSuccess","id":"10f909f0-e3de-41b5-a9e9-d15822d5e339","result":null,"error":null,"errorId":null}}
2026-08-18 13:38:13.635 [DEBUG] [r.internal.bridge.MatterBridgeClient] - onWebSocketText {"type":"response","message":{"type":"resultSuccess","id":"10f909f0-e3de-41b5-a9e9-d15822d5e339","result":null,"error":null,"errorId":null}}
2026-08-18 13:38:13.637 [DEBUG] [r.internal.bridge.MatterBridgeClient] - result type: resultSuccess
2026-08-18 13:38:13.637 [TRACE] [ternal.client.MatterWebsocketService] - Transaction: Tx ◦set<oh-bridge.aggregator.sRobOfficeLights>#59b waiting on @1:f3f3a5fe•3ea5⇵dc42✉05804868
2026-08-18 13:38:13.641 [TRACE] [ternal.client.MatterWebsocketService] - ResourceSet: Transaction @1:f3f3a5fe•3ea5⇵dc42✉05804868 blocked by ◦set<oh-bridge.aggregator.sRobOfficeLights>#59b
2026-08-18 13:38:13.642 [TRACE] [ternal.client.MatterWebsocketService] - ResourceSet: You may need to await transaction.begin() to acquire locks asynchronously
2026-08-18 13:38:13.644 [TRACE] [ternal.client.MatterWebsocketService] - Transaction: Rolling back @1:f3f3a5fe•3ea5⇵dc42✉05804868 due to pre-commit error: Cannot lock oh-bridge.aggregator.sRobOfficeLights.onOff.state synchronously
2026-08-18 13:38:13.652 [TRACE] [ternal.client.MatterWebsocketService] - InteractionMessenger: @1:f3f3a5fe•3ea5⇵dc42 1↔0 [synchronous-transaction-conflict] Cannot lock oh-bridge.aggregator.sRobOfficeLights.onOff.state synchronously
2026-08-18 13:38:13.653 [TRACE] [ternal.client.MatterWebsocketService] -   at ResourceSet.acquireLocksSync (webpack://matter-server/./node_modules/@matter/general/dist/cjs/transaction/ResourceSet.js?:90:13)
2026-08-18 13:38:13.655 [TRACE] [ternal.client.MatterWebsocketService] -   at Tx.addResourcesSync (webpack://matter-server/./node_modules/@matter/general/dist/cjs/transaction/Tx.js?:223:26)
2026-08-18 13:38:13.655 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: I/StatusResponse status: Failure(0x1) backOff: 398ms id: @1:f3f3a5fe•3ea5⇵dc42✉0477474d type: 0x1/0x1 acked: 05804868 reqAck size: 8 payload: 1524000124ff0c18
2026-08-18 13:38:13.656 [TRACE] [ternal.client.MatterWebsocketService] -   at #startWrite (webpack://matter-server/./node_modules/@matter/node/dist/cjs/behavior/state/managed/Datasource.js?:528:17)
2026-08-18 13:38:13.657 [TRACE] [ternal.client.MatterWebsocketService] -   at RootReference.change (webpack://matter-server/./node_modules/@matter/node/dist/cjs/behavior/state/managed/Datasource.js?:406:21)
2026-08-18 13:38:13.658 [TRACE] [ternal.client.MatterWebsocketService] -   at Object.set [as onOff] (webpack://matter-server/./node_modules/@matter/node/dist/cjs/behavior/state/managed/values/StructManager.js?:153:48)
2026-08-18 13:38:13.659 [TRACE] [ternal.client.MatterWebsocketService] -   at Object.preCommit (webpack://matter-server/./node_modules/@matter/node/dist/cjs/behaviors/level-control/LevelControlServer.js?:399:35)
2026-08-18 13:38:13.661 [TRACE] [ternal.client.MatterWebsocketService] -   at executePreCommit (webpack://matter-server/./node_modules/@matter/general/dist/cjs/transaction/Tx.js?:529:49)
2026-08-18 13:38:13.662 [TRACE] [ternal.client.MatterWebsocketService] -   at #executeCommitCycle (webpack://matter-server/./node_modules/@matter/general/dist/cjs/transaction/Tx.js?:379:49)
2026-08-18 13:38:13.663 [TRACE] [ternal.client.MatterWebsocketService] -   at Tx.commit (webpack://matter-server/./node_modules/@matter/general/dist/cjs/transaction/Tx.js?:296:44)
2026-08-18 13:38:13.663 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/StatusResponse id: @1:f3f3a5fe•3ea5⇵dc42✉05804869 type: 0x1/0x1 acked: 0477474d reqAck size: 8 payload: 1524008024ff0c18
2026-08-18 13:38:13.664 [TRACE] [ternal.client.MatterWebsocketService] -   at #invokeCommand (webpack://matter-server/./node_modules/@matter/protocol/dist/cjs/action/server/CommandInvokeResponse.js?:402:39)
2026-08-18 13:38:13.666 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: SC/StandaloneAck id: @1:f3f3a5fe•3ea5⇵dc42✉0477474e type: 0x0/0x10 acked: 05804869
2026-08-18 13:38:13.726 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: New exchange » @1:f3f3a5fe•3ea5⇵7ca4 protocol: 1 peerSess: 65ce SAT: 4s SAI: 300ms SII: 500ms maxTrans: 5 MRP I
2026-08-18 13:38:13.734 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: I/ReportData sub#: b79e9cbd attr: 1 backOff: 332ms id: @1:f3f3a5fe•3ea5⇵7ca4✉0477474f type: 0x1/0x5 reqAck size: 42 payload: 152600bd9c9eb73601153501260089854fc3370124022424030824040018240203181818280424ff0c18
2026-08-18 13:38:13.739 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/StatusResponse id: @1:f3f3a5fe•3ea5⇵7ca4✉0580486a type: 0x1/0x1 acked: 0477474f reqAck size: 8 payload: 1524000024ff0c18
2026-08-18 13:38:13.740 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: SC/StandaloneAck id: @1:f3f3a5fe•3ea5⇵7ca4✉04774750 type: 0x0/0x10 acked: 0580486a
2026-08-18 13:38:15.094 [TRACE] [ternal.client.MatterWebsocketService] - MdnsAdvertisement: Broadcast kind: operational service: mdns:13889D6ADB4DE20A-000000004191D24F._matter._tcp.local number: 75 next: 1m 30s

And 100% -> 1% -> 2%:

2026-08-18 13:40:11.250 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: New exchange « @1:f3f3a5fe•3ea5⇵dc45 protocol: 1 peerSess: 3ea5 SAT: 4s SAI: 300ms SII: 500ms maxTrans: 5 MRP
2026-08-18 13:40:11.252 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/InvokeRequest id: @1:f3f3a5fe•3ea5⇵dc45✉05804874 type: 0x1/0x8 reqAck size: 40 payload: 15280028013602153700240024240108240204183501240004340124020024030018181824ff0c18
2026-08-18 13:40:11.252 [TRACE] [ternal.client.MatterWebsocketService] - InteractionServer: Invoke « @1:f3f3a5fe•3ea5⇵dc45 invokes: 36.levelControl.moveToLevelWithOnOff
2026-08-18 13:40:11.254 [TRACE] [ternal.client.MatterWebsocketService] - ProtocolService: Invoke « oh-bridge.aggregator.sRobOfficeLights.levelControl.moveToLevelWithOnOff @1:f3f3a5fe•3ea5⇵dc45✉05804874 level: 4 transitionTime: null optionsMask: { executeIfOff: false, coupleColorTempToLevel: false } optionsOverride: { executeIfOff: false, coupleColorTempToLevel: false }
2026-08-18 13:40:11.256 [TRACE] [ternal.client.MatterWebsocketService] - matter: Sending event: {"type":"event","message":{"type":"bridgeEvent","data":{"type":"attributeChanged","data":{"endpointId":"sRobOfficeLights","clusterName":"levelControl","attributeName":"currentLevel","data":4}}}}
2026-08-18 13:40:11.257 [DEBUG] [r.internal.bridge.MatterBridgeClient] - onWebSocketText {"type":"event","message":{"type":"bridgeEvent","data":{"type":"attributeChanged","data":{"endpointId":"sRobOfficeLights","clusterName":"levelControl","attributeName":"currentLevel","data":4}}}}
2026-08-18 13:40:11.259 [DEBUG] [r.internal.bridge.MatterBridgeClient] - bridgeEvent message {"type":"attributeChanged","data":{"endpointId":"sRobOfficeLights","clusterName":"levelControl","attributeName":"currentLevel","data":4}}
2026-08-18 13:40:11.265 [DEBUG] [l.bridge.devices.DimmableLightDevice] - sRobOfficeLights state changed from 100 to 2
2026-08-18 13:40:11.267 [DEBUG] [r.internal.bridge.MatterBridgeClient] - sendMessage: {"id":"337c0883-e84c-4360-91c3-ad6209658531","namespace":"bridge","function":"setEndpointStates","args":["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":5}]]}
2026-08-18 13:40:11.271 [TRACE] [ternal.client.MatterWebsocketService] - Controller: Received request: {"id":"337c0883-e84c-4360-91c3-ad6209658531","namespace":"bridge","function":"setEndpointStates","args":["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":5}]]}
2026-08-18 13:40:11.272 [TRACE] [ternal.client.MatterWebsocketService] - BridgeController: Executing function bridge.setEndpointStates(["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":5}]])
2026-08-18 13:40:11.273 [TRACE] [ternal.client.MatterWebsocketService] - GenericDevice: Updating states: {"onOff":{"onOff":true},"levelControl":{"currentLevel":5}}
2026-08-18 13:40:11.280 [TRACE] [ternal.client.MatterWebsocketService] - Transition: oh-bridge.aggregator.sRobOfficeLights#LevelControlBaseServer: Set currentLevel to 4
2026-08-18 13:40:11.282 [TRACE] [ternal.client.MatterWebsocketService] - matter: Sending response: {"type":"response","message":{"type":"resultSuccess","id":"337c0883-e84c-4360-91c3-ad6209658531","result":null,"error":null,"errorId":null}}
2026-08-18 13:40:11.283 [DEBUG] [r.internal.bridge.MatterBridgeClient] - onWebSocketText {"type":"response","message":{"type":"resultSuccess","id":"337c0883-e84c-4360-91c3-ad6209658531","result":null,"error":null,"errorId":null}}
2026-08-18 13:40:11.285 [DEBUG] [r.internal.bridge.MatterBridgeClient] - result type: resultSuccess
2026-08-18 13:40:11.286 [TRACE] [ternal.client.MatterWebsocketService] - Transaction: Tx ◦set<oh-bridge.aggregator.sRobOfficeLights>#59e waiting on @1:f3f3a5fe•3ea5⇵dc45✉05804874
2026-08-18 13:40:11.304 [TRACE] [ternal.client.MatterWebsocketService] - InteractionServer: Invoke (final) » @1:f3f3a5fe•3ea5⇵dc45 commands: 1
2026-08-18 13:40:11.307 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: I/InvokeResponse backOff: 374ms id: @1:f3f3a5fe•3ea5⇵dc45✉0477475a type: 0x1/0x9 acked: 05804874 reqAck size: 33 payload: 152800360115350137002400242401082402041835012400001818181824ff0c18
2026-08-18 13:40:11.314 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: SC/StandaloneAck id: @1:f3f3a5fe•3ea5⇵dc45✉05804875 type: 0x0/0x10 acked: 0477475a
2026-08-18 13:40:11.349 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: New exchange » @1:f3f3a5fe•3ea5⇵7caa protocol: 1 peerSess: 65ce SAT: 4s SAI: 300ms SII: 500ms maxTrans: 5 MRP I
2026-08-18 13:40:11.357 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: I/ReportData sub#: b79e9cbd attr: 1 backOff: 349ms id: @1:f3f3a5fe•3ea5⇵7caa✉0477475b type: 0x1/0x5 reqAck size: 42 payload: 152600bd9c9eb7360115350126008d854fc3370124022424030824040018240205181818280424ff0c18
2026-08-18 13:40:11.365 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/StatusResponse id: @1:f3f3a5fe•3ea5⇵7caa✉05804876 type: 0x1/0x1 acked: 0477475b reqAck size: 8 payload: 1524000024ff0c18
2026-08-18 13:40:11.367 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: SC/StandaloneAck id: @1:f3f3a5fe•3ea5⇵7caa✉0477475c type: 0x0/0x10 acked: 05804876
2026-08-18 13:40:12.349 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: New exchange » @1:f3f3a5fe•3ea5⇵7cab protocol: 1 peerSess: 65ce SAT: 4s SAI: 300ms SII: 500ms maxTrans: 5 MRP I
2026-08-18 13:40:12.353 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: I/ReportData sub#: b79e9cbd attr: 1 backOff: 366ms id: @1:f3f3a5fe•3ea5⇵7cab✉0477475d type: 0x1/0x5 reqAck size: 42 payload: 152600bd9c9eb7360115350126008d854fc3370124022424030824040018240205181818280424ff0c18
2026-08-18 13:40:12.358 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/StatusResponse id: @1:f3f3a5fe•3ea5⇵7cab✉05804877 type: 0x1/0x1 acked: 0477475d reqAck size: 8 payload: 1524000024ff0c18
2026-08-18 13:40:12.360 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: SC/StandaloneAck id: @1:f3f3a5fe•3ea5⇵7cab✉0477475e type: 0x0/0x10 acked: 05804877
2026-08-18 13:40:13.146 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: New exchange « @1:61d52ab820bc21a•e523⇵0e09 protocol: 1 peerSess: e523 SAT: 4s SAI: 1s SII: 2s maxTrans: 5 MRP
2026-08-18 13:40:13.147 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/ReportData id: @1:61d52ab820bc21a•e523⇵0e09✉0d019a33 type: 0x1/0x5 reqAck size: 11 payload: 1526001bfc673324ff0b18
2026-08-18 13:40:13.149 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: I/StatusResponse status: Success(0x0) subId: 3367fc1b empty backOff: 1.26s id: @1:61d52ab820bc21a•e523⇵0e09✉0052dfef type: 0x1/0x1 acked: 0d019a33 reqAck size: 8 payload: 1524000024ff0c18
2026-08-18 13:40:13.240 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: SC/StandaloneAck id: @1:61d52ab820bc21a•e523⇵0e09✉0d019a34 type: 0x0/0x10 acked: 0052dfef
2026-08-18 13:40:15.057 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: New exchange « @1:28fe6712847bdc15•e522⇵baa8 protocol: 1 peerSess: e522 SAT: 4s SAI: 1s SII: 2s maxTrans: 5 MRP
2026-08-18 13:40:15.058 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: I/ReportData id: @1:28fe6712847bdc15•e522⇵baa8✉0228c80b type: 0x1/0x5 reqAck size: 11 payload: 152600c808a9c824ff0b18
2026-08-18 13:40:15.060 [TRACE] [ternal.client.MatterWebsocketService] - MessageChannel: Message » for: I/StatusResponse status: Success(0x0) subId: c8a908c8 empty backOff: 1.28s id: @1:28fe6712847bdc15•e522⇵baa8✉00f653bf type: 0x1/0x1 acked: 0228c80b reqAck size: 8 payload: 1524000024ff0c18
2026-08-18 13:40:15.090 [TRACE] [ternal.client.MatterWebsocketService] - MessageExchange: Message « for: SC/StandaloneAck id: @1:28fe6712847bdc15•e522⇵baa8✉0228c80c type: 0x0/0x10 acked: 00f653bf

@robnielsen

Copy link
Copy Markdown
Contributor

And hopefully the analysis from antigravity is helpful:


Log Analysis: Light Turning Off & Setting Level from 1%

Log Source: openhab.log
Binding: org.openhab.binding.matter
Endpoint Analyzed: sRobOfficeLights (Bridged Dimmable Light)


1. Summary of Issues

When controlling bridged dimmable lights (such as sRobOfficeLights) from a Matter controller:

  1. Matter Command Failure (Rollback): Sending a level change (e.g. to 1%) causes the Matter invocation moveToLevelWithOnOff to fail with StatusResponse: Failure (0x1) due to a synchronous transaction lock conflict in matter-server.
  2. State Echo & Feedback Loop: When the Matter server emits an attributeChanged event to openHAB, openHAB updates the item state, which immediately triggers the openHAB StateChangeListener to send a setEndpointStates command back to the Matter server while the original Matter transaction is still running.
  3. Level Quantization / Rounding Drift: Level 1 (out of 254) in Matter maps to 1% in openHAB, but 1% in openHAB calculates back to Level 3 in Matter, causing immediate value divergence upon feedback.
  4. Turn-Off / Turn-On Level Jumps: When turning the light off (level drops to 0), subsequent onOff.on commands from Matter restore the openHAB DimmerItem state to 100% (Level 254) rather than the previous dim level.

2. Detailed Breakdown & Log Evidence

A. Setting Level to 1% Fails (13:13:34)

Step-by-Step Execution Sequence

  1. Incoming Invoke Request:
    The Matter controller issues moveToLevelWithOnOff targeting endpoint sRobOfficeLights with level 1:

    2026-08-18 13:13:34.219 [TRACE] [InteractionServer] - Invoke « @1:f3f3a5fe•3ea5⇵dc1f invokes: 36.levelControl.moveToLevelWithOnOff
    2026-08-18 13:13:34.222 [TRACE] [ProtocolService] - Invoke « oh-bridge.aggregator.sRobOfficeLights.levelControl.moveToLevelWithOnOff @1:f3f3a5fe•3ea5⇵dc1f✉058047d0 level: 1 ...
    
  2. Matter Server Emits Attribute Change Event:
    The Matter bridge server emits an event over the WebSocket:

    2026-08-18 13:13:34.227 [TRACE] [MatterWebsocketService] - matter: Sending event: {"type":"event","message":{"type":"bridgeEvent","data":{"type":"attributeChanged","data":{"endpointId":"sRobOfficeLights","clusterName":"levelControl","attributeName":"currentLevel","data":1}}}}
    
  3. openHAB Updates Item State:
    DimmableLightDevice.handleMatterEvent receives attributeChanged for currentLevel: 1. It converts 1 to 1% via ValueUtils.levelToPercent(1) and calls DimmerItem.send(1%, MATTER_SOURCE):

    2026-08-18 13:13:34.245 [DEBUG] [DimmableLightDevice] - sRobOfficeLights state changed from 100 to 1
    
  4. openHAB State Listener Immediately Echoes State Back:
    BaseDevice.stateChanged calls DimmableLightDevice.updateState(PercentType(1)).
    ValueUtils.percentToLevel(PercentType(1)) computes (int)(1 * 254 / 100 + 0.5) = 3.
    openHAB immediately sends setEndpointStates with currentLevel: 3 and onOff: true:

    2026-08-18 13:13:34.247 [DEBUG] [MatterBridgeClient] - sendMessage: {"id":"aabf7166-060c-444b-8f69-96853d95b728","namespace":"bridge","function":"setEndpointStates","args":["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":3}]]}
    
  5. Concurrent Transaction Conflict in matter-server:
    While the Matter controller's moveToLevelWithOnOff invoke transaction (@1:f3f3a5fe...) is in-flight, matter-server starts a synchronous write transaction for the incoming setEndpointStates (Tx ◦set<...>#56b):

    2026-08-18 13:13:34.260 [TRACE] [Transaction] - Tx ◦set<oh-bridge.aggregator.sRobOfficeLights>#56b waiting on @1:f3f3a5fe•3ea5⇵dc1f✉058047d0
    2026-08-18 13:13:34.262 [TRACE] [ResourceSet] - Transaction @1:f3f3a5fe•3ea5⇵dc1f✉058047d0 blocked by ◦set<oh-bridge.aggregator.sRobOfficeLights>#56b
    
  6. Pre-commit Lock Failure & Rollback:
    Because moveToLevelWithOnOff automatically updates onOff.onOff in its preCommit hook, it attempts to lock onOff.state. Since the endpoint is locked by the bridge setEndpointStates transaction, synchronous lock acquisition fails:

    2026-08-18 13:13:34.264 [TRACE] [Transaction] - Rolling back @1:f3f3a5fe•3ea5⇵dc1f✉058047d0 due to pre-commit error: Cannot lock oh-bridge.aggregator.sRobOfficeLights.onOff.state synchronously
    2026-08-18 13:13:34.269 [TRACE] [InteractionMessenger] - @1:f3f3a5fe•3ea5⇵dc1f 1↔0 [synchronous-transaction-conflict] Cannot lock oh-bridge.aggregator.sRobOfficeLights.onOff.state synchronously
    2026-08-18 13:13:34.274 [TRACE] [MessageChannel] - Message » for: I/StatusResponse status: Failure(0x1) ...
    

    Result: The Matter controller receives an error Failure(0x1).


B. Level Conversion & Rounding Discrepancies

In ValueUtils.java:

public static PercentType levelToPercent(int level) {
    int result = (int) Math.round(level * 100.0 / 254.0);
    return level == 0 ? PercentType.ZERO : new PercentType(Math.max(result, 1));
}

public static int percentToLevel(PercentType percent) {
    return (int) (percent.floatValue() * 254.0f / 100.0f + 0.5f);
}
  • When Matter sets level 1:
    • levelToPercent(1): 1 * 100 / 254 = 0.3937 $\rightarrow$ rounded to 0 $\rightarrow$ clamped to 1%.
    • When openHAB updates state to 1%, percentToLevel(1): 1 * 254 / 100 + 0.5 = 3.04 $\rightarrow$ level 3.
    • OpenHAB pushes level 3 back to Matter, overriding the target level 1.
  • When Matter sets level 4:
    • levelToPercent(4): 4 * 100 / 254 = 1.57 $\rightarrow$ rounded to 2%.
    • percentToLevel(2): 2 * 254 / 100 + 0.5 = 5.58 $\rightarrow$ level 5.
    • OpenHAB pushes level 5 back to Matter.

C. Turning Off & On Behavior (13:39:5813:40:01)

  1. Turning Off (13:39:58):

    • Matter invoke onOff.off arrives.
    • attributeChanged(onOff: false) triggers openHAB item state change from 1% to 0%.
    • DimmableLightDevice.updateState immediately dispatches setEndpointStates(onOff: false, currentLevel: 1).
    • The transaction lock contention occurs again (Tx ◦set<...>#59c waiting on invoke).
  2. Turning Back On (13:40:01):

    • Matter invoke onOff.on arrives.
    • attributeChanged(onOff: true) triggers openHAB.
    • In openHAB core, sending ON to a DimmerItem whose state is 0 changes its state to 100%:
      2026-08-18 13:40:01.962 [DEBUG] [DimmableLightDevice] - sRobOfficeLights state changed from 0 to 100
      
    • updateState immediately pushes currentLevel: 254 (100%) and onOff: true back to Matter:
      2026-08-18 13:40:01.962 [DEBUG] [MatterBridgeClient] - sendMessage: {"function":"setEndpointStates","args":["sRobOfficeLights",[{"clusterName":"onOff","attributeName":"onOff","state":true},{"clusterName":"levelControl","attributeName":"currentLevel","state":254}]]}
      
    • Consequently, turning ON a light that was previously at 1% causes it to jump immediately to 100% brightness.

3. Recommended Fixes

  1. Avoid Echoing Matter-Originated State Changes Back to Matter:
    • When a state change in openHAB is triggered by an incoming Matter event (e.g. matching current/expected Matter state or marked with MATTER_SOURCE), suppress sending setEndpointStates back to matter-server.
  2. Handle Lock / Transaction Concurrency:
    • Do not update bridge endpoint state during active Matter command execution, or check if the state is already consistent before sending setEndpointStates.
  3. Preserve Previous Level on Dimmer Off:
    • In DimmableLightDevice, track lastLevel when turning off so that turning the light back on restores the previous brightness level instead of resetting or jumping to 100%.

@digitaldan

Copy link
Copy Markdown
Contributor Author

Thanks Rob, although again ai analysis like this is really just noise thrown into this conversation between humans, the first post with the logs was perfect and all that is needed, we all have AI tools that do our own analysis.

So first off your logs show a transaction dead lock, so thats not good.

The second , and this is something i've struggled with matter, Lights can be 1-100, but never zero. What i misunderstood until i started working this issue, is that 1% means off, which makes no sense to me, but I'm sure the Matter folks have their reasons. Whats worse, is that other "Levels" that are not Dimmable lights do allow for 0%. So we can not use the same helper function for everything (which was what was changed in the first PR to try and fix this) as we need different helper functions for this.

I'll have a fix shortly

and a move to the lowest level is reported as off instead of a brightness.

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
@digitaldan

digitaldan commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

What i misunderstood until i started working this issue, is that 1% means off, which makes no sense to me

So this is a good lesson that if something does not smell right , to push back on AI and not just roll with it. Turns out this is not right, and when i pressed claude for references about this, it realized it was using logic in the matter.js source to come to that conclusion, which was actually not quite right either (thats another story). Fortunately, this realization did not change the logic here much....but there you go.

I have a fix up that i think should hopefully do it 🤞 .

tl;dr

So fun fact, when i decided i would introduce the bridge as part of the 1.0 of this binding, the very first feature was this cluster. I originally thought dimmable lights would be the most used device, and also be the simplest one to implement (besides ON/OFF) . I mean, like openHAB its just 0-100, right? Turns out i was half right, and half very wrong. It is the most used cluster, but thats also why LevelControl cluster might be one of the most complicated of all the ~100 clusters in the matter standard. There is so much diversity and legacy out there, likely also from its zigbee inheritance, its remarkably complex to implement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug An unexpected problem or unintended behavior of an add-on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants