Skip to content

Commit

Permalink
[mqtt.homeassistant] Add support for Event component (#17599)
Browse files Browse the repository at this point in the history
Signed-off-by: Cody Cutrer <cody@cutrer.us>
  • Loading branch information
ccutrer authored Oct 21, 2024
1 parent f85da1a commit eac49a8
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 24 deletions.
1 change: 1 addition & 0 deletions bundles/org.openhab.binding.mqtt.homeassistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,13 @@ public void accept(List<AbstractComponent<?>> 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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}

Expand All @@ -467,6 +467,7 @@ private void addComponent(AbstractComponent component) {
}
haComponents.put(component.getComponentId(), component);
haComponentsByUniqueId.put(component.getUniqueId(), component);
return true;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,27 +233,22 @@ 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));
}

/**
* Assert a channel does not triggers=
*/
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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}

0 comments on commit eac49a8

Please sign in to comment.