From 98fb791dc554c927389715dcdbeb3320576af3ff Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Thu, 14 Dec 2023 15:53:14 -0700 Subject: [PATCH] [mqtt.homeassistant] Add support for Update component (#14241) * [mqtt.homeassistant] add support for Update component This component is fairly non-standard - it doesn't add any channels. Instead, it provides several properties to the thing, and also adds a thing configuration allowing you to trigger an OTA update on a Home Assistant device from MainUI. --------- Signed-off-by: Cody Cutrer --- .../internal/DiscoverComponents.java | 2 +- .../internal/component/ComponentFactory.java | 2 + .../internal/component/Update.java | 275 ++++++++++++++++++ .../handler/HomeAssistantThingHandler.java | 47 ++- .../config/homeassistant-thing-config.xml | 43 +++ .../resources/OH-INF/i18n/mqtt.properties | 6 + .../OH-INF/thing/homeassistant-thing.xml | 13 +- 7 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Update.java create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/config/homeassistant-thing-config.xml diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java index b77eeffec7e15..c6432e1ad13a2 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java @@ -104,7 +104,7 @@ public void processMessage(String topic, byte[] payload) { gson, transformationServiceProvider); component.setConfigSeen(); - logger.trace("Found HomeAssistant thing {} component {}", haID.objectID, haID.component); + logger.trace("Found HomeAssistant component {}", haID); if (discoveredListener != null) { discoveredListener.componentDiscovered(haID, component); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java index 3246c3789480c..1a02711b4a367 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java @@ -83,6 +83,8 @@ public static AbstractComponent createComponent(ThingUID thingUID, HaID haID, return new Sensor(componentConfiguration); case "switch": return new Switch(componentConfiguration); + case "update": + return new Update(componentConfiguration); case "vacuum": return new Vacuum(componentConfiguration); default: diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Update.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Update.java new file mode 100644 index 0000000000000..5347e7a14e0b1 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Update.java @@ -0,0 +1,275 @@ +/** + * Copyright (c) 2010-2023 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.Map; +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.ChannelStateUpdateListener; +import org.openhab.binding.mqtt.generic.values.TextValue; +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.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonSyntaxException; +import com.google.gson.annotations.SerializedName; + +/** + * A MQTT Update component, following the https://www.home-assistant.io/integrations/update.mqtt/ specification. + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class Update extends AbstractComponent implements ChannelStateUpdateListener { + public static final String UPDATE_CHANNEL_ID = "update"; + public static final String LATEST_VERSION_CHANNEL_ID = "latestVersion"; + + /** + * Configuration class for MQTT component + */ + static class ChannelConfiguration extends AbstractChannelConfiguration { + ChannelConfiguration() { + super("MQTT Update"); + } + + @SerializedName("latest_version_template") + protected @Nullable String latestVersionTemplate; + @SerializedName("latest_version_topic") + protected @Nullable String latestVersionTopic; + @SerializedName("command_topic") + protected @Nullable String commandTopic; + @SerializedName("state_topic") + protected @Nullable String stateTopic; + + protected @Nullable String title; + @SerializedName("release_summary") + protected @Nullable String releaseSummary; + @SerializedName("release_url") + protected @Nullable String releaseUrl; + + @SerializedName("payload_install") + protected @Nullable String payloadInstall; + } + + /** + * Describes the state payload if it's JSON + */ + public static class ReleaseState { + // these are designed to fit in with the default property of firmwareVersion + public static final String PROPERTY_LATEST_VERSION = "latestFirmwareVersion"; + public static final String PROPERTY_TITLE = "firmwareTitle"; + public static final String PROPERTY_RELEASE_SUMMARY = "firmwareSummary"; + public static final String PROPERTY_RELEASE_URL = "firmwareURL"; + + @Nullable + String installedVersion; + @Nullable + String latestVersion; + @Nullable + String title; + @Nullable + String releaseSummary; + @Nullable + String releaseUrl; + @Nullable + String entityPicture; + + public Map appendToProperties(Map properties) { + String installedVersion = this.installedVersion; + if (installedVersion != null && !installedVersion.isBlank()) { + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, installedVersion); + } + // don't remove the firmwareVersion property; it might be coming from the + // device as well + + String latestVersion = this.latestVersion; + if (latestVersion != null) { + properties.put(PROPERTY_LATEST_VERSION, latestVersion); + } else { + properties.remove(PROPERTY_LATEST_VERSION); + } + String title = this.title; + if (title != null) { + properties.put(PROPERTY_TITLE, title); + } else { + properties.remove(title); + } + String releaseSummary = this.releaseSummary; + if (releaseSummary != null) { + properties.put(PROPERTY_RELEASE_SUMMARY, releaseSummary); + } else { + properties.remove(PROPERTY_RELEASE_SUMMARY); + } + String releaseUrl = this.releaseUrl; + if (releaseUrl != null) { + properties.put(PROPERTY_RELEASE_URL, releaseUrl); + } else { + properties.remove(PROPERTY_RELEASE_URL); + } + return properties; + } + } + + public interface ReleaseStateListener { + void releaseStateUpdated(ReleaseState newState); + } + + private final Logger logger = LoggerFactory.getLogger(Update.class); + + private ComponentChannel updateChannel; + private @Nullable ComponentChannel latestVersionChannel; + private boolean updatable = false; + private ReleaseState state = new ReleaseState(); + private @Nullable ReleaseStateListener listener = null; + + public Update(ComponentFactory.ComponentConfiguration componentConfiguration) { + super(componentConfiguration, ChannelConfiguration.class); + + TextValue value = new TextValue(); + String commandTopic = channelConfiguration.commandTopic; + String payloadInstall = channelConfiguration.payloadInstall; + + var builder = buildChannel(UPDATE_CHANNEL_ID, value, getName(), this); + if (channelConfiguration.stateTopic != null) { + builder.stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()); + } + if (commandTopic != null && payloadInstall != null) { + updatable = true; + builder.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos()); + } + updateChannel = builder.build(false); + + if (channelConfiguration.latestVersionTopic != null) { + value = new TextValue(); + latestVersionChannel = buildChannel(LATEST_VERSION_CHANNEL_ID, value, getName(), this) + .stateTopic(channelConfiguration.latestVersionTopic, channelConfiguration.latestVersionTemplate) + .build(false); + } + + state.title = channelConfiguration.title; + state.releaseSummary = channelConfiguration.releaseSummary; + state.releaseUrl = channelConfiguration.releaseUrl; + } + + /** + * Returns if this device can be updated + */ + public boolean isUpdatable() { + return updatable; + } + + /** + * Trigger an OTA update for this device + */ + public void doUpdate() { + if (!updatable) { + return; + } + String commandTopic = channelConfiguration.commandTopic; + String payloadInstall = channelConfiguration.payloadInstall; + + updateChannel.getState().publishValue(new StringType(payloadInstall)).handle((v, ex) -> { + if (ex != null) { + logger.debug("Failed publishing value {} to topic {}: {}", payloadInstall, commandTopic, + ex.getMessage()); + } else { + logger.debug("Successfully published value {} to topic {}", payloadInstall, commandTopic); + } + return null; + }); + } + + @Override + public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler, + int timeout) { + var updateFuture = updateChannel.start(connection, scheduler, timeout); + ComponentChannel latestVersionChannel = this.latestVersionChannel; + if (latestVersionChannel == null) { + return updateFuture; + } + + var latestVersionFuture = latestVersionChannel.start(connection, scheduler, timeout); + return CompletableFuture.allOf(updateFuture, latestVersionFuture); + } + + @Override + public CompletableFuture<@Nullable Void> stop() { + var updateFuture = updateChannel.stop(); + ComponentChannel latestVersionChannel = this.latestVersionChannel; + if (latestVersionChannel == null) { + return updateFuture; + } + + var latestVersionFuture = latestVersionChannel.stop(); + return CompletableFuture.allOf(updateFuture, latestVersionFuture); + } + + @Override + public void updateChannelState(ChannelUID channelUID, State value) { + switch (channelUID.getIdWithoutGroup()) { + case UPDATE_CHANNEL_ID: + String strValue = value.toString(); + try { + // check if it's JSON first + @Nullable + final ReleaseState releaseState = getGson().fromJson(strValue, ReleaseState.class); + if (releaseState != null) { + state = releaseState; + notifyReleaseStateUpdated(); + return; + } + } catch (JsonSyntaxException e) { + // Ignore; it's just a string of installed_version + } + state.installedVersion = strValue; + break; + case LATEST_VERSION_CHANNEL_ID: + state.latestVersion = value.toString(); + break; + } + notifyReleaseStateUpdated(); + } + + @Override + public void postChannelCommand(ChannelUID channelUID, Command value) { + throw new UnsupportedOperationException(); + } + + @Override + public void triggerChannel(ChannelUID channelUID, String eventPayload) { + throw new UnsupportedOperationException(); + } + + public void setReleaseStateUpdateListener(ReleaseStateListener listener) { + this.listener = listener; + notifyReleaseStateUpdated(); + } + + private void notifyReleaseStateUpdated() { + var listener = this.listener; + if (listener != null) { + listener.releaseStateUpdated(state); + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java index 99a14d4abd9c4..718ea19d9c9f2 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.mqtt.homeassistant.internal.handler; +import java.net.URI; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -41,8 +42,10 @@ 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.component.Update; import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory; import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; +import org.openhab.core.config.core.validation.ConfigValidationException; import org.openhab.core.io.transport.mqtt.MqttBrokerConnection; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelGroupUID; @@ -84,7 +87,8 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler implements ComponentDiscovered, Consumer>> { public static final String AVAILABILITY_CHANNEL = "availability"; private static final Comparator CHANNEL_COMPARATOR_BY_UID = Comparator - .comparing(channel -> channel.getUID().toString());; + .comparing(channel -> channel.getUID().toString()); + private static final URI UPDATABLE_CONFIG_DESCRIPTION_URI = URI.create("thing-type:mqtt:homeassistant-updatable"); private final Logger logger = LoggerFactory.getLogger(HomeAssistantThingHandler.class); @@ -102,6 +106,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler protected final TransformationServiceProvider transformationServiceProvider; private boolean started; + private @Nullable Update updateComponent; /** * Create a new thing handler for HomeAssistant MQTT components. @@ -293,6 +298,11 @@ public void accept(List> discoveredComponentsList) { return null; }); + if (discovered instanceof Update) { + updateComponent = (Update) discovered; + updateComponent.setReleaseStateUpdateListener(this::releaseStateUpdated); + } + List discoveredChannels = discovered.getChannelMap().values().stream() .map(ComponentChannel::getChannel).collect(Collectors.toList()); if (known != null) { @@ -342,6 +352,26 @@ protected void updateThingStatus(boolean messageReceived, Optional avai } } + @Override + public void handleConfigurationUpdate(Map configurationParameters) + throws ConfigValidationException { + if (configurationParameters.containsKey("doUpdate")) { + configurationParameters = new HashMap<>(configurationParameters); + Object value = configurationParameters.remove("doUpdate"); + if (value instanceof Boolean doUpdate && doUpdate) { + Update updateComponent = this.updateComponent; + if (updateComponent == null) { + logger.warn( + "Received update command for Home Assistant device {}, but it does not have an update component.", + getThing().getUID()); + } else { + updateComponent.doUpdate(); + } + } + } + super.handleConfigurationUpdate(configurationParameters); + } + private void updateThingType() { // if this is a dynamic type, then we update the type ThingTypeUID typeID = thing.getThingTypeUID(); @@ -354,10 +384,21 @@ private void updateThingType() { channelDefs = haComponents.values().stream().map(AbstractComponent::getChannels).flatMap(List::stream) .collect(Collectors.toList()); } - ThingType thingType = channelTypeProvider.derive(typeID, MqttBindingConstants.HOMEASSISTANT_MQTT_THING) - .withChannelDefinitions(channelDefs).withChannelGroupDefinitions(groupDefs).build(); + var builder = channelTypeProvider.derive(typeID, MqttBindingConstants.HOMEASSISTANT_MQTT_THING) + .withChannelDefinitions(channelDefs).withChannelGroupDefinitions(groupDefs); + Update updateComponent = this.updateComponent; + if (updateComponent != null && updateComponent.isUpdatable()) { + builder.withConfigDescriptionURI(UPDATABLE_CONFIG_DESCRIPTION_URI); + } + ThingType thingType = builder.build(); channelTypeProvider.setThingType(typeID, thingType); } } + + private void releaseStateUpdated(Update.ReleaseState state) { + Map properties = editProperties(); + properties = state.appendToProperties(properties); + updateProperties(properties); + } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/config/homeassistant-thing-config.xml b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/config/homeassistant-thing-config.xml new file mode 100644 index 0000000000000..f523cbf1155d7 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/config/homeassistant-thing-config.xml @@ -0,0 +1,43 @@ + + + + + + + List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config) + + + + + MQTT base prefix + homeassistant + + + + + + + + + + + List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config) + + + + + MQTT base prefix + homeassistant + + + + + Request the device do an OTA update + true + false + + + diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties index 2095eecad3be8..8fabdeed27c18 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties @@ -9,6 +9,12 @@ thing-type.config.mqtt.homeassistant.basetopic.label = MQTT Base Prefix thing-type.config.mqtt.homeassistant.basetopic.description = MQTT base prefix thing-type.config.mqtt.homeassistant.topics.label = MQTT Config Topic thing-type.config.mqtt.homeassistant.topics.description = List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config) +thing-type.config.mqtt.homeassistant-updatable.basetopic.label = MQTT Base Prefix +thing-type.config.mqtt.homeassistant-updatable.basetopic.description = MQTT base prefix +thing-type.config.mqtt.homeassistant-updatable.topics.label = MQTT Config Topic +thing-type.config.mqtt.homeassistant-updatable.topics.description = List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config) +thing-type.config.mqtt.homeassistant-updatable.doUpdate.label = Update +thing-type.config.mqtt.homeassistant-updatable.doUpdate.description = Request the device do an OTA update # channel types config diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-thing.xml b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-thing.xml index 51e9957a01df9..7af89b8ac0fdb 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-thing.xml +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-thing.xml @@ -11,17 +11,6 @@ You need a configured Broker first. This Thing represents a device, that follows the "HomeAssistant MQTT Component" specification. - - - - List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config) - - - - - MQTT base prefix - homeassistant - - +