[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 <cody@cutrer.us>
This commit is contained in:
Cody Cutrer 2023-12-14 15:53:14 -07:00 committed by GitHub
parent 1712783945
commit 98fb791dc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 372 additions and 16 deletions

View File

@ -104,7 +104,7 @@ public class DiscoverComponents implements MqttMessageSubscriber {
gson, transformationServiceProvider); gson, transformationServiceProvider);
component.setConfigSeen(); component.setConfigSeen();
logger.trace("Found HomeAssistant thing {} component {}", haID.objectID, haID.component); logger.trace("Found HomeAssistant component {}", haID);
if (discoveredListener != null) { if (discoveredListener != null) {
discoveredListener.componentDiscovered(haID, component); discoveredListener.componentDiscovered(haID, component);

View File

@ -83,6 +83,8 @@ public class ComponentFactory {
return new Sensor(componentConfiguration); return new Sensor(componentConfiguration);
case "switch": case "switch":
return new Switch(componentConfiguration); return new Switch(componentConfiguration);
case "update":
return new Update(componentConfiguration);
case "vacuum": case "vacuum":
return new Vacuum(componentConfiguration); return new Vacuum(componentConfiguration);
default: default:

View File

@ -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<Update.ChannelConfiguration> 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<String, String> appendToProperties(Map<String, String> 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);
}
}
}

View File

@ -12,6 +12,7 @@
*/ */
package org.openhab.binding.mqtt.homeassistant.internal.handler; package org.openhab.binding.mqtt.homeassistant.internal.handler;
import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@ -41,8 +42,10 @@ import org.openhab.binding.mqtt.homeassistant.internal.HaID;
import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration; 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.AbstractComponent;
import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory; 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.config.ChannelConfigurationTypeAdapterFactory;
import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; 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.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.Channel; import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID; import org.openhab.core.thing.ChannelGroupUID;
@ -84,7 +87,8 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
implements ComponentDiscovered, Consumer<List<AbstractComponent<?>>> { implements ComponentDiscovered, Consumer<List<AbstractComponent<?>>> {
public static final String AVAILABILITY_CHANNEL = "availability"; public static final String AVAILABILITY_CHANNEL = "availability";
private static final Comparator<Channel> CHANNEL_COMPARATOR_BY_UID = Comparator private static final Comparator<Channel> 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); private final Logger logger = LoggerFactory.getLogger(HomeAssistantThingHandler.class);
@ -102,6 +106,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
protected final TransformationServiceProvider transformationServiceProvider; protected final TransformationServiceProvider transformationServiceProvider;
private boolean started; private boolean started;
private @Nullable Update updateComponent;
/** /**
* Create a new thing handler for HomeAssistant MQTT components. * Create a new thing handler for HomeAssistant MQTT components.
@ -293,6 +298,11 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
return null; return null;
}); });
if (discovered instanceof Update) {
updateComponent = (Update) discovered;
updateComponent.setReleaseStateUpdateListener(this::releaseStateUpdated);
}
List<Channel> discoveredChannels = discovered.getChannelMap().values().stream() List<Channel> discoveredChannels = discovered.getChannelMap().values().stream()
.map(ComponentChannel::getChannel).collect(Collectors.toList()); .map(ComponentChannel::getChannel).collect(Collectors.toList());
if (known != null) { if (known != null) {
@ -342,6 +352,26 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
} }
} }
@Override
public void handleConfigurationUpdate(Map<String, Object> 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() { private void updateThingType() {
// if this is a dynamic type, then we update the type // if this is a dynamic type, then we update the type
ThingTypeUID typeID = thing.getThingTypeUID(); ThingTypeUID typeID = thing.getThingTypeUID();
@ -354,10 +384,21 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
channelDefs = haComponents.values().stream().map(AbstractComponent::getChannels).flatMap(List::stream) channelDefs = haComponents.values().stream().map(AbstractComponent::getChannels).flatMap(List::stream)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
ThingType thingType = channelTypeProvider.derive(typeID, MqttBindingConstants.HOMEASSISTANT_MQTT_THING) var builder = channelTypeProvider.derive(typeID, MqttBindingConstants.HOMEASSISTANT_MQTT_THING)
.withChannelDefinitions(channelDefs).withChannelGroupDefinitions(groupDefs).build(); .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); channelTypeProvider.setThingType(typeID, thingType);
} }
} }
private void releaseStateUpdated(Update.ReleaseState state) {
Map<String, String> properties = editProperties();
properties = state.appendToProperties(properties);
updateProperties(properties);
}
} }

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="thing-type:mqtt:homeassistant">
<parameter name="topics" type="text" required="true" multiple="true">
<label>MQTT Config Topic</label>
<description>List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config)</description>
</parameter>
<parameter name="basetopic" type="text" required="true">
<label>MQTT Base Prefix</label>
<description>MQTT base prefix</description>
<default>homeassistant</default>
</parameter>
</config-description>
<config-description uri="thing-type:mqtt:homeassistant-updatable">
<parameter-group name="actions">
<label>Actions</label>
</parameter-group>
<parameter name="topics" type="text" required="true" multiple="true">
<label>MQTT Config Topic</label>
<description>List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config)</description>
</parameter>
<parameter name="basetopic" type="text" required="true">
<label>MQTT Base Prefix</label>
<description>MQTT base prefix</description>
<default>homeassistant</default>
</parameter>
<parameter name="doUpdate" type="boolean" groupName="actions">
<label>Update</label>
<description>Request the device do an OTA update</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@ -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.basetopic.description = MQTT base prefix
thing-type.config.mqtt.homeassistant.topics.label = MQTT Config Topic 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.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 # channel types config

View File

@ -11,17 +11,6 @@
<label>HomeAssistant MQTT Component</label> <label>HomeAssistant MQTT Component</label>
<description>You need a configured Broker first. This Thing represents a device, that follows the "HomeAssistant MQTT <description>You need a configured Broker first. This Thing represents a device, that follows the "HomeAssistant MQTT
Component" specification.</description> Component" specification.</description>
<config-description> <config-description-ref uri="thing-type:mqtt:homeassistant"/>
<parameter name="topics" type="text" required="true" multiple="true">
<label>MQTT Config Topic</label>
<description>List of HomeAssistant configuration topics (e.g. /homeassistant/switch/4711/config)</description>
</parameter>
<parameter name="basetopic" type="text" required="true">
<label>MQTT Base Prefix</label>
<description>MQTT base prefix</description>
<default>homeassistant</default>
</parameter>
</config-description>
</thing-type> </thing-type>
</thing:thing-descriptions> </thing:thing-descriptions>