<version>${project.version}</version>
<scope>provided</scope>
</dependency>
+
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.transform.jinja</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.stream.Collectors;
-
-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.MqttChannelTypeProvider;
-import org.openhab.binding.mqtt.generic.utils.FutureCollector;
-import org.openhab.binding.mqtt.generic.values.Value;
-import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
-import org.openhab.binding.mqtt.homeassistant.internal.CFactory.ComponentConfiguration;
-import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
-import org.openhab.core.thing.ChannelGroupUID;
-import org.openhab.core.thing.type.ChannelDefinition;
-import org.openhab.core.thing.type.ChannelGroupDefinition;
-import org.openhab.core.thing.type.ChannelGroupType;
-import org.openhab.core.thing.type.ChannelGroupTypeBuilder;
-import org.openhab.core.thing.type.ChannelGroupTypeUID;
-
-/**
- * A HomeAssistant component is comparable to a channel group.
- * It has a name and consists of multiple channels.
- *
- * @author David Graeff - Initial contribution
- * @param <C> Config class derived from {@link BaseChannelConfiguration}
- */
-@NonNullByDefault
-public abstract class AbstractComponent<C extends BaseChannelConfiguration> {
- // Component location fields
- private final ComponentConfiguration componentConfiguration;
- protected final ChannelGroupTypeUID channelGroupTypeUID;
- protected final ChannelGroupUID channelGroupUID;
- protected final HaID haID;
-
- // Channels and configuration
- protected final Map<String, CChannel> channels = new TreeMap<>();
- // The hash code ({@link String#hashCode()}) of the configuration string
- // Used to determine if a component has changed.
- protected final int configHash;
- protected final String channelConfigurationJson;
- protected final C channelConfiguration;
-
- protected boolean configSeen;
-
- /**
- * Provide a thingUID and HomeAssistant topic ID to determine the channel group UID and type.
- *
- * @param thing A ThingUID
- * @param haID A HomeAssistant topic ID
- * @param configJson The configuration string
- * @param gson A Gson instance
- */
- public AbstractComponent(CFactory.ComponentConfiguration componentConfiguration, Class<C> clazz) {
- this.componentConfiguration = componentConfiguration;
-
- this.channelConfigurationJson = componentConfiguration.getConfigJSON();
- this.channelConfiguration = componentConfiguration.getConfig(clazz);
- this.configHash = channelConfigurationJson.hashCode();
-
- this.haID = componentConfiguration.getHaID();
-
- String groupId = this.haID.getGroupId(channelConfiguration.unique_id);
-
- this.channelGroupTypeUID = new ChannelGroupTypeUID(MqttBindingConstants.BINDING_ID, groupId);
- this.channelGroupUID = new ChannelGroupUID(componentConfiguration.getThingUID(), groupId);
-
- this.configSeen = false;
-
- String availability_topic = this.channelConfiguration.availability_topic;
- if (availability_topic != null) {
- componentConfiguration.getTracker().addAvailabilityTopic(availability_topic,
- this.channelConfiguration.payload_available, this.channelConfiguration.payload_not_available);
- }
- }
-
- protected CChannel.Builder buildChannel(String channelID, Value valueState, String label,
- ChannelStateUpdateListener channelStateUpdateListener) {
- return new CChannel.Builder(this, componentConfiguration, channelID, valueState, label,
- channelStateUpdateListener);
- }
-
- public void setConfigSeen() {
- this.configSeen = true;
- }
-
- /**
- * Subscribes to all state channels of the component and adds all channels to the provided channel type provider.
- *
- * @param connection The connection
- * @param channelStateUpdateListener A listener
- * @return A future that completes as soon as all subscriptions have been performed. Completes exceptionally on
- * errors.
- */
- public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
- int timeout) {
- return channels.values().parallelStream().map(v -> v.start(connection, scheduler, timeout))
- .collect(FutureCollector.allOf());
- }
-
- /**
- * Unsubscribes from all state channels of the component.
- *
- * @return A future that completes as soon as all subscriptions removals have been performed. Completes
- * exceptionally on errors.
- */
- public CompletableFuture<@Nullable Void> stop() {
- return channels.values().parallelStream().map(CChannel::stop).collect(FutureCollector.allOf());
- }
-
- /**
- * Add all channel types to the channel type provider.
- *
- * @param channelTypeProvider The channel type provider
- */
- public void addChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
- channelTypeProvider.setChannelGroupType(groupTypeUID(), type());
- channels.values().forEach(v -> v.addChannelTypes(channelTypeProvider));
- }
-
- /**
- * Removes all channels from the channel type provider.
- * Call this if the corresponding Thing handler gets disposed.
- *
- * @param channelTypeProvider The channel type provider
- */
- public void removeChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
- channels.values().forEach(v -> v.removeChannelTypes(channelTypeProvider));
- channelTypeProvider.removeChannelGroupType(groupTypeUID());
- }
-
- /**
- * Each HomeAssistant component corresponds to a Channel Group Type.
- */
- public ChannelGroupTypeUID groupTypeUID() {
- return channelGroupTypeUID;
- }
-
- /**
- * The unique id of this component.
- */
- public ChannelGroupUID uid() {
- return channelGroupUID;
- }
-
- /**
- * Component (Channel Group) name.
- */
- public String name() {
- return channelConfiguration.name;
- }
-
- /**
- * Each component consists of multiple Channels.
- */
- public Map<String, CChannel> channelTypes() {
- return channels;
- }
-
- /**
- * Return a components channel. A HomeAssistant MQTT component consists of multiple functions
- * and those are mapped to one or more channels. The channel IDs are constants within the
- * derived Component, like the {@link ComponentSwitch#switchChannelID}.
- *
- * @param channelID The channel ID
- * @return A components channel
- */
- public @Nullable CChannel channel(String channelID) {
- return channels.get(channelID);
- }
-
- /**
- * @return Returns the configuration hash value for easy comparison.
- */
- public int getConfigHash() {
- return configHash;
- }
-
- /**
- * Return the channel group type.
- */
- public ChannelGroupType type() {
- final List<ChannelDefinition> channelDefinitions = channels.values().stream().map(CChannel::type)
- .collect(Collectors.toList());
- return ChannelGroupTypeBuilder.instance(channelGroupTypeUID, name()).withChannelDefinitions(channelDefinitions)
- .build();
- }
-
- /**
- * Resets all channel states to state UNDEF. Call this method after the connection
- * to the MQTT broker got lost.
- */
- public void resetState() {
- channels.values().forEach(CChannel::resetState);
- }
-
- /**
- * Return the channel group definition for this component.
- */
- public ChannelGroupDefinition getGroupDefinition() {
- return new ChannelGroupDefinition(channelGroupUID.getId(), groupTypeUID(), name(), null);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.util.UIDUtils;
-
-import com.google.gson.Gson;
-import com.google.gson.annotations.JsonAdapter;
-import com.google.gson.annotations.SerializedName;
-
-/**
- * Base class for home assistant configurations.
- *
- * @author Jochen Klein - Initial contribution
- */
-@NonNullByDefault
-public abstract class BaseChannelConfiguration {
-
- /**
- * This class is needed, to be able to parse only the common base attributes.
- * Without this, {@link BaseChannelConfiguration} cannot be instantiated, as it is abstract.
- * This is needed during the discovery.
- */
- private static class Config extends BaseChannelConfiguration {
- public Config() {
- super("private");
- }
- }
-
- /**
- * Parse the configJSON into a subclass of {@link BaseChannelConfiguration}
- *
- * @param configJSON
- * @param gson
- * @param clazz
- * @return configuration object
- */
- public static <C extends BaseChannelConfiguration> C fromString(final String configJSON, final Gson gson,
- final Class<C> clazz) {
- return Objects.requireNonNull(gson.fromJson(configJSON, clazz));
- }
-
- /**
- * Parse the base properties of the configJSON into a {@link BaseChannelConfiguration}
- *
- * @param configJSON
- * @param gson
- * @return configuration object
- */
- public static BaseChannelConfiguration fromString(final String configJSON, final Gson gson) {
- return fromString(configJSON, gson, Config.class);
- }
-
- public String name;
-
- protected String icon = "";
- protected int qos; // defaults to 0 according to HA specification
- protected boolean retain; // defaults to false according to HA specification
- protected @Nullable String value_template;
- protected @Nullable String unique_id;
-
- protected @Nullable String availability_topic;
- protected String payload_available = "online";
- protected String payload_not_available = "offline";
-
- @SerializedName(value = "~")
- protected String tilde = "";
-
- protected BaseChannelConfiguration(String defaultName) {
- this.name = defaultName;
- }
-
- public @Nullable String expand(@Nullable String value) {
- return value == null ? null : value.replaceAll("~", tilde);
- }
-
- protected @Nullable Device device;
-
- static class Device {
- @JsonAdapter(ListOrStringDeserializer.class)
- protected @Nullable List<String> identifiers;
- protected @Nullable List<Connection> connections;
- protected @Nullable String manufacturer;
- protected @Nullable String model;
- protected @Nullable String name;
- protected @Nullable String sw_version;
-
- public @Nullable String getId() {
- List<String> identifiers = this.identifiers;
- return identifiers == null ? null : String.join("_", identifiers);
- }
- }
-
- @JsonAdapter(ConnectionDeserializer.class)
- static class Connection {
- protected @Nullable String type;
- protected @Nullable String identifier;
- }
-
- public String getThingName() {
- @Nullable
- String result = null;
-
- if (this.device != null) {
- result = this.device.name;
- }
- if (result == null) {
- result = name;
- }
- return result;
- }
-
- public String getThingId(String defaultId) {
- @Nullable
- String result = null;
- if (this.device != null) {
- result = this.device.getId();
- }
- if (result == null) {
- result = unique_id;
- }
- return UIDUtils.encode(result != null ? result : defaultId);
- }
-
- public Map<String, Object> appendToProperties(Map<String, Object> properties) {
- final Device device_ = device;
- if (device_ == null) {
- return properties;
- }
- final String manufacturer = device_.manufacturer;
- if (manufacturer != null) {
- properties.put(Thing.PROPERTY_VENDOR, manufacturer);
- }
- final String model = device_.model;
- if (model != null) {
- properties.put(Thing.PROPERTY_MODEL_ID, model);
- }
- final String sw_version = device_.sw_version;
- if (sw_version != null) {
- properties.put(Thing.PROPERTY_FIRMWARE_VERSION, sw_version);
- }
- return properties;
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.net.URI;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ScheduledExecutorService;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.mqtt.generic.ChannelConfigBuilder;
-import org.openhab.binding.mqtt.generic.ChannelState;
-import org.openhab.binding.mqtt.generic.ChannelStateTransformation;
-import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
-import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
-import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
-import org.openhab.binding.mqtt.generic.values.Value;
-import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
-import org.openhab.binding.mqtt.homeassistant.internal.CFactory.ComponentConfiguration;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
-import org.openhab.core.thing.Channel;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.binding.builder.ChannelBuilder;
-import org.openhab.core.thing.type.ChannelDefinition;
-import org.openhab.core.thing.type.ChannelDefinitionBuilder;
-import org.openhab.core.thing.type.ChannelType;
-import org.openhab.core.thing.type.ChannelTypeBuilder;
-import org.openhab.core.thing.type.ChannelTypeUID;
-import org.openhab.core.types.StateDescriptionFragment;
-
-/**
- * An {@link AbstractComponent}s derived class consists of one or multiple channels.
- * Each component channel consists of the determined channel type, channel type UID and the
- * channel description itself as well as the the channels state.
- *
- * After the discovery process has completed and the tree of components and component channels
- * have been built up, the channel types are registered to a custom channel type provider
- * before adding the channel descriptions to the Thing themselves.
- * <br>
- * <br>
- * An object of this class creates the required {@link ChannelType} and {@link ChannelTypeUID} as well
- * as keeps the {@link ChannelState} and {@link Channel} in one place.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class CChannel {
- private static final String JINJA = "JINJA";
-
- private final ChannelUID channelUID;
- private final ChannelState channelState;
- private final Channel channel;
- private final ChannelType type;
- private final ChannelTypeUID channelTypeUID;
- private final ChannelStateUpdateListener channelStateUpdateListener;
-
- private CChannel(ChannelUID channelUID, ChannelState channelState, Channel channel, ChannelType type,
- ChannelTypeUID channelTypeUID, ChannelStateUpdateListener channelStateUpdateListener) {
- super();
- this.channelUID = channelUID;
- this.channelState = channelState;
- this.channel = channel;
- this.type = type;
- this.channelTypeUID = channelTypeUID;
- this.channelStateUpdateListener = channelStateUpdateListener;
- }
-
- public ChannelUID getChannelUID() {
- return channelUID;
- }
-
- public Channel getChannel() {
- return channel;
- }
-
- public ChannelState getState() {
- return channelState;
- }
-
- public CompletableFuture<@Nullable Void> stop() {
- return channelState.stop();
- }
-
- public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
- int timeout) {
- // Make sure we set the callback again which might have been nulled during an stop
- channelState.setChannelStateUpdateListener(this.channelStateUpdateListener);
-
- return channelState.start(connection, scheduler, timeout);
- }
-
- public void addChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
- channelTypeProvider.setChannelType(channelTypeUID, type);
- }
-
- public void removeChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
- channelTypeProvider.removeChannelType(channelTypeUID);
- }
-
- public ChannelDefinition type() {
- return new ChannelDefinitionBuilder(channelUID.getId(), channelTypeUID).build();
- }
-
- public void resetState() {
- channelState.getCache().resetState();
- }
-
- public static class Builder {
- private AbstractComponent<?> component;
- private ComponentConfiguration componentConfiguration;
- private String channelID;
- private Value valueState;
- private String label;
- private @Nullable String state_topic;
- private @Nullable String command_topic;
- private boolean retain;
- private boolean trigger;
- private @Nullable Integer qos;
- private ChannelStateUpdateListener channelStateUpdateListener;
-
- private @Nullable String templateIn;
-
- public Builder(AbstractComponent<?> component, ComponentConfiguration componentConfiguration, String channelID,
- Value valueState, String label, ChannelStateUpdateListener channelStateUpdateListener) {
- this.component = component;
- this.componentConfiguration = componentConfiguration;
- this.channelID = channelID;
- this.valueState = valueState;
- this.label = label;
- this.channelStateUpdateListener = channelStateUpdateListener;
- }
-
- public Builder stateTopic(@Nullable String state_topic) {
- this.state_topic = state_topic;
- return this;
- }
-
- public Builder stateTopic(@Nullable String state_topic, @Nullable String... templates) {
- this.state_topic = state_topic;
- if (state_topic != null && !state_topic.isBlank()) {
- for (String template : templates) {
- if (template != null && !template.isBlank()) {
- this.templateIn = template;
- break;
- }
- }
- }
- return this;
- }
-
- /**
- * @deprecated use commandTopic(String, boolean, int)
- * @param command_topic
- * @param retain
- * @return
- */
- @Deprecated
- public Builder commandTopic(@Nullable String command_topic, boolean retain) {
- this.command_topic = command_topic;
- this.retain = retain;
- return this;
- }
-
- public Builder commandTopic(@Nullable String command_topic, boolean retain, int qos) {
- this.command_topic = command_topic;
- this.retain = retain;
- this.qos = qos;
- return this;
- }
-
- public Builder trigger(boolean trigger) {
- this.trigger = trigger;
- return this;
- }
-
- public CChannel build() {
- return build(true);
- }
-
- public CChannel build(boolean addToComponent) {
- ChannelUID channelUID;
- ChannelState channelState;
- Channel channel;
- ChannelType type;
- ChannelTypeUID channelTypeUID;
-
- channelUID = new ChannelUID(component.channelGroupUID, channelID);
- channelTypeUID = new ChannelTypeUID(MqttBindingConstants.BINDING_ID,
- channelUID.getGroupId() + "_" + channelID);
- channelState = new ChannelState(
- ChannelConfigBuilder.create().withRetain(retain).withQos(qos).withStateTopic(state_topic)
- .withCommandTopic(command_topic).makeTrigger(trigger).build(),
- channelUID, valueState, channelStateUpdateListener);
-
- String localStateTopic = state_topic;
- if (localStateTopic == null || localStateTopic.isBlank() || this.trigger) {
- type = ChannelTypeBuilder.trigger(channelTypeUID, label)
- .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL)).build();
- } else {
- StateDescriptionFragment description = valueState.createStateDescription(command_topic == null).build();
- type = ChannelTypeBuilder.state(channelTypeUID, label, channelState.getItemType())
- .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL))
- .withStateDescriptionFragment(description).build();
- }
-
- Configuration configuration = new Configuration();
- configuration.put("config", component.channelConfigurationJson);
- component.haID.toConfig(configuration);
-
- channel = ChannelBuilder.create(channelUID, channelState.getItemType()).withType(channelTypeUID)
- .withKind(type.getKind()).withLabel(label).withConfiguration(configuration).build();
-
- CChannel result = new CChannel(channelUID, channelState, channel, type, channelTypeUID,
- channelStateUpdateListener);
-
- @Nullable
- TransformationServiceProvider transformationProvider = componentConfiguration
- .getTransformationServiceProvider();
-
- final String templateIn = this.templateIn;
- if (templateIn != null && transformationProvider != null) {
- channelState
- .addTransformation(new ChannelStateTransformation(JINJA, templateIn, transformationProvider));
- }
- if (addToComponent) {
- component.channels.put(channelID, result);
- }
- return result;
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.util.concurrent.ScheduledExecutorService;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.mqtt.generic.AvailabilityTracker;
-import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
-import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
-import org.openhab.core.thing.ThingUID;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.google.gson.Gson;
-
-/**
- * A factory to create HomeAssistant MQTT components. Those components are specified at:
- * https://www.home-assistant.io/docs/mqtt/discovery/
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class CFactory {
- private static final Logger logger = LoggerFactory.getLogger(CFactory.class);
-
- /**
- * Create a HA MQTT component. The configuration JSon string is required.
- *
- * @param thingUID The Thing UID that this component will belong to.
- * @param haID The location of this component. The HomeAssistant ID contains the object-id, node-id and
- * component-id.
- * @param configJSON Most components expect a "name", a "state_topic" and "command_topic" like with
- * "{name:'Name',state_topic:'homeassistant/switch/0/object/state',command_topic:'homeassistant/switch/0/object/set'".
- * @param updateListener A channel state update listener
- * @return A HA MQTT Component
- */
- public static @Nullable AbstractComponent<?> createComponent(ThingUID thingUID, HaID haID,
- String channelConfigurationJSON, ChannelStateUpdateListener updateListener, AvailabilityTracker tracker,
- ScheduledExecutorService scheduler, Gson gson,
- TransformationServiceProvider transformationServiceProvider) {
- ComponentConfiguration componentConfiguration = new ComponentConfiguration(thingUID, haID,
- channelConfigurationJSON, gson, updateListener, tracker, scheduler)
- .transformationProvider(transformationServiceProvider);
- try {
- switch (haID.component) {
- case "alarm_control_panel":
- return new ComponentAlarmControlPanel(componentConfiguration);
- case "binary_sensor":
- return new ComponentBinarySensor(componentConfiguration);
- case "camera":
- return new ComponentCamera(componentConfiguration);
- case "cover":
- return new ComponentCover(componentConfiguration);
- case "fan":
- return new ComponentFan(componentConfiguration);
- case "climate":
- return new ComponentClimate(componentConfiguration);
- case "light":
- return new ComponentLight(componentConfiguration);
- case "lock":
- return new ComponentLock(componentConfiguration);
- case "sensor":
- return new ComponentSensor(componentConfiguration);
- case "switch":
- return new ComponentSwitch(componentConfiguration);
- }
- } catch (UnsupportedOperationException e) {
- logger.warn("Not supported", e);
- }
- return null;
- }
-
- protected static class ComponentConfiguration {
- private final ThingUID thingUID;
- private final HaID haID;
- private final String configJSON;
- private final ChannelStateUpdateListener updateListener;
- private final AvailabilityTracker tracker;
- private final Gson gson;
- private final ScheduledExecutorService scheduler;
- private @Nullable TransformationServiceProvider transformationServiceProvider;
-
- protected ComponentConfiguration(ThingUID thingUID, HaID haID, String configJSON, Gson gson,
- ChannelStateUpdateListener updateListener, AvailabilityTracker tracker,
- ScheduledExecutorService scheduler) {
- this.thingUID = thingUID;
- this.haID = haID;
- this.configJSON = configJSON;
- this.gson = gson;
- this.updateListener = updateListener;
- this.tracker = tracker;
- this.scheduler = scheduler;
- }
-
- public ComponentConfiguration transformationProvider(
- TransformationServiceProvider transformationServiceProvider) {
- this.transformationServiceProvider = transformationServiceProvider;
- return this;
- }
-
- public ThingUID getThingUID() {
- return thingUID;
- }
-
- public HaID getHaID() {
- return haID;
- }
-
- public String getConfigJSON() {
- return configJSON;
- }
-
- public ChannelStateUpdateListener getUpdateListener() {
- return updateListener;
- }
-
- @Nullable
- public TransformationServiceProvider getTransformationServiceProvider() {
- return transformationServiceProvider;
- }
-
- public Gson getGson() {
- return gson;
- }
-
- public AvailabilityTracker getTracker() {
- return tracker;
- }
-
- public ScheduledExecutorService getScheduler() {
- return scheduler;
- }
-
- public <C extends BaseChannelConfiguration> C getConfig(Class<C> clazz) {
- return BaseChannelConfiguration.fromString(configJSON, gson, clazz);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.io.IOException;
-import java.lang.reflect.Field;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-import com.google.gson.Gson;
-import com.google.gson.TypeAdapter;
-import com.google.gson.TypeAdapterFactory;
-import com.google.gson.reflect.TypeToken;
-import com.google.gson.stream.JsonReader;
-import com.google.gson.stream.JsonWriter;
-
-/**
- * This a Gson type adapter factory.
- *
- * It will create a type adapter for every class derived from {@link BaseChannelConfiguration} and ensures,
- * that abbreviated names are replaces with their long versions during the read.
- *
- * In elements, whose name end in'_topic' '~' replacement is performed.
- *
- * The adapters also handle {@link BaseChannelConfiguration.Device}
- *
- * @author Jochen Klein - Initial contribution
- */
-@NonNullByDefault
-public class ChannelConfigurationTypeAdapterFactory implements TypeAdapterFactory {
-
- @Override
- @Nullable
- public <T> TypeAdapter<T> create(@Nullable Gson gson, @Nullable TypeToken<T> type) {
- if (gson == null || type == null) {
- return null;
- }
- if (BaseChannelConfiguration.class.isAssignableFrom(type.getRawType())) {
- return createHAConfig(gson, type);
- }
- if (BaseChannelConfiguration.Device.class.isAssignableFrom(type.getRawType())) {
- return createHADevice(gson, type);
- }
- return null;
- }
-
- /**
- * Handle {@link BaseChannelConfiguration}
- *
- * @param gson
- * @param type
- * @return
- */
- private <T> TypeAdapter<T> createHAConfig(Gson gson, TypeToken<T> type) {
- /* The delegate is the 'default' adapter */
- final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
-
- return new TypeAdapter<T>() {
- @Override
- public @Nullable T read(JsonReader in) throws IOException {
- /* read the object using the default adapter, but translate the names in the reader */
- T result = delegate.read(MappingJsonReader.getConfigMapper(in));
- /* do the '~' expansion afterwards */
- expandTidleInTopics(BaseChannelConfiguration.class.cast(result));
- return result;
- }
-
- @Override
- public void write(JsonWriter out, @Nullable T value) throws IOException {
- delegate.write(out, value);
- }
- };
- }
-
- private <T> TypeAdapter<T> createHADevice(Gson gson, TypeToken<T> type) {
- /* The delegate is the 'default' adapter */
- final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
-
- return new TypeAdapter<T>() {
- @Override
- public @Nullable T read(JsonReader in) throws IOException {
- /* read the object using the default adapter, but translate the names in the reader */
- T result = delegate.read(MappingJsonReader.getDeviceMapper(in));
- return result;
- }
-
- @Override
- public void write(JsonWriter out, @Nullable T value) throws IOException {
- delegate.write(out, value);
- }
- };
- }
-
- private void expandTidleInTopics(BaseChannelConfiguration config) {
- Class<?> type = config.getClass();
-
- String tilde = config.tilde;
-
- while (type != Object.class) {
- Field[] fields = type.getDeclaredFields();
-
- for (Field field : fields) {
- if (String.class.isAssignableFrom(field.getType()) && field.getName().endsWith("_topic")) {
- field.setAccessible(true);
-
- try {
- final String oldValue = (String) field.get(config);
-
- String newValue = oldValue;
- if (oldValue != null && !oldValue.isBlank()) {
- if (oldValue.charAt(0) == '~') {
- newValue = tilde + oldValue.substring(1);
- } else if (oldValue.charAt(oldValue.length() - 1) == '~') {
- newValue = oldValue.substring(0, oldValue.length() - 1) + tilde;
- }
- }
-
- field.set(config, newValue);
- } catch (IllegalArgumentException e) {
- throw new RuntimeException(e);
- } catch (IllegalAccessException e) {
- throw new RuntimeException(e);
- }
- }
- }
-
- type = type.getSuperclass();
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.mqtt.generic.values.TextValue;
-
-/**
- * A MQTT alarm control panel, following the https://www.home-assistant.io/components/alarm_control_panel.mqtt/
- * specification.
- *
- * The implemented provides three state-less switches (For disarming, arming@home, arming@away) and one alarm state
- * text.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentAlarmControlPanel extends AbstractComponent<ComponentAlarmControlPanel.ChannelConfiguration> {
- public static final String stateChannelID = "alarm"; // Randomly chosen channel "ID"
- public static final String switchDisarmChannelID = "disarm"; // Randomly chosen channel "ID"
- public static final String switchArmHomeChannelID = "armhome"; // Randomly chosen channel "ID"
- public static final String switchArmAwayChannelID = "armaway"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Alarm");
- }
-
- protected @Nullable String code;
-
- protected String state_topic = "";
- protected String state_disarmed = "disarmed";
- protected String state_armed_home = "armed_home";
- protected String state_armed_away = "armed_away";
- protected String state_pending = "pending";
- protected String state_triggered = "triggered";
-
- protected @Nullable String command_topic;
- protected String payload_disarm = "DISARM";
- protected String payload_arm_home = "ARM_HOME";
- protected String payload_arm_away = "ARM_AWAY";
- }
-
- public ComponentAlarmControlPanel(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- final String[] state_enum = { channelConfiguration.state_disarmed, channelConfiguration.state_armed_home,
- channelConfiguration.state_armed_away, channelConfiguration.state_pending,
- channelConfiguration.state_triggered };
- buildChannel(stateChannelID, new TextValue(state_enum), channelConfiguration.name,
- componentConfiguration.getUpdateListener())
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.value_template)//
- .build();
-
- String command_topic = channelConfiguration.command_topic;
- if (command_topic != null) {
- buildChannel(switchDisarmChannelID, new TextValue(new String[] { channelConfiguration.payload_disarm }),
- channelConfiguration.name, componentConfiguration.getUpdateListener())
- .commandTopic(command_topic, channelConfiguration.retain).build();
-
- buildChannel(switchArmHomeChannelID, new TextValue(new String[] { channelConfiguration.payload_arm_home }),
- channelConfiguration.name, componentConfiguration.getUpdateListener())
- .commandTopic(command_topic, channelConfiguration.retain).build();
-
- buildChannel(switchArmAwayChannelID, new TextValue(new String[] { channelConfiguration.payload_arm_away }),
- channelConfiguration.name, componentConfiguration.getUpdateListener())
- .commandTopic(command_topic, channelConfiguration.retain).build();
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-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.OnOffValue;
-import org.openhab.binding.mqtt.generic.values.Value;
-import org.openhab.binding.mqtt.homeassistant.internal.listener.ExpireUpdateStateListener;
-import org.openhab.binding.mqtt.homeassistant.internal.listener.OffDelayUpdateStateListener;
-
-/**
- * A MQTT BinarySensor, following the https://www.home-assistant.io/components/binary_sensor.mqtt/ specification.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentBinarySensor extends AbstractComponent<ComponentBinarySensor.ChannelConfiguration> {
- public static final String sensorChannelID = "sensor"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Binary Sensor");
- }
-
- protected @Nullable String device_class;
- protected boolean force_update = false;
- protected @Nullable Integer expire_after;
- protected @Nullable Integer off_delay;
-
- protected String state_topic = "";
- protected String payload_on = "ON";
- protected String payload_off = "OFF";
-
- protected @Nullable String json_attributes_topic;
- protected @Nullable String json_attributes_template;
- protected @Nullable List<String> json_attributes;
- }
-
- public ComponentBinarySensor(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- OnOffValue value = new OnOffValue(channelConfiguration.payload_on, channelConfiguration.payload_off);
-
- buildChannel(sensorChannelID, value, "value", getListener(componentConfiguration, value))
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.value_template).build();
- }
-
- private ChannelStateUpdateListener getListener(CFactory.ComponentConfiguration componentConfiguration,
- Value value) {
- ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener();
-
- if (channelConfiguration.expire_after != null) {
- updateListener = new ExpireUpdateStateListener(updateListener, channelConfiguration.expire_after, value,
- componentConfiguration.getTracker(), componentConfiguration.getScheduler());
- }
- if (channelConfiguration.off_delay != null) {
- updateListener = new OffDelayUpdateStateListener(updateListener, channelConfiguration.off_delay, value,
- componentConfiguration.getScheduler());
- }
-
- return updateListener;
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.mqtt.generic.values.ImageValue;
-
-/**
- * A MQTT camera, following the https://www.home-assistant.io/components/camera.mqtt/ specification.
- *
- * At the moment this only notifies the user that this feature is not yet supported.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentCamera extends AbstractComponent<ComponentCamera.ChannelConfiguration> {
- public static final String cameraChannelID = "camera"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Camera");
- }
-
- protected String topic = "";
- }
-
- public ComponentCamera(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- ImageValue value = new ImageValue();
-
- buildChannel(cameraChannelID, value, channelConfiguration.name, componentConfiguration.getUpdateListener())
- .stateTopic(channelConfiguration.topic).build();
- }
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Predicate;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.ChannelConfigBuilder;
+import org.openhab.binding.mqtt.generic.ChannelState;
+import org.openhab.binding.mqtt.generic.ChannelStateTransformation;
+import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
+import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
+import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
+import org.openhab.binding.mqtt.generic.values.Value;
+import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
+import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.type.ChannelDefinition;
+import org.openhab.core.thing.type.ChannelDefinitionBuilder;
+import org.openhab.core.thing.type.ChannelType;
+import org.openhab.core.thing.type.ChannelTypeBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.StateDescriptionFragment;
+
+/**
+ * An {@link AbstractComponent}s derived class consists of one or multiple channels.
+ * Each component channel consists of the determined channel type, channel type UID and the
+ * channel description itself as well as the the channels state.
+ *
+ * After the discovery process has completed and the tree of components and component channels
+ * have been built up, the channel types are registered to a custom channel type provider
+ * before adding the channel descriptions to the Thing themselves.
+ * <br>
+ * <br>
+ * An object of this class creates the required {@link ChannelType} and {@link ChannelTypeUID} as well
+ * as keeps the {@link ChannelState} and {@link Channel} in one place.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class ComponentChannel {
+ private static final String JINJA = "JINJA";
+
+ private final ChannelUID channelUID;
+ private final ChannelState channelState;
+ private final Channel channel;
+ private final ChannelType type;
+ private final ChannelTypeUID channelTypeUID;
+ private final ChannelStateUpdateListener channelStateUpdateListener;
+
+ private ComponentChannel(ChannelUID channelUID, ChannelState channelState, Channel channel, ChannelType type,
+ ChannelTypeUID channelTypeUID, ChannelStateUpdateListener channelStateUpdateListener) {
+ super();
+ this.channelUID = channelUID;
+ this.channelState = channelState;
+ this.channel = channel;
+ this.type = type;
+ this.channelTypeUID = channelTypeUID;
+ this.channelStateUpdateListener = channelStateUpdateListener;
+ }
+
+ public ChannelUID getChannelUID() {
+ return channelUID;
+ }
+
+ public Channel getChannel() {
+ return channel;
+ }
+
+ public ChannelState getState() {
+ return channelState;
+ }
+
+ public CompletableFuture<@Nullable Void> stop() {
+ return channelState.stop();
+ }
+
+ public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
+ int timeout) {
+ // Make sure we set the callback again which might have been nulled during an stop
+ channelState.setChannelStateUpdateListener(this.channelStateUpdateListener);
+
+ return channelState.start(connection, scheduler, timeout);
+ }
+
+ public void addChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
+ channelTypeProvider.setChannelType(channelTypeUID, type);
+ }
+
+ public void removeChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
+ channelTypeProvider.removeChannelType(channelTypeUID);
+ }
+
+ public ChannelDefinition type() {
+ return new ChannelDefinitionBuilder(channelUID.getId(), channelTypeUID).build();
+ }
+
+ public void resetState() {
+ channelState.getCache().resetState();
+ }
+
+ public static class Builder {
+ private final AbstractComponent<?> component;
+ private final String channelID;
+ private final Value valueState;
+ private final String label;
+ private final ChannelStateUpdateListener channelStateUpdateListener;
+
+ private @Nullable String state_topic;
+ private @Nullable String command_topic;
+ private boolean retain;
+ private boolean trigger;
+ private @Nullable Integer qos;
+ private @Nullable Predicate<Command> commandFilter;
+
+ private @Nullable String templateIn;
+ private @Nullable String templateOut;
+
+ public Builder(AbstractComponent<?> component, String channelID, Value valueState, String label,
+ ChannelStateUpdateListener channelStateUpdateListener) {
+ this.component = component;
+ this.channelID = channelID;
+ this.valueState = valueState;
+ this.label = label;
+ this.channelStateUpdateListener = channelStateUpdateListener;
+ }
+
+ public Builder stateTopic(@Nullable String state_topic) {
+ this.state_topic = state_topic;
+ return this;
+ }
+
+ public Builder stateTopic(@Nullable String state_topic, @Nullable String... templates) {
+ this.state_topic = state_topic;
+ if (state_topic != null && !state_topic.isBlank()) {
+ for (String template : templates) {
+ if (template != null && !template.isBlank()) {
+ this.templateIn = template;
+ break;
+ }
+ }
+ }
+ return this;
+ }
+
+ /**
+ * @deprecated use commandTopic(String, boolean, int)
+ * @param command_topic topic
+ * @param retain retain
+ * @return this
+ */
+ @Deprecated
+ public Builder commandTopic(@Nullable String command_topic, boolean retain) {
+ this.command_topic = command_topic;
+ this.retain = retain;
+ return this;
+ }
+
+ public Builder commandTopic(@Nullable String command_topic, boolean retain, int qos) {
+ return commandTopic(command_topic, retain, qos, null);
+ }
+
+ public Builder commandTopic(@Nullable String command_topic, boolean retain, int qos,
+ @Nullable String template) {
+ this.command_topic = command_topic;
+ this.retain = retain;
+ this.qos = qos;
+ if (command_topic != null && !command_topic.isBlank()) {
+ this.templateOut = template;
+ }
+ return this;
+ }
+
+ public Builder trigger(boolean trigger) {
+ this.trigger = trigger;
+ return this;
+ }
+
+ public Builder commandFilter(@Nullable Predicate<Command> commandFilter) {
+ this.commandFilter = commandFilter;
+ return this;
+ }
+
+ public ComponentChannel build() {
+ return build(true);
+ }
+
+ public ComponentChannel build(boolean addToComponent) {
+ ChannelUID channelUID;
+ ChannelState channelState;
+ Channel channel;
+ ChannelType type;
+ ChannelTypeUID channelTypeUID;
+
+ channelUID = new ChannelUID(component.getGroupUID(), channelID);
+ channelTypeUID = new ChannelTypeUID(MqttBindingConstants.BINDING_ID,
+ channelUID.getGroupId() + "_" + channelID);
+ channelState = new HomeAssistantChannelState(
+ ChannelConfigBuilder.create().withRetain(retain).withQos(qos).withStateTopic(state_topic)
+ .withCommandTopic(command_topic).makeTrigger(trigger).build(),
+ channelUID, valueState, channelStateUpdateListener, commandFilter);
+
+ String localStateTopic = state_topic;
+ if (localStateTopic == null || localStateTopic.isBlank() || this.trigger) {
+ type = ChannelTypeBuilder.trigger(channelTypeUID, label)
+ .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL)).build();
+ } else {
+ StateDescriptionFragment description = valueState.createStateDescription(command_topic == null).build();
+ type = ChannelTypeBuilder.state(channelTypeUID, label, channelState.getItemType())
+ .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL))
+ .withStateDescriptionFragment(description).build();
+ }
+
+ Configuration configuration = new Configuration();
+ configuration.put("config", component.getChannelConfigurationJson());
+ component.getHaID().toConfig(configuration);
+
+ channel = ChannelBuilder.create(channelUID, channelState.getItemType()).withType(channelTypeUID)
+ .withKind(type.getKind()).withLabel(label).withConfiguration(configuration).build();
+
+ ComponentChannel result = new ComponentChannel(channelUID, channelState, channel, type, channelTypeUID,
+ channelStateUpdateListener);
+
+ TransformationServiceProvider transformationProvider = component.getTransformationServiceProvider();
+
+ final String templateIn = this.templateIn;
+ if (templateIn != null && transformationProvider != null) {
+ channelState
+ .addTransformation(new ChannelStateTransformation(JINJA, templateIn, transformationProvider));
+ }
+ final String templateOut = this.templateOut;
+ if (templateOut != null && transformationProvider != null) {
+ channelState.addTransformationOut(
+ new ChannelStateTransformation(JINJA, templateOut, transformationProvider));
+ }
+ if (addToComponent) {
+ component.getChannelMap().put(channelID, result);
+ }
+ return result;
+ }
+ }
+}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * A MQTT climate component, following the https://www.home-assistant.io/components/climate.mqtt/ specification.
- *
- * At the moment this only notifies the user that this feature is not yet supported.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentClimate extends AbstractComponent<ComponentClimate.ChannelConfiguration> {
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT HVAC");
- }
- }
-
- public ComponentClimate(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
- throw new UnsupportedOperationException("Component:Climate not supported yet");
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.mqtt.generic.values.RollershutterValue;
-
-/**
- * A MQTT Cover component, following the https://www.home-assistant.io/components/cover.mqtt/ specification.
- *
- * Only Open/Close/Stop works so far.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentCover extends AbstractComponent<ComponentCover.ChannelConfiguration> {
- public static final String switchChannelID = "cover"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Cover");
- }
-
- protected @Nullable String state_topic;
- protected @Nullable String command_topic;
- protected String payload_open = "OPEN";
- protected String payload_close = "CLOSE";
- protected String payload_stop = "STOP";
- }
-
- public ComponentCover(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- RollershutterValue value = new RollershutterValue(channelConfiguration.payload_open,
- channelConfiguration.payload_close, channelConfiguration.payload_stop);
-
- buildChannel(switchChannelID, value, channelConfiguration.name, componentConfiguration.getUpdateListener())
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.value_template)
- .commandTopic(channelConfiguration.command_topic, channelConfiguration.retain).build();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.mqtt.generic.values.OnOffValue;
-
-/**
- * A MQTT Fan component, following the https://www.home-assistant.io/components/fan.mqtt/ specification.
- *
- * Only ON/OFF is supported so far.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentFan extends AbstractComponent<ComponentFan.ChannelConfiguration> {
- public static final String switchChannelID = "fan"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Fan");
- }
-
- protected @Nullable String state_topic;
- protected String command_topic = "";
- protected String payload_on = "ON";
- protected String payload_off = "OFF";
- }
-
- public ComponentFan(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- OnOffValue value = new OnOffValue(channelConfiguration.payload_on, channelConfiguration.payload_off);
- buildChannel(switchChannelID, value, channelConfiguration.name, componentConfiguration.getUpdateListener())
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.value_template)
- .commandTopic(channelConfiguration.command_topic, channelConfiguration.retain).build();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.stream.Stream;
-
-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.mapping.ColorMode;
-import org.openhab.binding.mqtt.generic.values.ColorValue;
-import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.State;
-
-/**
- * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
- *
- * This class condenses the three state/command topics (for ON/OFF, Brightness, Color) to one
- * color channel.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentLight extends AbstractComponent<ComponentLight.ChannelConfiguration>
- implements ChannelStateUpdateListener {
- public static final String switchChannelID = "light"; // Randomly chosen channel "ID"
- public static final String brightnessChannelID = "brightness"; // Randomly chosen channel "ID"
- public static final String colorChannelID = "color"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Light");
- }
-
- protected int brightness_scale = 255;
- protected boolean optimistic = false;
- protected @Nullable List<String> effect_list;
-
- // Defines when on the payload_on is sent. Using last (the default) will send any style (brightness, color, etc)
- // topics first and then a payload_on to the command_topic. Using first will send the payload_on and then any
- // style topics. Using brightness will only send brightness commands instead of the payload_on to turn the light
- // on.
- protected String on_command_type = "last";
-
- protected @Nullable String state_topic;
- protected @Nullable String command_topic;
- protected @Nullable String state_value_template;
-
- protected @Nullable String brightness_state_topic;
- protected @Nullable String brightness_command_topic;
- protected @Nullable String brightness_value_template;
-
- protected @Nullable String color_temp_state_topic;
- protected @Nullable String color_temp_command_topic;
- protected @Nullable String color_temp_value_template;
-
- protected @Nullable String effect_command_topic;
- protected @Nullable String effect_state_topic;
- protected @Nullable String effect_value_template;
-
- protected @Nullable String rgb_command_topic;
- protected @Nullable String rgb_state_topic;
- protected @Nullable String rgb_value_template;
- protected @Nullable String rgb_command_template;
-
- protected @Nullable String white_value_command_topic;
- protected @Nullable String white_value_state_topic;
- protected @Nullable String white_value_template;
-
- protected @Nullable String xy_command_topic;
- protected @Nullable String xy_state_topic;
- protected @Nullable String xy_value_template;
-
- protected String payload_on = "ON";
- protected String payload_off = "OFF";
- }
-
- protected CChannel colorChannel;
- protected CChannel switchChannel;
- protected CChannel brightnessChannel;
- private final @Nullable ChannelStateUpdateListener channelStateUpdateListener;
-
- public ComponentLight(CFactory.ComponentConfiguration builder) {
- super(builder, ChannelConfiguration.class);
- this.channelStateUpdateListener = builder.getUpdateListener();
- ColorValue value = new ColorValue(ColorMode.RGB, channelConfiguration.payload_on,
- channelConfiguration.payload_off, 100);
-
- // Create three MQTT subscriptions and use this class object as update listener
- switchChannel = buildChannel(switchChannelID, value, channelConfiguration.name, this)
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.state_value_template,
- channelConfiguration.value_template)
- .commandTopic(channelConfiguration.command_topic, channelConfiguration.retain).build(false);
-
- colorChannel = buildChannel(colorChannelID, value, channelConfiguration.name, this)
- .stateTopic(channelConfiguration.rgb_state_topic, channelConfiguration.rgb_value_template)
- .commandTopic(channelConfiguration.rgb_command_topic, channelConfiguration.retain).build(false);
-
- brightnessChannel = buildChannel(brightnessChannelID, value, channelConfiguration.name, this)
- .stateTopic(channelConfiguration.brightness_state_topic, channelConfiguration.brightness_value_template)
- .commandTopic(channelConfiguration.brightness_command_topic, channelConfiguration.retain).build(false);
-
- channels.put(colorChannelID, colorChannel);
- }
-
- @Override
- public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
- int timeout) {
- return Stream.of(switchChannel, brightnessChannel, colorChannel) //
- .map(v -> v.start(connection, scheduler, timeout)) //
- .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
- }
-
- @Override
- public CompletableFuture<@Nullable Void> stop() {
- return Stream.of(switchChannel, brightnessChannel, colorChannel) //
- .map(v -> v.stop()) //
- .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
- }
-
- /**
- * Proxy method to condense all three MQTT subscriptions to one channel
- */
- @Override
- public void updateChannelState(ChannelUID channelUID, State value) {
- ChannelStateUpdateListener listener = channelStateUpdateListener;
- if (listener != null) {
- listener.updateChannelState(colorChannel.getChannelUID(), value);
- }
- }
-
- /**
- * Proxy method to condense all three MQTT subscriptions to one channel
- */
- @Override
- public void postChannelCommand(ChannelUID channelUID, Command value) {
- ChannelStateUpdateListener listener = channelStateUpdateListener;
- if (listener != null) {
- listener.postChannelCommand(colorChannel.getChannelUID(), value);
- }
- }
-
- /**
- * Proxy method to condense all three MQTT subscriptions to one channel
- */
- @Override
- public void triggerChannel(ChannelUID channelUID, String eventPayload) {
- ChannelStateUpdateListener listener = channelStateUpdateListener;
- if (listener != null) {
- listener.triggerChannel(colorChannel.getChannelUID(), eventPayload);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.mqtt.generic.values.OnOffValue;
-
-/**
- * A MQTT lock, following the https://www.home-assistant.io/components/lock.mqtt/ specification.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentLock extends AbstractComponent<ComponentLock.ChannelConfiguration> {
- public static final String switchChannelID = "lock"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Lock");
- }
-
- protected boolean optimistic = false;
-
- protected String state_topic = "";
- protected String payload_lock = "LOCK";
- protected String payload_unlock = "UNLOCK";
- protected @Nullable String command_topic;
- }
-
- public ComponentLock(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- // We do not support all HomeAssistant quirks
- if (channelConfiguration.optimistic && !channelConfiguration.state_topic.isBlank()) {
- throw new UnsupportedOperationException("Component:Lock does not support forced optimistic mode");
- }
-
- buildChannel(switchChannelID,
- new OnOffValue(channelConfiguration.payload_lock, channelConfiguration.payload_unlock),
- channelConfiguration.name, componentConfiguration.getUpdateListener())
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.value_template)
- .commandTopic(channelConfiguration.command_topic, channelConfiguration.retain).build();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.util.List;
-import java.util.regex.Pattern;
-
-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.NumberValue;
-import org.openhab.binding.mqtt.generic.values.TextValue;
-import org.openhab.binding.mqtt.generic.values.Value;
-import org.openhab.binding.mqtt.homeassistant.internal.listener.ExpireUpdateStateListener;
-
-/**
- * A MQTT sensor, following the https://www.home-assistant.io/components/sensor.mqtt/ specification.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentSensor extends AbstractComponent<ComponentSensor.ChannelConfiguration> {
- public static final String sensorChannelID = "sensor"; // Randomly chosen channel "ID"
- private static final Pattern triggerIcons = Pattern.compile("^mdi:(toggle|gesture).*$");
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Sensor");
- }
-
- protected @Nullable String unit_of_measurement;
- protected @Nullable String device_class;
- protected boolean force_update = false;
- protected @Nullable Integer expire_after;
-
- protected String state_topic = "";
-
- protected @Nullable String json_attributes_topic;
- protected @Nullable String json_attributes_template;
- protected @Nullable List<String> json_attributes;
- }
-
- public ComponentSensor(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- Value value;
-
- String uom = channelConfiguration.unit_of_measurement;
-
- if (uom != null && !uom.isBlank()) {
- value = new NumberValue(null, null, null, uom);
- } else {
- value = new TextValue();
- }
-
- String icon = channelConfiguration.icon;
-
- boolean trigger = triggerIcons.matcher(icon).matches();
-
- buildChannel(sensorChannelID, value, channelConfiguration.name, getListener(componentConfiguration, value))
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.value_template)//
- .trigger(trigger).build();
- }
-
- private ChannelStateUpdateListener getListener(CFactory.ComponentConfiguration componentConfiguration,
- Value value) {
- ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener();
-
- if (channelConfiguration.expire_after != null) {
- updateListener = new ExpireUpdateStateListener(updateListener, channelConfiguration.expire_after, value,
- componentConfiguration.getTracker(), componentConfiguration.getScheduler());
- }
- return updateListener;
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.mqtt.generic.values.OnOffValue;
-
-/**
- * A MQTT switch, following the https://www.home-assistant.io/components/switch.mqtt/ specification.
- *
- * @author David Graeff - Initial contribution
- */
-@NonNullByDefault
-public class ComponentSwitch extends AbstractComponent<ComponentSwitch.ChannelConfiguration> {
- public static final String switchChannelID = "switch"; // Randomly chosen channel "ID"
-
- /**
- * Configuration class for MQTT component
- */
- static class ChannelConfiguration extends BaseChannelConfiguration {
- ChannelConfiguration() {
- super("MQTT Switch");
- }
-
- protected @Nullable Boolean optimistic;
-
- protected @Nullable String command_topic;
- protected String state_topic = "";
-
- protected @Nullable String state_on;
- protected @Nullable String state_off;
- protected String payload_on = "ON";
- protected String payload_off = "OFF";
-
- protected @Nullable String json_attributes_topic;
- protected @Nullable String json_attributes_template;
- }
-
- public ComponentSwitch(CFactory.ComponentConfiguration componentConfiguration) {
- super(componentConfiguration, ChannelConfiguration.class);
-
- boolean optimistic = channelConfiguration.optimistic != null ? channelConfiguration.optimistic
- : channelConfiguration.state_topic.isBlank();
-
- if (optimistic && !channelConfiguration.state_topic.isBlank()) {
- throw new UnsupportedOperationException("Component:Switch does not support forced optimistic mode");
- }
-
- String state_on = channelConfiguration.state_on != null ? channelConfiguration.state_on
- : channelConfiguration.payload_on;
- String state_off = channelConfiguration.state_off != null ? channelConfiguration.state_off
- : channelConfiguration.payload_off;
-
- OnOffValue value = new OnOffValue(state_on, state_off, channelConfiguration.payload_on,
- channelConfiguration.payload_off);
-
- buildChannel(switchChannelID, value, "state", componentConfiguration.getUpdateListener())
- .stateTopic(channelConfiguration.state_topic, channelConfiguration.value_template)
- .commandTopic(channelConfiguration.command_topic, channelConfiguration.retain, channelConfiguration.qos)
- .build();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.lang.reflect.Type;
-
-import com.google.gson.JsonArray;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonParseException;
-
-/**
- * The {@link ConnectionDeserializer} will de-serialize a connection-list
- *
- * see: https://www.home-assistant.io/integrations/sensor.mqtt/#connections
- *
- * @author Jan N. Klug - Initial contribution
- */
-public class ConnectionDeserializer implements JsonDeserializer<BaseChannelConfiguration.Connection> {
- @Override
- public BaseChannelConfiguration.Connection deserialize(JsonElement json, Type typeOfT,
- JsonDeserializationContext context) throws JsonParseException {
- JsonArray list = json.getAsJsonArray();
- BaseChannelConfiguration.Connection conn = new BaseChannelConfiguration.Connection();
- conn.type = list.get(0).getAsString();
- conn.identifier = list.get(1).getAsString();
- return conn;
- }
-}
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
import org.openhab.binding.mqtt.generic.utils.FutureCollector;
+import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent;
+import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
import org.openhab.core.thing.ThingUID;
private @Nullable ScheduledFuture<?> stopDiscoveryFuture;
private WeakReference<@Nullable MqttBrokerConnection> connectionRef = new WeakReference<>(null);
- protected @NonNullByDefault({}) ComponentDiscovered discoveredListener;
+ protected @Nullable ComponentDiscovered discoveredListener;
private int discoverTime;
private Set<String> topics = new HashSet<>();
HaID haID = new HaID(topic);
String config = new String(payload);
-
AbstractComponent<?> component = null;
if (config.length() > 0) {
- component = CFactory.createComponent(thingUID, haID, config, updateListener, tracker, scheduler, gson,
- transformationServiceProvider);
+ component = ComponentFactory.createComponent(thingUID, haID, config, updateListener, tracker, scheduler,
+ gson, transformationServiceProvider);
}
if (component != null) {
component.setConfigSeen();
* @param connection A MQTT broker connection
* @param discoverTime The time in milliseconds for the discovery to run. Can be 0 to disable the
* timeout.
- * You need to call {@link #stopDiscovery(MqttBrokerConnection)} at some
+ * You need to call {@link #stopDiscovery()} at some
* point in that case.
- * @param topicDescription Contains the object-id (=device id) and potentially a node-id as well.
+ * @param topicDescriptions Contains the object-id (=device id) and potentially a node-id as well.
* @param componentsDiscoveredListener Listener for results
* @return A future that completes normally after the given time in milliseconds or exceptionally on any error.
* Completes immediately if the timeout is disabled.
/**
* Stops an ongoing discovery or do nothing if no discovery is running.
- *
- * @param connection A MQTT broker connection
*/
public void stopDiscovery() {
subscribeFail(new Throwable("Stopped"));
this.topic = createTopic(this);
}
- private static final String createTopic(HaID id) {
+ private static String createTopic(HaID id) {
StringBuilder str = new StringBuilder();
str.append(id.baseTopic).append('/').append(id.component).append('/');
if (!id.nodeID.isBlank()) {
* <p>
* <code>objectid</code>, <code>nodeid</code>, and <code>component</code> values are fetched from the configuration.
*
- * @param baseTopic
- * @param config
+ * @param baseTopic base topic
+ * @param config config
* @return newly created HaID
*/
public static HaID fromConfig(String baseTopic, Configuration config) {
* <p>
* <code>objectid</code>, <code>nodeid</code>, and <code>component</code> values are added to the configuration.
*
- * @param config
+ * @param config config
* @return the modified configuration
*/
public Configuration toConfig(Configuration config) {
* The <code>component</code> component in the resulting HaID will be set to <code>+</code>.
* This enables the HaID to be used as an mqtt subscription topic.
*
- * @param config
+ * @param config config
* @return newly created HaID
*/
public static Collection<HaID> fromConfig(HandlerConfiguration config) {
*/
@NonNullByDefault
public class HandlerConfiguration {
+ public static final String PROPERTY_BASETOPIC = "basetopic";
+ public static final String PROPERTY_TOPICS = "topics";
+ public static final String DEFAULT_BASETOPIC = "homeassistant";
/**
* hint: cannot be final, or <code>getConfigAs</code> will not work.
* The MQTT prefix topic
public List<String> topics;
public HandlerConfiguration() {
- this("homeassistant", Collections.emptyList());
+ this(DEFAULT_BASETOPIC, Collections.emptyList());
}
public HandlerConfiguration(String basetopic, List<String> topics) {
/**
* Add the <code>basetopic</code> and <code>objectid</code> to the properties.
*
- * @param properties
+ * @param properties properties
* @return the modified properties
*/
public <T extends Map<String, Object>> T appendToProperties(T properties) {
- properties.put("basetopic", basetopic);
- properties.put("topics", topics);
+ properties.put(PROPERTY_BASETOPIC, basetopic);
+ properties.put(PROPERTY_TOPICS, topics);
return properties;
}
}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Predicate;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.ChannelConfig;
+import org.openhab.binding.mqtt.generic.ChannelState;
+import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
+import org.openhab.binding.mqtt.generic.values.Value;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Extended {@link ChannelState} with added filter for {@link #publishValue(Command)}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@NonNullByDefault
+public class HomeAssistantChannelState extends ChannelState {
+ private final Logger logger = LoggerFactory.getLogger(HomeAssistantChannelState.class);
+ private final @Nullable Predicate<Command> commandFilter;
+
+ /**
+ * Creates a new channel state.
+ *
+ * @param config The channel configuration
+ * @param channelUID The channelUID is used for the {@link ChannelStateUpdateListener} to notify about value changes
+ * @param cachedValue MQTT only notifies us once about a value, during the subscribe. The channel state therefore
+ * needs a cache for the current value.
+ * @param channelStateUpdateListener A channel state update listener
+ * @param commandFilter A filter for commands, on <code>true</code> command will be published, on
+ * <code>false</code> ignored. Can be <code>null</code> to publish all commands.
+ */
+ public HomeAssistantChannelState(ChannelConfig config, ChannelUID channelUID, Value cachedValue,
+ @Nullable ChannelStateUpdateListener channelStateUpdateListener,
+ @Nullable Predicate<Command> commandFilter) {
+ super(config, channelUID, cachedValue, channelStateUpdateListener);
+ this.commandFilter = commandFilter;
+ }
+
+ @Override
+ public CompletableFuture<Boolean> publishValue(Command command) {
+ if (commandFilter != null && !commandFilter.test(command)) {
+ logger.trace("Channel {} updates are disabled by command filter, ignoring command {}", channelUID, command);
+ return CompletableFuture.completedFuture(false);
+ }
+ return super.publishValue(command);
+ }
+}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Objects;
-
-import org.eclipse.jdt.annotation.NonNull;
-import org.eclipse.jdt.annotation.Nullable;
-
-import com.google.gson.TypeAdapter;
-import com.google.gson.stream.JsonReader;
-import com.google.gson.stream.JsonToken;
-import com.google.gson.stream.JsonWriter;
-
-/**
- * JsonTypeAdapter which will read a single string or a string list
- *
- * see: https://www.home-assistant.io/components/binary_sensor.mqtt/ -> device / identifiers
- *
- * @author Jochen Klein - Initial contribution
- */
-public class ListOrStringDeserializer extends TypeAdapter<List<String>> {
-
- @Override
- public void write(@Nullable JsonWriter out, @Nullable List<String> value) throws IOException {
- Objects.requireNonNull(out);
-
- if (value == null) {
- out.nullValue();
- return;
- }
-
- out.beginArray();
- for (String str : value) {
- out.jsonValue(str);
- }
- out.endArray();
- }
-
- @Override
- public @Nullable List<String> read(@Nullable JsonReader in) throws IOException {
- Objects.requireNonNull(in);
-
- JsonToken peek = in.peek();
-
- switch (peek) {
- case NULL:
- in.nextNull();
- return null;
- case STRING:
- return Arrays.asList(in.nextString());
- case BEGIN_ARRAY:
- return readList(in);
- default:
- throw new IOException("unexpected token " + peek + ". Array of string or string expected");
- }
- }
-
- private @NonNull List<String> readList(@NonNull JsonReader in) throws IOException {
- in.beginArray();
-
- List<String> result = new ArrayList<>();
-
- JsonToken peek = in.peek();
-
- while (peek != JsonToken.END_ARRAY) {
- if (peek == JsonToken.STRING) {
- result.add(in.nextString());
- } else {
- throw new IOException("unexpected token " + peek + ". Array of string or string expected");
- }
- peek = in.peek();
- }
- in.endArray();
-
- return result;
- }
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.stream.Collectors;
+
+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.MqttChannelTypeProvider;
+import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
+import org.openhab.binding.mqtt.generic.utils.FutureCollector;
+import org.openhab.binding.mqtt.generic.values.Value;
+import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
+import org.openhab.binding.mqtt.homeassistant.internal.HaID;
+import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory.ComponentConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
+import org.openhab.core.thing.ChannelGroupUID;
+import org.openhab.core.thing.type.ChannelDefinition;
+import org.openhab.core.thing.type.ChannelGroupDefinition;
+import org.openhab.core.thing.type.ChannelGroupType;
+import org.openhab.core.thing.type.ChannelGroupTypeBuilder;
+import org.openhab.core.thing.type.ChannelGroupTypeUID;
+
+/**
+ * A HomeAssistant component is comparable to a channel group.
+ * It has a name and consists of multiple channels.
+ *
+ * @author David Graeff - Initial contribution
+ * @param <C> Config class derived from {@link AbstractChannelConfiguration}
+ */
+@NonNullByDefault
+public abstract class AbstractComponent<C extends AbstractChannelConfiguration> {
+ // Component location fields
+ private final ComponentConfiguration componentConfiguration;
+ protected final ChannelGroupTypeUID channelGroupTypeUID;
+ protected final ChannelGroupUID channelGroupUID;
+ protected final HaID haID;
+
+ // Channels and configuration
+ protected final Map<String, ComponentChannel> channels = new TreeMap<>();
+ // The hash code ({@link String#hashCode()}) of the configuration string
+ // Used to determine if a component has changed.
+ protected final int configHash;
+ protected final String channelConfigurationJson;
+ protected final C channelConfiguration;
+
+ protected boolean configSeen;
+
+ /**
+ * Creates component based on generic configuration and component configuration type.
+ *
+ * @param componentConfiguration generic componentConfiguration with not parsed JSON config
+ * @param clazz target configuration type
+ */
+ public AbstractComponent(ComponentFactory.ComponentConfiguration componentConfiguration, Class<C> clazz) {
+ this.componentConfiguration = componentConfiguration;
+
+ this.channelConfigurationJson = componentConfiguration.getConfigJSON();
+ this.channelConfiguration = componentConfiguration.getConfig(clazz);
+ this.configHash = channelConfigurationJson.hashCode();
+
+ this.haID = componentConfiguration.getHaID();
+
+ String groupId = this.haID.getGroupId(channelConfiguration.getUniqueId());
+
+ this.channelGroupTypeUID = new ChannelGroupTypeUID(MqttBindingConstants.BINDING_ID, groupId);
+ this.channelGroupUID = new ChannelGroupUID(componentConfiguration.getThingUID(), groupId);
+
+ this.configSeen = false;
+
+ String availability_topic = this.channelConfiguration.getAvailabilityTopic();
+ if (availability_topic != null) {
+ componentConfiguration.getTracker().addAvailabilityTopic(availability_topic,
+ this.channelConfiguration.getPayloadAvailable(),
+ this.channelConfiguration.getPayloadNotAvailable());
+ }
+ }
+
+ protected ComponentChannel.Builder buildChannel(String channelID, Value valueState, String label,
+ ChannelStateUpdateListener channelStateUpdateListener) {
+ return new ComponentChannel.Builder(this, channelID, valueState, label, channelStateUpdateListener);
+ }
+
+ public void setConfigSeen() {
+ this.configSeen = true;
+ }
+
+ /**
+ * Subscribes to all state channels of the component and adds all channels to the provided channel type provider.
+ *
+ * @param connection connection to the MQTT broker
+ * @param scheduler thing scheduler
+ * @param timeout channel subscription timeout
+ * @return A future that completes as soon as all subscriptions have been performed. Completes exceptionally on
+ * errors.
+ */
+ public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
+ int timeout) {
+ return channels.values().parallelStream().map(cChannel -> cChannel.start(connection, scheduler, timeout))
+ .collect(FutureCollector.allOf());
+ }
+
+ /**
+ * Unsubscribes from all state channels of the component.
+ *
+ * @return A future that completes as soon as all subscriptions removals have been performed. Completes
+ * exceptionally on errors.
+ */
+ public CompletableFuture<@Nullable Void> stop() {
+ return channels.values().parallelStream().map(ComponentChannel::stop).collect(FutureCollector.allOf());
+ }
+
+ /**
+ * Add all channel types to the channel type provider.
+ *
+ * @param channelTypeProvider The channel type provider
+ */
+ public void addChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
+ channelTypeProvider.setChannelGroupType(getGroupTypeUID(), getType());
+ channels.values().forEach(v -> v.addChannelTypes(channelTypeProvider));
+ }
+
+ /**
+ * Removes all channels from the channel type provider.
+ * Call this if the corresponding Thing handler gets disposed.
+ *
+ * @param channelTypeProvider The channel type provider
+ */
+ public void removeChannelTypes(MqttChannelTypeProvider channelTypeProvider) {
+ channels.values().forEach(v -> v.removeChannelTypes(channelTypeProvider));
+ channelTypeProvider.removeChannelGroupType(getGroupTypeUID());
+ }
+
+ /**
+ * Each HomeAssistant component corresponds to a Channel Group Type.
+ */
+ public ChannelGroupTypeUID getGroupTypeUID() {
+ return channelGroupTypeUID;
+ }
+
+ /**
+ * The unique id of this component.
+ */
+ public ChannelGroupUID getGroupUID() {
+ return channelGroupUID;
+ }
+
+ /**
+ * Component (Channel Group) name.
+ */
+ public String getName() {
+ return channelConfiguration.getName();
+ }
+
+ /**
+ * Each component consists of multiple Channels.
+ */
+ public Map<String, ComponentChannel> getChannelMap() {
+ return channels;
+ }
+
+ /**
+ * Return a components channel. A HomeAssistant MQTT component consists of multiple functions
+ * and those are mapped to one or more channels. The channel IDs are constants within the
+ * derived Component, like the {@link Switch#switchChannelID}.
+ *
+ * @param channelID The channel ID
+ * @return A components channel
+ */
+ public @Nullable ComponentChannel getChannel(String channelID) {
+ return channels.get(channelID);
+ }
+
+ /**
+ * @return Returns the configuration hash value for easy comparison.
+ */
+ public int getConfigHash() {
+ return configHash;
+ }
+
+ /**
+ * Return the channel group type.
+ */
+ public ChannelGroupType getType() {
+ final List<ChannelDefinition> channelDefinitions = channels.values().stream().map(ComponentChannel::type)
+ .collect(Collectors.toList());
+ return ChannelGroupTypeBuilder.instance(channelGroupTypeUID, getName())
+ .withChannelDefinitions(channelDefinitions).build();
+ }
+
+ /**
+ * Resets all channel states to state UNDEF. Call this method after the connection
+ * to the MQTT broker got lost.
+ */
+ public void resetState() {
+ channels.values().forEach(ComponentChannel::resetState);
+ }
+
+ /**
+ * Return the channel group definition for this component.
+ */
+ public ChannelGroupDefinition getGroupDefinition() {
+ return new ChannelGroupDefinition(channelGroupUID.getId(), getGroupTypeUID(), getName(), null);
+ }
+
+ public HaID getHaID() {
+ return haID;
+ }
+
+ public String getChannelConfigurationJson() {
+ return channelConfigurationJson;
+ }
+
+ @Nullable
+ public TransformationServiceProvider getTransformationServiceProvider() {
+ return componentConfiguration.getTransformationServiceProvider();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+
+/**
+ * A MQTT alarm control panel, following the https://www.home-assistant.io/components/alarm_control_panel.mqtt/
+ * specification.
+ *
+ * The implemented provides three state-less switches (For disarming, arming@home, arming@away) and one alarm state
+ * text.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class AlarmControlPanel extends AbstractComponent<AlarmControlPanel.ChannelConfiguration> {
+ public static final String stateChannelID = "alarm"; // Randomly chosen channel "ID"
+ public static final String switchDisarmChannelID = "disarm"; // Randomly chosen channel "ID"
+ public static final String switchArmHomeChannelID = "armhome"; // Randomly chosen channel "ID"
+ public static final String switchArmAwayChannelID = "armaway"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Alarm");
+ }
+
+ protected @Nullable String code;
+
+ protected String state_topic = "";
+ protected String state_disarmed = "disarmed";
+ protected String state_armed_home = "armed_home";
+ protected String state_armed_away = "armed_away";
+ protected String state_pending = "pending";
+ protected String state_triggered = "triggered";
+
+ protected @Nullable String command_topic;
+ protected String payload_disarm = "DISARM";
+ protected String payload_arm_home = "ARM_HOME";
+ protected String payload_arm_away = "ARM_AWAY";
+ }
+
+ public AlarmControlPanel(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ final String[] state_enum = { channelConfiguration.state_disarmed, channelConfiguration.state_armed_home,
+ channelConfiguration.state_armed_away, channelConfiguration.state_pending,
+ channelConfiguration.state_triggered };
+ buildChannel(stateChannelID, new TextValue(state_enum), channelConfiguration.getName(),
+ componentConfiguration.getUpdateListener())
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.getValueTemplate())//
+ .build();
+
+ String command_topic = channelConfiguration.command_topic;
+ if (command_topic != null) {
+ buildChannel(switchDisarmChannelID, new TextValue(new String[] { channelConfiguration.payload_disarm }),
+ channelConfiguration.getName(), componentConfiguration.getUpdateListener())
+ .commandTopic(command_topic, channelConfiguration.isRetain(), channelConfiguration.getQos())
+ .build();
+
+ buildChannel(switchArmHomeChannelID, new TextValue(new String[] { channelConfiguration.payload_arm_home }),
+ channelConfiguration.getName(), componentConfiguration.getUpdateListener())
+ .commandTopic(command_topic, channelConfiguration.isRetain(), channelConfiguration.getQos())
+ .build();
+
+ buildChannel(switchArmAwayChannelID, new TextValue(new String[] { channelConfiguration.payload_arm_away }),
+ channelConfiguration.getName(), componentConfiguration.getUpdateListener())
+ .commandTopic(command_topic, channelConfiguration.isRetain(), channelConfiguration.getQos())
+ .build();
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.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.OnOffValue;
+import org.openhab.binding.mqtt.generic.values.Value;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.listener.ExpireUpdateStateListener;
+import org.openhab.binding.mqtt.homeassistant.internal.listener.OffDelayUpdateStateListener;
+
+/**
+ * A MQTT BinarySensor, following the https://www.home-assistant.io/components/binary_sensor.mqtt/ specification.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class BinarySensor extends AbstractComponent<BinarySensor.ChannelConfiguration> {
+ public static final String sensorChannelID = "sensor"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Binary Sensor");
+ }
+
+ protected @Nullable String device_class;
+ protected boolean force_update = false;
+ protected @Nullable Integer expire_after;
+ protected @Nullable Integer off_delay;
+
+ protected String state_topic = "";
+ protected String payload_on = "ON";
+ protected String payload_off = "OFF";
+
+ protected @Nullable String json_attributes_topic;
+ protected @Nullable String json_attributes_template;
+ protected @Nullable List<String> json_attributes;
+ }
+
+ public BinarySensor(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ OnOffValue value = new OnOffValue(channelConfiguration.payload_on, channelConfiguration.payload_off);
+
+ buildChannel(sensorChannelID, value, "value", getListener(componentConfiguration, value))
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.getValueTemplate()).build();
+ }
+
+ private ChannelStateUpdateListener getListener(ComponentFactory.ComponentConfiguration componentConfiguration,
+ Value value) {
+ ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener();
+
+ if (channelConfiguration.expire_after != null) {
+ updateListener = new ExpireUpdateStateListener(updateListener, channelConfiguration.expire_after, value,
+ componentConfiguration.getTracker(), componentConfiguration.getScheduler());
+ }
+ if (channelConfiguration.off_delay != null) {
+ updateListener = new OffDelayUpdateStateListener(updateListener, channelConfiguration.off_delay, value,
+ componentConfiguration.getScheduler());
+ }
+
+ return updateListener;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.mqtt.generic.values.ImageValue;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+
+/**
+ * A MQTT camera, following the https://www.home-assistant.io/components/camera.mqtt/ specification.
+ *
+ * At the moment this only notifies the user that this feature is not yet supported.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class Camera extends AbstractComponent<Camera.ChannelConfiguration> {
+ public static final String cameraChannelID = "camera"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Camera");
+ }
+
+ protected String topic = "";
+ }
+
+ public Camera(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ ImageValue value = new ImageValue();
+
+ buildChannel(cameraChannelID, value, channelConfiguration.getName(), componentConfiguration.getUpdateListener())
+ .stateTopic(channelConfiguration.topic).build();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.math.BigDecimal;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Predicate;
+
+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.NumberValue;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.binding.mqtt.generic.values.Value;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * A MQTT climate component, following the https://www.home-assistant.io/components/climate.mqtt/ specification.
+ *
+ * @author David Graeff - Initial contribution
+ * @author Anton Kharuzhy - Implementation
+ */
+@NonNullByDefault
+public class Climate extends AbstractComponent<Climate.ChannelConfiguration> {
+ public static final String ACTION_CH_ID = "action";
+ public static final String AUX_CH_ID = "aux";
+ public static final String AWAY_MODE_CH_ID = "awayMode";
+ public static final String CURRENT_TEMPERATURE_CH_ID = "currentTemperature";
+ public static final String FAN_MODE_CH_ID = "fanMode";
+ public static final String HOLD_CH_ID = "hold";
+ public static final String MODE_CH_ID = "mode";
+ public static final String SWING_CH_ID = "swing";
+ public static final String TEMPERATURE_CH_ID = "temperature";
+ public static final String TEMPERATURE_HIGH_CH_ID = "temperatureHigh";
+ public static final String TEMPERATURE_LOW_CH_ID = "temperatureLow";
+ public static final String POWER_CH_ID = "power";
+
+ private static final String CELSIUM = "C";
+ private static final String FAHRENHEIT = "F";
+ private static final float DEFAULT_CELSIUM_PRECISION = 0.1f;
+ private static final float DEFAULT_FAHRENHEIT_PRECISION = 1f;
+
+ private static final String ACTION_OFF = "off";
+ private static final State ACTION_OFF_STATE = new StringType(ACTION_OFF);
+ private static final List<String> ACTION_MODES = List.of(ACTION_OFF, "heating", "cooling", "drying", "idle", "fan");
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT HVAC");
+ }
+
+ protected @Nullable String action_template;
+ protected @Nullable String action_topic;
+
+ protected @Nullable String aux_command_topic;
+ protected @Nullable String aux_state_template;
+ protected @Nullable String aux_state_topic;
+
+ protected @Nullable String away_mode_command_topic;
+ protected @Nullable String away_mode_state_template;
+ protected @Nullable String away_mode_state_topic;
+
+ protected @Nullable String current_temperature_template;
+ protected @Nullable String current_temperature_topic;
+
+ protected @Nullable String fan_mode_command_template;
+ protected @Nullable String fan_mode_command_topic;
+ protected @Nullable String fan_mode_state_template;
+ protected @Nullable String fan_mode_state_topic;
+ protected List<String> fan_modes = Arrays.asList("auto", "low", "medium", "high");
+
+ protected @Nullable String hold_command_template;
+ protected @Nullable String hold_command_topic;
+ protected @Nullable String hold_state_template;
+ protected @Nullable String hold_state_topic;
+ protected @Nullable List<String> hold_modes; // Are there default modes? Now the channel will be ignored without
+ // hold modes.
+
+ protected @Nullable String json_attributes_template; // Attributes are not supported yet
+ protected @Nullable String json_attributes_topic;
+
+ protected @Nullable String mode_command_template;
+ protected @Nullable String mode_command_topic;
+ protected @Nullable String mode_state_template;
+ protected @Nullable String mode_state_topic;
+ protected List<String> modes = Arrays.asList("auto", "off", "cool", "heat", "dry", "fan_only");
+
+ protected @Nullable String swing_command_template;
+ protected @Nullable String swing_command_topic;
+ protected @Nullable String swing_state_template;
+ protected @Nullable String swing_state_topic;
+ protected List<String> swing_modes = Arrays.asList("on", "off");
+
+ protected @Nullable String temperature_command_template;
+ protected @Nullable String temperature_command_topic;
+ protected @Nullable String temperature_state_template;
+ protected @Nullable String temperature_state_topic;
+
+ protected @Nullable String temperature_high_command_template;
+ protected @Nullable String temperature_high_command_topic;
+ protected @Nullable String temperature_high_state_template;
+ protected @Nullable String temperature_high_state_topic;
+
+ protected @Nullable String temperature_low_command_template;
+ protected @Nullable String temperature_low_command_topic;
+ protected @Nullable String temperature_low_state_template;
+ protected @Nullable String temperature_low_state_topic;
+
+ protected @Nullable String power_command_topic;
+
+ protected Integer initial = 21;
+ protected @Nullable Float max_temp;
+ protected @Nullable Float min_temp;
+ protected String temperature_unit = CELSIUM; // System unit by default
+ protected Float temp_step = 1f;
+ protected @Nullable Float precision;
+ protected Boolean send_if_off = true;
+ }
+
+ public Climate(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ BigDecimal minTemp = channelConfiguration.min_temp != null ? BigDecimal.valueOf(channelConfiguration.min_temp)
+ : null;
+ BigDecimal maxTemp = channelConfiguration.max_temp != null ? BigDecimal.valueOf(channelConfiguration.max_temp)
+ : null;
+ float precision = channelConfiguration.precision != null ? channelConfiguration.precision
+ : (FAHRENHEIT.equals(channelConfiguration.temperature_unit) ? DEFAULT_FAHRENHEIT_PRECISION
+ : DEFAULT_CELSIUM_PRECISION);
+ final ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener();
+
+ ComponentChannel actionChannel = buildOptionalChannel(ACTION_CH_ID,
+ new TextValue(ACTION_MODES.toArray(new String[0])), updateListener, null, null,
+ channelConfiguration.action_template, channelConfiguration.action_topic, null);
+
+ final Predicate<Command> commandFilter = channelConfiguration.send_if_off ? null
+ : getCommandFilter(actionChannel);
+
+ buildOptionalChannel(AUX_CH_ID, new OnOffValue(), updateListener, null, channelConfiguration.aux_command_topic,
+ channelConfiguration.aux_state_template, channelConfiguration.aux_state_topic, commandFilter);
+
+ buildOptionalChannel(AWAY_MODE_CH_ID, new OnOffValue(), updateListener, null,
+ channelConfiguration.away_mode_command_topic, channelConfiguration.away_mode_state_template,
+ channelConfiguration.away_mode_state_topic, commandFilter);
+
+ buildOptionalChannel(CURRENT_TEMPERATURE_CH_ID,
+ new NumberValue(minTemp, maxTemp, BigDecimal.valueOf(precision), channelConfiguration.temperature_unit),
+ updateListener, null, null, channelConfiguration.current_temperature_template,
+ channelConfiguration.current_temperature_topic, commandFilter);
+
+ buildOptionalChannel(FAN_MODE_CH_ID, new TextValue(channelConfiguration.fan_modes.toArray(new String[0])),
+ updateListener, channelConfiguration.fan_mode_command_template,
+ channelConfiguration.fan_mode_command_topic, channelConfiguration.fan_mode_state_template,
+ channelConfiguration.fan_mode_state_topic, commandFilter);
+
+ if (channelConfiguration.hold_modes != null && !channelConfiguration.hold_modes.isEmpty()) {
+ buildOptionalChannel(HOLD_CH_ID, new TextValue(channelConfiguration.hold_modes.toArray(new String[0])),
+ updateListener, channelConfiguration.hold_command_template, channelConfiguration.hold_command_topic,
+ channelConfiguration.hold_state_template, channelConfiguration.hold_state_topic, commandFilter);
+ }
+
+ buildOptionalChannel(MODE_CH_ID, new TextValue(channelConfiguration.modes.toArray(new String[0])),
+ updateListener, channelConfiguration.mode_command_template, channelConfiguration.mode_command_topic,
+ channelConfiguration.mode_state_template, channelConfiguration.mode_state_topic, commandFilter);
+
+ buildOptionalChannel(SWING_CH_ID, new TextValue(channelConfiguration.swing_modes.toArray(new String[0])),
+ updateListener, channelConfiguration.swing_command_template, channelConfiguration.swing_command_topic,
+ channelConfiguration.swing_state_template, channelConfiguration.swing_state_topic, commandFilter);
+
+ buildOptionalChannel(TEMPERATURE_CH_ID,
+ new NumberValue(minTemp, maxTemp, BigDecimal.valueOf(channelConfiguration.temp_step),
+ channelConfiguration.temperature_unit),
+ updateListener, channelConfiguration.temperature_command_template,
+ channelConfiguration.temperature_command_topic, channelConfiguration.temperature_state_template,
+ channelConfiguration.temperature_state_topic, commandFilter);
+
+ buildOptionalChannel(TEMPERATURE_HIGH_CH_ID,
+ new NumberValue(minTemp, maxTemp, BigDecimal.valueOf(channelConfiguration.temp_step),
+ channelConfiguration.temperature_unit),
+ updateListener, channelConfiguration.temperature_high_command_template,
+ channelConfiguration.temperature_high_command_topic,
+ channelConfiguration.temperature_high_state_template, channelConfiguration.temperature_high_state_topic,
+ commandFilter);
+
+ buildOptionalChannel(TEMPERATURE_LOW_CH_ID,
+ new NumberValue(minTemp, maxTemp, BigDecimal.valueOf(channelConfiguration.temp_step),
+ channelConfiguration.temperature_unit),
+ updateListener, channelConfiguration.temperature_low_command_template,
+ channelConfiguration.temperature_low_command_topic, channelConfiguration.temperature_low_state_template,
+ channelConfiguration.temperature_low_state_topic, commandFilter);
+
+ buildOptionalChannel(POWER_CH_ID, new OnOffValue(), updateListener, null,
+ channelConfiguration.power_command_topic, null, null, null);
+ }
+
+ @Nullable
+ private ComponentChannel buildOptionalChannel(String channelId, Value valueState,
+ ChannelStateUpdateListener channelStateUpdateListener, @Nullable String commandTemplate,
+ @Nullable String commandTopic, @Nullable String stateTemplate, @Nullable String stateTopic,
+ @Nullable Predicate<Command> commandFilter) {
+ if ((commandTopic != null && !commandTopic.isBlank()) || (stateTopic != null && !stateTopic.isBlank())) {
+ return buildChannel(channelId, valueState, channelConfiguration.getName(), channelStateUpdateListener)
+ .stateTopic(stateTopic, stateTemplate, channelConfiguration.getValueTemplate())
+ .commandTopic(commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(),
+ commandTemplate)
+ .commandFilter(commandFilter).build();
+ }
+ return null;
+ }
+
+ private @Nullable Predicate<Command> getCommandFilter(@Nullable ComponentChannel actionChannel) {
+ if (actionChannel == null) {
+ return null;
+ }
+ final var val = actionChannel.getState().getCache();
+ return command -> !ACTION_OFF_STATE.equals(val.getChannelState());
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.concurrent.ScheduledExecutorService;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.AvailabilityTracker;
+import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
+import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
+import org.openhab.binding.mqtt.homeassistant.internal.HaID;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * A factory to create HomeAssistant MQTT components. Those components are specified at:
+ * https://www.home-assistant.io/docs/mqtt/discovery/
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class ComponentFactory {
+ private static final Logger logger = LoggerFactory.getLogger(ComponentFactory.class);
+
+ /**
+ * Create a HA MQTT component. The configuration JSon string is required.
+ *
+ * @param thingUID The Thing UID that this component will belong to.
+ * @param haID The location of this component. The HomeAssistant ID contains the object-id, node-id and
+ * component-id.
+ * @param channelConfigurationJSON Most components expect a "name", a "state_topic" and "command_topic" like with
+ * "{name:'Name',state_topic:'homeassistant/switch/0/object/state',command_topic:'homeassistant/switch/0/object/set'".
+ * @param updateListener A channel state update listener
+ * @return A HA MQTT Component
+ */
+ public static @Nullable AbstractComponent<?> createComponent(ThingUID thingUID, HaID haID,
+ String channelConfigurationJSON, ChannelStateUpdateListener updateListener, AvailabilityTracker tracker,
+ ScheduledExecutorService scheduler, Gson gson,
+ TransformationServiceProvider transformationServiceProvider) {
+ ComponentConfiguration componentConfiguration = new ComponentConfiguration(thingUID, haID,
+ channelConfigurationJSON, gson, updateListener, tracker, scheduler)
+ .transformationProvider(transformationServiceProvider);
+ try {
+ switch (haID.component) {
+ case "alarm_control_panel":
+ return new AlarmControlPanel(componentConfiguration);
+ case "binary_sensor":
+ return new BinarySensor(componentConfiguration);
+ case "camera":
+ return new Camera(componentConfiguration);
+ case "cover":
+ return new Cover(componentConfiguration);
+ case "fan":
+ return new Fan(componentConfiguration);
+ case "climate":
+ return new Climate(componentConfiguration);
+ case "light":
+ return new Light(componentConfiguration);
+ case "lock":
+ return new Lock(componentConfiguration);
+ case "sensor":
+ return new Sensor(componentConfiguration);
+ case "switch":
+ return new Switch(componentConfiguration);
+ }
+ } catch (UnsupportedOperationException e) {
+ logger.warn("Not supported", e);
+ }
+ return null;
+ }
+
+ protected static class ComponentConfiguration {
+ private final ThingUID thingUID;
+ private final HaID haID;
+ private final String configJSON;
+ private final ChannelStateUpdateListener updateListener;
+ private final AvailabilityTracker tracker;
+ private final Gson gson;
+ private final ScheduledExecutorService scheduler;
+ private @Nullable TransformationServiceProvider transformationServiceProvider;
+
+ /**
+ * Provide a thingUID and HomeAssistant topic ID to determine the channel group UID and type.
+ *
+ * @param thingUID A ThingUID
+ * @param haID A HomeAssistant topic ID
+ * @param configJSON The configuration string
+ * @param gson A Gson instance
+ */
+ protected ComponentConfiguration(ThingUID thingUID, HaID haID, String configJSON, Gson gson,
+ ChannelStateUpdateListener updateListener, AvailabilityTracker tracker,
+ ScheduledExecutorService scheduler) {
+ this.thingUID = thingUID;
+ this.haID = haID;
+ this.configJSON = configJSON;
+ this.gson = gson;
+ this.updateListener = updateListener;
+ this.tracker = tracker;
+ this.scheduler = scheduler;
+ }
+
+ public ComponentConfiguration transformationProvider(
+ TransformationServiceProvider transformationServiceProvider) {
+ this.transformationServiceProvider = transformationServiceProvider;
+ return this;
+ }
+
+ public ThingUID getThingUID() {
+ return thingUID;
+ }
+
+ public HaID getHaID() {
+ return haID;
+ }
+
+ public String getConfigJSON() {
+ return configJSON;
+ }
+
+ public ChannelStateUpdateListener getUpdateListener() {
+ return updateListener;
+ }
+
+ @Nullable
+ public TransformationServiceProvider getTransformationServiceProvider() {
+ return transformationServiceProvider;
+ }
+
+ public Gson getGson() {
+ return gson;
+ }
+
+ public AvailabilityTracker getTracker() {
+ return tracker;
+ }
+
+ public ScheduledExecutorService getScheduler() {
+ return scheduler;
+ }
+
+ public <C extends AbstractChannelConfiguration> C getConfig(Class<C> clazz) {
+ return AbstractChannelConfiguration.fromString(configJSON, gson, clazz);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.values.RollershutterValue;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+
+/**
+ * A MQTT Cover component, following the https://www.home-assistant.io/components/cover.mqtt/ specification.
+ *
+ * Only Open/Close/Stop works so far.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class Cover extends AbstractComponent<Cover.ChannelConfiguration> {
+ public static final String switchChannelID = "cover"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Cover");
+ }
+
+ protected @Nullable String state_topic;
+ protected @Nullable String command_topic;
+ protected String payload_open = "OPEN";
+ protected String payload_close = "CLOSE";
+ protected String payload_stop = "STOP";
+ }
+
+ public Cover(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ RollershutterValue value = new RollershutterValue(channelConfiguration.payload_open,
+ channelConfiguration.payload_close, channelConfiguration.payload_stop);
+
+ buildChannel(switchChannelID, value, channelConfiguration.getName(), componentConfiguration.getUpdateListener())
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.getValueTemplate())
+ .commandTopic(channelConfiguration.command_topic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+
+/**
+ * A MQTT Fan component, following the https://www.home-assistant.io/components/fan.mqtt/ specification.
+ *
+ * Only ON/OFF is supported so far.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class Fan extends AbstractComponent<Fan.ChannelConfiguration> {
+ public static final String switchChannelID = "fan"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Fan");
+ }
+
+ protected @Nullable String state_topic;
+ protected String command_topic = "";
+ protected String payload_on = "ON";
+ protected String payload_off = "OFF";
+ }
+
+ public Fan(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ OnOffValue value = new OnOffValue(channelConfiguration.payload_on, channelConfiguration.payload_off);
+ buildChannel(switchChannelID, value, channelConfiguration.getName(), componentConfiguration.getUpdateListener())
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.getValueTemplate())
+ .commandTopic(channelConfiguration.command_topic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.stream.Stream;
+
+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.mapping.ColorMode;
+import org.openhab.binding.mqtt.generic.values.ColorValue;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
+ *
+ * This class condenses the three state/command topics (for ON/OFF, Brightness, Color) to one
+ * color channel.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class Light extends AbstractComponent<Light.ChannelConfiguration> implements ChannelStateUpdateListener {
+ public static final String switchChannelID = "light"; // Randomly chosen channel "ID"
+ public static final String brightnessChannelID = "brightness"; // Randomly chosen channel "ID"
+ public static final String colorChannelID = "color"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Light");
+ }
+
+ protected int brightness_scale = 255;
+ protected boolean optimistic = false;
+ protected @Nullable List<String> effect_list;
+
+ // Defines when on the payload_on is sent. Using last (the default) will send any style (brightness, color, etc)
+ // topics first and then a payload_on to the command_topic. Using first will send the payload_on and then any
+ // style topics. Using brightness will only send brightness commands instead of the payload_on to turn the light
+ // on.
+ protected String on_command_type = "last";
+
+ protected @Nullable String state_topic;
+ protected @Nullable String command_topic;
+ protected @Nullable String state_value_template;
+
+ protected @Nullable String brightness_state_topic;
+ protected @Nullable String brightness_command_topic;
+ protected @Nullable String brightness_value_template;
+
+ protected @Nullable String color_temp_state_topic;
+ protected @Nullable String color_temp_command_topic;
+ protected @Nullable String color_temp_value_template;
+
+ protected @Nullable String effect_command_topic;
+ protected @Nullable String effect_state_topic;
+ protected @Nullable String effect_value_template;
+
+ protected @Nullable String rgb_command_topic;
+ protected @Nullable String rgb_state_topic;
+ protected @Nullable String rgb_value_template;
+ protected @Nullable String rgb_command_template;
+
+ protected @Nullable String white_value_command_topic;
+ protected @Nullable String white_value_state_topic;
+ protected @Nullable String white_value_template;
+
+ protected @Nullable String xy_command_topic;
+ protected @Nullable String xy_state_topic;
+ protected @Nullable String xy_value_template;
+
+ protected String payload_on = "ON";
+ protected String payload_off = "OFF";
+ }
+
+ protected ComponentChannel colorChannel;
+ protected ComponentChannel switchChannel;
+ protected ComponentChannel brightnessChannel;
+ private final @Nullable ChannelStateUpdateListener channelStateUpdateListener;
+
+ public Light(ComponentFactory.ComponentConfiguration builder) {
+ super(builder, ChannelConfiguration.class);
+ this.channelStateUpdateListener = builder.getUpdateListener();
+ ColorValue value = new ColorValue(ColorMode.RGB, channelConfiguration.payload_on,
+ channelConfiguration.payload_off, 100);
+
+ // Create three MQTT subscriptions and use this class object as update listener
+ switchChannel = buildChannel(switchChannelID, value, channelConfiguration.getName(), this)
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.state_value_template,
+ channelConfiguration.getValueTemplate())
+ .commandTopic(channelConfiguration.command_topic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build(false);
+
+ colorChannel = buildChannel(colorChannelID, value, channelConfiguration.getName(), this)
+ .stateTopic(channelConfiguration.rgb_state_topic, channelConfiguration.rgb_value_template)
+ .commandTopic(channelConfiguration.rgb_command_topic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build(false);
+
+ brightnessChannel = buildChannel(brightnessChannelID, value, channelConfiguration.getName(), this)
+ .stateTopic(channelConfiguration.brightness_state_topic, channelConfiguration.brightness_value_template)
+ .commandTopic(channelConfiguration.brightness_command_topic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build(false);
+
+ channels.put(colorChannelID, colorChannel);
+ }
+
+ @Override
+ public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
+ int timeout) {
+ return Stream.of(switchChannel, brightnessChannel, colorChannel) //
+ .map(v -> v.start(connection, scheduler, timeout)) //
+ .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
+ }
+
+ @Override
+ public CompletableFuture<@Nullable Void> stop() {
+ return Stream.of(switchChannel, brightnessChannel, colorChannel) //
+ .map(v -> v.stop()) //
+ .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
+ }
+
+ /**
+ * Proxy method to condense all three MQTT subscriptions to one channel
+ */
+ @Override
+ public void updateChannelState(ChannelUID channelUID, State value) {
+ ChannelStateUpdateListener listener = channelStateUpdateListener;
+ if (listener != null) {
+ listener.updateChannelState(colorChannel.getChannelUID(), value);
+ }
+ }
+
+ /**
+ * Proxy method to condense all three MQTT subscriptions to one channel
+ */
+ @Override
+ public void postChannelCommand(ChannelUID channelUID, Command value) {
+ ChannelStateUpdateListener listener = channelStateUpdateListener;
+ if (listener != null) {
+ listener.postChannelCommand(colorChannel.getChannelUID(), value);
+ }
+ }
+
+ /**
+ * Proxy method to condense all three MQTT subscriptions to one channel
+ */
+ @Override
+ public void triggerChannel(ChannelUID channelUID, String eventPayload) {
+ ChannelStateUpdateListener listener = channelStateUpdateListener;
+ if (listener != null) {
+ listener.triggerChannel(colorChannel.getChannelUID(), eventPayload);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+
+/**
+ * A MQTT lock, following the https://www.home-assistant.io/components/lock.mqtt/ specification.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class Lock extends AbstractComponent<Lock.ChannelConfiguration> {
+ public static final String switchChannelID = "lock"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Lock");
+ }
+
+ protected boolean optimistic = false;
+
+ protected String state_topic = "";
+ protected String payload_lock = "LOCK";
+ protected String payload_unlock = "UNLOCK";
+ protected @Nullable String command_topic;
+ }
+
+ public Lock(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ // We do not support all HomeAssistant quirks
+ if (channelConfiguration.optimistic && !channelConfiguration.state_topic.isBlank()) {
+ throw new UnsupportedOperationException("Component:Lock does not support forced optimistic mode");
+ }
+
+ buildChannel(switchChannelID,
+ new OnOffValue(channelConfiguration.payload_lock, channelConfiguration.payload_unlock),
+ channelConfiguration.getName(), componentConfiguration.getUpdateListener())
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.getValueTemplate())
+ .commandTopic(channelConfiguration.command_topic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.List;
+import java.util.regex.Pattern;
+
+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.NumberValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.binding.mqtt.generic.values.Value;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.listener.ExpireUpdateStateListener;
+
+/**
+ * A MQTT sensor, following the https://www.home-assistant.io/components/sensor.mqtt/ specification.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class Sensor extends AbstractComponent<Sensor.ChannelConfiguration> {
+ public static final String sensorChannelID = "sensor"; // Randomly chosen channel "ID"
+ private static final Pattern triggerIcons = Pattern.compile("^mdi:(toggle|gesture).*$");
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Sensor");
+ }
+
+ protected @Nullable String unit_of_measurement;
+ protected @Nullable String device_class;
+ protected boolean force_update = false;
+ protected @Nullable Integer expire_after;
+
+ protected String state_topic = "";
+
+ protected @Nullable String json_attributes_topic;
+ protected @Nullable String json_attributes_template;
+ protected @Nullable List<String> json_attributes;
+ }
+
+ public Sensor(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ Value value;
+ String uom = channelConfiguration.unit_of_measurement;
+
+ if (uom != null && !uom.isBlank()) {
+ value = new NumberValue(null, null, null, uom);
+ } else {
+ value = new TextValue();
+ }
+
+ String icon = channelConfiguration.getIcon();
+
+ boolean trigger = triggerIcons.matcher(icon).matches();
+
+ buildChannel(sensorChannelID, value, channelConfiguration.getName(), getListener(componentConfiguration, value))
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.getValueTemplate())//
+ .trigger(trigger).build();
+ }
+
+ private ChannelStateUpdateListener getListener(ComponentFactory.ComponentConfiguration componentConfiguration,
+ Value value) {
+ ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener();
+
+ if (channelConfiguration.expire_after != null) {
+ updateListener = new ExpireUpdateStateListener(updateListener, channelConfiguration.expire_after, value,
+ componentConfiguration.getTracker(), componentConfiguration.getScheduler());
+ }
+ return updateListener;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+
+/**
+ * A MQTT switch, following the https://www.home-assistant.io/components/switch.mqtt/ specification.
+ *
+ * @author David Graeff - Initial contribution
+ */
+@NonNullByDefault
+public class Switch extends AbstractComponent<Switch.ChannelConfiguration> {
+ public static final String switchChannelID = "switch"; // Randomly chosen channel "ID"
+
+ /**
+ * Configuration class for MQTT component
+ */
+ static class ChannelConfiguration extends AbstractChannelConfiguration {
+ ChannelConfiguration() {
+ super("MQTT Switch");
+ }
+
+ protected @Nullable Boolean optimistic;
+
+ protected @Nullable String command_topic;
+ protected String state_topic = "";
+
+ protected @Nullable String state_on;
+ protected @Nullable String state_off;
+ protected String payload_on = "ON";
+ protected String payload_off = "OFF";
+
+ protected @Nullable String json_attributes_topic;
+ protected @Nullable String json_attributes_template;
+ }
+
+ public Switch(ComponentFactory.ComponentConfiguration componentConfiguration) {
+ super(componentConfiguration, ChannelConfiguration.class);
+
+ boolean optimistic = channelConfiguration.optimistic != null ? channelConfiguration.optimistic
+ : channelConfiguration.state_topic.isBlank();
+
+ if (optimistic && !channelConfiguration.state_topic.isBlank()) {
+ throw new UnsupportedOperationException("Component:Switch does not support forced optimistic mode");
+ }
+
+ String state_on = channelConfiguration.state_on != null ? channelConfiguration.state_on
+ : channelConfiguration.payload_on;
+ String state_off = channelConfiguration.state_off != null ? channelConfiguration.state_off
+ : channelConfiguration.payload_off;
+
+ OnOffValue value = new OnOffValue(state_on, state_off, channelConfiguration.payload_on,
+ channelConfiguration.payload_off);
+
+ buildChannel(switchChannelID, value, "state", componentConfiguration.getUpdateListener())
+ .stateTopic(channelConfiguration.state_topic, channelConfiguration.getValueTemplate())
+ .commandTopic(channelConfiguration.command_topic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.homeassistant.internal.MappingJsonReader;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Device;
+
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+
+/**
+ * This a Gson type adapter factory.
+ *
+ * <p>
+ * It will create a type adapter for every class derived from {@link
+ * AbstractChannelConfiguration} and ensures,
+ * that abbreviated names are replaces with their long versions during the read.
+ *
+ * <p>
+ * In elements, whose name end in'_topic' '~' replacement is performed.
+ *
+ * <p>
+ * The adapters also handle {@link Device}
+ *
+ * @author Jochen Klein - Initial contribution
+ */
+@NonNullByDefault
+public class ChannelConfigurationTypeAdapterFactory implements TypeAdapterFactory {
+
+ @Override
+ @Nullable
+ public <T> TypeAdapter<T> create(@Nullable Gson gson, @Nullable TypeToken<T> type) {
+ if (gson == null || type == null) {
+ return null;
+ }
+ if (AbstractChannelConfiguration.class.isAssignableFrom(type.getRawType())) {
+ return createHAConfig(gson, type);
+ }
+ if (Device.class.isAssignableFrom(type.getRawType())) {
+ return createHADevice(gson, type);
+ }
+ return null;
+ }
+
+ /**
+ * Handle {@link
+ * AbstractChannelConfiguration}
+ *
+ * @param gson parser
+ * @param type type
+ * @return adapter
+ */
+ private <T> TypeAdapter<T> createHAConfig(Gson gson, TypeToken<T> type) {
+ /* The delegate is the 'default' adapter */
+ final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
+
+ return new TypeAdapter<T>() {
+ @Override
+ public @Nullable T read(JsonReader in) throws IOException {
+ /* read the object using the default adapter, but translate the names in the reader */
+ T result = delegate.read(MappingJsonReader.getConfigMapper(in));
+ /* do the '~' expansion afterwards */
+ expandTidleInTopics(AbstractChannelConfiguration.class.cast(result));
+ return result;
+ }
+
+ @Override
+ public void write(JsonWriter out, @Nullable T value) throws IOException {
+ delegate.write(out, value);
+ }
+ };
+ }
+
+ private <T> TypeAdapter<T> createHADevice(Gson gson, TypeToken<T> type) {
+ /* The delegate is the 'default' adapter */
+ final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
+
+ return new TypeAdapter<T>() {
+ @Override
+ public @Nullable T read(JsonReader in) throws IOException {
+ /* read the object using the default adapter, but translate the names in the reader */
+ T result = delegate.read(MappingJsonReader.getDeviceMapper(in));
+ return result;
+ }
+
+ @Override
+ public void write(JsonWriter out, @Nullable T value) throws IOException {
+ delegate.write(out, value);
+ }
+ };
+ }
+
+ private void expandTidleInTopics(AbstractChannelConfiguration config) {
+ Class<?> type = config.getClass();
+
+ String tilde = config.getTilde();
+
+ while (type != Object.class) {
+ Field[] fields = type.getDeclaredFields();
+
+ for (Field field : fields) {
+ if (String.class.isAssignableFrom(field.getType()) && field.getName().endsWith("_topic")) {
+ field.setAccessible(true);
+
+ try {
+ final String oldValue = (String) field.get(config);
+
+ String newValue = oldValue;
+ if (oldValue != null && !oldValue.isBlank()) {
+ if (oldValue.charAt(0) == '~') {
+ newValue = tilde + oldValue.substring(1);
+ } else if (oldValue.charAt(oldValue.length() - 1) == '~') {
+ newValue = oldValue.substring(0, oldValue.length() - 1) + tilde;
+ }
+ }
+
+ field.set(config, newValue);
+ } catch (IllegalArgumentException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ type = type.getSuperclass();
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config;
+
+import java.lang.reflect.Type;
+
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Connection;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+
+/**
+ * The {@link ConnectionDeserializer} will de-serialize a connection-list
+ *
+ * see: https://www.home-assistant.io/integrations/sensor.mqtt/#connections
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+public class ConnectionDeserializer implements JsonDeserializer<Connection> {
+ @Override
+ public Connection deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+ throws JsonParseException {
+ JsonArray list = json.getAsJsonArray();
+ return new Connection(list.get(0).getAsString(), list.get(1).getAsString());
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+
+/**
+ * JsonTypeAdapter which will read a single string or a string list
+ *
+ * see: https://www.home-assistant.io/components/binary_sensor.mqtt/ -> device / identifiers
+ *
+ * @author Jochen Klein - Initial contribution
+ */
+public class ListOrStringDeserializer extends TypeAdapter<List<String>> {
+
+ @Override
+ public void write(@Nullable JsonWriter out, @Nullable List<String> value) throws IOException {
+ Objects.requireNonNull(out);
+
+ if (value == null) {
+ out.nullValue();
+ return;
+ }
+
+ out.beginArray();
+ for (String str : value) {
+ out.jsonValue(str);
+ }
+ out.endArray();
+ }
+
+ @Override
+ public @Nullable List<String> read(@Nullable JsonReader in) throws IOException {
+ Objects.requireNonNull(in);
+
+ JsonToken peek = in.peek();
+
+ switch (peek) {
+ case NULL:
+ in.nextNull();
+ return null;
+ case STRING:
+ return Collections.singletonList(in.nextString());
+ case BEGIN_ARRAY:
+ return readList(in);
+ default:
+ throw new IOException("unexpected token " + peek + ". Array of string or string expected");
+ }
+ }
+
+ private @NonNull List<String> readList(@NonNull JsonReader in) throws IOException {
+ in.beginArray();
+
+ List<String> result = new ArrayList<>();
+
+ JsonToken peek = in.peek();
+
+ while (peek != JsonToken.END_ARRAY) {
+ if (peek == JsonToken.STRING) {
+ result.add(in.nextString());
+ } else {
+ throw new IOException("unexpected token " + peek + ". Array of string or string expected");
+ }
+ peek = in.peek();
+ }
+ in.endArray();
+
+ return result;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config.dto;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.util.UIDUtils;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Base class for home assistant configurations.
+ *
+ * @author Jochen Klein - Initial contribution
+ */
+@NonNullByDefault
+public abstract class AbstractChannelConfiguration {
+ protected String name;
+
+ protected String icon = "";
+ protected int qos; // defaults to 0 according to HA specification
+ protected boolean retain; // defaults to false according to HA specification
+ protected @Nullable String value_template;
+ protected @Nullable String unique_id;
+
+ protected AvailabilityMode availability_mode = AvailabilityMode.LATEST;
+ protected @Nullable String availability_topic;
+ protected String payload_available = "online";
+ protected String payload_not_available = "offline";
+
+ /**
+ * A list of MQTT topics subscribed to receive availability (online/offline) updates. Must not be used together with
+ * availability_topic
+ */
+ protected @Nullable List<Availability> availability;
+
+ @SerializedName(value = "~")
+ protected String tilde = "";
+
+ protected @Nullable Device device;
+
+ /**
+ * Parse the base properties of the configJSON into a {@link AbstractChannelConfiguration}
+ *
+ * @param configJSON channels configuration in JSON
+ * @param gson parser
+ * @return configuration object
+ */
+ public static AbstractChannelConfiguration fromString(final String configJSON, final Gson gson) {
+ return fromString(configJSON, gson, Config.class);
+ }
+
+ protected AbstractChannelConfiguration(String defaultName) {
+ this.name = defaultName;
+ }
+
+ public @Nullable String expand(@Nullable String value) {
+ return value == null ? null : value.replaceAll("~", tilde);
+ }
+
+ public String getThingName() {
+ String result = null;
+
+ if (this.device != null) {
+ result = this.device.name;
+ }
+ if (result == null) {
+ result = name;
+ }
+ return result;
+ }
+
+ public String getThingId(String defaultId) {
+ String result = null;
+ if (this.device != null) {
+ result = this.device.getId();
+ }
+ if (result == null) {
+ result = unique_id;
+ }
+ return UIDUtils.encode(result != null ? result : defaultId);
+ }
+
+ public Map<String, Object> appendToProperties(Map<String, Object> properties) {
+ final Device device_ = device;
+ if (device_ == null) {
+ return properties;
+ }
+ final String manufacturer = device_.manufacturer;
+ if (manufacturer != null) {
+ properties.put(Thing.PROPERTY_VENDOR, manufacturer);
+ }
+ final String model = device_.model;
+ if (model != null) {
+ properties.put(Thing.PROPERTY_MODEL_ID, model);
+ }
+ final String sw_version = device_.swVersion;
+ if (sw_version != null) {
+ properties.put(Thing.PROPERTY_FIRMWARE_VERSION, sw_version);
+ }
+ return properties;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getIcon() {
+ return icon;
+ }
+
+ public int getQos() {
+ return qos;
+ }
+
+ public boolean isRetain() {
+ return retain;
+ }
+
+ @Nullable
+ public String getValueTemplate() {
+ return value_template;
+ }
+
+ @Nullable
+ public String getUniqueId() {
+ return unique_id;
+ }
+
+ @Nullable
+ public String getAvailabilityTopic() {
+ return availability_topic;
+ }
+
+ public String getPayloadAvailable() {
+ return payload_available;
+ }
+
+ public String getPayloadNotAvailable() {
+ return payload_not_available;
+ }
+
+ @Nullable
+ public Device getDevice() {
+ return device;
+ }
+
+ @Nullable
+ public List<Availability> getAvailability() {
+ return availability;
+ }
+
+ public String getTilde() {
+ return tilde;
+ }
+
+ public AvailabilityMode getAvailabilityMode() {
+ return availability_mode;
+ }
+
+ /**
+ * This class is needed, to be able to parse only the common base attributes.
+ * Without this, {@link AbstractChannelConfiguration} cannot be instantiated, as it is abstract.
+ * This is needed during the discovery.
+ */
+ private static class Config extends AbstractChannelConfiguration {
+ public Config() {
+ super("private");
+ }
+ }
+
+ /**
+ * Parse the configJSON into a subclass of {@link AbstractChannelConfiguration}
+ *
+ * @param configJSON channels configuration in JSON
+ * @param gson parser
+ * @param clazz target configuration class
+ * @return configuration object
+ */
+ public static <C extends AbstractChannelConfiguration> C fromString(final String configJSON, final Gson gson,
+ final Class<C> clazz) {
+ return Objects.requireNonNull(gson.fromJson(configJSON, clazz));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config.dto;
+
+/**
+ * MQTT topic subscribed to receive availability (online/offline) updates. Must not be used together with
+ * availability_topic
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+public class Availability {
+ protected String payload_available = "online";
+ protected String payload_not_available = "offline";
+ protected String topic;
+
+ public String getPayload_available() {
+ return payload_available;
+ }
+
+ public String getPayload_not_available() {
+ return payload_not_available;
+ }
+
+ public String getTopic() {
+ return topic;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config.dto;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * controls the conditions needed to set the entity to available
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+public enum AvailabilityMode {
+ /**
+ * payload_available must be received on all configured availability topics before the entity is marked as online
+ */
+ @SerializedName("all")
+ ALL,
+
+ /**
+ * payload_available must be received on at least one configured availability topic before the entity is marked as
+ * online
+ */
+ @SerializedName("any")
+ ANY,
+
+ /**
+ * the last payload_available or payload_not_available received on any configured availability topic controls the
+ * availability
+ */
+ @SerializedName("latest")
+ LATEST
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config.dto;
+
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.homeassistant.internal.config.ConnectionDeserializer;
+
+import com.google.gson.annotations.JsonAdapter;
+
+/**
+ * Connection configuration
+ *
+ * @author Jochen Klein - Initial contribution
+ */
+@JsonAdapter(ConnectionDeserializer.class)
+public class Connection {
+ protected @Nullable String type;
+ protected @Nullable String identifier;
+
+ public Connection() {
+ }
+
+ public Connection(@Nullable String type, @Nullable String identifier) {
+ this.type = type;
+ this.identifier = identifier;
+ }
+
+ @Nullable
+ public String getType() {
+ return type;
+ }
+
+ @Nullable
+ public String getIdentifier() {
+ return identifier;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.config.dto;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.mqtt.homeassistant.internal.config.ListOrStringDeserializer;
+
+import com.google.gson.annotations.JsonAdapter;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Device configuration
+ *
+ * @author Jochen Klein - Initial contribution
+ */
+public class Device {
+ @JsonAdapter(ListOrStringDeserializer.class)
+ protected @Nullable List<String> identifiers;
+ protected @Nullable List<Connection> connections;
+ protected @Nullable String manufacturer;
+ protected @Nullable String model;
+ protected @Nullable String name;
+
+ @SerializedName("sw_version")
+ protected @Nullable String swVersion;
+
+ public @Nullable String getId() {
+ List<String> identifiers = this.identifiers;
+ return identifiers == null ? null : String.join("_", identifiers);
+ }
+
+ @Nullable
+ public List<Connection> getConnections() {
+ return connections;
+ }
+
+ @Nullable
+ public String getManufacturer() {
+ return manufacturer;
+ }
+
+ @Nullable
+ public String getModel() {
+ return model;
+ }
+
+ @Nullable
+ public String getName() {
+ return name;
+ }
+
+ @Nullable
+ public String getSwVersion() {
+ return swVersion;
+ }
+
+ @Nullable
+ public List<String> getIdentifiers() {
+ return identifiers;
+ }
+}
import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
-import org.openhab.binding.mqtt.homeassistant.internal.BaseChannelConfiguration;
-import org.openhab.binding.mqtt.homeassistant.internal.ChannelConfigurationTypeAdapterFactory;
import org.openhab.binding.mqtt.homeassistant.internal.HaID;
import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
}
this.future = scheduler.schedule(this::publishResults, 2, TimeUnit.SECONDS);
- BaseChannelConfiguration config = BaseChannelConfiguration
+ AbstractChannelConfiguration config = AbstractChannelConfiguration
.fromString(new String(payload, StandardCharsets.UTF_8), gson);
// We will of course find multiple of the same unique Thing IDs, for each different component another one.
import org.openhab.binding.mqtt.generic.tools.DelayedBatchProcessing;
import org.openhab.binding.mqtt.generic.utils.FutureCollector;
import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
-import org.openhab.binding.mqtt.homeassistant.internal.AbstractComponent;
-import org.openhab.binding.mqtt.homeassistant.internal.CChannel;
-import org.openhab.binding.mqtt.homeassistant.internal.CFactory;
-import org.openhab.binding.mqtt.homeassistant.internal.ChannelConfigurationTypeAdapterFactory;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
import org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents;
import org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents.ComponentDiscovered;
import org.openhab.binding.mqtt.homeassistant.internal.HaID;
import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent;
+import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory;
+import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
if (channelConfigurationJSON == null) {
logger.warn("Provided channel does not have a 'config' configuration key!");
} else {
- component = CFactory.createComponent(thingUID, haID, channelConfigurationJSON, this, this, scheduler,
- gson, transformationServiceProvider);
+ component = ComponentFactory.createComponent(thingUID, haID, channelConfigurationJSON, this, this,
+ scheduler, gson, transformationServiceProvider);
}
if (component != null) {
- haComponents.put(component.uid().getId(), component);
+ haComponents.put(component.getGroupUID().getId(), component);
component.addChannelTypes(channelTypeProvider);
} else {
logger.warn("Could not restore component {}", thing);
if (component == null) {
return null;
}
- CChannel componentChannel = component.channel(channelUID.getIdWithoutGroup());
+ ComponentChannel componentChannel = component.getChannel(channelUID.getIdWithoutGroup());
if (componentChannel == null) {
return null;
}
synchronized (haComponents) { // sync whenever discoverComponents is started
for (AbstractComponent<?> discovered : discoveredComponentsList) {
- AbstractComponent<?> known = haComponents.get(discovered.uid().getId());
+ AbstractComponent<?> known = haComponents.get(discovered.getGroupUID().getId());
// Is component already known?
if (known != null) {
if (discovered.getConfigHash() != known.getConfigHash()) {
// Add channel and group types to the types registry
discovered.addChannelTypes(channelTypeProvider);
// Add component to the component map
- haComponents.put(discovered.uid().getId(), discovered);
+ haComponents.put(discovered.getGroupUID().getId(), discovered);
// Start component / Subscribe to channel topics
discovered.start(connection, scheduler, 0).exceptionally(e -> {
- logger.warn("Failed to start component {}", discovered.uid(), e);
+ logger.warn("Failed to start component {}", discovered.getGroupUID(), e);
return null;
});
- Collection<Channel> channels = discovered.channelTypes().values().stream().map(CChannel::getChannel)
- .collect(Collectors.toList());
+ Collection<Channel> channels = discovered.getChannelMap().values().stream()
+ .map(ComponentChannel::getChannel).collect(Collectors.toList());
ThingHelper.addChannelsToThing(thing, channels);
}
}
synchronized (haComponents) { // sync whenever discoverComponents is started
groupDefs = haComponents.values().stream().map(AbstractComponent::getGroupDefinition)
.collect(Collectors.toList());
- channelDefs = haComponents.values().stream().map(AbstractComponent::type)
+ channelDefs = haComponents.values().stream().map(AbstractComponent::getType)
.map(ChannelGroupType::getChannelDefinitions).flatMap(List::stream)
.collect(Collectors.toList());
}
private final AvailabilityTracker tracker;
private final ScheduledExecutorService scheduler;
- private AtomicReference<@Nullable ScheduledFuture<?>> expire = new AtomicReference<>();
+ private final AtomicReference<@Nullable ScheduledFuture<?>> expire = new AtomicReference<>();
public ExpireUpdateStateListener(ChannelStateUpdateListener original, int expireAfter, Value value,
AvailabilityTracker tracker, ScheduledExecutorService scheduler) {
private final Value value;
private final ScheduledExecutorService scheduler;
- private AtomicReference<@Nullable ScheduledFuture<?>> delay = new AtomicReference<>();
+ private final AtomicReference<@Nullable ScheduledFuture<?>> delay = new AtomicReference<>();
public OffDelayUpdateStateListener(ChannelStateUpdateListener original, int offDelay, Value value,
ScheduledExecutorService scheduler) {
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
+import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
+import org.openhab.binding.mqtt.handler.BrokerHandler;
+import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
+import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
+import org.openhab.core.test.java.JavaTest;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.BridgeBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.ThingTypeBuilder;
+import org.openhab.core.thing.type.ThingTypeRegistry;
+import org.openhab.transform.jinja.internal.JinjaTransformationService;
+import org.openhab.transform.jinja.internal.profiles.JinjaTransformationProfile;
+
+/**
+ * Abstract class for HomeAssistant unit tests.
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings({ "ConstantConditions" })
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public abstract class AbstractHomeAssistantTests extends JavaTest {
+ public static final String BINDING_ID = "mqtt";
+
+ public static final String BRIDGE_TYPE_ID = "broker";
+ public static final String BRIDGE_TYPE_LABEL = "MQTT Broker";
+ public static final ThingTypeUID BRIDGE_TYPE_UID = new ThingTypeUID(BINDING_ID, BRIDGE_TYPE_ID);
+ public static final String BRIDGE_ID = UUID.randomUUID().toString();
+ public static final ThingUID BRIDGE_UID = new ThingUID(BRIDGE_TYPE_UID, BRIDGE_ID);
+
+ public static final String HA_TYPE_ID = "homeassistant";
+ public static final String HA_TYPE_LABEL = "Homeassistant";
+ public static final ThingTypeUID HA_TYPE_UID = new ThingTypeUID(BINDING_ID, HA_TYPE_ID);
+ public static final String HA_ID = UUID.randomUUID().toString();
+ public static final ThingUID HA_UID = new ThingUID(HA_TYPE_UID, HA_ID);
+
+ protected @Mock @NonNullByDefault({}) MqttBrokerConnection bridgeConnection;
+ protected @Mock @NonNullByDefault({}) ThingTypeRegistry thingTypeRegistry;
+ protected @Mock @NonNullByDefault({}) TransformationServiceProvider transformationServiceProvider;
+
+ @SuppressWarnings("NotNullFieldNotInitialized")
+ protected @NonNullByDefault({}) MqttChannelTypeProvider channelTypeProvider;
+
+ protected final Bridge bridgeThing = BridgeBuilder.create(BRIDGE_TYPE_UID, BRIDGE_UID).build();
+ protected final BrokerHandler bridgeHandler = spy(new BrokerHandler(bridgeThing));
+ protected final Thing haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).build();
+ protected final Map<String, Set<MqttMessageSubscriber>> subscriptions = new HashMap<>();
+
+ private final JinjaTransformationService jinjaTransformationService = new JinjaTransformationService();
+
+ @BeforeEach
+ public void beforeEachAbstractHomeAssistantTests() {
+ when(thingTypeRegistry.getThingType(BRIDGE_TYPE_UID))
+ .thenReturn(ThingTypeBuilder.instance(BRIDGE_TYPE_UID, BRIDGE_TYPE_LABEL).build());
+ when(thingTypeRegistry.getThingType(HA_TYPE_UID))
+ .thenReturn(ThingTypeBuilder.instance(HA_TYPE_UID, HA_TYPE_LABEL).build());
+ when(transformationServiceProvider
+ .getTransformationService(JinjaTransformationProfile.PROFILE_TYPE_UID.getId()))
+ .thenReturn(jinjaTransformationService);
+
+ channelTypeProvider = spy(new MqttChannelTypeProvider(thingTypeRegistry));
+
+ setupConnection();
+
+ // Return the mocked connection object if the bridge handler is asked for it
+ when(bridgeHandler.getConnectionAsync()).thenReturn(CompletableFuture.completedFuture(bridgeConnection));
+
+ bridgeThing.setStatusInfo(new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.ONLINE.NONE, ""));
+ bridgeThing.setHandler(bridgeHandler);
+
+ haThing.setStatusInfo(new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.ONLINE.NONE, ""));
+ }
+
+ protected void setupConnection() {
+ doAnswer(invocation -> {
+ final var topic = (String) invocation.getArgument(0);
+ final var subscriber = (MqttMessageSubscriber) invocation.getArgument(1);
+ final var topicSubscriptions = subscriptions.getOrDefault(topic, new HashSet<>());
+
+ topicSubscriptions.add(subscriber);
+ subscriptions.put(topic, topicSubscriptions);
+ return CompletableFuture.completedFuture(true);
+ }).when(bridgeConnection).subscribe(any(), any());
+
+ doAnswer(invocation -> {
+ final var topic = (String) invocation.getArgument(0);
+ final var subscriber = (MqttMessageSubscriber) invocation.getArgument(1);
+ final var topicSubscriptions = subscriptions.get(topic);
+
+ if (topicSubscriptions != null) {
+ topicSubscriptions.remove(subscriber);
+ }
+ return CompletableFuture.completedFuture(true);
+ }).when(bridgeConnection).unsubscribe(any(), any());
+
+ doAnswer(invocation -> {
+ subscriptions.clear();
+ return CompletableFuture.completedFuture(true);
+ }).when(bridgeConnection).unsubscribeAll();
+
+ doReturn(CompletableFuture.completedFuture(true)).when(bridgeConnection).publish(any(), any(), anyInt(),
+ anyBoolean());
+ }
+
+ /**
+ * @param relativePath path from src/test/java/org/openhab/binding/mqtt/homeassistant/internal
+ * @return path
+ */
+ protected Path getResourcePath(String relativePath) {
+ try {
+ return Paths.get(AbstractHomeAssistantTests.class.getResource(relativePath).toURI());
+ } catch (URISyntaxException e) {
+ Assertions.fail(e);
+ }
+ throw new IllegalArgumentException();
+ }
+
+ protected String getResourceAsString(String relativePath) {
+ try {
+ return Files.readString(getResourcePath(relativePath));
+ } catch (IOException e) {
+ Assertions.fail(e);
+ }
+ throw new IllegalArgumentException();
+ }
+
+ protected byte[] getResourceAsByteArray(String relativePath) {
+ try {
+ return Files.readAllBytes(getResourcePath(relativePath));
+ } catch (IOException e) {
+ Assertions.fail(e);
+ }
+ throw new IllegalArgumentException();
+ }
+
+ protected static String configTopicToMqtt(String configTopic) {
+ return HandlerConfiguration.DEFAULT_BASETOPIC + "/" + configTopic + "/config";
+ }
+}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 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;
-
-import static org.hamcrest.CoreMatchers.*;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.collection.IsIterableContainingInOrder.contains;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.util.Arrays;
-import java.util.List;
-
-import org.eclipse.jdt.annotation.NonNull;
-import org.junit.jupiter.api.Test;
-import org.openhab.binding.mqtt.homeassistant.internal.BaseChannelConfiguration.Connection;
-
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-
-/**
- * @author Jochen Klein - Initial contribution
- */
-public class HAConfigurationTests {
-
- private Gson gson = new GsonBuilder().registerTypeAdapterFactory(new ChannelConfigurationTypeAdapterFactory())
- .create();
-
- private static String readTestJson(final String name) {
- StringBuilder result = new StringBuilder();
-
- try (BufferedReader in = new BufferedReader(
- new InputStreamReader(HAConfigurationTests.class.getResourceAsStream(name), "UTF-8"))) {
- String line;
-
- while ((line = in.readLine()) != null) {
- result.append(line).append('\n');
- }
- return result.toString();
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
-
- @Test
- public void testAbbreviations() {
- String json = readTestJson("configA.json");
-
- BaseChannelConfiguration config = BaseChannelConfiguration.fromString(json, gson);
-
- assertThat(config.name, is("A"));
- assertThat(config.icon, is("2"));
- assertThat(config.qos, is(1));
- assertThat(config.retain, is(true));
- assertThat(config.value_template, is("B"));
- assertThat(config.unique_id, is("C"));
- assertThat(config.availability_topic, is("D/E"));
- assertThat(config.payload_available, is("F"));
- assertThat(config.payload_not_available, is("G"));
-
- assertThat(config.device, is(notNullValue()));
-
- BaseChannelConfiguration.Device device = config.device;
- if (device != null) {
- assertThat(device.identifiers, contains("H"));
- assertThat(device.connections, is(notNullValue()));
- List<@NonNull Connection> connections = device.connections;
- if (connections != null) {
- assertThat(connections.get(0).type, is("I1"));
- assertThat(connections.get(0).identifier, is("I2"));
- }
- assertThat(device.name, is("J"));
- assertThat(device.model, is("K"));
- assertThat(device.sw_version, is("L"));
- assertThat(device.manufacturer, is("M"));
- }
- }
-
- @Test
- public void testTildeSubstritution() {
- String json = readTestJson("configB.json");
-
- ComponentSwitch.ChannelConfiguration config = BaseChannelConfiguration.fromString(json, gson,
- ComponentSwitch.ChannelConfiguration.class);
-
- assertThat(config.availability_topic, is("D/E"));
- assertThat(config.state_topic, is("O/D/"));
- assertThat(config.command_topic, is("P~Q"));
- assertThat(config.device, is(notNullValue()));
-
- BaseChannelConfiguration.Device device = config.device;
- if (device != null) {
- assertThat(device.identifiers, contains("H"));
- }
- }
-
- @Test
- public void testSampleFanConfig() {
- String json = readTestJson("configFan.json");
-
- ComponentFan.ChannelConfiguration config = BaseChannelConfiguration.fromString(json, gson,
- ComponentFan.ChannelConfiguration.class);
- assertThat(config.name, is("Bedroom Fan"));
- }
-
- @Test
- public void testDeviceListConfig() {
- String json = readTestJson("configDeviceList.json");
-
- ComponentFan.ChannelConfiguration config = BaseChannelConfiguration.fromString(json, gson,
- ComponentFan.ChannelConfiguration.class);
- assertThat(config.device, is(notNullValue()));
-
- BaseChannelConfiguration.Device device = config.device;
- if (device != null) {
- assertThat(device.identifiers, is(Arrays.asList("A", "B", "C")));
- }
- }
-
- @Test
- public void testDeviceSingleStringConfig() {
- String json = readTestJson("configDeviceSingleString.json");
-
- ComponentFan.ChannelConfiguration config = BaseChannelConfiguration.fromString(json, gson,
- ComponentFan.ChannelConfiguration.class);
- assertThat(config.device, is(notNullValue()));
-
- BaseChannelConfiguration.Device device = config.device;
- if (device != null) {
- assertThat(device.identifiers, is(Arrays.asList("A")));
- }
- }
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.instanceOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.hamcrest.CoreMatchers;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.mockito.Mock;
+import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
+import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
+import org.openhab.binding.mqtt.generic.values.Value;
+import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTests;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
+import org.openhab.binding.mqtt.homeassistant.internal.HaID;
+import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.handler.HomeAssistantThingHandler;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.openhab.core.types.State;
+
+/**
+ * Abstract class for components tests.
+ * TODO: need a way to test all channel properties, not only topics.
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings({ "ConstantConditions" })
+public abstract class AbstractComponentTests extends AbstractHomeAssistantTests {
+ private final static int SUBSCRIBE_TIMEOUT = 10000;
+ private final static int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
+
+ private @Mock ThingHandlerCallback callback;
+ private LatchThingHandler thingHandler;
+
+ @BeforeEach
+ public void setupThingHandler() {
+ final var config = haThing.getConfiguration();
+
+ config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
+ config.put(HandlerConfiguration.PROPERTY_TOPICS, getConfigTopics());
+
+ when(callback.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
+
+ thingHandler = new LatchThingHandler(haThing, channelTypeProvider, transformationServiceProvider,
+ SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
+ thingHandler.setConnection(bridgeConnection);
+ thingHandler.setCallback(callback);
+ thingHandler = spy(thingHandler);
+
+ thingHandler.initialize();
+ }
+
+ @AfterEach
+ public void disposeThingHandler() {
+ thingHandler.dispose();
+ }
+
+ /**
+ * {@link org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents} will wait a config on specified
+ * topics.
+ * Topics in config must be without prefix and suffix, they can be converted to full with method
+ * {@link #configTopicToMqtt(String)}
+ *
+ * @return config topics
+ */
+ protected abstract Set<String> getConfigTopics();
+
+ /**
+ * Process payload to discover and configure component. Topic should be added to {@link #getConfigTopics()}
+ *
+ * @param mqttTopic mqtt topic with configuration
+ * @param json configuration payload in Json
+ * @return discovered component
+ */
+ protected AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> discoverComponent(String mqttTopic,
+ String json) {
+ return discoverComponent(mqttTopic, json.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Process payload to discover and configure component. Topic should be added to {@link #getConfigTopics()}
+ *
+ * @param mqttTopic mqtt topic with configuration
+ * @param jsonPayload configuration payload in Json
+ * @return discovered component
+ */
+ protected AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> discoverComponent(String mqttTopic,
+ byte[] jsonPayload) {
+ var latch = thingHandler.createWaitForComponentDiscoveredLatch(1);
+ assertThat(publishMessage(mqttTopic, jsonPayload), is(true));
+ try {
+ assert latch.await(1, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ assertThat(e.getMessage(), false);
+ }
+ var component = thingHandler.getDiscoveredComponent();
+ assertThat(component, CoreMatchers.notNullValue());
+ return component;
+ }
+
+ /**
+ * Assert channel topics, label and value class
+ *
+ * @param component component
+ * @param channelId channel
+ * @param stateTopic state topic or empty string
+ * @param commandTopic command topic or empty string
+ * @param label label
+ * @param valueClass value class
+ */
+ protected static void assertChannel(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component,
+ String channelId, String stateTopic, String commandTopic, String label, Class<? extends Value> valueClass) {
+ var stateChannel = component.getChannel(channelId);
+ assertChannel(stateChannel, stateTopic, commandTopic, label, valueClass);
+ }
+
+ /**
+ * Assert channel topics, label and value class
+ *
+ * @param stateChannel channel
+ * @param stateTopic state topic or empty string
+ * @param commandTopic command topic or empty string
+ * @param label label
+ * @param valueClass value class
+ */
+ protected static void assertChannel(ComponentChannel stateChannel, String stateTopic, String commandTopic,
+ String label, Class<? extends Value> valueClass) {
+ assertThat(stateChannel.getChannel().getLabel(), is(label));
+ assertThat(stateChannel.getState().getStateTopic(), is(stateTopic));
+ assertThat(stateChannel.getState().getCommandTopic(), is(commandTopic));
+ assertThat(stateChannel.getState().getCache(), is(instanceOf(valueClass)));
+ }
+
+ /**
+ * Assert channel state
+ *
+ * @param component component
+ * @param channelId channel
+ * @param state expected state
+ */
+ protected static void assertState(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component,
+ String channelId, State state) {
+ assertThat(component.getChannel(channelId).getState().getCache().getChannelState(), is(state));
+ }
+
+ /**
+ * Assert that given payload was published exact-once on given topic.
+ *
+ * @param mqttTopic Mqtt topic
+ * @param payload payload
+ */
+ protected void assertPublished(String mqttTopic, String payload) {
+ verify(bridgeConnection).publish(eq(mqttTopic), eq(payload.getBytes(StandardCharsets.UTF_8)), anyInt(),
+ anyBoolean());
+ }
+
+ /**
+ * Assert that given payload was published N times on given topic.
+ *
+ * @param mqttTopic Mqtt topic
+ * @param payload payload
+ * @param t payload must be published N times on given topic
+ */
+ protected void assertPublished(String mqttTopic, String payload, int t) {
+ verify(bridgeConnection, times(t)).publish(eq(mqttTopic), eq(payload.getBytes(StandardCharsets.UTF_8)),
+ anyInt(), anyBoolean());
+ }
+
+ /**
+ * Assert that given payload was not published on given topic.
+ *
+ * @param mqttTopic Mqtt topic
+ * @param payload payload
+ */
+ protected void assertNotPublished(String mqttTopic, String payload) {
+ verify(bridgeConnection, never()).publish(eq(mqttTopic), eq(payload.getBytes(StandardCharsets.UTF_8)), anyInt(),
+ anyBoolean());
+ }
+
+ /**
+ * Publish payload to all subscribers on specified topic.
+ *
+ * @param mqttTopic Mqtt topic
+ * @param payload payload
+ * @return true when at least one subscriber found
+ */
+ protected boolean publishMessage(String mqttTopic, String payload) {
+ return publishMessage(mqttTopic, payload.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Publish payload to all subscribers on specified topic.
+ *
+ * @param mqttTopic Mqtt topic
+ * @param payload payload
+ * @return true when at least one subscriber found
+ */
+ protected boolean publishMessage(String mqttTopic, byte[] payload) {
+ final var topicSubscribers = subscriptions.get(mqttTopic);
+
+ if (topicSubscribers != null && !topicSubscribers.isEmpty()) {
+ topicSubscribers.forEach(mqttMessageSubscriber -> mqttMessageSubscriber.processMessage(mqttTopic, payload));
+ return true;
+ }
+ return false;
+ }
+
+ @NonNullByDefault
+ protected static class LatchThingHandler extends HomeAssistantThingHandler {
+ private @Nullable CountDownLatch latch;
+ private @Nullable AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> discoveredComponent;
+
+ public LatchThingHandler(Thing thing, MqttChannelTypeProvider channelTypeProvider,
+ TransformationServiceProvider transformationServiceProvider, int subscribeTimeout,
+ int attributeReceiveTimeout) {
+ super(thing, channelTypeProvider, transformationServiceProvider, subscribeTimeout, attributeReceiveTimeout);
+ }
+
+ public void componentDiscovered(HaID homeAssistantTopicID, AbstractComponent<@NonNull ?> component) {
+ accept(List.of(component));
+ discoveredComponent = component;
+ if (latch != null) {
+ latch.countDown();
+ }
+ }
+
+ public CountDownLatch createWaitForComponentDiscoveredLatch(int count) {
+ final var newLatch = new CountDownLatch(count);
+ latch = newLatch;
+ return newLatch;
+ }
+
+ public @Nullable AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> getDiscoveredComponent() {
+ return discoveredComponent;
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.core.library.types.StringType;
+
+/**
+ * Tests for {@link AlarmControlPanel}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings("ConstantConditions")
+public class AlarmControlPanelTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "alarm_control_panel/0x0000000000000000_alarm_control_panel_zigbee2mqtt";
+
+ @Test
+ public void testAlarmControlPanel() {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"code\": \"12345\", " +
+ " \"command_topic\": \"zigbee2mqtt/alarm/set/state\", " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"BestAlarmEver\", " +
+ " \"model\": \"Heavy duty super duper alarm\", " +
+ " \"name\": \"Alarm\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"alarm\", " +
+ " \"payload_arm_away\": \"ARM_AWAY_\", " +
+ " \"payload_arm_home\": \"ARM_HOME_\", " +
+ " \"payload_arm_night\": \"ARM_NIGHT_\", " +
+ " \"payload_arm_custom_bypass\": \"ARM_CUSTOM_BYPASS_\", " +
+ " \"payload_disarm\": \"DISARM_\", " +
+ " \"state_topic\": \"zigbee2mqtt/alarm/state\" " +
+ "} ");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(4));
+ assertThat(component.getName(), is("alarm"));
+
+ assertChannel(component, AlarmControlPanel.stateChannelID, "zigbee2mqtt/alarm/state", "", "alarm",
+ TextValue.class);
+ assertChannel(component, AlarmControlPanel.switchDisarmChannelID, "", "zigbee2mqtt/alarm/set/state", "alarm",
+ TextValue.class);
+ assertChannel(component, AlarmControlPanel.switchArmAwayChannelID, "", "zigbee2mqtt/alarm/set/state", "alarm",
+ TextValue.class);
+ assertChannel(component, AlarmControlPanel.switchArmHomeChannelID, "", "zigbee2mqtt/alarm/set/state", "alarm",
+ TextValue.class);
+
+ publishMessage("zigbee2mqtt/alarm/state", "armed_home");
+ assertState(component, AlarmControlPanel.stateChannelID, new StringType("armed_home"));
+ publishMessage("zigbee2mqtt/alarm/state", "armed_away");
+ assertState(component, AlarmControlPanel.stateChannelID, new StringType("armed_away"));
+
+ component.getChannel(AlarmControlPanel.switchDisarmChannelID).getState()
+ .publishValue(new StringType("DISARM_"));
+ assertPublished("zigbee2mqtt/alarm/set/state", "DISARM_");
+ component.getChannel(AlarmControlPanel.switchArmAwayChannelID).getState()
+ .publishValue(new StringType("ARM_AWAY_"));
+ assertPublished("zigbee2mqtt/alarm/set/state", "ARM_AWAY_");
+ component.getChannel(AlarmControlPanel.switchArmHomeChannelID).getState()
+ .publishValue(new StringType("ARM_HOME_"));
+ assertPublished("zigbee2mqtt/alarm/set/state", "ARM_HOME_");
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Tests for {@link BinarySensor}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+public class BinarySensorTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "binary_sensor/0x0000000000000000_binary_sensor_zigbee2mqtt";
+
+ @Test
+ public void test() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Sensors inc\", " +
+ " \"model\": \"On Off Sensor\", " +
+ " \"name\": \"OnOffSensor\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"onoffsensor\", " +
+ " \"force_update\": \"true\", " +
+ " \"payload_off\": \"OFF_\", " +
+ " \"payload_on\": \"ON_\", " +
+ " \"state_topic\": \"zigbee2mqtt/sensor/state\", " +
+ " \"unique_id\": \"sn1\", " +
+ " \"value_template\": \"{{ value_json.state }}\" " +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("onoffsensor"));
+ assertThat(component.getGroupUID().getId(), is("sn1"));
+
+ assertChannel(component, BinarySensor.sensorChannelID, "zigbee2mqtt/sensor/state", "", "value",
+ OnOffValue.class);
+
+ publishMessage("zigbee2mqtt/sensor/state", "{ \"state\": \"ON_\" }");
+ assertState(component, BinarySensor.sensorChannelID, OnOffType.ON);
+ publishMessage("zigbee2mqtt/sensor/state", "{ \"state\": \"ON_\" }");
+ assertState(component, BinarySensor.sensorChannelID, OnOffType.ON);
+ publishMessage("zigbee2mqtt/sensor/state", "{ \"state\": \"OFF_\" }");
+ assertState(component, BinarySensor.sensorChannelID, OnOffType.OFF);
+ publishMessage("zigbee2mqtt/sensor/state", "{ \"state\": \"ON_\" }");
+ assertState(component, BinarySensor.sensorChannelID, OnOffType.ON);
+ }
+
+ @Test
+ public void offDelayTest() {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Sensors inc\", " +
+ " \"model\": \"On Off Sensor\", " +
+ " \"name\": \"OnOffSensor\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"onoffsensor\", " +
+ " \"force_update\": \"true\", " +
+ " \"off_delay\": \"1\", " +
+ " \"payload_off\": \"OFF_\", " +
+ " \"payload_on\": \"ON_\", " +
+ " \"state_topic\": \"zigbee2mqtt/sensor/state\", " +
+ " \"unique_id\": \"sn1\", " +
+ " \"value_template\": \"{{ value_json.state }}\" " +
+ "}");
+ // @formatter:on
+
+ publishMessage("zigbee2mqtt/sensor/state", "{ \"state\": \"ON_\" }");
+ assertState(component, BinarySensor.sensorChannelID, OnOffType.ON);
+
+ waitForAssert(() -> assertState(component, BinarySensor.sensorChannelID, OnOffType.OFF), 10000, 200);
+ }
+
+ @Test
+ public void expireAfterTest() {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Sensors inc\", " +
+ " \"model\": \"On Off Sensor\", " +
+ " \"name\": \"OnOffSensor\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"onoffsensor\", " +
+ " \"expire_after\": \"1\", " +
+ " \"force_update\": \"true\", " +
+ " \"payload_off\": \"OFF_\", " +
+ " \"payload_on\": \"ON_\", " +
+ " \"state_topic\": \"zigbee2mqtt/sensor/state\", " +
+ " \"unique_id\": \"sn1\", " +
+ " \"value_template\": \"{{ value_json.state }}\" " +
+ "}");
+ // @formatter:on
+
+ publishMessage("zigbee2mqtt/sensor/state", "{ \"state\": \"OFF_\" }");
+ assertState(component, BinarySensor.sensorChannelID, OnOffType.OFF);
+
+ waitForAssert(() -> assertState(component, BinarySensor.sensorChannelID, UnDefType.UNDEF), 10000, 200);
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.ImageValue;
+import org.openhab.core.library.types.RawType;
+
+/**
+ * Tests for {@link Camera}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+public class CameraTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "camera/0x0000000000000000_camera_zigbee2mqtt";
+
+ @Test
+ public void test() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Cameras inc\", " +
+ " \"model\": \"Camera\", " +
+ " \"name\": \"camera\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"cam1\", " +
+ " \"topic\": \"zigbee2mqtt/cam1/state\"" +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("cam1"));
+
+ assertChannel(component, Camera.cameraChannelID, "zigbee2mqtt/cam1/state", "", "cam1", ImageValue.class);
+
+ var imageBytes = getResourceAsByteArray("component/image.png");
+ publishMessage("zigbee2mqtt/cam1/state", imageBytes);
+ assertState(component, Camera.cameraChannelID, new RawType(imageBytes, "image/png"));
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.NumberValue;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+
+/**
+ * Tests for {@link Climate}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings("ConstantConditions")
+public class ClimateTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "climate/0x847127fffe11dd6a_climate_zigbee2mqtt";
+
+ @Test
+ public void testTS0601Climate() {
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), "{"
+ + " \"action_template\": \"{% set values = {'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'} %}{{ values[value_json.running_state] }}\","
+ + " \"action_topic\": \"zigbee2mqtt/th1\", \"availability\": [ {"
+ + " \"topic\": \"zigbee2mqtt/bridge/state\" } ],"
+ + " \"away_mode_command_topic\": \"zigbee2mqtt/th1/set/away_mode\","
+ + " \"away_mode_state_template\": \"{{ value_json.away_mode }}\","
+ + " \"away_mode_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"current_temperature_template\": \"{{ value_json.local_temperature }}\","
+ + " \"current_temperature_topic\": \"zigbee2mqtt/th1\", \"device\": {"
+ + " \"identifiers\": [ \"zigbee2mqtt_0x847127fffe11dd6a\" ], \"manufacturer\": \"TuYa\","
+ + " \"model\": \"Radiator valve with thermostat (TS0601_thermostat)\","
+ + " \"name\": \"th1\", \"sw_version\": \"Zigbee2MQTT 1.18.2\" },"
+ + " \"hold_command_topic\": \"zigbee2mqtt/th1/set/preset\", \"hold_modes\": ["
+ + " \"schedule\", \"manual\", \"boost\", \"complex\", \"comfort\", \"eco\" ],"
+ + " \"hold_state_template\": \"{{ value_json.preset }}\","
+ + " \"hold_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"json_attributes_topic\": \"zigbee2mqtt/th1\", \"max_temp\": \"35\","
+ + " \"min_temp\": \"5\", \"mode_command_topic\": \"zigbee2mqtt/th1/set/system_mode\","
+ + " \"mode_state_template\": \"{{ value_json.system_mode }}\","
+ + " \"mode_state_topic\": \"zigbee2mqtt/th1\", \"modes\": [ \"heat\","
+ + " \"auto\", \"off\" ], \"name\": \"th1\", \"temp_step\": 0.5,"
+ + " \"temperature_command_topic\": \"zigbee2mqtt/th1/set/current_heating_setpoint\","
+ + " \"temperature_state_template\": \"{{ value_json.current_heating_setpoint }}\","
+ + " \"temperature_state_topic\": \"zigbee2mqtt/th1\", \"temperature_unit\": \"C\","
+ + " \"unique_id\": \"0x847127fffe11dd6a_climate_zigbee2mqtt\"}");
+
+ assertThat(component.channels.size(), is(6));
+ assertThat(component.getName(), is("th1"));
+
+ assertChannel(component, Climate.ACTION_CH_ID, "zigbee2mqtt/th1", "", "th1", TextValue.class);
+ assertChannel(component, Climate.AWAY_MODE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/set/away_mode", "th1",
+ OnOffValue.class);
+ assertChannel(component, Climate.CURRENT_TEMPERATURE_CH_ID, "zigbee2mqtt/th1", "", "th1", NumberValue.class);
+ assertChannel(component, Climate.HOLD_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/set/preset", "th1",
+ TextValue.class);
+ assertChannel(component, Climate.MODE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/set/system_mode", "th1",
+ TextValue.class);
+ assertChannel(component, Climate.TEMPERATURE_CH_ID, "zigbee2mqtt/th1",
+ "zigbee2mqtt/th1/set/current_heating_setpoint", "th1", NumberValue.class);
+
+ publishMessage("zigbee2mqtt/th1",
+ "{\"running_state\": \"idle\", \"away_mode\": \"ON\", "
+ + "\"local_temperature\": \"22.2\", \"preset\": \"schedule\", \"system_mode\": \"heat\", "
+ + "\"current_heating_setpoint\": \"24\"}");
+ assertState(component, Climate.ACTION_CH_ID, new StringType("off"));
+ assertState(component, Climate.AWAY_MODE_CH_ID, OnOffType.ON);
+ assertState(component, Climate.CURRENT_TEMPERATURE_CH_ID, new DecimalType(22.2));
+ assertState(component, Climate.HOLD_CH_ID, new StringType("schedule"));
+ assertState(component, Climate.MODE_CH_ID, new StringType("heat"));
+ assertState(component, Climate.TEMPERATURE_CH_ID, new DecimalType(24));
+
+ component.getChannel(Climate.AWAY_MODE_CH_ID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/th1/set/away_mode", "OFF");
+ component.getChannel(Climate.HOLD_CH_ID).getState().publishValue(new StringType("eco"));
+ assertPublished("zigbee2mqtt/th1/set/preset", "eco");
+ component.getChannel(Climate.MODE_CH_ID).getState().publishValue(new StringType("auto"));
+ assertPublished("zigbee2mqtt/th1/set/system_mode", "auto");
+ component.getChannel(Climate.TEMPERATURE_CH_ID).getState().publishValue(new DecimalType(25));
+ assertPublished("zigbee2mqtt/th1/set/current_heating_setpoint", "25");
+ }
+
+ @Test
+ public void testTS0601ClimateNotSendIfOff() {
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), "{"
+ + " \"action_template\": \"{% set values = {'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'} %}{{ values[value_json.running_state] }}\","
+ + " \"action_topic\": \"zigbee2mqtt/th1\", \"availability\": [ {"
+ + " \"topic\": \"zigbee2mqtt/bridge/state\" } ],"
+ + " \"away_mode_command_topic\": \"zigbee2mqtt/th1/set/away_mode\","
+ + " \"away_mode_state_template\": \"{{ value_json.away_mode }}\","
+ + " \"away_mode_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"current_temperature_template\": \"{{ value_json.local_temperature }}\","
+ + " \"current_temperature_topic\": \"zigbee2mqtt/th1\", \"device\": {"
+ + " \"identifiers\": [ \"zigbee2mqtt_0x847127fffe11dd6a\" ], \"manufacturer\": \"TuYa\","
+ + " \"model\": \"Radiator valve with thermostat (TS0601_thermostat)\","
+ + " \"name\": \"th1\", \"sw_version\": \"Zigbee2MQTT 1.18.2\" },"
+ + " \"hold_command_topic\": \"zigbee2mqtt/th1/set/preset\", \"hold_modes\": ["
+ + " \"schedule\", \"manual\", \"boost\", \"complex\", \"comfort\", \"eco\" ],"
+ + " \"hold_state_template\": \"{{ value_json.preset }}\","
+ + " \"hold_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"json_attributes_topic\": \"zigbee2mqtt/th1\", \"max_temp\": \"35\","
+ + " \"min_temp\": \"5\", \"mode_command_topic\": \"zigbee2mqtt/th1/set/system_mode\","
+ + " \"mode_state_template\": \"{{ value_json.system_mode }}\","
+ + " \"mode_state_topic\": \"zigbee2mqtt/th1\", \"modes\": [ \"heat\","
+ + " \"auto\", \"off\" ], \"name\": \"th1\", \"temp_step\": 0.5,"
+ + " \"temperature_command_topic\": \"zigbee2mqtt/th1/set/current_heating_setpoint\","
+ + " \"temperature_state_template\": \"{{ value_json.current_heating_setpoint }}\","
+ + " \"temperature_state_topic\": \"zigbee2mqtt/th1\", \"temperature_unit\": \"C\","
+ + " \"power_command_topic\": \"zigbee2mqtt/th1/power\","
+ + " \"unique_id\": \"0x847127fffe11dd6a_climate_zigbee2mqtt\", \"send_if_off\": \"false\"}");
+
+ assertThat(component.channels.size(), is(7));
+ assertThat(component.getName(), is("th1"));
+
+ assertChannel(component, Climate.ACTION_CH_ID, "zigbee2mqtt/th1", "", "th1", TextValue.class);
+ assertChannel(component, Climate.AWAY_MODE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/set/away_mode", "th1",
+ OnOffValue.class);
+ assertChannel(component, Climate.CURRENT_TEMPERATURE_CH_ID, "zigbee2mqtt/th1", "", "th1", NumberValue.class);
+ assertChannel(component, Climate.HOLD_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/set/preset", "th1",
+ TextValue.class);
+ assertChannel(component, Climate.MODE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/set/system_mode", "th1",
+ TextValue.class);
+ assertChannel(component, Climate.TEMPERATURE_CH_ID, "zigbee2mqtt/th1",
+ "zigbee2mqtt/th1/set/current_heating_setpoint", "th1", NumberValue.class);
+
+ publishMessage("zigbee2mqtt/th1",
+ "{\"running_state\": \"idle\", \"away_mode\": \"ON\", "
+ + "\"local_temperature\": \"22.2\", \"preset\": \"schedule\", \"system_mode\": \"heat\", "
+ + "\"current_heating_setpoint\": \"24\"}");
+ assertState(component, Climate.ACTION_CH_ID, new StringType("off"));
+ assertState(component, Climate.AWAY_MODE_CH_ID, OnOffType.ON);
+ assertState(component, Climate.CURRENT_TEMPERATURE_CH_ID, new DecimalType(22.2));
+ assertState(component, Climate.HOLD_CH_ID, new StringType("schedule"));
+ assertState(component, Climate.MODE_CH_ID, new StringType("heat"));
+ assertState(component, Climate.TEMPERATURE_CH_ID, new DecimalType(24));
+
+ // Climate is in OFF state
+ component.getChannel(Climate.AWAY_MODE_CH_ID).getState().publishValue(OnOffType.OFF);
+ assertNotPublished("zigbee2mqtt/th1/set/away_mode", "OFF");
+ component.getChannel(Climate.HOLD_CH_ID).getState().publishValue(new StringType("eco"));
+ assertNotPublished("zigbee2mqtt/th1/set/preset", "eco");
+ component.getChannel(Climate.MODE_CH_ID).getState().publishValue(new StringType("auto"));
+ assertNotPublished("zigbee2mqtt/th1/set/system_mode", "auto");
+ component.getChannel(Climate.TEMPERATURE_CH_ID).getState().publishValue(new DecimalType(25));
+ assertNotPublished("zigbee2mqtt/th1/set/current_heating_setpoint", "25");
+ component.getChannel(Climate.POWER_CH_ID).getState().publishValue(OnOffType.ON);
+ assertPublished("zigbee2mqtt/th1/power", "ON");
+
+ // Enabled
+ publishMessage("zigbee2mqtt/th1",
+ "{\"running_state\": \"heat\", \"away_mode\": \"ON\", "
+ + "\"local_temperature\": \"22.2\", \"preset\": \"schedule\", \"system_mode\": \"heat\", "
+ + "\"current_heating_setpoint\": \"24\"}");
+
+ // Climate is in ON state
+ component.getChannel(Climate.AWAY_MODE_CH_ID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/th1/set/away_mode", "OFF");
+ component.getChannel(Climate.HOLD_CH_ID).getState().publishValue(new StringType("eco"));
+ assertPublished("zigbee2mqtt/th1/set/preset", "eco");
+ component.getChannel(Climate.MODE_CH_ID).getState().publishValue(new StringType("auto"));
+ assertPublished("zigbee2mqtt/th1/set/system_mode", "auto");
+ component.getChannel(Climate.TEMPERATURE_CH_ID).getState().publishValue(new DecimalType(25));
+ assertPublished("zigbee2mqtt/th1/set/current_heating_setpoint", "25");
+ }
+
+ @Test
+ public void testClimate() {
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{\"action_template\": \"{{ value_json.action }}\", \"action_topic\": \"zigbee2mqtt/th1\","
+ + " \"aux_command_topic\": \"zigbee2mqtt/th1/aux\","
+ + " \"aux_state_template\": \"{{ value_json.aux }}\", \"aux_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"away_mode_command_topic\": \"zigbee2mqtt/th1/away_mode\","
+ + " \"away_mode_state_template\": \"{{ value_json.away_mode }}\","
+ + " \"away_mode_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"current_temperature_template\": \"{{ value_json.current_temperature }}\","
+ + " \"current_temperature_topic\": \"zigbee2mqtt/th1\","
+ + " \"fan_mode_command_template\": \"fan_mode={{ value }}\","
+ + " \"fan_mode_command_topic\": \"zigbee2mqtt/th1/fan_mode\","
+ + " \"fan_mode_state_template\": \"{{ value_json.fan_mode }}\","
+ + " \"fan_mode_state_topic\": \"zigbee2mqtt/th1\", \"fan_modes\": [ \"p1\","
+ + " \"p2\" ], \"hold_command_template\": \"hold={{ value }}\","
+ + " \"hold_command_topic\": \"zigbee2mqtt/th1/hold\","
+ + " \"hold_state_template\": \"{{ value_json.hold }}\","
+ + " \"hold_state_topic\": \"zigbee2mqtt/th1\", \"hold_modes\": [ \"u1\", \"u2\","
+ + " \"u3\" ], \"json_attributes_template\": \"{{ value_json.attrs }}\","
+ + " \"json_attributes_topic\": \"zigbee2mqtt/th1\","
+ + " \"mode_command_template\": \"mode={{ value }}\","
+ + " \"mode_command_topic\": \"zigbee2mqtt/th1/mode\","
+ + " \"mode_state_template\": \"{{ value_json.mode }}\","
+ + " \"mode_state_topic\": \"zigbee2mqtt/th1\", \"modes\": [ \"B1\", \"B2\""
+ + " ], \"swing_command_template\": \"swing={{ value }}\","
+ + " \"swing_command_topic\": \"zigbee2mqtt/th1/swing\","
+ + " \"swing_state_template\": \"{{ value_json.swing }}\","
+ + " \"swing_state_topic\": \"zigbee2mqtt/th1\", \"swing_modes\": [ \"G1\","
+ + " \"G2\" ], \"temperature_command_template\": \"temperature={{ value }}\","
+ + " \"temperature_command_topic\": \"zigbee2mqtt/th1/temperature\","
+ + " \"temperature_state_template\": \"{{ value_json.temperature }}\","
+ + " \"temperature_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"temperature_high_command_template\": \"temperature_high={{ value }}\","
+ + " \"temperature_high_command_topic\": \"zigbee2mqtt/th1/temperature_high\","
+ + " \"temperature_high_state_template\": \"{{ value_json.temperature_high }}\","
+ + " \"temperature_high_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"temperature_low_command_template\": \"temperature_low={{ value }}\","
+ + " \"temperature_low_command_topic\": \"zigbee2mqtt/th1/temperature_low\","
+ + " \"temperature_low_state_template\": \"{{ value_json.temperature_low }}\","
+ + " \"temperature_low_state_topic\": \"zigbee2mqtt/th1\","
+ + " \"power_command_topic\": \"zigbee2mqtt/th1/power\", \"initial\": \"10\","
+ + " \"max_temp\": \"40\", \"min_temp\": \"0\", \"temperature_unit\": \"F\","
+ + " \"temp_step\": \"1\", \"precision\": \"0.5\", \"send_if_off\": \"false\" }");
+
+ assertThat(component.channels.size(), is(12));
+ assertThat(component.getName(), is("MQTT HVAC"));
+
+ assertChannel(component, Climate.ACTION_CH_ID, "zigbee2mqtt/th1", "", "MQTT HVAC", TextValue.class);
+ assertChannel(component, Climate.AUX_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/aux", "MQTT HVAC",
+ OnOffValue.class);
+ assertChannel(component, Climate.AWAY_MODE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/away_mode", "MQTT HVAC",
+ OnOffValue.class);
+ assertChannel(component, Climate.CURRENT_TEMPERATURE_CH_ID, "zigbee2mqtt/th1", "", "MQTT HVAC",
+ NumberValue.class);
+ assertChannel(component, Climate.FAN_MODE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/fan_mode", "MQTT HVAC",
+ TextValue.class);
+ assertChannel(component, Climate.HOLD_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/hold", "MQTT HVAC",
+ TextValue.class);
+ assertChannel(component, Climate.MODE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/mode", "MQTT HVAC",
+ TextValue.class);
+ assertChannel(component, Climate.SWING_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/swing", "MQTT HVAC",
+ TextValue.class);
+ assertChannel(component, Climate.TEMPERATURE_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/temperature",
+ "MQTT HVAC", NumberValue.class);
+ assertChannel(component, Climate.TEMPERATURE_HIGH_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/temperature_high",
+ "MQTT HVAC", NumberValue.class);
+ assertChannel(component, Climate.TEMPERATURE_LOW_CH_ID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/temperature_low",
+ "MQTT HVAC", NumberValue.class);
+ assertChannel(component, Climate.POWER_CH_ID, "", "zigbee2mqtt/th1/power", "MQTT HVAC", OnOffValue.class);
+
+ publishMessage("zigbee2mqtt/th1",
+ "{ \"action\": \"fan\", \"aux\": \"ON\", \"away_mode\": \"OFF\", "
+ + "\"current_temperature\": \"35.5\", \"fan_mode\": \"p2\", \"hold\": \"u2\", "
+ + "\"mode\": \"B1\", \"swing\": \"G1\", \"temperature\": \"30\", "
+ + "\"temperature_high\": \"37\", \"temperature_low\": \"20\" }");
+
+ assertState(component, Climate.ACTION_CH_ID, new StringType("fan"));
+ assertState(component, Climate.AUX_CH_ID, OnOffType.ON);
+ assertState(component, Climate.AWAY_MODE_CH_ID, OnOffType.OFF);
+ assertState(component, Climate.CURRENT_TEMPERATURE_CH_ID, new DecimalType(35.5));
+ assertState(component, Climate.FAN_MODE_CH_ID, new StringType("p2"));
+ assertState(component, Climate.HOLD_CH_ID, new StringType("u2"));
+ assertState(component, Climate.MODE_CH_ID, new StringType("B1"));
+ assertState(component, Climate.SWING_CH_ID, new StringType("G1"));
+ assertState(component, Climate.TEMPERATURE_CH_ID, new DecimalType(30));
+ assertState(component, Climate.TEMPERATURE_HIGH_CH_ID, new DecimalType(37));
+ assertState(component, Climate.TEMPERATURE_LOW_CH_ID, new DecimalType(20));
+
+ component.getChannel(Climate.AUX_CH_ID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/th1/aux", "OFF");
+ component.getChannel(Climate.AWAY_MODE_CH_ID).getState().publishValue(OnOffType.ON);
+ assertPublished("zigbee2mqtt/th1/away_mode", "ON");
+ component.getChannel(Climate.FAN_MODE_CH_ID).getState().publishValue(new StringType("p1"));
+ assertPublished("zigbee2mqtt/th1/fan_mode", "fan_mode=p1");
+ component.getChannel(Climate.HOLD_CH_ID).getState().publishValue(new StringType("u3"));
+ assertPublished("zigbee2mqtt/th1/hold", "hold=u3");
+ component.getChannel(Climate.MODE_CH_ID).getState().publishValue(new StringType("B2"));
+ assertPublished("zigbee2mqtt/th1/mode", "mode=B2");
+ component.getChannel(Climate.SWING_CH_ID).getState().publishValue(new StringType("G2"));
+ assertPublished("zigbee2mqtt/th1/swing", "swing=G2");
+ component.getChannel(Climate.TEMPERATURE_CH_ID).getState().publishValue(new DecimalType(30.5));
+ assertPublished("zigbee2mqtt/th1/temperature", "temperature=30.5");
+ component.getChannel(Climate.TEMPERATURE_HIGH_CH_ID).getState().publishValue(new DecimalType(39.5));
+ assertPublished("zigbee2mqtt/th1/temperature_high", "temperature_high=39.5");
+ component.getChannel(Climate.TEMPERATURE_LOW_CH_ID).getState().publishValue(new DecimalType(19.5));
+ assertPublished("zigbee2mqtt/th1/temperature_low", "temperature_low=19.5");
+ component.getChannel(Climate.POWER_CH_ID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/th1/power", "OFF");
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.RollershutterValue;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.StopMoveType;
+
+/**
+ * Tests for {@link Cover}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings("ConstantConditions")
+public class CoverTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "cover/0x0000000000000000_cover_zigbee2mqtt";
+
+ @Test
+ public void test() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Covers inc\", " +
+ " \"model\": \"cover v1\", " +
+ " \"name\": \"Cover\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"cover\", " +
+ " \"payload_open\": \"OPEN_\", " +
+ " \"payload_close\": \"CLOSE_\", " +
+ " \"payload_stop\": \"STOP_\", " +
+ " \"state_topic\": \"zigbee2mqtt/cover/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/cover/set/state\" " +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("cover"));
+
+ assertChannel(component, Cover.switchChannelID, "zigbee2mqtt/cover/state", "zigbee2mqtt/cover/set/state",
+ "cover", RollershutterValue.class);
+
+ publishMessage("zigbee2mqtt/cover/state", "100");
+ assertState(component, Cover.switchChannelID, PercentType.HUNDRED);
+ publishMessage("zigbee2mqtt/cover/state", "0");
+ assertState(component, Cover.switchChannelID, PercentType.ZERO);
+
+ component.getChannel(Cover.switchChannelID).getState().publishValue(PercentType.ZERO);
+ assertPublished("zigbee2mqtt/cover/set/state", "OPEN_");
+ component.getChannel(Cover.switchChannelID).getState().publishValue(PercentType.HUNDRED);
+ assertPublished("zigbee2mqtt/cover/set/state", "CLOSE_");
+ component.getChannel(Cover.switchChannelID).getState().publishValue(StopMoveType.STOP);
+ assertPublished("zigbee2mqtt/cover/set/state", "STOP_");
+ component.getChannel(Cover.switchChannelID).getState().publishValue(PercentType.ZERO);
+ assertPublished("zigbee2mqtt/cover/set/state", "OPEN_", 2);
+ component.getChannel(Cover.switchChannelID).getState().publishValue(StopMoveType.STOP);
+ assertPublished("zigbee2mqtt/cover/set/state", "STOP_", 2);
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.core.library.types.OnOffType;
+
+/**
+ * Tests for {@link Fan}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings("ALL")
+public class FanTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "fan/0x0000000000000000_fan_zigbee2mqtt";
+
+ @Test
+ public void test() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Fans inc\", " +
+ " \"model\": \"Fan\", " +
+ " \"name\": \"FanBlower\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"fan\", " +
+ " \"payload_off\": \"OFF_\", " +
+ " \"payload_on\": \"ON_\", " +
+ " \"state_topic\": \"zigbee2mqtt/fan/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/fan/set/state\" " +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("fan"));
+
+ assertChannel(component, Fan.switchChannelID, "zigbee2mqtt/fan/state", "zigbee2mqtt/fan/set/state", "fan",
+ OnOffValue.class);
+
+ publishMessage("zigbee2mqtt/fan/state", "ON_");
+ assertState(component, Fan.switchChannelID, OnOffType.ON);
+ publishMessage("zigbee2mqtt/fan/state", "ON_");
+ assertState(component, Fan.switchChannelID, OnOffType.ON);
+ publishMessage("zigbee2mqtt/fan/state", "OFF_");
+ assertState(component, Fan.switchChannelID, OnOffType.OFF);
+ publishMessage("zigbee2mqtt/fan/state", "ON_");
+ assertState(component, Fan.switchChannelID, OnOffType.ON);
+
+ component.getChannel(Fan.switchChannelID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/fan/set/state", "OFF_");
+ component.getChannel(Fan.switchChannelID).getState().publishValue(OnOffType.ON);
+ assertPublished("zigbee2mqtt/fan/set/state", "ON_");
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsIterableContainingInOrder.contains;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.Arrays;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Connection;
+import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Device;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * @author Jochen Klein - Initial contribution
+ */
+public class HAConfigurationTests {
+
+ private Gson gson = new GsonBuilder().registerTypeAdapterFactory(new ChannelConfigurationTypeAdapterFactory())
+ .create();
+
+ private static String readTestJson(final String name) {
+ StringBuilder result = new StringBuilder();
+
+ try (BufferedReader in = new BufferedReader(
+ new InputStreamReader(HAConfigurationTests.class.getResourceAsStream(name), "UTF-8"))) {
+ String line;
+
+ while ((line = in.readLine()) != null) {
+ result.append(line).append('\n');
+ }
+ return result.toString();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ public void testAbbreviations() {
+ String json = readTestJson("configA.json");
+
+ AbstractChannelConfiguration config = AbstractChannelConfiguration.fromString(json, gson);
+
+ assertThat(config.getName(), is("A"));
+ assertThat(config.getIcon(), is("2"));
+ assertThat(config.getQos(), is(1));
+ assertThat(config.isRetain(), is(true));
+ assertThat(config.getValueTemplate(), is("B"));
+ assertThat(config.getUniqueId(), is("C"));
+ assertThat(config.getAvailabilityTopic(), is("D/E"));
+ assertThat(config.getPayloadAvailable(), is("F"));
+ assertThat(config.getPayloadNotAvailable(), is("G"));
+
+ assertThat(config.getDevice(), is(notNullValue()));
+
+ Device device = config.getDevice();
+ if (device != null) {
+ assertThat(device.getIdentifiers(), contains("H"));
+ assertThat(device.getConnections(), is(notNullValue()));
+ List<@NonNull Connection> connections = device.getConnections();
+ if (connections != null) {
+ assertThat(connections.get(0).getType(), is("I1"));
+ assertThat(connections.get(0).getIdentifier(), is("I2"));
+ }
+ assertThat(device.getName(), is("J"));
+ assertThat(device.getModel(), is("K"));
+ assertThat(device.getSwVersion(), is("L"));
+ assertThat(device.getManufacturer(), is("M"));
+ }
+ }
+
+ @Test
+ public void testTildeSubstritution() {
+ String json = readTestJson("configB.json");
+
+ Switch.ChannelConfiguration config = AbstractChannelConfiguration.fromString(json, gson,
+ Switch.ChannelConfiguration.class);
+
+ assertThat(config.getAvailabilityTopic(), is("D/E"));
+ assertThat(config.state_topic, is("O/D/"));
+ assertThat(config.command_topic, is("P~Q"));
+ assertThat(config.getDevice(), is(notNullValue()));
+
+ Device device = config.getDevice();
+ if (device != null) {
+ assertThat(device.getIdentifiers(), contains("H"));
+ }
+ }
+
+ @Test
+ public void testSampleFanConfig() {
+ String json = readTestJson("configFan.json");
+
+ Fan.ChannelConfiguration config = AbstractChannelConfiguration.fromString(json, gson,
+ Fan.ChannelConfiguration.class);
+ assertThat(config.getName(), is("Bedroom Fan"));
+ }
+
+ @Test
+ public void testDeviceListConfig() {
+ String json = readTestJson("configDeviceList.json");
+
+ Fan.ChannelConfiguration config = AbstractChannelConfiguration.fromString(json, gson,
+ Fan.ChannelConfiguration.class);
+ assertThat(config.getDevice(), is(notNullValue()));
+
+ Device device = config.getDevice();
+ if (device != null) {
+ assertThat(device.getIdentifiers(), is(Arrays.asList("A", "B", "C")));
+ }
+ }
+
+ @Test
+ public void testDeviceSingleStringConfig() {
+ String json = readTestJson("configDeviceSingleString.json");
+
+ Fan.ChannelConfiguration config = AbstractChannelConfiguration.fromString(json, gson,
+ Fan.ChannelConfiguration.class);
+ assertThat(config.getDevice(), is(notNullValue()));
+
+ Device device = config.getDevice();
+ if (device != null) {
+ assertThat(device.getIdentifiers(), is(Arrays.asList("A")));
+ }
+ }
+
+ @Test
+ public void testTS0601ClimateConfig() {
+ String json = readTestJson("configTS0601ClimateThermostat.json");
+ Climate.ChannelConfiguration config = AbstractChannelConfiguration.fromString(json, gson,
+ Climate.ChannelConfiguration.class);
+ assertThat(config.getDevice(), is(notNullValue()));
+ assertThat(config.getDevice().getIdentifiers(), is(notNullValue()));
+ assertThat(config.getDevice().getIdentifiers().get(0), is("zigbee2mqtt_0x847127fffe11dd6a"));
+ assertThat(config.getDevice().getManufacturer(), is("TuYa"));
+ assertThat(config.getDevice().getModel(), is("Radiator valve with thermostat (TS0601_thermostat)"));
+ assertThat(config.getDevice().getName(), is("th1"));
+ assertThat(config.getDevice().getSwVersion(), is("Zigbee2MQTT 1.18.2"));
+
+ assertThat(config.action_template, is(
+ "{% set values = {'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'} %}{{ values[value_json.running_state] }}"));
+ assertThat(config.action_topic, is("zigbee2mqtt/th1"));
+ assertThat(config.away_mode_command_topic, is("zigbee2mqtt/th1/set/away_mode"));
+ assertThat(config.away_mode_state_template, is("{{ value_json.away_mode }}"));
+ assertThat(config.away_mode_state_topic, is("zigbee2mqtt/th1"));
+ assertThat(config.current_temperature_template, is("{{ value_json.local_temperature }}"));
+ assertThat(config.current_temperature_topic, is("zigbee2mqtt/th1"));
+ assertThat(config.hold_command_topic, is("zigbee2mqtt/th1/set/preset"));
+ assertThat(config.hold_modes, is(List.of("schedule", "manual", "boost", "complex", "comfort", "eco")));
+ assertThat(config.hold_state_template, is("{{ value_json.preset }}"));
+ assertThat(config.hold_state_topic, is("zigbee2mqtt/th1"));
+ assertThat(config.json_attributes_topic, is("zigbee2mqtt/th1"));
+ assertThat(config.max_temp, is(35f));
+ assertThat(config.min_temp, is(5f));
+ assertThat(config.mode_command_topic, is("zigbee2mqtt/th1/set/system_mode"));
+ assertThat(config.mode_state_template, is("{{ value_json.system_mode }}"));
+ assertThat(config.mode_state_topic, is("zigbee2mqtt/th1"));
+ assertThat(config.modes, is(List.of("heat", "auto", "off")));
+ assertThat(config.getName(), is("th1"));
+ assertThat(config.temp_step, is(0.5f));
+ assertThat(config.temperature_command_topic, is("zigbee2mqtt/th1/set/current_heating_setpoint"));
+ assertThat(config.temperature_state_template, is("{{ value_json.current_heating_setpoint }}"));
+ assertThat(config.temperature_state_topic, is("zigbee2mqtt/th1"));
+ assertThat(config.temperature_unit, is("C"));
+ assertThat(config.getUniqueId(), is("0x847127fffe11dd6a_climate_zigbee2mqtt"));
+
+ assertThat(config.initial, is(21));
+ assertThat(config.send_if_off, is(true));
+ }
+
+ @Test
+ public void testClimateConfig() {
+ String json = readTestJson("configClimate.json");
+ Climate.ChannelConfiguration config = AbstractChannelConfiguration.fromString(json, gson,
+ Climate.ChannelConfiguration.class);
+ assertThat(config.action_template, is("a"));
+ assertThat(config.action_topic, is("b"));
+ assertThat(config.aux_command_topic, is("c"));
+ assertThat(config.aux_state_template, is("d"));
+ assertThat(config.aux_state_topic, is("e"));
+ assertThat(config.away_mode_command_topic, is("f"));
+ assertThat(config.away_mode_state_template, is("g"));
+ assertThat(config.away_mode_state_topic, is("h"));
+ assertThat(config.current_temperature_template, is("i"));
+ assertThat(config.current_temperature_topic, is("j"));
+ assertThat(config.fan_mode_command_template, is("k"));
+ assertThat(config.fan_mode_command_topic, is("l"));
+ assertThat(config.fan_mode_state_template, is("m"));
+ assertThat(config.fan_mode_state_topic, is("n"));
+ assertThat(config.fan_modes, is(List.of("p1", "p2")));
+ assertThat(config.hold_command_template, is("q"));
+ assertThat(config.hold_command_topic, is("r"));
+ assertThat(config.hold_state_template, is("s"));
+ assertThat(config.hold_state_topic, is("t"));
+ assertThat(config.hold_modes, is(List.of("u1", "u2", "u3")));
+ assertThat(config.json_attributes_template, is("v"));
+ assertThat(config.json_attributes_topic, is("w"));
+ assertThat(config.mode_command_template, is("x"));
+ assertThat(config.mode_command_topic, is("y"));
+ assertThat(config.mode_state_template, is("z"));
+ assertThat(config.mode_state_topic, is("A"));
+ assertThat(config.modes, is(List.of("B1", "B2")));
+ assertThat(config.swing_command_template, is("C"));
+ assertThat(config.swing_command_topic, is("D"));
+ assertThat(config.swing_state_template, is("E"));
+ assertThat(config.swing_state_topic, is("F"));
+ assertThat(config.swing_modes, is(List.of("G1")));
+ assertThat(config.temperature_command_template, is("H"));
+ assertThat(config.temperature_command_topic, is("I"));
+ assertThat(config.temperature_state_template, is("J"));
+ assertThat(config.temperature_state_topic, is("K"));
+ assertThat(config.temperature_high_command_template, is("L"));
+ assertThat(config.temperature_high_command_topic, is("N"));
+ assertThat(config.temperature_high_state_template, is("O"));
+ assertThat(config.temperature_high_state_topic, is("P"));
+ assertThat(config.temperature_low_command_template, is("Q"));
+ assertThat(config.temperature_low_command_topic, is("R"));
+ assertThat(config.temperature_low_state_template, is("S"));
+ assertThat(config.temperature_low_state_topic, is("T"));
+ assertThat(config.power_command_topic, is("U"));
+ assertThat(config.initial, is(10));
+ assertThat(config.max_temp, is(40f));
+ assertThat(config.min_temp, is(0f));
+ assertThat(config.temperature_unit, is("F"));
+ assertThat(config.temp_step, is(1f));
+ assertThat(config.precision, is(0.5f));
+ assertThat(config.send_if_off, is(false));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.ColorValue;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+
+/**
+ * Tests for {@link Light}
+ * The current {@link Light} is non-compliant with the Specification and must be rewritten from scratch.
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+public class LightTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "light/0x0000000000000000_light_zigbee2mqtt";
+
+ @Test
+ public void test() throws InterruptedException {
+ // @formatter:off
+ var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Lights inc\", " +
+ " \"model\": \"light v1\", " +
+ " \"name\": \"Light\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"light\", " +
+ " \"state_topic\": \"zigbee2mqtt/light/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+ " \"state_value_template\": \"{{ value_json.power }}\", " +
+ " \"payload_on\": \"ON_\", " +
+ " \"payload_off\": \"OFF_\", " +
+ " \"rgb_state_topic\": \"zigbee2mqtt/light/rgb\", " +
+ " \"rgb_command_topic\": \"zigbee2mqtt/light/set/rgb\", " +
+ " \"rgb_value_template\": \"{{ value_json.rgb }}\", " +
+ " \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
+ " \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
+ " \"brightness_value_template\": \"{{ value_json.br }}\" " +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("light"));
+
+ assertChannel(component, Light.colorChannelID, "zigbee2mqtt/light/rgb", "zigbee2mqtt/light/set/rgb", "light",
+ ColorValue.class);
+
+ assertChannel(component.switchChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "light",
+ ColorValue.class);
+ assertChannel(component.brightnessChannel, "zigbee2mqtt/light/brightness", "zigbee2mqtt/light/set/brightness",
+ "light", ColorValue.class);
+
+ publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"255,255,255\"}");
+ assertState(component, Light.colorChannelID, HSBType.fromRGB(255, 255, 255));
+ publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"10,20,30\"}");
+ assertState(component, Light.colorChannelID, HSBType.fromRGB(10, 20, 30));
+
+ component.switchChannel.getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/light/set/state", "0,0,0");
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.Rule;
+import org.junit.jupiter.api.Test;
+import org.junit.rules.ExpectedException;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.core.library.types.OnOffType;
+
+/**
+ * Tests for {@link Lock}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings("ALL")
+public class LockTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "lock/0x0000000000000000_lock_zigbee2mqtt";
+
+ @Rule
+ public ExpectedException exceptionGrabber = ExpectedException.none();
+
+ @Test
+ public void test() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Locks inc\", " +
+ " \"model\": \"Lock\", " +
+ " \"name\": \"LockBlower\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"lock\", " +
+ " \"payload_unlock\": \"UNLOCK_\", " +
+ " \"payload_lock\": \"LOCK_\", " +
+ " \"state_topic\": \"zigbee2mqtt/lock/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/lock/set/state\" " +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("lock"));
+
+ assertChannel(component, Lock.switchChannelID, "zigbee2mqtt/lock/state", "zigbee2mqtt/lock/set/state", "lock",
+ OnOffValue.class);
+
+ publishMessage("zigbee2mqtt/lock/state", "LOCK_");
+ assertState(component, Lock.switchChannelID, OnOffType.ON);
+ publishMessage("zigbee2mqtt/lock/state", "LOCK_");
+ assertState(component, Lock.switchChannelID, OnOffType.ON);
+ publishMessage("zigbee2mqtt/lock/state", "UNLOCK_");
+ assertState(component, Lock.switchChannelID, OnOffType.OFF);
+ publishMessage("zigbee2mqtt/lock/state", "LOCK_");
+ assertState(component, Lock.switchChannelID, OnOffType.ON);
+
+ component.getChannel(Lock.switchChannelID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK_");
+ component.getChannel(Lock.switchChannelID).getState().publishValue(OnOffType.ON);
+ assertPublished("zigbee2mqtt/lock/set/state", "LOCK_");
+ }
+
+ @Test
+ public void forceOptimisticIsNotSupported() {
+ exceptionGrabber.expect(UnsupportedOperationException.class);
+
+ // @formatter:off
+ publishMessage(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Locks inc\", " +
+ " \"model\": \"Lock\", " +
+ " \"name\": \"LockBlower\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"lock\", " +
+ " \"payload_unlock\": \"UNLOCK_\", " +
+ " \"payload_lock\": \"LOCK_\", " +
+ " \"optimistic\": \"true\", " +
+ " \"state_topic\": \"zigbee2mqtt/lock/state\", " +
+ " \"command_topic\": \"zigbee2mqtt/lock/set/state\" " +
+ "}");
+ // @formatter:on
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.NumberValue;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * Tests for {@link Sensor}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings("ConstantConditions")
+public class SensorTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "sensor/0x0000000000000000_sensor_zigbee2mqtt";
+
+ @Test
+ public void test() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "{ " +
+ " \"availability\": [ " +
+ " { " +
+ " \"topic\": \"zigbee2mqtt/bridge/state\" " +
+ " } " +
+ " ], " +
+ " \"device\": { " +
+ " \"identifiers\": [ " +
+ " \"zigbee2mqtt_0x0000000000000000\" " +
+ " ], " +
+ " \"manufacturer\": \"Sensors inc\", " +
+ " \"model\": \"Sensor\", " +
+ " \"name\": \"Sensor\", " +
+ " \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
+ " }, " +
+ " \"name\": \"sensor1\", " +
+ " \"expire_after\": \"1\", " +
+ " \"force_update\": \"true\", " +
+ " \"unit_of_measurement\": \"W\", " +
+ " \"state_topic\": \"zigbee2mqtt/sensor/state\", " +
+ " \"unique_id\": \"sn1\" " +
+ "}");
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("sensor1"));
+ assertThat(component.getGroupUID().getId(), is("sn1"));
+
+ assertChannel(component, Sensor.sensorChannelID, "zigbee2mqtt/sensor/state", "", "sensor1", NumberValue.class);
+
+ publishMessage("zigbee2mqtt/sensor/state", "10");
+ assertState(component, Sensor.sensorChannelID, DecimalType.valueOf("10"));
+ publishMessage("zigbee2mqtt/sensor/state", "20");
+ assertState(component, Sensor.sensorChannelID, DecimalType.valueOf("20"));
+ assertThat(component.getChannel(Sensor.sensorChannelID).getState().getCache().createStateDescription(true)
+ .build().getPattern(), is("%s W"));
+
+ waitForAssert(() -> assertState(component, Sensor.sensorChannelID, UnDefType.UNDEF), 10000, 200);
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.junit.jupiter.api.Test;
+import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.core.library.types.OnOffType;
+
+/**
+ * Tests for {@link Switch}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings("ConstantConditions")
+public class SwitchTests extends AbstractComponentTests {
+ public static final String CONFIG_TOPIC = "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt";
+
+ @Test
+ public void testSwitchWithStateAndCommand() {
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "" + "{\n" + " \"availability\": [\n" + " {\n" + " \"topic\": \"zigbee2mqtt/bridge/state\"\n"
+ + " }\n" + " ],\n" + " \"command_topic\": \"zigbee2mqtt/th1/set/auto_lock\",\n"
+ + " \"device\": {\n" + " \"identifiers\": [\n"
+ + " \"zigbee2mqtt_0x847127fffe11dd6a\"\n" + " ],\n"
+ + " \"manufacturer\": \"TuYa\",\n"
+ + " \"model\": \"Radiator valve with thermostat (TS0601_thermostat)\",\n"
+ + " \"name\": \"th1\",\n" + " \"sw_version\": \"Zigbee2MQTT 1.18.2\"\n" + " },\n"
+ + " \"json_attributes_topic\": \"zigbee2mqtt/th1\",\n" + " \"name\": \"th1 auto lock\",\n"
+ + " \"payload_off\": \"MANUAL\",\n" + " \"payload_on\": \"AUTO\",\n"
+ + " \"state_off\": \"MANUAL\",\n" + " \"state_on\": \"AUTO\",\n"
+ + " \"state_topic\": \"zigbee2mqtt/th1\",\n"
+ + " \"unique_id\": \"0x847127fffe11dd6a_auto_lock_zigbee2mqtt\",\n"
+ + " \"value_template\": \"{{ value_json.auto_lock }}\"\n" + "}");
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("th1 auto lock"));
+
+ assertChannel(component, Switch.switchChannelID, "zigbee2mqtt/th1", "zigbee2mqtt/th1/set/auto_lock", "state",
+ OnOffValue.class);
+
+ publishMessage("zigbee2mqtt/th1", "{\"auto_lock\": \"MANUAL\"}");
+ assertState(component, Switch.switchChannelID, OnOffType.OFF);
+ publishMessage("zigbee2mqtt/th1", "{\"auto_lock\": \"AUTO\"}");
+ assertState(component, Switch.switchChannelID, OnOffType.ON);
+
+ component.getChannel(Switch.switchChannelID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/th1/set/auto_lock", "MANUAL");
+ component.getChannel(Switch.switchChannelID).getState().publishValue(OnOffType.ON);
+ assertPublished("zigbee2mqtt/th1/set/auto_lock", "AUTO");
+ }
+
+ @Test
+ public void testSwitchWithState() {
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "" + "{\n" + " \"availability\": [\n" + " {\n" + " \"topic\": \"zigbee2mqtt/bridge/state\"\n"
+ + " }\n" + " ],\n" + " \"device\": {\n" + " \"identifiers\": [\n"
+ + " \"zigbee2mqtt_0x847127fffe11dd6a\"\n" + " ],\n"
+ + " \"manufacturer\": \"TuYa\",\n"
+ + " \"model\": \"Radiator valve with thermostat (TS0601_thermostat)\",\n"
+ + " \"name\": \"th1\",\n" + " \"sw_version\": \"Zigbee2MQTT 1.18.2\"\n" + " },\n"
+ + " \"json_attributes_topic\": \"zigbee2mqtt/th1\",\n" + " \"name\": \"th1 auto lock\",\n"
+ + " \"state_off\": \"MANUAL\",\n" + " \"state_on\": \"AUTO\",\n"
+ + " \"state_topic\": \"zigbee2mqtt/th1\",\n"
+ + " \"unique_id\": \"0x847127fffe11dd6a_auto_lock_zigbee2mqtt\",\n"
+ + " \"value_template\": \"{{ value_json.auto_lock }}\"\n" + "}");
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("th1 auto lock"));
+
+ assertChannel(component, Switch.switchChannelID, "zigbee2mqtt/th1", "", "state", OnOffValue.class);
+
+ publishMessage("zigbee2mqtt/th1", "{\"auto_lock\": \"MANUAL\"}");
+ assertState(component, Switch.switchChannelID, OnOffType.OFF);
+ publishMessage("zigbee2mqtt/th1", "{\"auto_lock\": \"AUTO\"}");
+ assertState(component, Switch.switchChannelID, OnOffType.ON);
+ }
+
+ @Test
+ public void testSwitchWithCommand() {
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ "" + "{\n" + " \"availability\": [\n" + " {\n" + " \"topic\": \"zigbee2mqtt/bridge/state\"\n"
+ + " }\n" + " ],\n" + " \"command_topic\": \"zigbee2mqtt/th1/set/auto_lock\",\n"
+ + " \"device\": {\n" + " \"identifiers\": [\n"
+ + " \"zigbee2mqtt_0x847127fffe11dd6a\"\n" + " ],\n"
+ + " \"manufacturer\": \"TuYa\",\n"
+ + " \"model\": \"Radiator valve with thermostat (TS0601_thermostat)\",\n"
+ + " \"name\": \"th1\",\n" + " \"sw_version\": \"Zigbee2MQTT 1.18.2\"\n" + " },\n"
+ + " \"json_attributes_topic\": \"zigbee2mqtt/th1\",\n" + " \"name\": \"th1 auto lock\",\n"
+ + " \"payload_off\": \"MANUAL\",\n" + " \"payload_on\": \"AUTO\",\n"
+ + " \"unique_id\": \"0x847127fffe11dd6a_auto_lock_zigbee2mqtt\",\n"
+ + " \"value_template\": \"{{ value_json.auto_lock }}\"\n" + "}");
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("th1 auto lock"));
+
+ assertChannel(component, Switch.switchChannelID, "", "zigbee2mqtt/th1/set/auto_lock", "state",
+ OnOffValue.class);
+
+ component.getChannel(Switch.switchChannelID).getState().publishValue(OnOffType.OFF);
+ assertPublished("zigbee2mqtt/th1/set/auto_lock", "MANUAL");
+ component.getChannel(Switch.switchChannelID).getState().publishValue(OnOffType.ON);
+ assertPublished("zigbee2mqtt/th1/set/auto_lock", "AUTO");
+ }
+
+ protected Set<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.discovery;
+
+import static org.hamcrest.CoreMatchers.hasItems;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
+import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTests;
+import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
+import org.openhab.core.config.discovery.DiscoveryListener;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+
+/**
+ * Tests for {@link HomeAssistantDiscovery}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings({ "ConstantConditions", "unchecked" })
+@ExtendWith(MockitoExtension.class)
+public class HomeAssistantDiscoveryTests extends AbstractHomeAssistantTests {
+ private HomeAssistantDiscovery discovery;
+
+ @BeforeEach
+ public void beforeEach() {
+ discovery = new TestHomeAssistantDiscovery(channelTypeProvider);
+ }
+
+ @Test
+ public void testOneThingDiscovery() throws Exception {
+ var discoveryListener = new LatchDiscoveryListener();
+ var latch = discoveryListener.createWaitForThingsDiscoveredLatch(1);
+
+ // When discover one thing with two channels
+ discovery.addDiscoveryListener(discoveryListener);
+ discovery.receivedMessage(HA_UID, bridgeConnection,
+ "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
+ getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
+ discovery.receivedMessage(HA_UID, bridgeConnection,
+ "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
+ getResourceAsByteArray("component/configTS0601AutoLock.json"));
+
+ // Then one thing found
+ assert latch.await(3, TimeUnit.SECONDS);
+ var discoveryResults = discoveryListener.getDiscoveryResults();
+ assertThat(discoveryResults.size(), is(1));
+ var result = discoveryResults.get(0);
+ assertThat(result.getBridgeUID(), is(HA_UID));
+ assertThat(result.getProperties().get(Thing.PROPERTY_MODEL_ID),
+ is("Radiator valve with thermostat (TS0601_thermostat)"));
+ assertThat(result.getProperties().get(Thing.PROPERTY_VENDOR), is("TuYa"));
+ assertThat(result.getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION), is("Zigbee2MQTT 1.18.2"));
+ assertThat(result.getProperties().get(HandlerConfiguration.PROPERTY_BASETOPIC), is("homeassistant"));
+ assertThat((List<String>) result.getProperties().get(HandlerConfiguration.PROPERTY_TOPICS), hasItems(
+ "climate/0x847127fffe11dd6a_climate_zigbee2mqtt", "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt"));
+ }
+
+ private static class TestHomeAssistantDiscovery extends HomeAssistantDiscovery {
+ public TestHomeAssistantDiscovery(MqttChannelTypeProvider typeProvider) {
+ this.typeProvider = typeProvider;
+ }
+ }
+
+ @NonNullByDefault
+ private static class LatchDiscoveryListener implements DiscoveryListener {
+ private final CopyOnWriteArrayList<DiscoveryResult> discoveryResults = new CopyOnWriteArrayList<>();
+ private @Nullable CountDownLatch latch;
+
+ public void thingDiscovered(DiscoveryService source, DiscoveryResult result) {
+ discoveryResults.add(result);
+ if (latch != null) {
+ latch.countDown();
+ }
+ }
+
+ public void thingRemoved(DiscoveryService source, ThingUID thingUID) {
+ }
+
+ public @Nullable Collection<ThingUID> removeOlderResults(DiscoveryService source, long timestamp,
+ @Nullable Collection<ThingTypeUID> thingTypeUIDs, @Nullable ThingUID bridgeUID) {
+ return Collections.emptyList();
+ }
+
+ public CopyOnWriteArrayList<DiscoveryResult> getDiscoveryResults() {
+ return discoveryResults;
+ }
+
+ public CountDownLatch createWaitForThingsDiscoveredLatch(int count) {
+ final var newLatch = new CountDownLatch(count);
+ latch = newLatch;
+ return newLatch;
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 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.handler;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.hamcrest.CoreMatchers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTests;
+import org.openhab.binding.mqtt.homeassistant.internal.HaID;
+import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.component.Climate;
+import org.openhab.binding.mqtt.homeassistant.internal.component.Switch;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+
+/**
+ * Tests for {@link HomeAssistantThingHandler}
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@SuppressWarnings({ "ConstantConditions" })
+@ExtendWith(MockitoExtension.class)
+public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
+ private final static int SUBSCRIBE_TIMEOUT = 10000;
+ private final static int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
+
+ private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
+ "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
+
+ "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
+
+ "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
+ "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
+
+ private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
+ .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
+
+ private @Mock ThingHandlerCallback callback;
+ private HomeAssistantThingHandler thingHandler;
+
+ @BeforeEach
+ public void setup() {
+ final var config = haThing.getConfiguration();
+
+ config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
+ config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
+
+ when(callback.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
+
+ thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, transformationServiceProvider,
+ SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
+ thingHandler.setConnection(bridgeConnection);
+ thingHandler.setCallback(callback);
+ thingHandler = spy(thingHandler);
+ }
+
+ @Test
+ public void testInitialize() {
+ // When initialize
+ thingHandler.initialize();
+
+ verify(callback).statusUpdated(eq(haThing), any());
+ // Expect a call to the bridge status changed, the start, the propertiesChanged method
+ verify(thingHandler).bridgeStatusChanged(any());
+ verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
+
+ // Expect subscription on each topic from config
+ MQTT_TOPICS.forEach(t -> {
+ verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
+ });
+
+ verify(thingHandler, never()).componentDiscovered(any(), any());
+ assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
+ // Components discovered after messages in corresponding topics
+ var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config";
+ thingHandler.discoverComponents.processMessage(configTopic,
+ getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
+ verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Climate.class));
+
+ thingHandler.delayedProcessing.forceProcessNow();
+ assertThat(haThing.getChannels().size(), CoreMatchers.is(6));
+ verify(channelTypeProvider, times(6)).setChannelType(any(), any());
+ verify(channelTypeProvider, times(1)).setChannelGroupType(any(), any());
+
+ configTopic = "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config";
+ thingHandler.discoverComponents.processMessage(configTopic,
+ getResourceAsByteArray("component/configTS0601AutoLock.json"));
+ verify(thingHandler, times(2)).componentDiscovered(any(), any());
+ verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Switch.class));
+
+ thingHandler.delayedProcessing.forceProcessNow();
+ assertThat(haThing.getChannels().size(), CoreMatchers.is(7));
+ verify(channelTypeProvider, times(7)).setChannelType(any(), any());
+ verify(channelTypeProvider, times(2)).setChannelGroupType(any(), any());
+ }
+
+ @Test
+ public void testDispose() {
+ thingHandler.initialize();
+
+ // Expect subscription on each topic from config
+ CONFIG_TOPICS.forEach(t -> {
+ var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
+ verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
+ });
+ thingHandler.discoverComponents.processMessage(
+ "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
+ getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
+ thingHandler.discoverComponents.processMessage(
+ "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
+ getResourceAsByteArray("component/configTS0601AutoLock.json"));
+ thingHandler.delayedProcessing.forceProcessNow();
+ assertThat(haThing.getChannels().size(), CoreMatchers.is(7));
+ verify(channelTypeProvider, times(7)).setChannelType(any(), any());
+
+ // When dispose
+ thingHandler.dispose();
+
+ // Expect unsubscription on each topic from config
+ MQTT_TOPICS.forEach(t -> {
+ verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
+ });
+
+ // Expect channel types removed, 6 for climate and 1 for switch
+ verify(channelTypeProvider, times(7)).removeChannelType(any());
+ // Expect channel group types removed, 1 for each component
+ verify(channelTypeProvider, times(2)).removeChannelGroupType(any());
+ }
+}
--- /dev/null
+{
+ "name": "A",
+ "icon": "2",
+ "qos": 1,
+ "retain": true,
+ "val_tpl": "B",
+ "uniq_id": "C",
+ "avty_t": "~E",
+ "pl_avail": "F",
+ "pl_not_avail": "G",
+ "device": {
+ "ids": [
+ "H"
+ ],
+ "cns": [
+ [
+ "I1",
+ "I2"
+ ]
+ ],
+ "name": "J",
+ "mdl": "K",
+ "sw": "L",
+ "mf": "M"
+ },
+ "~": "D/"
+}
--- /dev/null
+{
+ "name": "A",
+ "icon": "2",
+ "qos": 1,
+ "retain": true,
+ "val_tpl": "B",
+ "uniq_id": "C",
+ "avty_t": "~E",
+ "pl_avail": "F",
+ "pl_not_avail": "G",
+ "optimistic": true,
+ "state_topic": "O/~",
+ "command_topic": "P~Q",
+ "device": {
+ "ids": "H",
+ "cns": [
+ [
+ "I1",
+ "I2"
+ ]
+ ],
+ "name": "J",
+ "mdl": "K",
+ "sw": "L",
+ "mf": "M"
+ },
+ "~": "D/"
+}
--- /dev/null
+{
+ "action_template": "a",
+ "action_topic": "b",
+ "aux_command_topic": "c",
+ "aux_state_template": "d",
+ "aux_state_topic": "e",
+ "away_mode_command_topic": "f",
+ "away_mode_state_template": "g",
+ "away_mode_state_topic": "h",
+ "current_temperature_template": "i",
+ "current_temperature_topic": "j",
+ "fan_mode_command_template": "k",
+ "fan_mode_command_topic": "l",
+ "fan_mode_state_template": "m",
+ "fan_mode_state_topic": "n",
+ "fan_modes": [
+ "p1",
+ "p2"
+ ],
+ "hold_command_template": "q",
+ "hold_command_topic": "r",
+ "hold_state_template": "s",
+ "hold_state_topic": "t",
+ "hold_modes": [
+ "u1",
+ "u2",
+ "u3"
+ ],
+ "json_attributes_template": "v",
+ "json_attributes_topic": "w",
+ "mode_command_template": "x",
+ "mode_command_topic": "y",
+ "mode_state_template": "z",
+ "mode_state_topic": "A",
+ "modes": [
+ "B1",
+ "B2"
+ ],
+ "swing_command_template": "C",
+ "swing_command_topic": "D",
+ "swing_state_template": "E",
+ "swing_state_topic": "F",
+ "swing_modes": [
+ "G1"
+ ],
+ "temperature_command_template": "H",
+ "temperature_command_topic": "I",
+ "temperature_state_template": "J",
+ "temperature_state_topic": "K",
+ "temperature_high_command_template": "L",
+ "temperature_high_command_topic": "N",
+ "temperature_high_state_template": "O",
+ "temperature_high_state_topic": "P",
+ "temperature_low_command_template": "Q",
+ "temperature_low_command_topic": "R",
+ "temperature_low_state_template": "S",
+ "temperature_low_state_topic": "T",
+ "power_command_topic": "U",
+ "initial": "10",
+ "max_temp": "40",
+ "min_temp": "0",
+ "temperature_unit": "F",
+ "temp_step": "1",
+ "precision": "0.5",
+ "send_if_off": "false"
+}
\ No newline at end of file
--- /dev/null
+{
+ "device": {
+ "ids": [
+ "A",
+ "B",
+ "C"
+ ]
+ }
+}
--- /dev/null
+{
+ "device": {
+ "ids": "A"
+ }
+}
--- /dev/null
+{
+ "name": "Bedroom Fan",
+ "state_topic": "bedroom_fan/on/state",
+ "command_topic": "bedroom_fan/on/set",
+ "oscillation_state_topic": "bedroom_fan/oscillation/state",
+ "oscillation_command_topic": "bedroom_fan/oscillation/set",
+ "speed_state_topic": "bedroom_fan/speed/state",
+ "speed_command_topic": "bedroom_fan/speed/set",
+ "qos": 0,
+ "payload_on": "true",
+ "payload_off": "false",
+ "payload_oscillation_on": "true",
+ "payload_oscillation_off": "false",
+ "payload_low_speed": "low",
+ "payload_medium_speed": "medium",
+ "payload_high_speed": "high",
+ "speeds": [
+ "low",
+ "medium",
+ "high"
+ ]
+}
--- /dev/null
+{
+ "availability": [
+ {
+ "topic": "zigbee2mqtt/bridge/state"
+ }
+ ],
+ "command_topic": "zigbee2mqtt/th1/set/auto_lock",
+ "device": {
+ "identifiers": [
+ "zigbee2mqtt_0x847127fffe11dd6a"
+ ],
+ "manufacturer": "TuYa",
+ "model": "Radiator valve with thermostat (TS0601_thermostat)",
+ "name": "th1",
+ "sw_version": "Zigbee2MQTT 1.18.2"
+ },
+ "json_attributes_topic": "zigbee2mqtt/th1",
+ "name": "th1 auto lock",
+ "payload_off": "MANUAL",
+ "payload_on": "AUTO",
+ "state_off": "MANUAL",
+ "state_on": "AUTO",
+ "state_topic": "zigbee2mqtt/th1",
+ "unique_id": "0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
+ "value_template": "{{ value_json.auto_lock }}"
+}
\ No newline at end of file
--- /dev/null
+{
+ "action_template": "{% set values = {'idle':'off','heat':'heating','cool':'cooling','fan only':'fan'} %}{{ values[value_json.running_state] }}",
+ "action_topic": "zigbee2mqtt/th1",
+ "availability": [
+ {
+ "topic": "zigbee2mqtt/bridge/state"
+ }
+ ],
+ "away_mode_command_topic": "zigbee2mqtt/th1/set/away_mode",
+ "away_mode_state_template": "{{ value_json.away_mode }}",
+ "away_mode_state_topic": "zigbee2mqtt/th1",
+ "current_temperature_template": "{{ value_json.local_temperature }}",
+ "current_temperature_topic": "zigbee2mqtt/th1",
+ "device": {
+ "identifiers": [
+ "zigbee2mqtt_0x847127fffe11dd6a"
+ ],
+ "manufacturer": "TuYa",
+ "model": "Radiator valve with thermostat (TS0601_thermostat)",
+ "name": "th1",
+ "sw_version": "Zigbee2MQTT 1.18.2"
+ },
+ "hold_command_topic": "zigbee2mqtt/th1/set/preset",
+ "hold_modes": [
+ "schedule",
+ "manual",
+ "boost",
+ "complex",
+ "comfort",
+ "eco"
+ ],
+ "hold_state_template": "{{ value_json.preset }}",
+ "hold_state_topic": "zigbee2mqtt/th1",
+ "json_attributes_topic": "zigbee2mqtt/th1",
+ "max_temp": "35",
+ "min_temp": "5",
+ "mode_command_topic": "zigbee2mqtt/th1/set/system_mode",
+ "mode_state_template": "{{ value_json.system_mode }}",
+ "mode_state_topic": "zigbee2mqtt/th1",
+ "modes": [
+ "heat",
+ "auto",
+ "off"
+ ],
+ "name": "th1",
+ "temp_step": 0.5,
+ "temperature_command_topic": "zigbee2mqtt/th1/set/current_heating_setpoint",
+ "temperature_state_template": "{{ value_json.current_heating_setpoint }}",
+ "temperature_state_topic": "zigbee2mqtt/th1",
+ "temperature_unit": "C",
+ "unique_id": "0x847127fffe11dd6a_climate_zigbee2mqtt"
+}
\ No newline at end of file
+++ /dev/null
-{
- "name": "A",
- "icon": "2",
- "qos": 1,
- "retain": true,
- "val_tpl": "B",
- "uniq_id": "C",
- "avty_t": "~E",
- "pl_avail": "F",
- "pl_not_avail": "G",
- "device": {
- "ids": [
- "H"
- ],
- "cns": [
- [
- "I1",
- "I2"
- ]
- ],
- "name": "J",
- "mdl": "K",
- "sw": "L",
- "mf": "M"
- },
- "~": "D/"
-}
+++ /dev/null
-{
- "name": "A",
- "icon": "2",
- "qos": 1,
- "retain": true,
- "val_tpl": "B",
- "uniq_id": "C",
- "avty_t": "~E",
- "pl_avail": "F",
- "pl_not_avail": "G",
- "optimistic": true,
- "state_topic": "O/~",
- "command_topic": "P~Q",
- "device": {
- "ids": "H",
- "cns": [
- [
- "I1",
- "I2"
- ]
- ],
- "name": "J",
- "mdl": "K",
- "sw": "L",
- "mf": "M"
- },
- "~": "D/"
-}
+++ /dev/null
-{
- "device": {
- "ids": [
- "A",
- "B",
- "C"
- ]
- }
-}
+++ /dev/null
-{
- "device": {
- "ids": "A"
- }
-}
+++ /dev/null
-{
- "name": "Bedroom Fan",
- "state_topic": "bedroom_fan/on/state",
- "command_topic": "bedroom_fan/on/set",
- "oscillation_state_topic": "bedroom_fan/oscillation/state",
- "oscillation_command_topic": "bedroom_fan/oscillation/set",
- "speed_state_topic": "bedroom_fan/speed/state",
- "speed_command_topic": "bedroom_fan/speed/set",
- "qos": 0,
- "payload_on": "true",
- "payload_off": "false",
- "payload_oscillation_on": "true",
- "payload_oscillation_off": "false",
- "payload_low_speed": "low",
- "payload_medium_speed": "medium",
- "payload_high_speed": "high",
- "speeds": [
- "low",
- "medium",
- "high"
- ]
-}