Skip to content

Commit

Permalink
[mqtt.homeassistant] Add support for Update component (#14241)
Browse files Browse the repository at this point in the history
* [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>
  • Loading branch information
ccutrer authored Dec 14, 2023
1 parent 1712783 commit 98fb791
Show file tree
Hide file tree
Showing 7 changed files with 372 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -84,7 +87,8 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
implements ComponentDiscovered, Consumer<List<AbstractComponent<?>>> {
public static final String AVAILABILITY_CHANNEL = "availability";
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);

Expand All @@ -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.
Expand Down Expand Up @@ -293,6 +298,11 @@ public void accept(List<AbstractComponent<?>> discoveredComponentsList) {
return null;
});

if (discovered instanceof Update) {
updateComponent = (Update) discovered;
updateComponent.setReleaseStateUpdateListener(this::releaseStateUpdated);
}

List<Channel> discoveredChannels = discovered.getChannelMap().values().stream()
.map(ComponentChannel::getChannel).collect(Collectors.toList());
if (known != null) {
Expand Down Expand Up @@ -342,6 +352,26 @@ protected void updateThingStatus(boolean messageReceived, Optional<Boolean> avai
}
}

@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() {
// if this is a dynamic type, then we update the type
ThingTypeUID typeID = thing.getThingTypeUID();
Expand All @@ -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<String, String> properties = editProperties();
properties = state.appendToProperties(properties);
updateProperties(properties);
}
}
Loading

0 comments on commit 98fb791

Please sign in to comment.