Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[mqtt.homeassistant] Add support for Update component #14241

Merged
merged 5 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -79,6 +79,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 String PROPERTY_LATEST_VERSION = "latestFirmwareVersion";
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
public static String PROPERTY_TITLE = "firmwareTitle";
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
public static String PROPERTY_RELEASE_SUMMARY = "firmwareSummary";
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
public static String PROPERTY_RELEASE_URL = "firmwareURL";
ccutrer marked this conversation as resolved.
Show resolved Hide resolved

@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 && ((Boolean) value) == true) {
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
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