[homeassistant] Support device-level configuration (#20225)

* [homeassistant] Support device-level configuration

Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
Cody Cutrer
2026-03-08 16:26:21 +01:00
committed by GitHub
parent af21b50424
commit 9f03494bf8
30 changed files with 1267 additions and 104 deletions
@@ -8,6 +8,26 @@ Each component will be represented as a Channel Group, with the attributes of th
Any device that publishes the component configuration under the `homeassistant` prefix in MQTT will have their components automatically discovered and added to the Inbox.
You can also manually create a Thing, and provide the individual component topics, as well as a different discovery prefix.
[Device Discovery](https://www.home-assistant.io/integrations/mqtt/#device-discovery-payload) is supported as well.
## Example
### Things file
```java
Bridge mqtt:broker:mybroker [ host="192.168.1.10", secure=false ] {
// 1) Single component configuration; channels won't be created until config is received from the MQTT broker
Thing homeassistant:device:kitchen_button [ topics="button/kitchen_button/restart" ]
// 2) Device-level configuration topic; channels won't be created until config is received from the MQTT broker
Thing homeassistant:device:kitchen_device [ topics="device/kitchen" ]
// 3) Device-level configuration with full JSON in deviceConfig
// Channels are restored from deviceConfig immediately, so the Thing is usable
// even before the retained MQTT discovery message is received from the broker.
Thing homeassistant:device:kitchen_cached [ topics="device/kitchen", deviceConfig="{\"dev\":{\"ids\":\"ea334450945afc\",\"name\":\"Kitchen\"},\"o\":{\"name\":\"bla2mqtt\",\"sw\":\"2.1\"},\"cmps\":{\"temperature\":{\"p\":\"sensor\",\"device_class\":\"temperature\",\"unit_of_measurement\":\"°C\",\"value_template\":\"{{ value_json.temperature}}\",\"unique_id\":\"temp01ae_t\"},\"humidity\":{\"p\":\"sensor\",\"device_class\":\"humidity\",\"unit_of_measurement\":\"%\",\"value_template\":\"{{ value_json.humidity}}\",\"unique_id\":\"temp01ae_h\"}},\"state_topic\":\"sensorKitchen/state\",\"qos\":2}" ]
}
```
## Supported Components and Channels
@@ -14,6 +14,7 @@ package org.openhab.binding.homeassistant.internal;
import java.lang.ref.WeakReference;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
@@ -25,6 +26,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.homeassistant.internal.component.AbstractComponent;
import org.openhab.binding.homeassistant.internal.component.ComponentFactory;
import org.openhab.binding.homeassistant.internal.config.dto.MqttComponentConfig;
import org.openhab.binding.homeassistant.internal.exception.ConfigurationException;
import org.openhab.binding.homeassistant.internal.exception.UnsupportedComponentException;
import org.openhab.binding.mqtt.generic.AvailabilityTracker;
@@ -64,6 +66,7 @@ public class DiscoverComponents implements MqttMessageSubscriber {
protected @Nullable ComponentDiscovered discoveredListener;
private int discoverTime;
private Set<String> topics = new HashSet<>();
private final Set<HaID> knownDeviceComponents = new HashSet<>();
/**
* Implement this to get notified of new components
@@ -72,6 +75,8 @@ public class DiscoverComponents implements MqttMessageSubscriber {
void componentDiscovered(HaID homeAssistantTopicID, AbstractComponent<?> component);
void componentRemoved(HaID homeAssistantTopicID);
void deviceConfigUpdated(HaID homeAssistantTopicID, String configPayload);
}
/**
@@ -95,35 +100,70 @@ public class DiscoverComponents implements MqttMessageSubscriber {
}
@Override
public void processMessage(String topic, byte[] payload) {
public synchronized void processMessage(String topic, byte[] payload) {
if (!topic.endsWith("/config")) {
return;
}
HaID haID = new HaID(topic);
String config = new String(payload);
AbstractComponent<?> component = null;
ComponentDiscovered discoveredListener = this.discoveredListener;
if (config.length() > 0) {
try {
component = ComponentFactory.createComponent(thingUID, haID, config, updateListener, linkageChecker,
tracker, scheduler, gson, python, unitProvider);
component.setConfigSeen();
List<MqttComponentConfig> parsedComponentConfigs = python.processDiscoveryConfig(haID.toShortTopic(),
config);
boolean migrationMessage = parsedComponentConfigs.stream()
.anyMatch(MqttComponentConfig::isMigrateDiscovery);
if (migrationMessage) {
// Just treat a migration message as the component disappearing -
// openHAB doesn't destroy Items when a Channel disappears, so we
// don't need to worry about keeping a sentinel around for components
// that are about to show up again under a device component.
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
if (discoveredListener != null) {
knownDeviceComponents.forEach(discoveredListener::componentRemoved);
}
knownDeviceComponents.clear();
} else if (discoveredListener != null) {
discoveredListener.componentRemoved(haID);
}
return;
}
logger.trace("Found HomeAssistant component {}", haID);
List<AbstractComponent<?>> components = ComponentFactory.createComponent(thingUID, haID, config,
parsedComponentConfigs, updateListener, linkageChecker, tracker, scheduler, gson, python,
unitProvider);
components.forEach(component -> component.setConfigSeen());
logger.trace("Found Home Assistant component {}", haID);
if (discoveredListener != null) {
discoveredListener.componentDiscovered(haID, component);
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
discoveredListener.deviceConfigUpdated(haID, config);
Set<HaID> currentComponents = components.stream().map(AbstractComponent::getHaID)
.collect(Collectors.toSet());
knownDeviceComponents.stream().filter(component -> !currentComponents.contains(component))
.forEach(discoveredListener::componentRemoved);
components.stream().filter(component -> !knownDeviceComponents.contains(component.getHaID()))
.forEach(component -> discoveredListener.componentDiscovered(component.getHaID(),
component));
knownDeviceComponents.clear();
knownDeviceComponents.addAll(currentComponents);
} else {
components.forEach(component -> discoveredListener.componentDiscovered(haID, component));
}
}
} catch (UnsupportedComponentException e) {
logger.warn("HomeAssistant discover error: thing {} component type is unsupported: {}", haID.objectID,
haID.component);
logger.warn("Home Assistant discovery error: component {} is unsupported", haID.toShortTopic());
} catch (ConfigurationException e) {
logger.warn("HomeAssistant discover error: invalid configuration of thing {} component {}: {}",
haID.objectID, haID.component, e.getMessage());
logger.warn("Home Assistant discovery error: invalid configuration of component {}: {}",
haID.toShortTopic(), e.getMessage());
}
} else if (discoveredListener != null) {
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
knownDeviceComponents.forEach(discoveredListener::componentRemoved);
knownDeviceComponents.clear();
}
discoveredListener.componentRemoved(haID);
}
}
@@ -176,13 +216,14 @@ public class DiscoverComponents implements MqttMessageSubscriber {
}
}
private @Nullable Void subscribeFail(Throwable e) {
private synchronized @Nullable Void subscribeFail(Throwable e) {
final ScheduledFuture<?> scheduledFuture = this.stopDiscoveryFuture;
if (scheduledFuture != null) { // Cancel timeout
scheduledFuture.cancel(false);
this.stopDiscoveryFuture = null;
}
this.discoveredListener = null;
this.knownDeviceComponents.clear();
final MqttBrokerConnection connection = connectionRef.get();
if (connection != null) {
this.topics.stream().forEach(t -> connection.unsubscribe(t, this));
@@ -81,7 +81,7 @@ public class HaID {
* @param nodeID The node ID (can be the empty string)
* @param component The component ID
*/
private HaID(String baseTopic, String objectID, String nodeID, String component) {
public HaID(String baseTopic, String objectID, String nodeID, String component) {
this.baseTopic = baseTopic;
this.objectID = objectID;
this.nodeID = nodeID;
@@ -28,6 +28,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
@NonNullByDefault
public class HandlerConfiguration {
public static final String PROPERTY_BASETOPIC = "basetopic";
public static final String PROPERTY_DEVICE_CONFIG = "deviceConfig";
public static final String PROPERTY_TOPICS = "topics";
public static final String DEFAULT_BASETOPIC = "homeassistant";
/**
@@ -64,14 +65,20 @@ public class HandlerConfiguration {
*
*/
public List<String> topics;
public String deviceConfig = "";
public HandlerConfiguration() {
this(DEFAULT_BASETOPIC, Collections.emptyList());
this(DEFAULT_BASETOPIC, Collections.emptyList(), "");
}
public HandlerConfiguration(String basetopic, List<String> topics) {
this(basetopic, topics, "");
}
public HandlerConfiguration(String basetopic, List<String> topics, String deviceConfig) {
this.basetopic = basetopic;
this.topics = topics;
this.deviceConfig = deviceConfig;
}
/**
@@ -83,6 +90,7 @@ public class HandlerConfiguration {
public <T extends Map<String, Object>> T appendToProperties(T properties) {
properties.put(PROPERTY_BASETOPIC, basetopic);
properties.put(PROPERTY_TOPICS, topics);
properties.put(PROPERTY_DEVICE_CONFIG, deviceConfig);
return properties;
}
}
@@ -27,6 +27,8 @@ public class HomeAssistantBindingConstants {
public static final String LEGACY_BINDING_ID = "mqtt";
public static final String BINDING_ID = "homeassistant";
public static final String DEVICE_COMPONENT = "device";
// List of all Thing Type UIDs
public static final ThingTypeUID LEGACY_MQTT_HOMEASSISTANT_THING = new ThingTypeUID(LEGACY_BINDING_ID,
"homeassistant");
@@ -29,6 +29,7 @@ import org.graalvm.polyglot.PolyglotException;
import org.graalvm.polyglot.Value;
import org.graalvm.python.embedding.GraalPyResources;
import org.graalvm.python.embedding.VirtualFileSystem;
import org.openhab.binding.homeassistant.internal.config.dto.MqttComponentConfig;
import org.openhab.binding.homeassistant.internal.exception.ConfigurationException;
import org.openhab.core.OpenHAB;
import org.osgi.service.component.annotations.Activate;
@@ -148,23 +149,20 @@ public class HomeAssistantPythonBridge {
return renderValueTemplateWithVariablesMeth.execute(template, payload, defaultValue, variables).asString();
}
public Map<String, @Nullable Object> processDiscoveryConfig(String component, String payload) {
public List<MqttComponentConfig> processDiscoveryConfig(String topic, String payload) {
try {
@SuppressWarnings("unchecked")
Map<String, @Nullable Object> config = (Map<String, @Nullable Object>) toJava(
processDiscoveryConfigMeth.execute(component, payload));
if (config == null) {
List<Value> configs = (List<Value>) toJava(processDiscoveryConfigMeth.execute(topic, payload));
if (configs == null || configs.isEmpty()) {
throw new ConfigurationException("Invalid configuration");
}
return config;
return configs.stream().map(c -> new MqttComponentConfig(this, c)).toList();
} catch (PolyglotException e) {
throw new ConfigurationException(
"Failed to process discovery config for " + component + ": " + e.getMessage());
throw new ConfigurationException("Failed to process discovery config for " + topic + ": " + e.getMessage());
}
}
private @Nullable Object toJava(Value value) {
public @Nullable Object toJava(Value value) {
if (value.isNull()) {
return null;
}
@@ -128,8 +128,7 @@ public abstract class AbstractComponent<C extends AbstractComponentConfiguration
* @param singleChannelComponent if this component only ever has one channel, so should never be in a group
*/
public AbstractComponent(ComponentFactory.ComponentContext componentContext, Class<C> clazz) {
this(componentContext, AbstractComponentConfiguration.create(componentContext.getPython(),
componentContext.getHaID().component, componentContext.getConfigJSON(), clazz));
this(componentContext, AbstractComponentConfiguration.create(componentContext.getDiscoveryPayload(), clazz));
}
/**
@@ -208,11 +207,13 @@ public abstract class AbstractComponent<C extends AbstractComponentConfiguration
if (channels.size() == 1) {
groupId = null;
channels.values().forEach(c -> c.resetUID(buildChannelUID(componentId), getName()));
} else {
}
if (componentContext.shouldPersistChannelConfiguration()) {
// only the first channel needs to persist the configuration
channels.values().stream().skip(1).forEach(c -> {
c.clearConfiguration();
});
channels.values().stream().skip(1).forEach(ComponentChannel::clearConfiguration);
} else {
channels.values().forEach(ComponentChannel::clearConfiguration);
}
}
@@ -12,18 +12,27 @@
*/
package org.openhab.binding.homeassistant.internal.component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.homeassistant.internal.HaID;
import org.openhab.binding.homeassistant.internal.HomeAssistantBindingConstants;
import org.openhab.binding.homeassistant.internal.HomeAssistantChannelLinkageChecker;
import org.openhab.binding.homeassistant.internal.HomeAssistantPythonBridge;
import org.openhab.binding.homeassistant.internal.config.dto.MqttComponentConfig;
import org.openhab.binding.homeassistant.internal.exception.ConfigurationException;
import org.openhab.binding.homeassistant.internal.exception.UnsupportedComponentException;
import org.openhab.binding.mqtt.generic.AvailabilityTracker;
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
import org.openhab.core.i18n.UnitProvider;
import org.openhab.core.thing.ThingUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
@@ -35,6 +44,8 @@ import com.google.gson.Gson;
*/
@NonNullByDefault
public class ComponentFactory {
private static final Logger logger = LoggerFactory.getLogger(ComponentFactory.class);
/**
* Create a HA MQTT component. The configuration JSon string is required.
*
@@ -46,13 +57,55 @@ public class ComponentFactory {
* @param updateListener A channel state update listener
* @return A HA MQTT Component
*/
public static AbstractComponent<?> createComponent(ThingUID thingUID, HaID haID, String channelConfigurationJSON,
public static List<AbstractComponent<?>> createComponent(ThingUID thingUID, HaID haID,
String channelConfigurationJSON, ChannelStateUpdateListener updateListener,
HomeAssistantChannelLinkageChecker linkageChecker, AvailabilityTracker tracker,
ScheduledExecutorService scheduler, Gson gson, HomeAssistantPythonBridge python, UnitProvider unitProvider)
throws ConfigurationException {
List<MqttComponentConfig> mqttComponentConfigs = python.processDiscoveryConfig(haID.toShortTopic(),
channelConfigurationJSON);
return createComponent(thingUID, haID, channelConfigurationJSON, mqttComponentConfigs, updateListener,
linkageChecker, tracker, scheduler, gson, python, unitProvider);
}
public static List<AbstractComponent<?>> createComponent(ThingUID thingUID, HaID haID,
String channelConfigurationJSON, List<MqttComponentConfig> mqttComponentConfigs,
ChannelStateUpdateListener updateListener, HomeAssistantChannelLinkageChecker linkageChecker,
AvailabilityTracker tracker, ScheduledExecutorService scheduler, Gson gson,
HomeAssistantPythonBridge python, UnitProvider unitProvider) throws ConfigurationException {
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
List<AbstractComponent<?>> components = new ArrayList<>();
for (MqttComponentConfig config : mqttComponentConfigs) {
String nodeId = config.getNodeId();
if (nodeId == null) {
// Use the device component's object id as the node id for the sub components if
// not explicitly specified
nodeId = haID.objectID;
}
HaID componentHaID = new HaID(haID.baseTopic, config.getObjectId(), nodeId, config.getComponent());
ComponentContext componentContext = new ComponentContext(thingUID, componentHaID,
channelConfigurationJSON, gson, python, updateListener, linkageChecker, tracker, scheduler,
unitProvider, config.getDiscoveryPayload(), false);
try {
components.add(createComponent(componentContext));
} catch (UnsupportedComponentException e) {
logger.warn("Home Assistant discovery error: component {} is unsupported", haID.toShortTopic());
}
}
return Collections.unmodifiableList(components);
}
MqttComponentConfig mqttComponentConfig = mqttComponentConfigs.getFirst();
ComponentContext componentContext = new ComponentContext(thingUID, haID, channelConfigurationJSON, gson, python,
updateListener, linkageChecker, tracker, scheduler, unitProvider);
switch (haID.component) {
updateListener, linkageChecker, tracker, scheduler, unitProvider,
mqttComponentConfig.getDiscoveryPayload(), true);
return List.of(createComponent(componentContext));
}
private static AbstractComponent<?> createComponent(ComponentContext componentContext)
throws ConfigurationException {
switch (componentContext.getHaID().component) {
case "alarm_control_panel":
return new AlarmControlPanel(componentContext);
case "binary_sensor":
@@ -102,7 +155,8 @@ public class ComponentFactory {
case "water_heater":
return new WaterHeater(componentContext);
default:
throw new UnsupportedComponentException("Component '" + haID + "' is unsupported!");
throw new UnsupportedComponentException(
"Component '" + componentContext.getHaID().toShortTopic() + "' is unsupported!");
}
}
@@ -117,6 +171,8 @@ public class ComponentFactory {
private final HomeAssistantPythonBridge python;
private final ScheduledExecutorService scheduler;
private final UnitProvider unitProvider;
private final Map<String, @Nullable Object> discoveryPayload;
private final boolean persistChannelConfiguration;
/**
* Provide a thingUID and HomeAssistant topic ID to determine the channel group UID and type.
@@ -128,7 +184,8 @@ public class ComponentFactory {
protected ComponentContext(ThingUID thingUID, HaID haID, String configJSON, Gson gson,
HomeAssistantPythonBridge python, ChannelStateUpdateListener updateListener,
HomeAssistantChannelLinkageChecker linkageChecker, AvailabilityTracker tracker,
ScheduledExecutorService scheduler, UnitProvider unitProvider) {
ScheduledExecutorService scheduler, UnitProvider unitProvider,
Map<String, @Nullable Object> discoveryPayload, boolean persistChannelConfiguration) {
this.thingUID = thingUID;
this.haID = haID;
this.configJSON = configJSON;
@@ -139,6 +196,8 @@ public class ComponentFactory {
this.tracker = tracker;
this.scheduler = scheduler;
this.unitProvider = unitProvider;
this.discoveryPayload = discoveryPayload;
this.persistChannelConfiguration = persistChannelConfiguration;
}
public ThingUID getThingUID() {
@@ -180,5 +239,13 @@ public class ComponentFactory {
public ScheduledExecutorService getScheduler() {
return scheduler;
}
public Map<String, @Nullable Object> getDiscoveryPayload() {
return discoveryPayload;
}
public boolean shouldPersistChannelConfiguration() {
return persistChannelConfiguration;
}
}
}
@@ -125,7 +125,8 @@ public abstract class Light<C extends Light.LightConfiguration> extends Abstract
public static Light<?> create(ComponentFactory.ComponentContext componentContext)
throws UnsupportedComponentException {
Map<String, @Nullable Object> config = componentContext.getPython()
.processDiscoveryConfig(componentContext.getHaID().component, componentContext.getConfigJSON());
.processDiscoveryConfig(componentContext.getHaID().toShortTopic(), componentContext.getConfigJSON())
.getFirst().getDiscoveryPayload();
String schema = Objects.requireNonNull((String) config.get("schema"));
switch (schema) {
@@ -19,7 +19,7 @@ import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.graalvm.polyglot.Value;
import org.openhab.binding.homeassistant.internal.HomeAssistantPythonBridge;
import org.openhab.binding.homeassistant.internal.HomeAssistantBindingConstants;
import org.openhab.core.thing.Thing;
import org.openhab.core.util.UIDUtils;
@@ -37,17 +37,15 @@ public class AbstractComponentConfiguration extends AbstractConfiguration {
private final @Nullable List<Availability> availability;
/**
* Parse the base properties of the configJSON into an {@link AbstractComponentConfiguration}
* Parse the base properties of the discoveryPayload into an {@link AbstractComponentConfiguration}
*
* @param configJSON channels configuration in JSON
* @param gson parser
* @param discoveryPayload pre-parsed JSON discovery payload
* @return configuration object
*/
public static <C extends AbstractComponentConfiguration> C create(HomeAssistantPythonBridge python,
String component, String configJSON, Class<C> clazz) {
public static <C extends AbstractComponentConfiguration> C create(Map<String, @Nullable Object> discoveryPayload,
Class<C> clazz) {
try {
return clazz.getDeclaredConstructor(Map.class)
.newInstance(python.processDiscoveryConfig(component, configJSON));
return clazz.getDeclaredConstructor(Map.class).newInstance(discoveryPayload);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause == null) {
@@ -62,9 +60,8 @@ public class AbstractComponentConfiguration extends AbstractConfiguration {
}
}
public static AbstractComponentConfiguration create(HomeAssistantPythonBridge python, String component,
String configJSON) {
return create(python, component, configJSON, AbstractComponentConfiguration.class);
public static AbstractComponentConfiguration create(Map<String, @Nullable Object> discoveryPayload) {
return create(discoveryPayload, AbstractComponentConfiguration.class);
}
protected AbstractComponentConfiguration(Map<String, @Nullable Object> config) {
@@ -75,7 +72,8 @@ public class AbstractComponentConfiguration extends AbstractConfiguration {
protected AbstractComponentConfiguration(Map<String, @Nullable Object> config, String defaultName) {
super(config);
this.qos = getInt("qos");
Map<String, @Nullable Object> deviceConfig = (Map<String, @Nullable Object>) config.get("device");
Map<String, @Nullable Object> deviceConfig = (Map<String, @Nullable Object>) config
.get(HomeAssistantBindingConstants.DEVICE_COMPONENT);
if (deviceConfig != null) {
this.device = new Device(deviceConfig);
} else {
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2010-2026 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.homeassistant.internal.config.dto;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.graalvm.polyglot.Value;
import org.openhab.binding.homeassistant.internal.HomeAssistantPythonBridge;
/**
* Base class for home assistant configurations.
*
* @author Cody Cutrer - Initial contribution
*/
@NonNullByDefault
public class MqttComponentConfig {
private final HomeAssistantPythonBridge bridge;
private final Value value;
public MqttComponentConfig(HomeAssistantPythonBridge bridge, Value value) {
this.bridge = bridge;
this.value = value;
}
public String getComponent() {
return value.getMember("component").asString();
}
public String getObjectId() {
return value.getMember("object_id").asString();
}
public @Nullable String getNodeId() {
Value nodeId = value.getMember("node_id");
if (nodeId.isNull()) {
return null;
}
return nodeId.asString();
}
public Map<String, @Nullable Object> getDiscoveryPayload() {
return (Map<String, @Nullable Object>) Objects
.requireNonNull(bridge.toJava(value.getMember("discovery_payload")));
}
public boolean isMigrateDiscovery() {
Value discoveryPayload = value.getMember("discovery_payload");
if (discoveryPayload.hasMember("migrate_discovery")) {
Value migrateDiscovery = discoveryPayload.getMember("migrate_discovery");
if (!migrateDiscovery.isNull()) {
return migrateDiscovery.asBoolean();
}
}
Object migrateDiscovery = getDiscoveryPayload().get("migrate_discovery");
return migrateDiscovery instanceof Boolean migrate && migrate;
}
}
@@ -33,6 +33,7 @@ import org.openhab.binding.homeassistant.internal.HomeAssistantBindingConstants;
import org.openhab.binding.homeassistant.internal.HomeAssistantConfiguration;
import org.openhab.binding.homeassistant.internal.HomeAssistantPythonBridge;
import org.openhab.binding.homeassistant.internal.config.dto.AbstractComponentConfiguration;
import org.openhab.binding.homeassistant.internal.config.dto.MqttComponentConfig;
import org.openhab.binding.homeassistant.internal.exception.ConfigurationException;
import org.openhab.binding.mqtt.discovery.AbstractMQTTDiscovery;
import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
@@ -147,10 +148,22 @@ public class HomeAssistantDiscovery extends AbstractMQTTDiscovery {
// Therefore the components are assembled into a list and given to the DiscoveryResult label for the user to
// easily recognize object capabilities.
HaID haID = new HaID(topic);
String payloadString = new String(payload, StandardCharsets.UTF_8);
try {
AbstractComponentConfiguration config = AbstractComponentConfiguration.create(python, haID.component,
new String(payload, StandardCharsets.UTF_8));
List<MqttComponentConfig> components = python.processDiscoveryConfig(haID.toShortTopic(), payloadString);
if (components.isEmpty()) {
logger.warn("Home Assistant discovery warning: device {} with no components found; this is a bug",
haID.objectID);
return;
}
if (components.getFirst().isMigrateDiscovery()) {
// Treat it the same as the component vanishing
topicVanished(bridgeUID, connection, topic);
return;
}
AbstractComponentConfiguration config = AbstractComponentConfiguration
.create(components.getFirst().getDiscoveryPayload());
final String thingID = config.getThingId(haID.objectID);
final ThingUID thingUID = new ThingUID(HomeAssistantBindingConstants.HOMEASSISTANT_DEVICE_THING, bridgeUID,
@@ -160,6 +173,9 @@ public class HomeAssistantDiscovery extends AbstractMQTTDiscovery {
Map<String, Object> properties = new HashMap<>();
properties = config.appendToProperties(properties);
properties.put("deviceId", thingID);
if ("device".equals(haID.component)) {
properties.put(HandlerConfiguration.PROPERTY_DEVICE_CONFIG, payloadString);
}
DiscoveryResult result = buildResult(thingID, thingUID, config.getThingName(), haID, properties, bridgeUID);
@@ -169,10 +185,10 @@ public class HomeAssistantDiscovery extends AbstractMQTTDiscovery {
applyResult(thingID, haID, result);
}
} catch (ConfigurationException e) {
logger.warn("HomeAssistant discover error: invalid configuration of thing {} component {}: {}",
haID.objectID, haID.component, e.getMessage());
logger.warn("Home Assistant discovery error: invalid configuration of {}: {}", haID.toShortTopic(),
e.getMessage());
} catch (Exception e) {
logger.warn("HomeAssistant discover error: {}", e.getMessage());
logger.warn("Home Assistant discovery error for {}: {}", haID.toShortTopic(), e.getMessage());
}
}
@@ -250,7 +266,9 @@ public class HomeAssistantDiscovery extends AbstractMQTTDiscovery {
List<String> topics = componentsSet.stream().map(HaID::toShortTopic).toList();
// Append handler configuration
HandlerConfiguration handlerConfig = new HandlerConfiguration(haID.baseTopic, topics);
Object deviceConfig = properties.get(HandlerConfiguration.PROPERTY_DEVICE_CONFIG);
String deviceConfigPayload = deviceConfig instanceof String deviceConfigString ? deviceConfigString : "";
HandlerConfiguration handlerConfig = new HandlerConfiguration(haID.baseTopic, topics, deviceConfigPayload);
properties = handlerConfig.appendToProperties(properties);
return DiscoveryResultBuilder.create(thingUID).withProperties(properties).withRepresentationProperty("deviceId")
@@ -144,12 +144,20 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
config = getConfigAs(HandlerConfiguration.class);
if (config.topics.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Device topics unknown");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error.no-component-topics");
return;
}
if (!validateTopics()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error.only-one-device");
return;
}
discoveryHomeAssistantIDs.addAll(HaID.fromConfig(config));
ThingTypeUID typeID = getThing().getThingTypeUID();
restorePersistedDeviceConfigFromThingConfig();
for (Channel channel : thing.getChannels()) {
final String groupID = channel.getUID().getGroupId();
if (groupID != null) {
@@ -182,14 +190,15 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
ThingUID thingUID = channel.getUID().getThingUID();
String channelConfigurationJSON = (String) channelConfig.get("config");
try {
AbstractComponent<?> component = ComponentFactory.createComponent(thingUID, haID,
List<AbstractComponent<?>> components = ComponentFactory.createComponent(thingUID, haID,
channelConfigurationJSON, this, this, this, scheduler, gson, python, unitProvider);
if (typeID.equals(HomeAssistantBindingConstants.HOMEASSISTANT_DEVICE_THING)
if (!components.isEmpty() && typeID.equals(HomeAssistantBindingConstants.HOMEASSISTANT_DEVICE_THING)
|| typeID.getBindingId().equals(HomeAssistantBindingConstants.LEGACY_BINDING_ID)) {
typeID = calculateThingTypeUID(component);
typeID = calculateThingTypeUID(components.getFirst());
}
addComponent(component);
components.forEach(component -> {
addComponent(component);
});
} catch (ConfigurationException e) {
logger.warn("Cannot restore component {}: {}", thing, e.getMessage());
}
@@ -219,6 +228,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
*/
@Override
protected CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection) {
logger.debug("Starting MQTT connection for {}", getThing().getUID());
started = true;
connection.setQos(1);
@@ -274,6 +284,22 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
delayedProcessing.accept(component);
}
/**
* Callback of {@link DiscoverComponents} for a device topic update.
* Persists the full raw device config JSON on the Thing, so the handler can
* restore device-originated components after restart and still reconcile
* component removals/migrations when later discovery messages arrive.
*/
@Override
public void deviceConfigUpdated(HaID homeAssistantTopicID, String configPayload) {
if (!config.deviceConfig.equals(configPayload)) {
config.deviceConfig = configPayload;
Configuration configuration = editConfiguration();
configuration.put(HandlerConfiguration.PROPERTY_DEVICE_CONFIG, config.deviceConfig);
updateConfiguration(configuration);
}
}
@Override
public void componentRemoved(HaID haID) {
delayedProcessing.accept(haID);
@@ -357,7 +383,10 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
private void removeComponents(List<HaID> removedComponentsList) {
synchronized (haComponents) {
boolean componentActuallyRemoved = false;
List<String> updatedTopics = new ArrayList<>(config.topics);
for (HaID removed : removedComponentsList) {
updatedTopics.remove(removed.getTopic());
AbstractComponent<?> known = haComponentsByHaId.get(removed);
if (known != null) {
// Don't wait for the future to complete. We are also not interested in failures.
@@ -373,6 +402,10 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
}
}
if (componentActuallyRemoved) {
config.topics = List.copyOf(updatedTopics);
Configuration configuration = editConfiguration();
configuration.put(HandlerConfiguration.PROPERTY_TOPICS, config.topics);
updateConfiguration(configuration);
updateThingType(getThing().getThingTypeUID());
}
}
@@ -424,7 +457,6 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
changeThingType(typeID, getConfig());
return false;
}
synchronized (haComponents) { // sync whenever discoverComponents is started
var sortedComponents = haComponents.values().stream().sorted(COMPONENT_COMPARATOR).toList();
@@ -437,14 +469,12 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
var channelDefs = sortedComponents.stream().map(AbstractComponent::getChannelDefinitions)
.flatMap(List::stream).toList();
thingTypeBuilder.withChannelDefinitions(channelDefs).withChannelGroupDefinitions(groupDefs);
channelTypeProvider.putThingType(thingTypeBuilder.build());
removeStateDescriptions();
sortedComponents.stream().forEach(c -> c.addStateDescriptions(stateDescriptionProvider));
ThingBuilder thingBuilder = editThing().withChannels();
sortedComponents.stream().map(AbstractComponent::getChannels).flatMap(List::stream)
.forEach(c -> thingBuilder.withChannel(c));
@@ -463,6 +493,49 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
+ component.getConfig().getThingId(component.getHaID().objectID));
}
private boolean validateTopics() {
boolean hasDeviceTopic = false;
for (String topic : config.topics) {
String[] parts = topic.split("/");
if (parts.length == 0 || parts[0].isBlank()) {
continue;
}
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(parts[0])) {
if (hasDeviceTopic) {
return false;
}
hasDeviceTopic = true;
}
}
return true;
}
private void restorePersistedDeviceConfigFromThingConfig() {
String persistedPayload = config.deviceConfig;
if (persistedPayload.isBlank()) {
return;
}
List<HaID> deviceHaIDs = HaID.fromConfig(config).stream()
.filter(id -> HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(id.component)).toList();
if (deviceHaIDs.size() != 1) {
logger.debug("Ignoring persisted device config for thing {} because {} device topics are configured",
thing.getUID(), deviceHaIDs.size());
return;
}
HaID haID = deviceHaIDs.getFirst();
try {
List<AbstractComponent<?>> components = ComponentFactory.createComponent(thing.getUID(), haID,
persistedPayload, this, this, this, scheduler, gson, python, unitProvider);
if (!components.isEmpty()) {
components.forEach(delayedProcessing::accept);
}
} catch (ConfigurationException e) {
logger.debug("Ignoring invalid deviceConfig in thing {}: {}", thing.getUID(), e.getMessage());
}
}
@Override
public void handleRemoval() {
synchronized (haComponents) {
@@ -3,9 +3,12 @@
import jinja2
from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
from homeassistant.const import CONF_PAYLOAD
from homeassistant.const import CONF_PAYLOAD, Platform
from homeassistant.exceptions import TemplateError
ATTR_DISCOVERY_HASH = "discovery_hash"
ATTR_DISCOVERY_PAYLOAD = "discovery_payload"
ATTR_DISCOVERY_TOPIC = "discovery_topic"
ATTR_QOS = "qos"
ATTR_RETAIN = "retain"
@@ -22,14 +25,18 @@ CONF_AVAILABILITY = "availability"
CONF_AVAILABILITY_MODE = "availability_mode"
CONF_AVAILABILITY_TEMPLATE = "availability_template"
CONF_AVAILABILITY_TOPIC = "availability_topic"
CONF_AVAILABLE_TONES = "available_tones"
CONF_CODE_ARM_REQUIRED = "code_arm_required"
CONF_CODE_DISARM_REQUIRED = "code_disarm_required"
CONF_CODE_FORMAT = "code_format"
CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required"
CONF_COMMAND_TEMPLATE = "command_template"
CONF_COMMAND_TOPIC = "command_topic"
CONF_CONTENT_TYPE = "content_type"
CONF_DEFAULT_ENTITY_ID = "default_entity_id"
CONF_ENCODING = "encoding"
CONF_IMAGE_ENCODING = "image_encoding"
CONF_IMAGE_TOPIC = "image_topic"
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
CONF_OPTIONS = "options"
@@ -181,6 +188,8 @@ CONF_STATE_UNLOCKED = "state_unlocked"
CONF_STATE_UNLOCKING = "state_unlocking"
CONF_STEP = "step"
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
CONF_SUPPORT_DURATION = "support_duration"
CONF_SUPPORT_VOLUME_SET = "support_volume_set"
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic"
@@ -218,6 +227,8 @@ CONF_TILT_MIN = "tilt_min"
CONF_TILT_OPEN_POSITION = "tilt_opened_value"
CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
CONF_TRANSITION = "transition"
CONF_URL_TEMPLATE = "url_template"
CONF_URL_TOPIC = "url_topic"
CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
CONF_XY_COMMAND_TOPIC = "xy_command_topic"
CONF_XY_STATE_TOPIC = "xy_state_topic"
@@ -307,4 +318,64 @@ ALARM_CONTROL_PANEL_SUPPORTED_FEATURES = {
"trigger": AlarmControlPanelEntityFeature.TRIGGER,
}
ENTITY_PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CAMERA,
Platform.CLIMATE,
Platform.COVER,
Platform.DEVICE_TRACKER,
Platform.EVENT,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.IMAGE,
Platform.LIGHT,
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER,
Platform.SCENE,
Platform.SELECT,
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
Platform.UPDATE,
Platform.VACUUM,
Platform.VALVE,
Platform.WATER_HEATER,
]
TEMPLATE_ERRORS = (jinja2.TemplateError, TemplateError, TypeError, ValueError)
SUPPORTED_COMPONENTS = (
"alarm_control_panel",
"binary_sensor",
"button",
"camera",
"climate",
"cover",
"device_automation",
"device_tracker",
"event",
"fan",
"humidifier",
"image",
"lawn_mower",
"light",
"lock",
"notify",
"number",
"scene",
"siren",
"select",
"sensor",
"switch",
"tag",
"text",
"update",
"vacuum",
"valve",
"water_heater",
)
@@ -8,33 +8,42 @@ from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_DEVICE
from homeassistant.const import CONF_DEVICE, CONF_PLATFORM
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.util.json import json_loads_object
from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS, ORIGIN_ABBREVIATIONS
from .const import (
ATTR_DISCOVERY_HASH,
ATTR_DISCOVERY_PAYLOAD,
ATTR_DISCOVERY_TOPIC,
CONF_AVAILABILITY,
CONF_COMPONENTS,
CONF_ORIGIN,
CONF_TOPIC,
)
from .schemas import MQTT_ORIGIN_INFO_SCHEMA
from .models import MqttComponentConfig
from .schemas import DEVICE_DISCOVERY_SCHEMA, MQTT_ORIGIN_INFO_SCHEMA, SHARED_OPTIONS
ABBREVIATIONS_SET = set(ABBREVIATIONS)
DEVICE_ABBREVIATIONS_SET = set(DEVICE_ABBREVIATIONS)
ORIGIN_ABBREVIATIONS_SET = set(ORIGIN_ABBREVIATIONS)
_LOGGER = logging.getLogger(__name__)
TOPIC_MATCHER = re.compile(
r"(?P<component>\w+)/(?:(?P<node_id>[a-zA-Z0-9_-]+)/)"
r"?(?P<object_id>[a-zA-Z0-9_-]+)/config"
r"?(?P<object_id>[a-zA-Z0-9_-]+)$"
)
TOPIC_BASE = "~"
CONF_MIGRATE_DISCOVERY = "migrate_discovery"
MIGRATE_DISCOVERY_SCHEMA = vol.Schema(
{vol.Optional(CONF_MIGRATE_DISCOVERY): True},
)
class MQTTDiscoveryPayload(dict[str, Any]):
"""Class to hold and MQTT discovery payload and discovery data."""
@@ -44,6 +53,19 @@ class MQTTDiscoveryPayload(dict[str, Any]):
discovery_data: DiscoveryInfoType
def _process_discovery_migration(payload: MQTTDiscoveryPayload) -> bool:
"""Process a discovery migration request in the discovery payload."""
# Allow abbreviation
if migr_discvry := (payload.pop("migr_discvry", None)):
payload[CONF_MIGRATE_DISCOVERY] = migr_discvry
if CONF_MIGRATE_DISCOVERY in payload:
MIGRATE_DISCOVERY_SCHEMA(payload)
payload.migrate_discovery = True
payload.clear()
return True
return False
def _replace_abbreviations(
payload: dict[str, Any] | str,
abbreviations: dict[str, str],
@@ -108,20 +130,57 @@ def _replace_topic_base(discovery_payload: MQTTDiscoveryPayload) -> None:
if topic[-1] == TOPIC_BASE:
availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}"
def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool:
def _parse_device_payload(
payload: ReceivePayloadType,
object_id: str,
node_id: str | None,
) -> MQTTDiscoveryPayload:
"""Parse a device discovery payload.
The device discovery payload is translated info the config payloads for every single
component inside the device based configuration.
An empty payload is translated in a cleanup, which forwards an empty payload to all
removed components.
"""
device_payload = MQTTDiscoveryPayload()
if payload == "":
# This should not be possible, because we handle vanishing topics separately in the Java code
return device_payload
device_payload = MQTTDiscoveryPayload(json_loads_object(payload))
if _process_discovery_migration(device_payload):
return device_payload
_replace_all_abbreviations(device_payload)
DEVICE_DISCOVERY_SCHEMA(device_payload)
return device_payload
def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload):
"""Parse and validate origin info from a single component discovery payload."""
if CONF_ORIGIN not in discovery_payload:
return True
try:
MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN])
except Exception as exc: # noqa:BLE001
_LOGGER.warning(
"Unable to parse origin information from discovery message: %s, got %s",
exc,
discovery_payload[CONF_ORIGIN],
)
return False
return True
return
MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN])
def _merge_common_device_options(
component_config: MQTTDiscoveryPayload, device_config: dict[str, Any]
) -> None:
"""Merge common device options with the component config options.
Common options are:
CONF_AVAILABILITY,
CONF_AVAILABILITY_MODE,
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
Common options in the body of the device based config are inherited into
the component. Unless the option is explicitly specified at component level,
in that case the option at component level will override the common option.
"""
for option in SHARED_OPTIONS:
if option in device_config and option not in component_config:
component_config[option] = device_config.get(option)
# ******************************************************************************
# The rest of this file is _not_ directly Home Assistant code -- but is based on
@@ -138,12 +197,16 @@ from .device_trigger import TRIGGER_DISCOVERY_SCHEMA as DEVICE_TRIGGER_DISCOVERY
from .event import DISCOVERY_SCHEMA as EVENT_DISCOVERY_SCHEMA
from .fan import DISCOVERY_SCHEMA as FAN_DISCOVERY_SCHEMA
from .humidifier import DISCOVERY_SCHEMA as HUMIDIFIER_DISCOVERY_SCHEMA
from .image import DISCOVERY_SCHEMA as IMAGE_DISCOVERY_SCHEMA
from .lawn_mower import DISCOVERY_SCHEMA as LAWN_MOWER_DISCOVERY_SCHEMA
from .light import DISCOVERY_SCHEMA as LIGHT_DISCOVERY_SCHEMA
from .lock import DISCOVERY_SCHEMA as LOCK_DISCOVERY_SCHEMA
from .notify import DISCOVERY_SCHEMA as NOTIFY_DISCOVERY_SCHEMA
from .number import DISCOVERY_SCHEMA as NUMBER_DISCOVERY_SCHEMA
from .scene import DISCOVERY_SCHEMA as SCENE_DISCOVERY_SCHEMA
from .select import DISCOVERY_SCHEMA as SELECT_DISCOVERY_SCHEMA
from .sensor import DISCOVERY_SCHEMA as SENSOR_DISCOVERY_SCHEMA
from .siren import DISCOVERY_SCHEMA as SIREN_DISCOVERY_SCHEMA
from .switch import DISCOVERY_SCHEMA as SWITCH_DISCOVERY_SCHEMA
from .tag import DISCOVERY_SCHEMA as TAG_DISCOVERY_SCHEMA
from .text import DISCOVERY_SCHEMA as TEXT_DISCOVERY_SCHEMA
@@ -152,6 +215,7 @@ from .vacuum import DISCOVERY_SCHEMA as VACUUM_DISCOVERY_SCHEMA
from .valve import DISCOVERY_SCHEMA as VALVE_DISCOVERY_SCHEMA
from .water_heater import DISCOVERY_SCHEMA as WATER_HEATER_DISCOVERY_SCHEMA
# This needs to be kept in sync with the Java AbstractComponent subclasses
_DISCOVERY_SCHEMAS = {
'alarm_control_panel': ALARM_CONTROL_PANEL_DISCOVERY_SCHEMA,
'binary_sensor': BINARY_SENSOR_DISCOVERY_SCHEMA,
@@ -164,12 +228,16 @@ _DISCOVERY_SCHEMAS = {
'event': EVENT_DISCOVERY_SCHEMA,
'fan': FAN_DISCOVERY_SCHEMA,
'humidifier': HUMIDIFIER_DISCOVERY_SCHEMA,
'image': IMAGE_DISCOVERY_SCHEMA,
'lawn_mower': LAWN_MOWER_DISCOVERY_SCHEMA,
'light': LIGHT_DISCOVERY_SCHEMA,
'lock': LOCK_DISCOVERY_SCHEMA,
'notify': NOTIFY_DISCOVERY_SCHEMA,
'number': NUMBER_DISCOVERY_SCHEMA,
'scene': SCENE_DISCOVERY_SCHEMA,
'select': SELECT_DISCOVERY_SCHEMA,
'sensor': SENSOR_DISCOVERY_SCHEMA,
'siren': SIREN_DISCOVERY_SCHEMA,
'switch': SWITCH_DISCOVERY_SCHEMA,
'tag': TAG_DISCOVERY_SCHEMA,
'text': TEXT_DISCOVERY_SCHEMA,
@@ -182,20 +250,106 @@ _DISCOVERY_SCHEMAS = {
# This is partially based on async_discovery_message_received
# Logging is not done; errors are simply raised so the logging
# can occur in Java
def process_discovery_config(component, payload):
# Process component based discovery message
discovery_payload = json_loads_object(payload) if payload else {}
_replace_all_abbreviations(discovery_payload)
if not _valid_origin_info(discovery_payload):
return
if TOPIC_BASE in discovery_payload:
_replace_topic_base(discovery_payload)
def process_discovery_config(topic, payload):
if not (match := TOPIC_MATCHER.match(topic)):
raise ValueError(f"Received message on illegal discovery topic '{topic}'. The topic"
" contains non allowed characters. For more information see "
"https://www.home-assistant.io/integrations/mqtt/#discovery-topic")
if component not in _DISCOVERY_SCHEMAS:
raise ValueError("Unknown component type %s", component)
component, node_id, object_id = match.groups()
discovery_schema = _DISCOVERY_SCHEMAS[component]
discovery_payload = discovery_schema(discovery_payload)
discovered_components: list[MqttComponentConfig] = []
if component == CONF_DEVICE:
# Process device based discovery message and regenerate
# cleanup config for the all the components that are being removed.
# This is done when a component in the device config is omitted and detected
# as being removed, or when the device config update payload is empty.
# In that case this will regenerate a cleanup message for all already
# discovered components that were linked to the initial device discovery.
device_discovery_payload = _parse_device_payload(
payload, object_id, node_id
)
if device_discovery_payload.migrate_discovery:
# If this is a migration discovery, we do not want to generate component configs
# based on the device config, because the device config will be used to trigger
# the migration and will not contain the necessary component config data.
return [MqttComponentConfig(component, object_id, node_id, device_discovery_payload)]
device_config: dict[str, Any]
origin_config: dict[str, Any] | None
component_configs: dict[str, dict[str, Any]]
device_config = device_discovery_payload[CONF_DEVICE]
origin_config = device_discovery_payload.get(CONF_ORIGIN)
component_configs = device_discovery_payload[CONF_COMPONENTS]
if len(component_configs) == 0:
return [MqttComponentConfig(component, object_id, node_id, device_discovery_payload)]
for component_id, config in component_configs.items():
component = config.pop(CONF_PLATFORM)
# The object_id in the device discovery topic is the unique identifier.
# It is used as node_id for the components it contains.
component_node_id = object_id
# The component_id in the discovery payload is used as object_id
# If we have an additional node_id in the discovery topic,
# we extend the component_id with it.
component_object_id = (
f"{node_id} {component_id}" if node_id else component_id
)
# We add wrapper to the discovery payload with the discovery data.
# If the dict is empty after removing the platform, the payload is
# assumed to remove the existing config and we do not want to add
# device or orig or shared availability attributes.
if discovery_payload := MQTTDiscoveryPayload(config):
discovery_payload[CONF_DEVICE] = device_config
discovery_payload[CONF_ORIGIN] = origin_config
# Only assign shared config options
# when they are not set at entity level
_merge_common_device_options(
discovery_payload, device_discovery_payload
)
discovery_payload.device_discovery = True
discovery_payload.migrate_discovery = (
device_discovery_payload.migrate_discovery
)
discovered_components.append(
MqttComponentConfig(
component,
component_object_id,
component_node_id,
discovery_payload,
)
)
else:
# Process component based discovery message
discovery_payload = MQTTDiscoveryPayload(json_loads_object(payload) if payload else {})
if not _process_discovery_migration(discovery_payload):
_replace_all_abbreviations(discovery_payload)
_valid_origin_info(discovery_payload)
discovered_components.append(
MqttComponentConfig(component, object_id, node_id, discovery_payload)
)
for component_config in discovered_components:
component = component_config.component
node_id = component_config.node_id
object_id = component_config.object_id
discovery_payload = component_config.discovery_payload
if TOPIC_BASE in discovery_payload:
_replace_topic_base(discovery_payload)
# If present, the node_id will be included in the discovery_id.
discovery_id = f"{node_id} {object_id}" if node_id else object_id
discovery_hash = (component, discovery_id)
# Attach MQTT topic to the payload, used for debug prints
discovery_payload.discovery_data = {
ATTR_DISCOVERY_HASH: discovery_hash,
ATTR_DISCOVERY_PAYLOAD: discovery_payload,
ATTR_DISCOVERY_TOPIC: topic,
}
if component in _DISCOVERY_SCHEMAS and not discovery_payload.migrate_discovery:
discovery_schema = _DISCOVERY_SCHEMAS[component]
component_config.discovery_payload = discovery_schema(discovery_payload)
return discovery_payload
return discovered_components
@@ -0,0 +1,48 @@
"""Support for MQTT images."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import CONF_NAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .config import MQTT_BASE_SCHEMA
from .const import (
CONF_CONTENT_TYPE,
CONF_IMAGE_ENCODING,
CONF_IMAGE_TOPIC,
CONF_URL_TEMPLATE,
CONF_URL_TOPIC,
)
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_subscribe_topic
def validate_topic_required(config: ConfigType) -> ConfigType:
"""Ensure at least one subscribe topic is configured."""
if CONF_IMAGE_TOPIC not in config and CONF_URL_TOPIC not in config:
raise vol.Invalid("Expected one of [`image_topic`, `url_topic`], got none")
if CONF_CONTENT_TYPE in config and CONF_URL_TOPIC in config:
raise vol.Invalid(
"Option `content_type` can not be used together with `url_topic`"
)
return config
PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
{
vol.Optional(CONF_CONTENT_TYPE): cv.string,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
vol.Exclusive(CONF_URL_TOPIC, "image_topic"): valid_subscribe_topic,
vol.Exclusive(CONF_IMAGE_TOPIC, "image_topic"): valid_subscribe_topic,
vol.Optional(CONF_IMAGE_ENCODING): vol.In({"b64", "raw"}),
vol.Optional(CONF_URL_TEMPLATE): cv.template,
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
PLATFORM_SCHEMA_MODERN = vol.All(PLATFORM_SCHEMA_BASE.schema, validate_topic_required)
DISCOVERY_SCHEMA = vol.All(
PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_topic_required
)
@@ -0,0 +1,40 @@
"""Support for MQTT lawn mowers."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC
from homeassistant.helpers import config_validation as cv
from .config import MQTT_BASE_SCHEMA
from .const import CONF_RETAIN, DEFAULT_OPTIMISTIC, DEFAULT_RETAIN
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic, valid_subscribe_topic
CONF_ACTIVITY_STATE_TOPIC = "activity_state_topic"
CONF_ACTIVITY_VALUE_TEMPLATE = "activity_value_template"
CONF_DOCK_COMMAND_TOPIC = "dock_command_topic"
CONF_DOCK_COMMAND_TEMPLATE = "dock_command_template"
CONF_PAUSE_COMMAND_TOPIC = "pause_command_topic"
CONF_PAUSE_COMMAND_TEMPLATE = "pause_command_template"
CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic"
CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template"
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
{
vol.Optional(CONF_ACTIVITY_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_ACTIVITY_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_DOCK_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_DOCK_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_PAUSE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_PAUSE_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
vol.Optional(CONF_START_MOWING_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_START_MOWING_COMMAND_TOPIC): valid_publish_topic,
},
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA))
@@ -4,15 +4,19 @@ from __future__ import annotations
from ast import literal_eval
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.exceptions import ServiceValidationError, TemplateError
from homeassistant.helpers import template
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import TemplateVarsType
if TYPE_CHECKING:
from .discovery import MQTTDiscoveryPayload
from .const import TEMPLATE_ERRORS
class PayloadSentinel(StrEnum):
@@ -212,3 +216,13 @@ class MqttValueTemplate:
payload=payload,
) from exc
return rendered_payload
@dataclass(slots=True)
class MqttComponentConfig:
"""(component, object_id, node_id, discovery_payload)."""
component: str
object_id: str
node_id: str | None
discovery_payload: MQTTDiscoveryPayload
@@ -0,0 +1,25 @@
"""Support for MQTT notify."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import CONF_NAME
from homeassistant.helpers import config_validation as cv
from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA
from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
{
vol.Optional(CONF_COMMAND_TEMPLATE): cv.template,
vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
@@ -28,11 +28,14 @@ from .const import (
CONF_AVAILABILITY_MODE,
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_COMPONENTS,
CONF_CONFIGURATION_URL,
CONF_CONNECTIONS,
CONF_DEFAULT_ENTITY_ID,
CONF_DEPRECATED_VIA_HUB,
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_ENTITY_PICTURE,
CONF_HW_VERSION,
CONF_IDENTIFIERS,
@@ -43,7 +46,9 @@ from .const import (
CONF_ORIGIN,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_QOS,
CONF_SERIAL_NUMBER,
CONF_STATE_TOPIC,
CONF_SUGGESTED_AREA,
CONF_SUPPORT_URL,
CONF_SW_VERSION,
@@ -51,8 +56,22 @@ from .const import (
CONF_VIA_DEVICE,
DEFAULT_PAYLOAD_AVAILABLE,
DEFAULT_PAYLOAD_NOT_AVAILABLE,
ENTITY_PLATFORMS,
SUPPORTED_COMPONENTS,
)
from .util import valid_subscribe_topic
from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
# Device discovery options that are also available at entity component level
SHARED_OPTIONS = [
CONF_AVAILABILITY,
CONF_AVAILABILITY_MODE,
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
]
_MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema(
{
@@ -157,3 +176,35 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
_UNIQUE_ID_SCHEMA = vol.Schema(
{vol.Required(CONF_UNIQUE_ID): cv.string},
).extend({}, extra=True)
def check_unique_id(config: dict[str, Any]) -> dict[str, Any]:
"""Check if a unique ID is set in case an entity platform is configured."""
platform = config[CONF_PLATFORM]
if platform in ENTITY_PLATFORMS and len(config.keys()) > 1:
_UNIQUE_ID_SCHEMA(config)
return config
_COMPONENT_CONFIG_SCHEMA = vol.All(
vol.Schema(
{vol.Required(CONF_PLATFORM): vol.In(SUPPORTED_COMPONENTS)},
).extend({}, extra=True),
check_unique_id,
)
DEVICE_DISCOVERY_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
{
vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
vol.Required(CONF_COMPONENTS): vol.Schema({str: _COMPONENT_CONFIG_SCHEMA}),
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING): cv.string,
}
)
@@ -0,0 +1,46 @@
"""Support for MQTT sirens."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import (
CONF_NAME,
CONF_PAYLOAD_OFF,
CONF_PAYLOAD_ON,
)
from homeassistant.helpers import config_validation as cv
from .config import MQTT_RW_SCHEMA
from .const import (
CONF_AVAILABLE_TONES,
CONF_COMMAND_OFF_TEMPLATE,
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_STATE_OFF,
CONF_STATE_ON,
CONF_STATE_VALUE_TEMPLATE,
CONF_SUPPORT_DURATION,
CONF_SUPPORT_VOLUME_SET,
DEFAULT_PAYLOAD_OFF,
DEFAULT_PAYLOAD_ON,
)
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend(
{
vol.Optional(CONF_AVAILABLE_TONES): cv.ensure_list,
vol.Optional(CONF_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_COMMAND_OFF_TEMPLATE): cv.template,
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
vol.Optional(CONF_STATE_OFF): cv.string,
vol.Optional(CONF_STATE_ON): cv.string,
vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_SUPPORT_DURATION, default=True): cv.boolean,
vol.Optional(CONF_SUPPORT_VOLUME_SET, default=True): cv.boolean,
},
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA))
@@ -5,6 +5,11 @@ from __future__ import annotations
from enum import StrEnum
from typing import Final
from .generated.entity_platforms import EntityPlatforms
# Explicit reexport to allow other modules to import Platform directly from const
Platform = EntityPlatforms
# Max characters for data stored in the recorder (changes to these limits would require
# a database migration)
@@ -0,0 +1,54 @@
"""Automatically generated file.
To update, run python3 -m script.hassfest
"""
from enum import StrEnum
class EntityPlatforms(StrEnum):
"""Available entity platforms."""
AI_TASK = "ai_task"
AIR_QUALITY = "air_quality"
ALARM_CONTROL_PANEL = "alarm_control_panel"
ASSIST_SATELLITE = "assist_satellite"
BINARY_SENSOR = "binary_sensor"
BUTTON = "button"
CALENDAR = "calendar"
CAMERA = "camera"
CLIMATE = "climate"
CONVERSATION = "conversation"
COVER = "cover"
DATE = "date"
DATETIME = "datetime"
DEVICE_TRACKER = "device_tracker"
EVENT = "event"
FAN = "fan"
GEO_LOCATION = "geo_location"
HUMIDIFIER = "humidifier"
IMAGE = "image"
IMAGE_PROCESSING = "image_processing"
LAWN_MOWER = "lawn_mower"
LIGHT = "light"
LOCK = "lock"
MEDIA_PLAYER = "media_player"
NOTIFY = "notify"
NUMBER = "number"
REMOTE = "remote"
SCENE = "scene"
SELECT = "select"
SENSOR = "sensor"
SIREN = "siren"
STT = "stt"
SWITCH = "switch"
TEXT = "text"
TIME = "time"
TODO = "todo"
TTS = "tts"
UPDATE = "update"
VACUUM = "vacuum"
VALVE = "valve"
WAKE_WORD = "wake_word"
WATER_HEATER = "water_heater"
WEATHER = "weather"
@@ -15,5 +15,11 @@
<description>MQTT base prefix</description>
<default>homeassistant</default>
</parameter>
<parameter name="deviceConfig" type="text">
<label>Device Config</label>
<description>Full device configuration in JSON</description>
<advanced>true</advanced>
</parameter>
</config-description>
</config-description:config-descriptions>
@@ -17,6 +17,8 @@ thing-type.homeassistant.device.description = You need a configured Broker first
thing-type.config.homeassistant.device.basetopic.label = MQTT Base Prefix
thing-type.config.homeassistant.device.basetopic.description = MQTT base prefix
thing-type.config.homeassistant.device.deviceConfig.label = Device Config
thing-type.config.homeassistant.device.deviceConfig.description = Full device configuration in JSON
thing-type.config.homeassistant.device.topics.label = MQTT Config Topic
thing-type.config.homeassistant.device.topics.description = List of Home Assistant configuration topics (e.g. button/my-device/restart)
@@ -132,3 +134,8 @@ state.water-heater.mode.performance = Performance
updateActionLabel = Update
updateActionDesc = Trigger the device to perform an update
# thing status messages
offline.conf-error.no-component-topics = No component topics configured.
offline.conf-error.only-one-device = Only one 'device' component is allowed per Thing.
@@ -22,9 +22,11 @@ import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.util.Arrays;
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.junit.jupiter.api.Test;
import org.openhab.binding.homeassistant.internal.AbstractHomeAssistantTests;
import org.openhab.binding.homeassistant.internal.config.dto.AbstractComponentConfiguration;
@@ -53,11 +55,15 @@ public class HAConfigurationTests extends AbstractHomeAssistantTests {
}
}
private static Map<String, @Nullable Object> parsePayload(String topic, String json) {
return PYTHON.processDiscoveryConfig(topic, json).getFirst().getDiscoveryPayload();
}
@Test
public void testAbbreviations() {
String json = readTestJson("configA.json");
Switch.Configuration config = AbstractComponentConfiguration.create(PYTHON, "switch", json,
Switch.Configuration config = AbstractComponentConfiguration.create(parsePayload("switch/a", json),
Switch.Configuration.class);
assertThat(config.getName(), is("A"));
@@ -85,7 +91,7 @@ public class HAConfigurationTests extends AbstractHomeAssistantTests {
public void testTildeSubstritution() {
String json = readTestJson("configB.json");
Switch.Configuration config = AbstractComponentConfiguration.create(PYTHON, "switch", json,
Switch.Configuration config = AbstractComponentConfiguration.create(parsePayload("switch/a", json),
Switch.Configuration.class);
assertThat(config.getAvailabilityTopic(), is("D/E"));
@@ -103,7 +109,8 @@ public class HAConfigurationTests extends AbstractHomeAssistantTests {
public void testSampleFanConfig() {
String json = readTestJson("configFan.json");
Fan.Configuration config = AbstractComponentConfiguration.create(PYTHON, "fan", json, Fan.Configuration.class);
Fan.Configuration config = AbstractComponentConfiguration.create(parsePayload("fan/a", json),
Fan.Configuration.class);
assertThat(config.getName(), is("Bedroom Fan"));
}
@@ -111,7 +118,8 @@ public class HAConfigurationTests extends AbstractHomeAssistantTests {
public void testDeviceListConfig() {
String json = readTestJson("configDeviceList.json");
Fan.Configuration config = AbstractComponentConfiguration.create(PYTHON, "fan", json, Fan.Configuration.class);
Fan.Configuration config = AbstractComponentConfiguration.create(parsePayload("fan/a", json),
Fan.Configuration.class);
assertThat(config.getDevice(), is(notNullValue()));
Device device = config.getDevice();
@@ -124,7 +132,8 @@ public class HAConfigurationTests extends AbstractHomeAssistantTests {
public void testDeviceSingleStringConfig() {
String json = readTestJson("configDeviceSingleString.json");
Fan.Configuration config = AbstractComponentConfiguration.create(PYTHON, "fan", json, Fan.Configuration.class);
Fan.Configuration config = AbstractComponentConfiguration.create(parsePayload("fan/a", json),
Fan.Configuration.class);
assertThat(config.getDevice(), is(notNullValue()));
Device device = config.getDevice();
@@ -136,7 +145,7 @@ public class HAConfigurationTests extends AbstractHomeAssistantTests {
@Test
public void testTS0601ClimateConfig() {
String json = readTestJson("configTS0601ClimateThermostat.json");
Climate.Configuration config = AbstractComponentConfiguration.create(PYTHON, "climate", json,
Climate.Configuration config = AbstractComponentConfiguration.create(parsePayload("climate/a", json),
Climate.Configuration.class);
assertThat(config.getDevice(), is(notNullValue()));
assertThat(config.getDevice().getIdentifiers(), is(notNullValue()));
@@ -174,7 +183,7 @@ public class HAConfigurationTests extends AbstractHomeAssistantTests {
@Test
public void testClimateConfig() {
String json = readTestJson("configClimate.json");
Climate.Configuration config = AbstractComponentConfiguration.create(PYTHON, "climate", json,
Climate.Configuration config = AbstractComponentConfiguration.create(parsePayload("climate/a", json),
Climate.Configuration.class);
assertThat(config.getActionTemplate().toString(), is("Template<template=(a) renders=0>"));
assertThat(config.getActionTopic(), is("b"));
@@ -16,6 +16,7 @@ import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
@@ -185,6 +186,69 @@ public class HomeAssistantDiscoveryTests extends AbstractHomeAssistantTests {
hasItems("climate/0x847127fffe11dd6a_climate_zigbee2mqtt"));
}
@Test
public void testDeviceDiscoveryAddsFullPayloadToDeviceConfigProperty() throws Exception {
var discoveryListener = new LatchDiscoveryListener();
var latch = discoveryListener.createWaitForThingsDiscoveredLatch(1);
discovery.addDiscoveryListener(discoveryListener);
byte[] payload = getResourceAsByteArray("component/configDevice1.json");
discovery.receivedMessage(HA_UID, bridgeConnection, "homeassistant/device/mydevice/config", payload);
assertTrue(latch.await(DISCOVERY_TIMEOUT_SECONDS, TimeUnit.SECONDS));
var discoveryResults = discoveryListener.getDiscoveryResults();
assertThat(discoveryResults.size(), is(1));
DiscoveryResult result = discoveryResults.getFirst();
assertThat(result.getProperties().get(HandlerConfiguration.PROPERTY_DEVICE_CONFIG),
is(new String(payload, StandardCharsets.UTF_8)));
}
@Test
public void testDeviceDiscoveryWithoutComponentsStillCreatesThing() throws Exception {
var discoveryListener = new LatchDiscoveryListener();
var latch = discoveryListener.createWaitForThingsDiscoveredLatch(1);
String payload = """
{
"dev": {
"ids": ["ea334450945afc"],
"name": "Kitchen",
"mf": "Bla electronics",
"mdl": "xya",
"sw": "1.0",
"sn": "ea334450945afc",
"hw": "1.0rev2"
},
"o": {
"name":"bla2mqtt",
"sw": "2.1",
"url": "https://bla2mqtt.example.com/support"
},
"cmps": {},
"state_topic":"sensorBedroom/state",
"qos": 2
}
""";
discovery.addDiscoveryListener(discoveryListener);
discovery.receivedMessage(HA_UID, bridgeConnection, "homeassistant/device/mydevice/config",
payload.getBytes(StandardCharsets.UTF_8));
assertTrue(latch.await(DISCOVERY_TIMEOUT_SECONDS, TimeUnit.SECONDS));
var discoveryResults = discoveryListener.getDiscoveryResults();
assertThat(discoveryResults.size(), is(1));
DiscoveryResult result = discoveryResults.getFirst();
assertThat(result.getBridgeUID(), is(HA_UID));
assertThat(result.getLabel(), is("Kitchen"));
assertThat(result.getProperties().get(Thing.PROPERTY_VENDOR), is("Bla electronics"));
assertThat(result.getProperties().get(Thing.PROPERTY_MODEL_ID), is("xya"));
assertThat(result.getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION), is("1.0"));
assertThat((List<String>) result.getProperties().get(HandlerConfiguration.PROPERTY_TOPICS),
hasItems("device/mydevice"));
assertThat(result.getProperties().get(HandlerConfiguration.PROPERTY_DEVICE_CONFIG), is(payload));
}
private static class TestHomeAssistantDiscovery extends HomeAssistantDiscovery {
public TestHomeAssistantDiscovery(MqttChannelTypeProvider typeProvider, HomeAssistantPythonBridge python) {
super(null, python);
@@ -68,6 +68,8 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
"lock/0x2222222222222222_test_lock_zigbee2mqtt", "binary_sensor/abc/activeEnergyReports",
"number/abc/activeEnergyReports", "sensor/abc/activeEnergyReports");
private static final List<String> DEVICE_ONLY_TOPICS = List.of("device/mydevice");
private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
.map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
@@ -100,6 +102,15 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
thingHandler = spy(thingHandler);
}
private void setupThingHandlerWithTopics(List<String> topics) {
Configuration thingConfiguration = new Configuration();
thingConfiguration.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
thingConfiguration.put(HandlerConfiguration.PROPERTY_TOPICS, topics);
haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).withConfiguration(thingConfiguration)
.build();
setupThingHandler();
}
@Test
public void testInitialize() {
// When initialize
@@ -482,4 +493,226 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
Channel sensorChannel = nonSpyThingHandler.getThing().getChannel("activeEnergyReports_sensor#sensor");
assertThat("Sensor channel is created", sensorChannel, notNullValue());
}
@Test
public void testDevices() {
setupThingHandlerWithTopics(DEVICE_ONLY_TOPICS);
// When initialize
thingHandler.initialize();
verify(thingHandler, never()).componentDiscovered(any(), any());
assertThat(haThing.getChannels().size(), is(0));
// Components discovered after messages in corresponding topics
var configTopic = "homeassistant/device/mydevice/config";
thingHandler.discoverComponents.processMessage(configTopic,
getResourceAsByteArray("component/configDevice1.json"));
verify(thingHandler).componentDiscovered(
eq(new HaID("homeassistant/sensor/mydevice/some_unique_component_id1/config")), any(Sensor.class));
verify(thingHandler).componentDiscovered(eq(new HaID("homeassistant/sensor/mydevice/some_unique_id2/config")),
any(Sensor.class));
thingHandler.delayedProcessing.forceProcessNow();
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(2));
nonSpyThingHandler.getThing().getChannels()
.forEach(channel -> assertThat(channel.getConfiguration().containsKey("config"), is(false)));
}
@Test
public void testFileConfiguredTopicsCreateChannelsAfterMqttConfigMessages() {
setupThingHandlerWithTopics(List.of("switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt"));
thingHandler.initialize();
assertThat("no channels before discovery payload arrives", nonSpyThingHandler.getThing().getChannels().size(),
is(0));
String configTopic = "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config";
thingHandler.discoverComponents.processMessage(configTopic,
getResourceAsByteArray("component/configTS0601AutoLock.json"));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> {
assertThat("channels created from MQTT discovery payload",
nonSpyThingHandler.getThing().getChannels().size(), is(2));
});
}
@Test
public void testDeviceJsonChangeRemovesMissingComponents() {
setupThingHandlerWithTopics(DEVICE_ONLY_TOPICS);
thingHandler.initialize();
String configTopic = "homeassistant/device/mydevice/config";
thingHandler.discoverComponents.processMessage(configTopic,
getResourceAsByteArray("component/configDevice1.json"));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> {
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(2));
});
thingHandler.discoverComponents.processMessage(configTopic, """
{
"dev": {
"ids": "ea334450945afc",
"name": "Kitchen"
},
"o": {
"name":"bla2mqtt",
"sw": "2.1",
"url": "https://bla2mqtt.example.com/support"
},
"cmps": {
"some_unique_component_id1": {
"p": "sensor",
"device_class": "temperature",
"unit_of_measurement": "°C",
"value_template": "{{ value_json.temperature}}",
"unique_id": "temp01ae_t"
}
},
"state_topic": "sensorBedroom/state",
"qos": 2
}
""".getBytes(StandardCharsets.UTF_8));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> {
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(1));
});
}
@Test
public void testDeviceDiscoveryRestoredFromPersistedConfigCache() {
setupThingHandlerWithTopics(DEVICE_ONLY_TOPICS);
String configTopic = "homeassistant/device/mydevice/config";
thingHandler.initialize();
thingHandler.discoverComponents.processMessage(configTopic,
getResourceAsByteArray("component/configDevice1.json"));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> {
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(2));
});
String persistedCache = thingHandler.config.deviceConfig;
assertThat(persistedCache, notNullValue());
assertThat("persisted device cache is written after device discovery", persistedCache.isBlank(), is(false));
assertThat("persisted cache contains device components payload", persistedCache.contains("\"cmps\""), is(true));
Configuration thingConfiguration = new Configuration();
thingConfiguration.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
thingConfiguration.put(HandlerConfiguration.PROPERTY_TOPICS, DEVICE_ONLY_TOPICS);
thingConfiguration.put(HandlerConfiguration.PROPERTY_DEVICE_CONFIG, persistedCache);
haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).withConfiguration(thingConfiguration)
.build();
setupThingHandler();
thingHandler.initialize();
assertThat("persisted cache available after reinitialize", thingHandler.config.deviceConfig,
is(persistedCache));
verify(thingHandler, never()).componentDiscovered(any(), any());
waitForAssert(() -> assertThat("components restored from persisted cache before new MQTT messages",
nonSpyThingHandler.getComponents().size(), is(2)));
waitForAssert(() -> assertThat("channels restored from deviceConfig before new MQTT messages",
nonSpyThingHandler.getThing().getChannels().size(), is(2)));
nonSpyThingHandler.getThing().getChannels()
.forEach(channel -> assertThat(channel.getConfiguration().containsKey("config"), is(false)));
var beforeChannelIds = nonSpyThingHandler.getThing().getChannels().stream().map(Channel::getUID)
.collect(Collectors.toSet());
var beforeComponentIds = nonSpyThingHandler.getComponents().keySet().stream().filter(Objects::nonNull)
.collect(Collectors.toSet());
thingHandler.discoverComponents.processMessage(configTopic,
getResourceAsByteArray("component/configDevice1.json"));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> assertThat("channels unchanged after same MQTT device message arrives",
nonSpyThingHandler.getThing().getChannels().size(), is(2)));
assertThat("channel IDs unchanged after same MQTT device message arrives",
nonSpyThingHandler.getThing().getChannels().stream().map(Channel::getUID).collect(Collectors.toSet()),
is(beforeChannelIds));
assertThat(
"component IDs unchanged after same MQTT device message arrives", nonSpyThingHandler.getComponents()
.keySet().stream().filter(Objects::nonNull).collect(Collectors.toSet()),
is(beforeComponentIds));
}
@Test
public void testMultipleDevicesAreRejected() {
Configuration thingConfiguration = new Configuration();
thingConfiguration.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
thingConfiguration.put(HandlerConfiguration.PROPERTY_TOPICS, List.of("device/mydevice", "device/mydevice2"));
haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).withConfiguration(thingConfiguration)
.build();
setupThingHandler();
thingHandler.initialize();
verify(thingHandler, never()).start(any());
verify(bridgeConnection, never()).subscribe(anyString(), any());
}
@Test
public void testMigrationMessageRemovesChannelsAndPrunesTopic() {
thingHandler.initialize();
nonSpyThingHandler.config.topics = thingHandler.config.topics;
String configTopic = "homeassistant/sensor/abc/activeEnergyReports/config";
thingHandler.discoverComponents.processMessage(configTopic, """
{
"command_topic":"zigbee2mqtt/Mud Room Cans Switch (Garage)/set/activeEnergyReports",
"max":32767,
"min":0,
"name":"ActiveEnergyReports",
"object_id":"mud_room_cans_switch_(garage)_activeEnergyReports",
"state_topic":"zigbee2mqtt/Mud Room Cans Switch (Garage)",
"unique_id":"0x04cd15fffedb7f81_activeEnergyReports_zigbee2mqtt",
"value_template":"{{ value_json.activeEnergyReports }}"
}
""".getBytes(StandardCharsets.UTF_8));
thingHandler.delayedProcessing.forceProcessNow();
assertThat(nonSpyThingHandler.config.topics.contains("sensor/abc/activeEnergyReports"), is(true));
waitForAssert(() -> {
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(1));
});
thingHandler.discoverComponents.processMessage(configTopic, """
{"migrate_discovery": true}
""".getBytes(StandardCharsets.UTF_8));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> {
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(0));
});
assertThat(nonSpyThingHandler.config.topics.contains("sensor/abc/activeEnergyReports"), is(false));
}
@Test
public void testDeviceMigrationMessageRemovesAllDeviceComponents() {
setupThingHandlerWithTopics(DEVICE_ONLY_TOPICS);
thingHandler.initialize();
String configTopic = "homeassistant/device/mydevice/config";
thingHandler.discoverComponents.processMessage(configTopic,
getResourceAsByteArray("component/configDevice1.json"));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> {
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(2));
});
thingHandler.discoverComponents.processMessage(configTopic, """
{"migrate_discovery": true}
""".getBytes(StandardCharsets.UTF_8));
thingHandler.delayedProcessing.forceProcessNow();
waitForAssert(() -> {
assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(0));
});
}
}
@@ -0,0 +1,34 @@
{
"dev": {
"ids": "ea334450945afc",
"name": "Kitchen",
"mf": "Bla electronics",
"mdl": "xya",
"sw": "1.0",
"sn": "ea334450945afc",
"hw": "1.0rev2"
},
"o": {
"name":"bla2mqtt",
"sw": "2.1",
"url": "https://bla2mqtt.example.com/support"
},
"cmps": {
"some_unique_component_id1": {
"p": "sensor",
"device_class":"temperature",
"unit_of_measurement":"°C",
"value_template":"{{ value_json.temperature}}",
"unique_id":"temp01ae_t"
},
"some_unique_id2": {
"p": "sensor",
"device_class":"humidity",
"unit_of_measurement":"%",
"value_template":"{{ value_json.humidity}}",
"unique_id":"temp01ae_h"
}
},
"state_topic":"sensorBedroom/state",
"qos": 2
}
@@ -158,6 +158,10 @@ public class HomeAssistantMQTTImplementationTest extends MqttOSGiTest {
@Override
public void componentRemoved(HaID homeAssistantTopicID) {
}
@Override
public void deviceConfigUpdated(HaID homeAssistantTopicID, String configPayload) {
}
}
@Test