diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/README.md b/bundles/org.openhab.binding.mqtt.homeassistant/README.md index ff5fb7cc4f889..fb7d53ca01948 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/README.md +++ b/bundles/org.openhab.binding.mqtt.homeassistant/README.md @@ -21,6 +21,7 @@ You can also manually create a Thing, and provide the individual component topic - [Climate](https://www.home-assistant.io/integrations/climate.mqtt/) - [Cover](https://www.home-assistant.io/integrations/cover.mqtt/) - [Device Trigger](https://www.home-assistant.io/integrations/device_trigger.mqtt/) +- [Event](https://www.home-assistant.io/integrations/event.mqtt/) - [Fan](https://www.home-assistant.io/integrations/fan.mqtt/) - [Light](https://www.home-assistant.io/integrations/light.mqtt/) - [Lock](https://www.home-assistant.io/integrations/lock.mqtt/) diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java index 36e9bbd66bdf1..4c2f989c8e982 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java @@ -59,14 +59,16 @@ public static AbstractComponent createComponent(ThingUID thingUID, HaID haID, return new Button(componentConfiguration, newStyleChannels); case "camera": return new Camera(componentConfiguration, newStyleChannels); - case "cover": - return new Cover(componentConfiguration, newStyleChannels); - case "fan": - return new Fan(componentConfiguration, newStyleChannels); case "climate": return new Climate(componentConfiguration, newStyleChannels); + case "cover": + return new Cover(componentConfiguration, newStyleChannels); case "device_automation": return new DeviceTrigger(componentConfiguration, newStyleChannels); + case "event": + return new Event(componentConfiguration, newStyleChannels); + case "fan": + return new Fan(componentConfiguration, newStyleChannels); case "light": return Light.create(componentConfiguration, newStyleChannels); case "lock": diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Event.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Event.java new file mode 100644 index 0000000000000..be11ca58bcf4c --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Event.java @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2010-2024 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.mqtt.homeassistant.internal.component; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType; +import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelTransformation; +import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; + +import com.google.gson.annotations.SerializedName; + +/** + * A MQTT Event, following the https://www.home-assistant.io/integrations/event.mqttspecification. + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class Event extends AbstractComponent implements ChannelStateUpdateListener { + public static final String EVENT_TYPE_CHANNEL_ID = "event-type"; + public static final String JSON_ATTRIBUTES_CHANNEL_ID = "json-attributes"; + private static final String EVENT_TYPE_TRANFORMATION = "{{ value_json.event_type }}"; + + /** + * Configuration class for MQTT component + */ + public static class ChannelConfiguration extends AbstractChannelConfiguration { + ChannelConfiguration() { + super("MQTT Event"); + } + + @SerializedName("state_topic") + protected String stateTopic = ""; + + @SerializedName("event_types") + protected List eventTypes = new ArrayList(); + + @SerializedName("json_attributes_topic") + protected @Nullable String jsonAttributesTopic; + + @SerializedName("json_attributes_template") + protected @Nullable String jsonAttributesTemplate; + } + + private final HomeAssistantChannelTransformation transformation; + + public Event(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { + super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); + + transformation = new HomeAssistantChannelTransformation(getJinjava(), this, ""); + + buildChannel(EVENT_TYPE_CHANNEL_ID, ComponentChannelType.TRIGGER, new TextValue(), getName(), this) + .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()).trigger(true) + .build(); + + if (channelConfiguration.jsonAttributesTopic != null) { + // It's unclear from the documentation if the JSON attributes value is expected + // to be the same as the main topic, and thus would always have an event_type + // attribute (and thus could possibly be shared with multiple components). + // If that were the case, we would need to intercept events, and check that they + // had an event_type that is in channelConfiguration.eventTypes. If/when that + // becomes an issue, change `channelStateUpdateListener` to `this`, and handle + // the filtering below. + buildChannel(JSON_ATTRIBUTES_CHANNEL_ID, ComponentChannelType.TRIGGER, new TextValue(), getName(), + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.jsonAttributesTopic, channelConfiguration.jsonAttributesTemplate) + .trigger(true).build(); + } + + finalizeChannels(); + } + + @Override + public void triggerChannel(ChannelUID channel, String event) { + String eventType = transformation.apply(EVENT_TYPE_TRANFORMATION, event).orElse(null); + if (eventType == null) { + // Warning logged from inside the transformation + return; + } + // The TextValue allows anything, because it receives the full JSON, and + // we don't check the actual event_type against valid event_types until here + if (!channelConfiguration.eventTypes.contains(eventType)) { + return; + } + + componentConfiguration.getUpdateListener().triggerChannel(channel, eventType); + } + + @Override + public void updateChannelState(ChannelUID channel, State state) { + // N/A (only trigger channels) + } + + @Override + public void postChannelCommand(ChannelUID channel, Command command) { + // N/A (only trigger channels) + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java index 7609fa7a44dd4..11ce67c84f7ae 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java @@ -304,12 +304,13 @@ public void accept(List> discoveredComponentsList) { } // Add component to the component map - addComponent(discovered); - // Start component / Subscribe to channel topics - discovered.start(connection, scheduler, 0).exceptionally(e -> { - logger.warn("Failed to start component {}", discovered.getHaID(), e); - return null; - }); + if (addComponent(discovered)) { + // Start component / Subscribe to channel topics + discovered.start(connection, scheduler, 0).exceptionally(e -> { + logger.warn("Failed to start component {}", discovered.getHaID(), e); + return null; + }); + } if (discovered instanceof Update) { updateComponent = (Update) discovered; @@ -427,7 +428,7 @@ private void releaseStateUpdated(Update.ReleaseState state) { } // should only be called when it's safe to access haComponents - private void addComponent(AbstractComponent component) { + private boolean addComponent(AbstractComponent component) { AbstractComponent existing = haComponents.get(component.getComponentId()); if (existing != null) { // DeviceTriggers that are for the same subtype, topic, and value template @@ -454,8 +455,7 @@ private void addComponent(AbstractComponent component) { }); } haComponentsByUniqueId.put(component.getUniqueId(), component); - System.out.println("don't forget to add to the channel config"); - return; + return false; } } @@ -467,6 +467,7 @@ private void addComponent(AbstractComponent component) { } haComponents.put(component.getComponentId(), component); haComponentsByUniqueId.put(component.getUniqueId(), component); + return true; } /** diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java index 2771859417baf..cb9c4fe5a306d 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java @@ -233,18 +233,13 @@ protected static void assertState(AbstractComponent<@NonNull ? extends AbstractC } } - protected void spyOnChannelUpdates(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component, - String channelId) { - // It's already thingHandler, but not the spy version - component.getChannel(channelId).getState().setChannelStateUpdateListener(thingHandler); - } - /** * Assert a channel triggers */ protected void assertTriggered(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component, String channelId, String trigger) { - verify(thingHandler).triggerChannel(eq(component.getChannel(channelId).getChannel().getUID()), eq(trigger)); + verify(callbackMock).channelTriggered(eq(haThing), eq(component.getChannel(channelId).getChannel().getUID()), + eq(trigger)); } /** @@ -252,8 +247,8 @@ protected void assertTriggered(AbstractComponent<@NonNull ? extends AbstractChan */ protected void assertNotTriggered(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component, String channelId, String trigger) { - verify(thingHandler, never()).triggerChannel(eq(component.getChannel(channelId).getChannel().getUID()), - eq(trigger)); + verify(callbackMock, never()).channelTriggered(eq(haThing), + eq(component.getChannel(channelId).getChannel().getUID()), eq(trigger)); } /** diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTriggerTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTriggerTests.java index 96becd8e2193c..a45c1eac3479d 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTriggerTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTriggerTests.java @@ -66,7 +66,6 @@ public void test() throws InterruptedException { assertChannel(component, "on", "zigbee2mqtt/Charge Now Button/action", "", "MQTT Device Trigger", TextValue.class); - spyOnChannelUpdates(component, "on"); publishMessage("zigbee2mqtt/Charge Now Button/action", "on"); assertTriggered(component, "on", "on"); @@ -132,7 +131,6 @@ public void testMerge() throws InterruptedException { List configList = (List) config; assertThat(configList.size(), is(2)); - spyOnChannelUpdates(component1, "turn_on"); publishMessage("zigbee2mqtt/Charge Now Button/action", "press"); assertTriggered(component1, "turn_on", "press"); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/EventTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/EventTests.java new file mode 100644 index 0000000000000..6dc20ca3674b0 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/EventTests.java @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2010-2024 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.mqtt.homeassistant.internal.component; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.mqtt.generic.values.TextValue; + +/** + * Tests for {@link Event} + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class EventTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC = "event/doorbell/action"; + + @SuppressWarnings("null") + @Test + public void test() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "event_types": [ + "press", + "release" + ], + "state_topic": "zigbee2mqtt/doorbell/action" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("MQTT Event")); + + assertChannel(component, "event-type", "zigbee2mqtt/doorbell/action", "", "MQTT Event", TextValue.class); + + publishMessage("zigbee2mqtt/doorbell/action", "{ \"event_type\": \"press\" }"); + assertTriggered(component, "event-type", "press"); + + publishMessage("zigbee2mqtt/doorbell/action", "{ \"event_type\": \"release\" }"); + assertTriggered(component, "event-type", "release"); + + publishMessage("zigbee2mqtt/doorbell/action", "{ \"event_type\": \"else\" }"); + assertNotTriggered(component, "event-type", "else"); + } + + @SuppressWarnings("null") + @Test + public void testJsonAttributes() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "event_types": [ + "press", + "release" + ], + "state_topic": "zigbee2mqtt/doorbell/action", + "json_attributes_topic": "zigbee2mqtt/doorbell/action" + } + """); + + assertThat(component.channels.size(), is(2)); + assertThat(component.getName(), is("MQTT Event")); + + assertChannel(component, "event-type", "zigbee2mqtt/doorbell/action", "", "MQTT Event", TextValue.class); + assertChannel(component, "json-attributes", "zigbee2mqtt/doorbell/action", "", "MQTT Event", TextValue.class); + + publishMessage("zigbee2mqtt/doorbell/action", "{ \"event_type\": \"press\" }"); + assertTriggered(component, "json-attributes", "{ \"event_type\": \"press\" }"); + } + + @Override + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +}