mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[homeassistant] Support device-level configuration (#20225)
* [homeassistant] Support device-level configuration Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+54
-13
@@ -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));
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+9
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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");
|
||||
|
||||
+7
-9
@@ -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;
|
||||
}
|
||||
|
||||
+7
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+72
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+10
-12
@@ -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 {
|
||||
|
||||
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
-6
@@ -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")
|
||||
|
||||
+82
-9
@@ -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) {
|
||||
|
||||
+72
-1
@@ -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",
|
||||
)
|
||||
|
||||
+185
-31
@@ -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
|
||||
|
||||
+48
@@ -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
|
||||
)
|
||||
+40
@@ -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))
|
||||
+15
-1
@@ -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
|
||||
+25
@@ -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)
|
||||
+52
-1
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
+46
@@ -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)
|
||||
|
||||
+54
@@ -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"
|
||||
+6
@@ -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>
|
||||
|
||||
+7
@@ -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.
|
||||
|
||||
+16
-7
@@ -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"));
|
||||
|
||||
+64
@@ -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);
|
||||
|
||||
+233
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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
|
||||
}
|
||||
+4
@@ -158,6 +158,10 @@ public class HomeAssistantMQTTImplementationTest extends MqttOSGiTest {
|
||||
@Override
|
||||
public void componentRemoved(HaID homeAssistantTopicID) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deviceConfigUpdated(HaID homeAssistantTopicID, String configPayload) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user