[matter] Fix very slow things initialization (Thread devices) (#20904)

* [matter] Fix very slow thing initialization

Use subscription cache for node init.

- Serialize attribute values from the local cache instead of one
  remote read per attribute (huge speedup on Thread). This caused
  a lot of unnecessary single Thread requests and slowed everything
  down drastically.
- Default requestFromRemote to false in serializePairedNode/
  sendSerializedNode.

Signed-off-by: Bernhard Kaszt <github-Bernhard@kaszt.at>
This commit is contained in:
Bernhard Kaszt
2026-06-09 08:18:42 +02:00
committed by GitHub
parent 921a136511
commit 5a0e63f3ae
5 changed files with 300 additions and 9 deletions
@@ -11,6 +11,16 @@ import { DclOtaUpdateService, PhysicalDeviceProperties } from "@matter/main/prot
const logger = Logger.get("ControllerNode");
// Attributes that are always read fresh from the device, even when the rest of the node is served from the
// local subscription cache (requestFromRemote=false). softwareVersion/softwareVersionString only change on a
// firmware update, so after a device reboots onto new firmware the cached value can be stale - and it is
// surfaced as the Thing's firmware-version property, so it must be accurate. The cost is one tiny read of a
// rarely-changing attribute per serialization, with a fallback to the cached value if the read fails.
const ALWAYS_FRESH_ATTRIBUTES: Record<string, ReadonlySet<string>> = {
BasicInformation: new Set(["softwareVersion", "softwareVersionString"]),
BridgedDeviceBasicInformation: new Set(["softwareVersion", "softwareVersionString"]),
};
function extractPhysicalProperties(node: PairedNode | undefined): PhysicalDeviceProperties | undefined {
if (!node) return undefined;
try {
@@ -169,6 +179,25 @@ export class ControllerNode {
});
this.observers.on(updateManagerEvents.updateDone, peer => {
logger.info(`Update done for peer `, peer);
const nodeId = peer?.nodeId;
if (!nodeId) {
logger.error(`Node ID not found for peer `, peer);
return;
}
// matter.js only emits updateDone once the device has already rebooted onto the new firmware and
// re-established its session, so it is reachable now and we refresh immediately. The serialized node
// (and thus the Thing's firmware-version property) is normally built from the local subscription cache
// (requestFromRemote=false), which may still hold the pre-update version until the subscription has
// re-primed; forcing a fresh remote read makes the reported version reflect the firmware just installed,
// rebuilds the structure/channels and brings the Thing (set OFFLINE while applying) back ONLINE in one
// step. If the device should be briefly unreachable again, sendSerializedNode triggers a reconnect on
// failure and ALWAYS_FRESH_ATTRIBUTES corrects the version on the next serialization. OTA is rare, so a
// one-off full read is cheap.
try {
this.sendSerializedNode(this.getNode(nodeId), undefined, true);
} catch (e) {
logger.error(`Could not refresh node ${nodeId} after OTA update: ${e}`);
}
});
// Query for updates now once
@@ -407,9 +436,13 @@ export class ControllerNode {
* Serializes a node and sends it to the web socket
* @param node
* @param endpointId Optional endpointId to serialize. If omitted, all endpoints will be serialized.
* @param requestFromRemote When false (the default) values are taken from the local subscription cache instead of
* reading every attribute individually from the device. Since the node is subscribed (autoSubscribe) the cache is
* continuously kept up to date by the device, so a remote read per attribute is redundant and very expensive on
* slow Thread meshes. Pass true only to force a fresh remote read of every attribute.
*/
sendSerializedNode(node: PairedNode, endpointId?: number) {
this.serializePairedNode(node, endpointId)
sendSerializedNode(node: PairedNode, endpointId?: number, requestFromRemote: boolean = false) {
this.serializePairedNode(node, endpointId, requestFromRemote)
.then(data => {
this.ws.sendEvent(EventType.NodeData, data);
})
@@ -424,9 +457,13 @@ export class ControllerNode {
* Serializes a node and returns the json object
* @param node
* @param endpointId Optional endpointId to serialize. If omitted, the root endpoint will be serialized.
* @param requestFromRemote When false (the default) attribute values are served from the local subscription cache.
* Reading every attribute remotely (true) issues one network request per attribute and is extremely slow for
* Thread / sleepy devices; the autoSubscribe cache already holds the current values. Fabric-scoped attributes are
* always read from the device regardless of this flag (enforced by matter.js).
* @returns
*/
async serializePairedNode(node: PairedNode, endpointId?: number, requestFromRemote: boolean = true) {
async serializePairedNode(node: PairedNode, endpointId?: number, requestFromRemote: boolean = false) {
if (!this.commissioningController) {
throw new Error("CommissioningController not initialized");
}
@@ -454,7 +491,29 @@ export class ControllerNode {
if (/^\d+$/.test(attributeName)) continue;
const attribute = cluster.attributes[attributeName];
if (!attribute) continue;
const attributeValue = await attribute.get(requestFromRemote);
// Force a fresh remote read for attributes that must never be served stale from the cache
// (see ALWAYS_FRESH_ATTRIBUTES), but fall back to the cached value if that read fails so one
// flaky read of a single attribute never fails the whole node serialization.
const forceFresh =
!requestFromRemote && (ALWAYS_FRESH_ATTRIBUTES[cluster.name]?.has(attributeName) ?? false);
let attributeValue: any;
if (forceFresh) {
try {
attributeValue = await attribute.get(true);
} catch (e) {
logger.debug(`Fresh read of ${cluster.name}.${attributeName} failed, using cache: ${e}`);
try {
attributeValue = await attribute.get(false);
} catch (e2) {
logger.debug(
`Cache read of ${cluster.name}.${attributeName} also failed, skipping: ${e2}`,
);
attributeValue = undefined;
}
}
} else {
attributeValue = await attribute.get(requestFromRemote);
}
logger.debug(`Attribute ${attributeName} value: ${attributeValue}`);
if (attributeValue !== undefined) {
clusterData[attributeName] = attributeValue;
@@ -423,9 +423,7 @@ public class MatterWebsocketClient implements WebSocketListener, MatterWebsocket
protected CompletableFuture<JsonElement> sendMessage(String namespace, String functionName, @Nullable Object args[],
int timeoutSeconds) {
if (timeoutSeconds <= 0) {
timeoutSeconds = REQUEST_TIMEOUT_SECONDS;
}
final int effectiveTimeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : REQUEST_TIMEOUT_SECONDS;
CompletableFuture<JsonElement> responseFuture = new CompletableFuture<>();
Session session = this.session;
@@ -446,9 +444,9 @@ public class MatterWebsocketClient implements WebSocketListener, MatterWebsocket
CompletableFuture<JsonElement> future = pendingRequests.remove(requestId);
if (future != null && !future.isDone()) {
future.completeExceptionally(new TimeoutException(String.format(
"Request %s:%s timed out after %d seconds", namespace, functionName, REQUEST_TIMEOUT_SECONDS)));
"Request %s:%s timed out after %d seconds", namespace, functionName, effectiveTimeoutSeconds)));
}
}, timeoutSeconds, TimeUnit.SECONDS);
}, effectiveTimeoutSeconds, TimeUnit.SECONDS);
return responseFuture;
}
@@ -243,6 +243,11 @@ public class ControllerHandler extends BaseBridgeHandler implements MatterClient
requestAllNodeDataIfNeeded(message.nodeId);
break;
case STRUCTURECHANGED:
// A structure change means the node's endpoints/clusters changed, so the channels must be rebuilt
// from fresh data. Drop the "already enumerated" marker first: otherwise the data request that the
// reconnect's Connected event triggers is skipped for sleepy nodes (see requestAllNodeDataIfNeeded),
// leaving the Thing online but with stale channels. Same marker handling as removeNode/dispose.
enumeratedNodes.remove(message.nodeId);
updateNode(message.nodeId);
break;
case DECOMMISSIONED:
@@ -13,11 +13,23 @@
package org.openhab.binding.matter.internal.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.math.BigInteger;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.websocket.api.RemoteEndpoint;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WebSocketPolicy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.binding.matter.internal.client.dto.Endpoint;
@@ -30,6 +42,7 @@ import org.openhab.binding.matter.internal.client.dto.ws.AttributeChangedMessage
import org.openhab.binding.matter.internal.client.dto.ws.EventTriggeredMessage;
import org.openhab.binding.matter.internal.client.dto.ws.Message;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
@@ -313,4 +326,47 @@ class MatterWebsocketClientTest {
assertNotNull(occupancyBitmap);
assertEquals(true, occupancyBitmap.occupied);
}
/**
* When sendMessage is called without an established WebSocket session, the returned future must fail fast with a
* {@link MatterRequestException} ("No valid session") instead of hanging until the timeout. This is the path taken
* while the node process is (re)starting or after a disconnect.
*/
@Test
void testSendMessageWithoutSessionCompletesExceptionally() {
// No onWebSocketConnect() called, so there is no active session.
CompletableFuture<JsonElement> future = client.sendMessage("nodes", "listNodes", new Object[0], 1);
ExecutionException ex = assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
assertInstanceOf(MatterRequestException.class, ex.getCause());
Throwable cause = ex.getCause();
assertNotNull(cause);
assertTrue(cause.getMessage().contains("No valid session"));
}
/**
* A non-positive timeout must fall back to the default request timeout (REQUEST_TIMEOUT_SECONDS), preserving the
* previous behaviour. We verify this indirectly: with a 0s timeout the request must stay pending well beyond a
* short
* wait (here 2s), proving 0 was not used as the actual delay.
*/
@Test
void testSendMessageNonPositiveTimeoutFallsBackToDefault() {
connectMockSession();
// A timeout <= 0 must fall back to the default, so the request stays pending well beyond 2s.
CompletableFuture<JsonElement> future = client.sendMessage("nodes", "listNodes", new Object[0], 0);
assertThrows(TimeoutException.class, () -> future.get(2, TimeUnit.SECONDS));
}
/**
* Installs a mocked WebSocket session on the client so sendMessage reaches the request-scheduling path (instead of
* failing early with "No valid session"). The remote endpoint is stubbed so the outgoing message is silently
* discarded; no real socket or node process is involved.
*/
private void connectMockSession() {
Session session = mock(Session.class);
when(session.getPolicy()).thenReturn(WebSocketPolicy.newClientPolicy());
RemoteEndpoint remote = mock(RemoteEndpoint.class);
when(session.getRemote()).thenReturn(remote);
client.onWebSocketConnect(session);
}
}
@@ -0,0 +1,173 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.matter.internal.handler;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.openhab.binding.matter.internal.MatterFirmwareProvider;
import org.openhab.binding.matter.internal.client.MatterWebsocketService;
import org.openhab.binding.matter.internal.client.dto.ws.NodeState;
import org.openhab.binding.matter.internal.client.dto.ws.NodeStateMessage;
import org.openhab.binding.matter.internal.controller.MatterControllerClient;
import org.openhab.binding.matter.internal.util.TranslationService;
import org.openhab.core.thing.Bridge;
/**
* Tests for {@link ControllerHandler} node refresh behavior.
*
* @author Bernhard Kaszt - Initial contribution
*/
@NonNullByDefault
public class ControllerHandlerTest {
private static final BigInteger NODE_ID = BigInteger.valueOf(42);
@Mock
@NonNullByDefault({})
private Bridge bridge;
@Mock
@NonNullByDefault({})
private MatterWebsocketService websocketService;
@Mock
@NonNullByDefault({})
private TranslationService translationService;
@Mock
@NonNullByDefault({})
private MatterFirmwareProvider firmwareProvider;
@Mock
@NonNullByDefault({})
private MatterControllerClient client;
@NonNullByDefault({})
private ControllerHandler handler;
@BeforeEach
@SuppressWarnings("null")
public void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
when(bridge.getThings()).thenReturn(List.of());
when(client.initializeNode(any(), any())).thenReturn(completedVoid());
when(client.requestAllNodeData(any())).thenReturn(completedVoid());
handler = new ControllerHandler(bridge, websocketService, translationService, firmwareProvider);
// The constructor creates a real MatterControllerClient; swap it for the mock so we can verify calls.
setField(handler, "client", client);
// Mark the handler ready so updateNode() does not short-circuit.
setField(handler, "ready", Boolean.TRUE);
}
@AfterEach
public void tearDown() {
ControllerHandler localHandler = handler;
if (localHandler != null) {
localHandler.dispose();
}
}
/**
* A structure change must force a full data refresh even for a sleepy node that was already enumerated.
* matter.js reports a structure change while the node stays connected; the binding reacts by reconnecting
* (updateNode -> initializeNode), whose resulting Connected event drives the data request. For sleepy nodes
* requestAllNodeDataIfNeeded skips that request, so without clearing the "enumerated" marker on the structure
* change the channels are never rebuilt.
*/
@Test
public void structureChangeRefreshesSleepyNode() throws Exception {
linkEnumeratedNode(NODE_ID, true);
handler.onEvent(nodeState(NODE_ID, NodeState.STRUCTURECHANGED));
handler.onEvent(nodeState(NODE_ID, NodeState.CONNECTED));
verify(client).requestAllNodeData(NODE_ID);
}
/**
* Control: a non-sleepy node is refreshed on reconnect anyway, so the structure change path must keep working
* for it too.
*/
@Test
public void structureChangeRefreshesNonSleepyNode() throws Exception {
linkEnumeratedNode(NODE_ID, false);
handler.onEvent(nodeState(NODE_ID, NodeState.STRUCTURECHANGED));
handler.onEvent(nodeState(NODE_ID, NodeState.CONNECTED));
verify(client).requestAllNodeData(NODE_ID);
}
/**
* Control: a plain reconnect (Connected without a preceding structure change) of an already enumerated sleepy
* node must still skip the expensive re-enumeration - that is the optimization the structure-change fix must
* not regress.
*/
@Test
public void reconnectSkipsRefreshForSleepyNode() throws Exception {
linkEnumeratedNode(NODE_ID, true);
handler.onEvent(nodeState(NODE_ID, NodeState.CONNECTED));
verify(client, never()).requestAllNodeData(any());
}
private void linkEnumeratedNode(BigInteger nodeId, boolean sleepy) throws Exception {
NodeHandler nodeHandler = mock(NodeHandler.class);
when(nodeHandler.shouldRefreshOnReconnect()).thenReturn(!sleepy);
Map<BigInteger, NodeHandler> linkedNodes = getField(handler, "linkedNodes");
linkedNodes.put(nodeId, nodeHandler);
Set<BigInteger> enumeratedNodes = getField(handler, "enumeratedNodes");
enumeratedNodes.add(nodeId);
}
private static NodeStateMessage nodeState(BigInteger nodeId, NodeState state) {
NodeStateMessage message = new NodeStateMessage();
message.nodeId = nodeId;
message.state = state;
return message;
}
@SuppressWarnings("null")
private static CompletableFuture<Void> completedVoid() {
return CompletableFuture.<Void> completedFuture(null);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = ControllerHandler.class.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
@SuppressWarnings("unchecked")
private static <T> T getField(Object target, String name) throws Exception {
Field field = ControllerHandler.class.getDeclaredField(name);
field.setAccessible(true);
return (T) field.get(target);
}
}