[mqtt.homeassistant] Add support for Event component (#17599)

Signed-off-by: Cody Cutrer <cody@cutrer.us>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Cody Cutrer 2024-10-21 15:48:01 -05:00 committed by Ciprian Pascu
parent 356aa13fb7
commit d135e24fe4
7 changed files with 226 additions and 24 deletions

View File

@ -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/) - [Climate](https://www.home-assistant.io/integrations/climate.mqtt/)
- [Cover](https://www.home-assistant.io/integrations/cover.mqtt/) - [Cover](https://www.home-assistant.io/integrations/cover.mqtt/)
- [Device Trigger](https://www.home-assistant.io/integrations/device_trigger.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/) - [Fan](https://www.home-assistant.io/integrations/fan.mqtt/)
- [Light](https://www.home-assistant.io/integrations/light.mqtt/) - [Light](https://www.home-assistant.io/integrations/light.mqtt/)
- [Lock](https://www.home-assistant.io/integrations/lock.mqtt/) - [Lock](https://www.home-assistant.io/integrations/lock.mqtt/)

View File

@ -59,14 +59,16 @@ public class ComponentFactory {
return new Button(componentConfiguration, newStyleChannels); return new Button(componentConfiguration, newStyleChannels);
case "camera": case "camera":
return new Camera(componentConfiguration, newStyleChannels); return new Camera(componentConfiguration, newStyleChannels);
case "cover":
return new Cover(componentConfiguration, newStyleChannels);
case "fan":
return new Fan(componentConfiguration, newStyleChannels);
case "climate": case "climate":
return new Climate(componentConfiguration, newStyleChannels); return new Climate(componentConfiguration, newStyleChannels);
case "cover":
return new Cover(componentConfiguration, newStyleChannels);
case "device_automation": case "device_automation":
return new DeviceTrigger(componentConfiguration, newStyleChannels); return new DeviceTrigger(componentConfiguration, newStyleChannels);
case "event":
return new Event(componentConfiguration, newStyleChannels);
case "fan":
return new Fan(componentConfiguration, newStyleChannels);
case "light": case "light":
return Light.create(componentConfiguration, newStyleChannels); return Light.create(componentConfiguration, newStyleChannels);
case "lock": case "lock":

View File

@ -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<Event.ChannelConfiguration> 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<String> 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)
}
}

View File

@ -304,12 +304,13 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
} }
// Add component to the component map // Add component to the component map
addComponent(discovered); if (addComponent(discovered)) {
// Start component / Subscribe to channel topics // Start component / Subscribe to channel topics
discovered.start(connection, scheduler, 0).exceptionally(e -> { discovered.start(connection, scheduler, 0).exceptionally(e -> {
logger.warn("Failed to start component {}", discovered.getHaID(), e); logger.warn("Failed to start component {}", discovered.getHaID(), e);
return null; return null;
}); });
}
if (discovered instanceof Update) { if (discovered instanceof Update) {
updateComponent = (Update) discovered; updateComponent = (Update) discovered;
@ -427,7 +428,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
} }
// should only be called when it's safe to access haComponents // 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()); AbstractComponent existing = haComponents.get(component.getComponentId());
if (existing != null) { if (existing != null) {
// DeviceTriggers that are for the same subtype, topic, and value template // DeviceTriggers that are for the same subtype, topic, and value template
@ -454,8 +455,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
}); });
} }
haComponentsByUniqueId.put(component.getUniqueId(), component); haComponentsByUniqueId.put(component.getUniqueId(), component);
System.out.println("don't forget to add to the channel config"); return false;
return;
} }
} }
@ -467,6 +467,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
} }
haComponents.put(component.getComponentId(), component); haComponents.put(component.getComponentId(), component);
haComponentsByUniqueId.put(component.getUniqueId(), component); haComponentsByUniqueId.put(component.getUniqueId(), component);
return true;
} }
/** /**

View File

@ -233,18 +233,13 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
} }
} }
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 * Assert a channel triggers
*/ */
protected void assertTriggered(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component, protected void assertTriggered(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component,
String channelId, String trigger) { 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 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
*/ */
protected void assertNotTriggered(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component, protected void assertNotTriggered(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component,
String channelId, String trigger) { String channelId, String trigger) {
verify(thingHandler, never()).triggerChannel(eq(component.getChannel(channelId).getChannel().getUID()), verify(callbackMock, never()).channelTriggered(eq(haThing),
eq(trigger)); eq(component.getChannel(channelId).getChannel().getUID()), eq(trigger));
} }
/** /**

View File

@ -66,7 +66,6 @@ public class DeviceTriggerTests extends AbstractComponentTests {
assertChannel(component, "on", "zigbee2mqtt/Charge Now Button/action", "", "MQTT Device Trigger", assertChannel(component, "on", "zigbee2mqtt/Charge Now Button/action", "", "MQTT Device Trigger",
TextValue.class); TextValue.class);
spyOnChannelUpdates(component, "on");
publishMessage("zigbee2mqtt/Charge Now Button/action", "on"); publishMessage("zigbee2mqtt/Charge Now Button/action", "on");
assertTriggered(component, "on", "on"); assertTriggered(component, "on", "on");
@ -132,7 +131,6 @@ public class DeviceTriggerTests extends AbstractComponentTests {
List<?> configList = (List<?>) config; List<?> configList = (List<?>) config;
assertThat(configList.size(), is(2)); assertThat(configList.size(), is(2));
spyOnChannelUpdates(component1, "turn_on");
publishMessage("zigbee2mqtt/Charge Now Button/action", "press"); publishMessage("zigbee2mqtt/Charge Now Button/action", "press");
assertTriggered(component1, "turn_on", "press"); assertTriggered(component1, "turn_on", "press");

View File

@ -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<String> getConfigTopics() {
return Set.of(CONFIG_TOPIC);
}
}