]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt-homeassistant] climate.mqtt support (#10690)
authorantroids <36043354+antroids@users.noreply.github.com>
Sun, 15 Aug 2021 09:48:26 +0000 (12:48 +0300)
committerGitHub <noreply@github.com>
Sun, 15 Aug 2021 09:48:26 +0000 (11:48 +0200)
* MQTT.Homeassistant Climate support

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant synthetic config test added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant refactoring

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant discovery test added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant thing handler test added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant switch test added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant Climate test added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant author header added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant copyright header added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant test fixed

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant test fixed

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant test infrastructure updated. Added tests with mqtt publishing and commands posting.

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant fixed Climate#send_if_off handling

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant do not filter the power command

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant climate unit test added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* Update bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java

Redundant annotation removed

Co-authored-by: Fabian Wolter <github@fabian-wolter.de>
* MQTT.Homeassistant Redundant @Nullable annotations removed

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant Unit tests added for all components

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant Unit tests stability fix

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant @NonNullByDefault removed from Device, config.dto package created

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant Climate author added

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant Device.sw_version renamed

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
* MQTT.Homeassistant tests wait timeout increased to 10s

Signed-off-by: Anton Kharuzhy <antroids@gmail.com>
Co-authored-by: antroids <antroids@gmail.com>
Co-authored-by: Fabian Wolter <github@fabian-wolter.de>
77 files changed:
bundles/org.openhab.binding.mqtt.homeassistant/pom.xml
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractComponent.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/BaseChannelConfiguration.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/CChannel.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/CFactory.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ChannelConfigurationTypeAdapterFactory.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentAlarmControlPanel.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentBinarySensor.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentCamera.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentClimate.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentCover.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentFan.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentLight.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentLock.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentSensor.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentSwitch.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ConnectionDeserializer.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HaID.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HandlerConfiguration.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ListOrStringDeserializer.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AlarmControlPanel.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/BinarySensor.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Camera.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Lock.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Sensor.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ChannelConfigurationTypeAdapterFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ConnectionDeserializer.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ListOrStringDeserializer.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Availability.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AvailabilityMode.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Connection.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Device.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/discovery/HomeAssistantDiscovery.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/listener/ExpireUpdateStateListener.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/listener/OffDelayUpdateStateListener.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HAConfigurationTests.java [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AlarmControlPanelTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/BinarySensorTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CameraTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/ClimateTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/HAConfigurationTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SensorTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SwitchTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/discovery/HomeAssistantDiscoveryTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandlerTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configA.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configB.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configClimate.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configDeviceList.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configDeviceSingleString.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configFan.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configTS0601AutoLock.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configTS0601ClimateThermostat.json [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/image.png [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configA.json [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configB.json [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configDeviceList.json [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configDeviceSingleString.json [deleted file]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configFan.json [deleted file]

index eb7e4052698a9768d1324d9766b07ed9fcfb3b32..d4528d7ec8738e6ecd0b3ce6d3ea3aece4a5ccba 100644 (file)
       <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>
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractComponent.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractComponent.java
deleted file mode 100644 (file)
index 074549d..0000000
+++ /dev/null
@@ -1,220 +0,0 @@
-/**
- * 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);
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/BaseChannelConfiguration.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/BaseChannelConfiguration.java
deleted file mode 100644 (file)
index bb17b03..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-/**
- * 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;
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/CChannel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/CChannel.java
deleted file mode 100644 (file)
index cffc702..0000000
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * 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;
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/CFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/CFactory.java
deleted file mode 100644 (file)
index fc3d43a..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * 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);
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ChannelConfigurationTypeAdapterFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ChannelConfigurationTypeAdapterFactory.java
deleted file mode 100644 (file)
index 5d61e98..0000000
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * 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();
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentAlarmControlPanel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentAlarmControlPanel.java
deleted file mode 100644 (file)
index 6ae7d4e..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * 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();
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentBinarySensor.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentBinarySensor.java
deleted file mode 100644 (file)
index 62936cf..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * 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;
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentCamera.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentCamera.java
deleted file mode 100644 (file)
index f61cc3b..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * 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();
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java
new file mode 100644 (file)
index 0000000..61d557f
--- /dev/null
@@ -0,0 +1,261 @@
+/**
+ * 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;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentClimate.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentClimate.java
deleted file mode 100644 (file)
index 9e5b5d3..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * 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");
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentCover.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentCover.java
deleted file mode 100644 (file)
index e2c89b0..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * 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();
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentFan.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentFan.java
deleted file mode 100644 (file)
index 069585b..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * 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();
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentLight.java
deleted file mode 100644 (file)
index e42ed29..0000000
+++ /dev/null
@@ -1,171 +0,0 @@
-/**
- * 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);
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentLock.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentLock.java
deleted file mode 100644 (file)
index aad866a..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * 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();
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentSensor.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentSensor.java
deleted file mode 100644 (file)
index 2e18a3a..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * 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;
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentSwitch.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentSwitch.java
deleted file mode 100644 (file)
index 87f179d..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * 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();
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ConnectionDeserializer.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ConnectionDeserializer.java
deleted file mode 100644 (file)
index 7383b4d..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * 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;
-    }
-}
index 3e6794b58fed8837b45147728c7bf68efaf4f8ea..a24c5cabb418a206f1160ed4ded7653f07e96111 100644 (file)
@@ -27,6 +27,8 @@ 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.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;
@@ -55,7 +57,7 @@ public class DiscoverComponents implements MqttMessageSubscriber {
 
     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<>();
 
@@ -92,12 +94,11 @@ public class DiscoverComponents implements MqttMessageSubscriber {
 
         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();
@@ -122,9 +123,9 @@ public class DiscoverComponents implements MqttMessageSubscriber {
      * @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.
@@ -177,8 +178,6 @@ public class DiscoverComponents implements MqttMessageSubscriber {
 
     /**
      * 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"));
index c7f895cd4557e74f6ff5bd5737872c896454b3ca..51a2509205d5599de681c024b786ca39df075ff2 100644 (file)
@@ -89,7 +89,7 @@ public class HaID {
         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()) {
@@ -104,8 +104,8 @@ public class HaID {
      * <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) {
@@ -120,7 +120,7 @@ public class HaID {
      * <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) {
@@ -139,7 +139,7 @@ public class HaID {
      * 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) {
index 8c589592e453858efad12e7e1a043fcb3fcdd0af..9936d91969389fbd32dfafbe4eebfac81b0ed13a 100644 (file)
@@ -28,6 +28,9 @@ import org.openhab.binding.mqtt.homeassistant.internal.handler.HomeAssistantThin
  */
 @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
@@ -64,7 +67,7 @@ public class HandlerConfiguration {
     public List<String> topics;
 
     public HandlerConfiguration() {
-        this("homeassistant", Collections.emptyList());
+        this(DEFAULT_BASETOPIC, Collections.emptyList());
     }
 
     public HandlerConfiguration(String basetopic, List<String> topics) {
@@ -76,12 +79,12 @@ public class HandlerConfiguration {
     /**
      * 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;
     }
 }
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java
new file mode 100644 (file)
index 0000000..8ffc0a4
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ListOrStringDeserializer.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ListOrStringDeserializer.java
deleted file mode 100644 (file)
index d848b1d..0000000
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * 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;
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java
new file mode 100644 (file)
index 0000000..6cdde58
--- /dev/null
@@ -0,0 +1,236 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AlarmControlPanel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AlarmControlPanel.java
new file mode 100644 (file)
index 0000000..cd1db7d
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * 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();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/BinarySensor.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/BinarySensor.java
new file mode 100644 (file)
index 0000000..5fc140b
--- /dev/null
@@ -0,0 +1,81 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Camera.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Camera.java
new file mode 100644 (file)
index 0000000..0b877cf
--- /dev/null
@@ -0,0 +1,49 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java
new file mode 100644 (file)
index 0000000..c030d8a
--- /dev/null
@@ -0,0 +1,237 @@
+/**
+ * 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());
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java
new file mode 100644 (file)
index 0000000..ed7b530
--- /dev/null
@@ -0,0 +1,160 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java
new file mode 100644 (file)
index 0000000..bf7af7b
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java
new file mode 100644 (file)
index 0000000..9f74032
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java
new file mode 100644 (file)
index 0000000..cf7f962
--- /dev/null
@@ -0,0 +1,178 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Lock.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Lock.java
new file mode 100644 (file)
index 0000000..e2461fe
--- /dev/null
@@ -0,0 +1,61 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Sensor.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Sensor.java
new file mode 100644 (file)
index 0000000..43bbf4a
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java
new file mode 100644 (file)
index 0000000..6d9996f
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ChannelConfigurationTypeAdapterFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ChannelConfigurationTypeAdapterFactory.java
new file mode 100644 (file)
index 0000000..ab4d08e
--- /dev/null
@@ -0,0 +1,147 @@
+/**
+ * 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();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ConnectionDeserializer.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ConnectionDeserializer.java
new file mode 100644 (file)
index 0000000..405931b
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * 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());
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ListOrStringDeserializer.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/ListOrStringDeserializer.java
new file mode 100644 (file)
index 0000000..94320c1
--- /dev/null
@@ -0,0 +1,92 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java
new file mode 100644 (file)
index 0000000..daa86aa
--- /dev/null
@@ -0,0 +1,200 @@
+/**
+ * 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));
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Availability.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Availability.java
new file mode 100644 (file)
index 0000000..ba4be86
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AvailabilityMode.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AvailabilityMode.java
new file mode 100644 (file)
index 0000000..7389994
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * 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
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Connection.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Connection.java
new file mode 100644 (file)
index 0000000..fe24552
--- /dev/null
@@ -0,0 +1,47 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Device.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/Device.java
new file mode 100644 (file)
index 0000000..84b5215
--- /dev/null
@@ -0,0 +1,73 @@
+/**
+ * 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;
+    }
+}
index eb8ff8b2dab4b2078d4664a9c45c79b6c0021ba6..ed00f64a1e8eeb53ca68f813778304a380ad111c 100644 (file)
@@ -33,10 +33,10 @@ import org.openhab.binding.mqtt.discovery.AbstractMQTTDiscovery;
 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;
@@ -147,7 +147,7 @@ public class HomeAssistantDiscovery extends AbstractMQTTDiscovery {
         }
         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.
index a6d92611626700d0637f698dc088d4dcb1c79595..bc66b1ca9789fb944d6f494bbd37ed462cd99318 100644 (file)
@@ -32,14 +32,14 @@ import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
 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;
@@ -153,12 +153,12 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
             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);
@@ -235,7 +235,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
         if (component == null) {
             return null;
         }
-        CChannel componentChannel = component.channel(channelUID.getIdWithoutGroup());
+        ComponentChannel componentChannel = component.getChannel(channelUID.getIdWithoutGroup());
         if (componentChannel == null) {
             return null;
         }
@@ -264,7 +264,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
 
         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()) {
@@ -280,15 +280,15 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
                 // 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);
             }
         }
@@ -314,7 +314,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
             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());
             }
index 9427ffb0e3f7b3d8310d1de86ac322b7876a74e3..15a9ef8f20a7d7802004ea3aad75bd750d15dd1e 100644 (file)
@@ -38,7 +38,7 @@ public class ExpireUpdateStateListener extends ChannelStateUpdateListenerProxy {
     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) {
index 16b83ae026df711f64532891301a785fa90d3556..9b117705bdf6c1c3a18724bae9d32c87c34ecdda 100644 (file)
@@ -37,7 +37,7 @@ public class OffDelayUpdateStateListener extends ChannelStateUpdateListenerProxy
     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) {
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java
new file mode 100644 (file)
index 0000000..49879ac
--- /dev/null
@@ -0,0 +1,189 @@
+/**
+ * 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";
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HAConfigurationTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HAConfigurationTests.java
deleted file mode 100644 (file)
index e25ccf9..0000000
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * 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")));
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java
new file mode 100644 (file)
index 0000000..f35aa45
--- /dev/null
@@ -0,0 +1,269 @@
+/**
+ * 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;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AlarmControlPanelTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AlarmControlPanelTests.java
new file mode 100644 (file)
index 0000000..6aa60e9
--- /dev/null
@@ -0,0 +1,95 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/BinarySensorTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/BinarySensorTests.java
new file mode 100644 (file)
index 0000000..8768853
--- /dev/null
@@ -0,0 +1,154 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CameraTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CameraTests.java
new file mode 100644 (file)
index 0000000..7b7f794
--- /dev/null
@@ -0,0 +1,69 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/ClimateTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/ClimateTests.java
new file mode 100644 (file)
index 0000000..3ceadb3
--- /dev/null
@@ -0,0 +1,297 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java
new file mode 100644 (file)
index 0000000..c8ad1d6
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java
new file mode 100644 (file)
index 0000000..34c5c7f
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/HAConfigurationTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/HAConfigurationTests.java
new file mode 100644 (file)
index 0000000..e454d0d
--- /dev/null
@@ -0,0 +1,250 @@
+/**
+ * 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));
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightTests.java
new file mode 100644 (file)
index 0000000..f71af00
--- /dev/null
@@ -0,0 +1,91 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java
new file mode 100644 (file)
index 0000000..c16672f
--- /dev/null
@@ -0,0 +1,120 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SensorTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SensorTests.java
new file mode 100644 (file)
index 0000000..5f219fd
--- /dev/null
@@ -0,0 +1,81 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SwitchTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SwitchTests.java
new file mode 100644 (file)
index 0000000..975d5f2
--- /dev/null
@@ -0,0 +1,123 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/discovery/HomeAssistantDiscoveryTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/discovery/HomeAssistantDiscoveryTests.java
new file mode 100644 (file)
index 0000000..4218114
--- /dev/null
@@ -0,0 +1,122 @@
+/**
+ * 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;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandlerTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandlerTests.java
new file mode 100644 (file)
index 0000000..2a4d451
--- /dev/null
@@ -0,0 +1,155 @@
+/**
+ * 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());
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configA.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configA.json
new file mode 100644 (file)
index 0000000..e832de0
--- /dev/null
@@ -0,0 +1,27 @@
+{
+       "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/"
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configB.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configB.json
new file mode 100644 (file)
index 0000000..b040418
--- /dev/null
@@ -0,0 +1,28 @@
+{
+       "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/"
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configClimate.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configClimate.json
new file mode 100644 (file)
index 0000000..671b0b6
--- /dev/null
@@ -0,0 +1,66 @@
+{
+  "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
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configDeviceList.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configDeviceList.json
new file mode 100644 (file)
index 0000000..4d45ea3
--- /dev/null
@@ -0,0 +1,9 @@
+{
+       "device": {
+               "ids": [
+                       "A",
+                       "B",
+                       "C"
+               ]
+       }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configDeviceSingleString.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configDeviceSingleString.json
new file mode 100644 (file)
index 0000000..6e296ec
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "device": {
+               "ids": "A"
+       }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configFan.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configFan.json
new file mode 100644 (file)
index 0000000..c24ee52
--- /dev/null
@@ -0,0 +1,22 @@
+{
+       "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"
+       ]
+} 
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configTS0601AutoLock.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configTS0601AutoLock.json
new file mode 100644 (file)
index 0000000..2ad9c44
--- /dev/null
@@ -0,0 +1,26 @@
+{
+  "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
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configTS0601ClimateThermostat.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/configTS0601ClimateThermostat.json
new file mode 100644 (file)
index 0000000..4ab5d2e
--- /dev/null
@@ -0,0 +1,52 @@
+{
+  "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
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/image.png b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/image.png
new file mode 100644 (file)
index 0000000..100bcfd
Binary files /dev/null and b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/component/image.png differ
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configA.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configA.json
deleted file mode 100644 (file)
index e832de0..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-{
-       "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/"
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configB.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configB.json
deleted file mode 100644 (file)
index b040418..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-{
-       "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/"
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configDeviceList.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configDeviceList.json
deleted file mode 100644 (file)
index 4d45ea3..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-       "device": {
-               "ids": [
-                       "A",
-                       "B",
-                       "C"
-               ]
-       }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configDeviceSingleString.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configDeviceSingleString.json
deleted file mode 100644 (file)
index 6e296ec..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-       "device": {
-               "ids": "A"
-       }
-}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configFan.json b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/resources/org/openhab/binding/mqtt/homeassistant/internal/configFan.json
deleted file mode 100644 (file)
index c24ee52..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-{
-       "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"
-       ]
-}