[zwavejs] Add notification channel (#19328)

* Add notification channel
* Add documentation
* Fix value being updated correctly
* Update bundles/org.openhab.binding.zwavejs/src/main/java/org/openhab/binding/zwavejs/internal/type/ZwaveJSTypeGeneratorImpl.java

Signed-off-by: Leo Siepel <leosiepel@gmail.com>
Signed-off-by: lsiepel <leosiepel@gmail.com>
This commit is contained in:
lsiepel
2025-09-20 11:07:43 +02:00
committed by GitHub
parent 5ba98f0c5f
commit e2091940ea
12 changed files with 317 additions and 19 deletions
@@ -57,6 +57,14 @@ Z-Wave nodes can have multiple channels corresponding to their capabilities.
The channels can be linked to items in openHAB to control and monitor the device.
These channels are dynamically added to the Thing during node initialization; therefore, there is no list of possible channels in this documentation.
### Notification-Virtual Channel
The binding automatically adds a `notification-virtual` channel for endpoints that have both a Notification (Alarm) and a Door Lock command class.
This virtual channel is designed to provide a unified state update when a notification event occurs.
It provides the event payload in JSON as a string value.
A common scenario is using this JSON to determine the userId that unlocked a door lock.
This channel is added automatically; no manual configuration is required.
## Channel Configuration and Inversion
Channels in the `zwavejs` binding support an **inversion** configuration option.
@@ -91,4 +91,9 @@ public class BindingConstants {
public static final String VIRTUAL_ROLLERSHUTTER_PROPERTY = "virtual";
public static final String VIRTUAL_ROLLERSHUTTER_CHANNEL_ID = String.format("%s-%s",
VIRTUAL_COMMAND_CLASS_ROLLERSHUTTER, VIRTUAL_ROLLERSHUTTER_PROPERTY);
public static final String VIRTUAL_COMMAND_CLASS_NOTIFICATION = "notification";
public static final String VIRTUAL_NOTIFICATION_PROPERTY = "virtual";
public static final String VIRTUAL_NOTIFICATION_CHANNEL_ID = String.format("%s-%s",
VIRTUAL_COMMAND_CLASS_NOTIFICATION, VIRTUAL_NOTIFICATION_PROPERTY);
}
@@ -299,7 +299,7 @@ public class ZWaveJSClient implements WebSocketListener {
}
} catch (Exception e) {
if (logger.isDebugEnabled()) {
logger.debug("Error invoking event listener on websockettext: {}", e.toString());
logger.debug("Error invoking event listener on websockettext: {}", e.toString(), e);
} else {
logger.warn("Error invoking event listener on websockettext");
}
@@ -24,4 +24,10 @@ public class Args {
public Object prevValue;
public String propertyName;
public Object propertyKey;
public int event;
public int type;
public String label;
public String eventLabel;
public Object parameters;
}
@@ -21,6 +21,8 @@ public class Event {
public int nodeId;
public Args args;
public Node node;
public int ccId;
public int endpointIndex;
enum Source {
driver,
@@ -160,6 +160,14 @@ public abstract class BaseMetadata {
this.factor = 1.0;
}
/**
* Determines if the given property name is either the first occurrence of its mapped value
* in the {@code CHANNEL_ID_PROPERTY_NAME_REPLACEMENTS} map or if it is not mapped at all.
*
* @param propertyName the name of the property to check; may be {@code null}.
* @return {@code true} if the property name is the first occurrence of its mapped value,
* not mapped, or {@code null}; {@code false} otherwise.
*/
public static boolean isFirstOrNotMapped(@Nullable String propertyName) {
if (propertyName == null || propertyName.contains("unknown")) {
return true;
@@ -12,6 +12,9 @@
*/
package org.openhab.binding.zwavejs.internal.handler;
import static org.openhab.binding.zwavejs.internal.BindingConstants.VIRTUAL_COMMAND_CLASS_NOTIFICATION;
import static org.openhab.binding.zwavejs.internal.BindingConstants.VIRTUAL_NOTIFICATION_PROPERTY;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
@@ -56,6 +59,8 @@ import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The {@link ZwaveJSBridgeHandler} is responsible for handling communication between the
* {@link ZwaveJSNodeHandler} 's and the {@link ZWaveJSClient} This handler also manages node discovery
@@ -166,6 +171,12 @@ public class ZwaveJSBridgeHandler extends BaseBridgeHandler implements ZwaveEven
String eventType = eventMsg.event.event;
ZwaveNodeListener nodeListener = nodeListeners.get(eventMsg.event.nodeId);
switch (eventType) {
case "notification":
if (nodeListener != null) {
Event event = normalizeNotificationEvent(eventMsg.event);
nodeListener.onNodeStateChanged(event);
}
break;
case "value updated":
case "value notification":
if (nodeListener != null) {
@@ -200,6 +211,19 @@ public class ZwaveJSBridgeHandler extends BaseBridgeHandler implements ZwaveEven
}
}
public static Event normalizeNotificationEvent(Event event) {
Event normalizedEvent = new Event();
normalizedEvent.event = event.event;
normalizedEvent.args = new Args();
normalizedEvent.args.commandClass = event.ccId;
normalizedEvent.args.commandClassName = VIRTUAL_COMMAND_CLASS_NOTIFICATION;
normalizedEvent.args.propertyName = VIRTUAL_NOTIFICATION_PROPERTY;
normalizedEvent.args.endpoint = event.endpointIndex;
normalizedEvent.args.newValue = new Gson().toJson(event.args);
normalizedEvent.nodeId = event.nodeId;
return normalizedEvent;
}
private @Nullable Event createEventFromMessageId(String messageId, @Nullable Object value) {
// Example messageId: getvalue|0|51|Color Switch|2|currentColor|44|2466
String[] parts = messageId.split("\\|");
@@ -13,6 +13,8 @@
package org.openhab.binding.zwavejs.internal.type;
import static org.openhab.binding.zwavejs.internal.BindingConstants.*;
import static org.openhab.binding.zwavejs.internal.CommandClassConstants.COMMAND_CLASS_ALARM;
import static org.openhab.binding.zwavejs.internal.CommandClassConstants.COMMAND_CLASS_DOOR_LOCK;
import static org.openhab.binding.zwavejs.internal.CommandClassConstants.COMMAND_CLASS_SWITCH_COLOR;
import java.net.URI;
@@ -24,6 +26,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -82,7 +85,6 @@ import org.slf4j.LoggerFactory;
@Component
@NonNullByDefault
public class ZwaveJSTypeGeneratorImpl implements ZwaveJSTypeGenerator {
private static final Object CHANNEL_TYPE_VERSION = "6"; // when static configuration is changed, the version must be
// changed as well to force new channel type generation
private static final Map<String, SemanticTag> ITEM_TYPES_TO_PROPERTY_TAGS = new HashMap<>();
@@ -182,6 +184,9 @@ public class ZwaveJSTypeGeneratorImpl implements ZwaveJSTypeGenerator {
// add a color temperature channel if necessary
addColorTemperatureChannel(thingUID, node, result);
// add raw notification channel if necessary
addRawNotificationChannel(thingUID, node, result);
logger.debug("Node {}. Generated {} channels and {} configDescriptions with URI {}", node.nodeId,
result.channels.size(), configDescriptions.size(), uri);
@@ -191,6 +196,67 @@ public class ZwaveJSTypeGeneratorImpl implements ZwaveJSTypeGenerator {
return result;
}
private void addRawNotificationChannel(ThingUID thingUID, Node node, ZwaveJSTypeGeneratorResult result) {
// loop all channels to find endpoints with a notification CC or a door lock CC
Map<Integer, List<Integer>> grouped = result.channels.values().stream()
.map(channel -> channel.getConfiguration().as(ZwaveJSChannelConfiguration.class))
.filter(config -> COMMAND_CLASS_ALARM == config.commandClassId
|| COMMAND_CLASS_DOOR_LOCK == config.commandClassId)
.collect(Collectors.groupingBy(config -> config.endpoint,
Collectors.mapping(config -> config.commandClassId, Collectors.toList())));
// 2. Find endpoints with both Notification and Door Lock CCs
List<Integer> endpoints = grouped.entrySet().stream()
.filter(entry -> entry.getValue().contains(COMMAND_CLASS_ALARM)
&& entry.getValue().contains(COMMAND_CLASS_DOOR_LOCK))
.map(Map.Entry::getKey).toList();
for (Integer endpoint : endpoints) {
createRawNotificationChannel(thingUID, node, result, endpoint);
}
}
private void createRawNotificationChannel(ThingUID thingUID, Node node, ZwaveJSTypeGeneratorResult result,
int endpoint) {
Value value = new Value();
value.endpoint = endpoint;
value.commandClass = COMMAND_CLASS_ALARM;
value.commandClassName = VIRTUAL_COMMAND_CLASS_NOTIFICATION;
value.propertyKey = VIRTUAL_NOTIFICATION_PROPERTY;
value.property = VIRTUAL_NOTIFICATION_PROPERTY;
value.metadata = new Metadata();
value.metadata.type = MetadataType.STRING;
value.metadata.writeable = false;
value.metadata.label = "Raw Notification";
value.metadata.description = "Notification channel that updates on alarm events";
value.metadata.readable = true;
ChannelMetadata details = new ChannelMetadata(node.nodeId, value);
if (!result.channels.containsKey(details.id)) {
logger.trace("Node {} building channel with Id: {}", details.nodeId, details.id);
logger.trace(" >> {}", details);
ChannelTypeUID channelTypeUID = generateChannelTypeUID(details);
ChannelType type = getOrGenerate(channelTypeUID, details);
if (type == null) {
return;
}
Configuration config = buildChannelConfiguration(details);
Channel channel = ChannelBuilder.create(new ChannelUID(thingUID, details.id), CoreItemFactory.STRING)
.withType(type.getUID()) //
.withDefaultTags(type.getTags()) //
.withKind(type.getKind()) //
.withLabel(details.label) //
.withDescription(Objects.requireNonNull(details.description)) //
.withAutoUpdatePolicy(type.getAutoUpdatePolicy()) //
.withConfiguration(config) //
.build();
result.channels.put(details.id, channel);
}
}
private ConfigDescriptionParameter createConfigDescription(ConfigMetadata details) {
logger.trace("Node {}. createConfigDescriptions with Id: {}", details.nodeId, details.id);
@@ -482,23 +548,12 @@ public class ZwaveJSTypeGeneratorImpl implements ZwaveJSTypeGenerator {
|| keyWords.isEmpty()) {
return false;
}
label = label != null ? label.toLowerCase() : "";
description = description != null ? description.toLowerCase() : "";
for (String keyWord : keyWords) {
String keyWordLowercase = keyWord.toLowerCase();
if (label.contains(keyWordLowercase) || description.contains(keyWordLowercase)) {
return true;
}
if (label.endsWith(keyWordLowercase.stripTrailing())
|| description.endsWith(keyWordLowercase.stripTrailing())) {
return true;
}
if (label.startsWith(keyWordLowercase.stripLeading())
|| description.startsWith(keyWordLowercase.stripLeading())) {
return true;
}
}
return false;
String labelLower = label != null ? label.toLowerCase() : "";
String descLower = description != null ? description.toLowerCase() : "";
return keyWords.stream().map(String::toLowerCase)
.anyMatch(kw -> labelLower.contains(kw) || descLower.contains(kw)
|| labelLower.endsWith(kw.stripTrailing()) || descLower.endsWith(kw.stripTrailing())
|| labelLower.startsWith(kw.stripLeading()) || descLower.startsWith(kw.stripLeading()));
}
/**
@@ -19,13 +19,22 @@ import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.zwavejs.internal.DataUtil;
import org.openhab.binding.zwavejs.internal.api.dto.Event;
import org.openhab.binding.zwavejs.internal.api.dto.Node;
import org.openhab.binding.zwavejs.internal.api.dto.Result;
import org.openhab.binding.zwavejs.internal.api.dto.State;
import org.openhab.binding.zwavejs.internal.api.dto.Status;
import org.openhab.binding.zwavejs.internal.api.dto.messages.EventMessage;
import org.openhab.binding.zwavejs.internal.api.dto.messages.ResultMessage;
import org.openhab.binding.zwavejs.internal.api.dto.messages.VersionMessage;
import org.openhab.binding.zwavejs.internal.discovery.NodeDiscoveryService;
import org.openhab.binding.zwavejs.internal.handler.mock.ZwaveJSBridgeHandlerMock;
import org.openhab.core.thing.Bridge;
@@ -86,4 +95,123 @@ public class ZwaveJSBridgeHandlerTest {
handler.dispose();
}
}
@Test
public void testOnEventWithVersionMessage() {
final Bridge thing = ZwaveJSBridgeHandlerMock.mockBridge("localhost");
final ThingHandlerCallback callback = mock(ThingHandlerCallback.class);
final ZwaveJSBridgeHandler handler = ZwaveJSBridgeHandlerMock.createAndInitHandler(callback, thing);
VersionMessage versionMessage = new VersionMessage();
versionMessage.driverVersion = "1.0.0";
versionMessage.serverVersion = "1.2.3";
versionMessage.minSchemaVersion = 1;
versionMessage.maxSchemaVersion = 3;
versionMessage.homeId = 12345;
handler.onEvent(versionMessage);
try {
verify(thing).setProperties(argThat(properties -> properties.containsKey("driverVersion")
&& properties.get("driverVersion").equals("1.0.0") && properties.containsKey("serverVersion")
&& properties.get("serverVersion").equals("1.2.3") && properties.containsKey("minSchemaVersion")
&& properties.get("minSchemaVersion").equals("1") && properties.containsKey("maxSchemaVersion")
&& properties.get("maxSchemaVersion").equals("3") && properties.containsKey("homeId")
&& properties.get("homeId").equals("12345")));
} finally {
handler.dispose();
}
}
@Test
public void testOnEventWithResultMessageGetValue() {
final Bridge thing = ZwaveJSBridgeHandlerMock.mockBridge("localhost");
final ThingHandlerCallback callback = mock(ThingHandlerCallback.class);
final ZwaveJSBridgeHandlerMock handler = ZwaveJSBridgeHandlerMock.createAndInitHandler(callback, thing);
ZwaveNodeListener nodeListener = mock(ZwaveNodeListener.class);
when(nodeListener.getId()).thenReturn(3);
handler.registerNodeListener(nodeListener);
ResultMessage resultMessage = new ResultMessage();
resultMessage.messageId = "getvalue|1|2|CommandClass|propertyKey|propertyName|3";
resultMessage.result = new Result();
resultMessage.result.value = "testValue";
handler.onEvent(resultMessage);
try {
verify(nodeListener).onNodeStateChanged(any(Event.class));
} finally {
handler.dispose();
}
}
@Test
public void testOnEventWithResultMessageStateUpdate() {
final Bridge thing = ZwaveJSBridgeHandlerMock.mockBridge("localhost");
final ThingHandlerCallback callback = mock(ThingHandlerCallback.class);
final ZwaveJSBridgeHandler handler = ZwaveJSBridgeHandlerMock.createAndInitHandler(callback, thing);
ResultMessage resultMessage = new ResultMessage();
resultMessage.result = new Result();
resultMessage.result.state = new State();
resultMessage.result.state.nodes = List.of(new Node() {
{
nodeId = 1;
status = Status.ALIVE;
}
});
handler.onEvent(resultMessage);
try {
verify(callback).statusUpdated(eq(thing), argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE)));
} finally {
handler.dispose();
}
}
@Test
public void testOnEventWithEventMessageNodeAdded() {
final Bridge thing = ZwaveJSBridgeHandlerMock.mockBridge("localhost");
final ThingHandlerCallback callback = mock(ThingHandlerCallback.class);
final ZwaveJSBridgeHandlerMock handler = ZwaveJSBridgeHandlerMock.createAndInitHandler(callback, thing);
final NodeDiscoveryService discoveryService = mock(NodeDiscoveryService.class);
handler.registerDiscoveryListener(discoveryService);
EventMessage eventMessage = new EventMessage();
eventMessage.event = new Event();
eventMessage.event.event = "node added";
eventMessage.event.node = new Node();
eventMessage.event.node.nodeId = 5;
eventMessage.event.node.status = Status.ALIVE;
handler.onEvent(eventMessage);
try {
verify(discoveryService).addNodeDiscovery(eq(eventMessage.event.node));
} finally {
handler.dispose();
}
}
@Test
public void testOnEventWithUnhandledEventType() {
final Bridge thing = ZwaveJSBridgeHandlerMock.mockBridge("localhost");
final ThingHandlerCallback callback = mock(ThingHandlerCallback.class);
final ZwaveJSBridgeHandler handler = ZwaveJSBridgeHandlerMock.createAndInitHandler(callback, thing);
EventMessage eventMessage = new EventMessage();
eventMessage.event = new Event();
eventMessage.event.event = "unhandled event";
handler.onEvent(eventMessage);
try {
// No specific verification, just ensuring no exceptions are thrown
} finally {
handler.dispose();
}
}
}
@@ -36,6 +36,7 @@ import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StopMoveType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.types.UpDownType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Channel;
@@ -45,6 +46,8 @@ import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import com.google.gson.Gson;
/**
* @author Leo Siepel - Initial contribution
*/
@@ -400,6 +403,29 @@ public class ZwaveJSNodeHandlerTest {
}
}
@Test
public void testNode186NotificationEventUpdate() throws IOException {
final Thing thing = ZwaveJSNodeHandlerMock.mockThing(186);
final ThingHandlerCallback callback = mock(ThingHandlerCallback.class);
final ZwaveJSNodeHandlerMock handler = ZwaveJSNodeHandlerMock.createAndInitHandler(callback, thing,
"store_4.json");
EventMessage eventMessage = DataUtil.fromJson("event_node_186_notification.json", EventMessage.class);
handler.onNodeStateChanged(ZwaveJSBridgeHandler.normalizeNotificationEvent(eventMessage.event));
ChannelUID channelIdNotification = new ChannelUID("zwavejs:test-bridge:test-thing:notification-virtual");
try {
verify(callback).statusUpdated(eq(thing), argThat(arg -> arg.getStatus().equals(ThingStatus.UNKNOWN)));
verify(callback).statusUpdated(argThat(arg -> arg.getUID().equals(thing.getUID())),
argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE)));
verify(callback, times(1)).stateUpdated(eq(channelIdNotification),
eq(new StringType(new Gson().toJson(eventMessage.event.args))));
} finally {
handler.dispose();
}
}
private ZwaveJSNodeHandlerMock arrangeHandleCommandTest(int nodeId) {
final Thing thing = ZwaveJSNodeHandlerMock.mockThing(nodeId);
final ThingHandlerCallback callback = mock(ThingHandlerCallback.class);
@@ -356,6 +356,23 @@ public class ZwaveJSTypeGeneratorTest {
assertNotNull(type);
}
@Test
public void testNode186NotificationChannelDetection() throws IOException {
Channel channel = getChannel("store_4.json", 186, "notification-virtual");
ChannelType type = channelTypeProvider.getChannelType(Objects.requireNonNull(channel.getChannelTypeUID()),
null);
Configuration configuration = channel.getConfiguration();
assertNotNull(type);
assertEquals("zwavejs:test-bridge:test-thing:notification-virtual", channel.getUID().getAsString());
assertEquals("String", type.getItemType());
assertEquals("Raw Notification", channel.getLabel());
assertNull(configuration.get(BindingConstants.CONFIG_CHANNEL_WRITE_PROPERTY_STR));
StateDescription statePattern = type.getState();
assertNull(statePattern);
}
@Test
public void testGenCTNode186WeirdType() throws IOException {
Channel channel = getChannel("store_4.json", 186, "door-lock-inside-handles-can-open-door");
@@ -0,0 +1,19 @@
{
"type": "event",
"event": {
"source": "node",
"event": "notification",
"nodeId": 186,
"endpointIndex": 0,
"ccId": 113,
"args": {
"type": 6,
"event": 5,
"label": "Access Control",
"eventLabel": "Keypad lock operation",
"parameters": {
"userId": 1
}
}
}
}