Skip to content

Commit 9f03494

Browse files
authored
[homeassistant] Support device-level configuration (#20225)
* [homeassistant] Support device-level configuration Signed-off-by: Cody Cutrer <cody@cutrer.us>
1 parent af21b50 commit 9f03494

30 files changed

Lines changed: 1267 additions & 104 deletions

File tree

bundles/org.openhab.binding.homeassistant/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ Each component will be represented as a Channel Group, with the attributes of th
88

99
Any device that publishes the component configuration under the `homeassistant` prefix in MQTT will have their components automatically discovered and added to the Inbox.
1010
You can also manually create a Thing, and provide the individual component topics, as well as a different discovery prefix.
11+
[Device Discovery](https://www.home-assistant.io/integrations/mqtt/#device-discovery-payload) is supported as well.
12+
13+
## Example
14+
15+
### Things file
16+
17+
```java
18+
Bridge mqtt:broker:mybroker [ host="192.168.1.10", secure=false ] {
19+
// 1) Single component configuration; channels won't be created until config is received from the MQTT broker
20+
Thing homeassistant:device:kitchen_button [ topics="button/kitchen_button/restart" ]
21+
22+
// 2) Device-level configuration topic; channels won't be created until config is received from the MQTT broker
23+
Thing homeassistant:device:kitchen_device [ topics="device/kitchen" ]
24+
25+
// 3) Device-level configuration with full JSON in deviceConfig
26+
// Channels are restored from deviceConfig immediately, so the Thing is usable
27+
// even before the retained MQTT discovery message is received from the broker.
28+
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}" ]
29+
}
30+
```
1131

1232
## Supported Components and Channels
1333

bundles/org.openhab.binding.homeassistant/src/main/java/org/openhab/binding/homeassistant/internal/DiscoverComponents.java

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import java.lang.ref.WeakReference;
1616
import java.util.HashSet;
17+
import java.util.List;
1718
import java.util.Set;
1819
import java.util.concurrent.CompletableFuture;
1920
import java.util.concurrent.ScheduledExecutorService;
@@ -25,6 +26,7 @@
2526
import org.eclipse.jdt.annotation.Nullable;
2627
import org.openhab.binding.homeassistant.internal.component.AbstractComponent;
2728
import org.openhab.binding.homeassistant.internal.component.ComponentFactory;
29+
import org.openhab.binding.homeassistant.internal.config.dto.MqttComponentConfig;
2830
import org.openhab.binding.homeassistant.internal.exception.ConfigurationException;
2931
import org.openhab.binding.homeassistant.internal.exception.UnsupportedComponentException;
3032
import org.openhab.binding.mqtt.generic.AvailabilityTracker;
@@ -64,6 +66,7 @@ public class DiscoverComponents implements MqttMessageSubscriber {
6466
protected @Nullable ComponentDiscovered discoveredListener;
6567
private int discoverTime;
6668
private Set<String> topics = new HashSet<>();
69+
private final Set<HaID> knownDeviceComponents = new HashSet<>();
6770

6871
/**
6972
* Implement this to get notified of new components
@@ -72,6 +75,8 @@ public static interface ComponentDiscovered {
7275
void componentDiscovered(HaID homeAssistantTopicID, AbstractComponent<?> component);
7376

7477
void componentRemoved(HaID homeAssistantTopicID);
78+
79+
void deviceConfigUpdated(HaID homeAssistantTopicID, String configPayload);
7580
}
7681

7782
/**
@@ -95,35 +100,70 @@ public DiscoverComponents(ThingUID thingUID, ScheduledExecutorService scheduler,
95100
}
96101

97102
@Override
98-
public void processMessage(String topic, byte[] payload) {
103+
public synchronized void processMessage(String topic, byte[] payload) {
99104
if (!topic.endsWith("/config")) {
100105
return;
101106
}
102107

103108
HaID haID = new HaID(topic);
104109
String config = new String(payload);
105-
AbstractComponent<?> component = null;
106110
ComponentDiscovered discoveredListener = this.discoveredListener;
107-
108111
if (config.length() > 0) {
109112
try {
110-
component = ComponentFactory.createComponent(thingUID, haID, config, updateListener, linkageChecker,
111-
tracker, scheduler, gson, python, unitProvider);
112-
component.setConfigSeen();
113+
List<MqttComponentConfig> parsedComponentConfigs = python.processDiscoveryConfig(haID.toShortTopic(),
114+
config);
115+
boolean migrationMessage = parsedComponentConfigs.stream()
116+
.anyMatch(MqttComponentConfig::isMigrateDiscovery);
117+
if (migrationMessage) {
118+
// Just treat a migration message as the component disappearing -
119+
// openHAB doesn't destroy Items when a Channel disappears, so we
120+
// don't need to worry about keeping a sentinel around for components
121+
// that are about to show up again under a device component.
122+
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
123+
if (discoveredListener != null) {
124+
knownDeviceComponents.forEach(discoveredListener::componentRemoved);
125+
}
126+
knownDeviceComponents.clear();
127+
} else if (discoveredListener != null) {
128+
discoveredListener.componentRemoved(haID);
129+
}
130+
return;
131+
}
113132

114-
logger.trace("Found HomeAssistant component {}", haID);
133+
List<AbstractComponent<?>> components = ComponentFactory.createComponent(thingUID, haID, config,
134+
parsedComponentConfigs, updateListener, linkageChecker, tracker, scheduler, gson, python,
135+
unitProvider);
136+
components.forEach(component -> component.setConfigSeen());
137+
138+
logger.trace("Found Home Assistant component {}", haID);
115139

116140
if (discoveredListener != null) {
117-
discoveredListener.componentDiscovered(haID, component);
141+
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
142+
discoveredListener.deviceConfigUpdated(haID, config);
143+
Set<HaID> currentComponents = components.stream().map(AbstractComponent::getHaID)
144+
.collect(Collectors.toSet());
145+
knownDeviceComponents.stream().filter(component -> !currentComponents.contains(component))
146+
.forEach(discoveredListener::componentRemoved);
147+
components.stream().filter(component -> !knownDeviceComponents.contains(component.getHaID()))
148+
.forEach(component -> discoveredListener.componentDiscovered(component.getHaID(),
149+
component));
150+
knownDeviceComponents.clear();
151+
knownDeviceComponents.addAll(currentComponents);
152+
} else {
153+
components.forEach(component -> discoveredListener.componentDiscovered(haID, component));
154+
}
118155
}
119156
} catch (UnsupportedComponentException e) {
120-
logger.warn("HomeAssistant discover error: thing {} component type is unsupported: {}", haID.objectID,
121-
haID.component);
157+
logger.warn("Home Assistant discovery error: component {} is unsupported", haID.toShortTopic());
122158
} catch (ConfigurationException e) {
123-
logger.warn("HomeAssistant discover error: invalid configuration of thing {} component {}: {}",
124-
haID.objectID, haID.component, e.getMessage());
159+
logger.warn("Home Assistant discovery error: invalid configuration of component {}: {}",
160+
haID.toShortTopic(), e.getMessage());
125161
}
126162
} else if (discoveredListener != null) {
163+
if (HomeAssistantBindingConstants.DEVICE_COMPONENT.equals(haID.component)) {
164+
knownDeviceComponents.forEach(discoveredListener::componentRemoved);
165+
knownDeviceComponents.clear();
166+
}
127167
discoveredListener.componentRemoved(haID);
128168
}
129169
}
@@ -176,13 +216,14 @@ private void subscribeSuccess() {
176216
}
177217
}
178218

179-
private @Nullable Void subscribeFail(Throwable e) {
219+
private synchronized @Nullable Void subscribeFail(Throwable e) {
180220
final ScheduledFuture<?> scheduledFuture = this.stopDiscoveryFuture;
181221
if (scheduledFuture != null) { // Cancel timeout
182222
scheduledFuture.cancel(false);
183223
this.stopDiscoveryFuture = null;
184224
}
185225
this.discoveredListener = null;
226+
this.knownDeviceComponents.clear();
186227
final MqttBrokerConnection connection = connectionRef.get();
187228
if (connection != null) {
188229
this.topics.stream().forEach(t -> connection.unsubscribe(t, this));

bundles/org.openhab.binding.homeassistant/src/main/java/org/openhab/binding/homeassistant/internal/HaID.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public HaID() {
8181
* @param nodeID The node ID (can be the empty string)
8282
* @param component The component ID
8383
*/
84-
private HaID(String baseTopic, String objectID, String nodeID, String component) {
84+
public HaID(String baseTopic, String objectID, String nodeID, String component) {
8585
this.baseTopic = baseTopic;
8686
this.objectID = objectID;
8787
this.nodeID = nodeID;

bundles/org.openhab.binding.homeassistant/src/main/java/org/openhab/binding/homeassistant/internal/HandlerConfiguration.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
@NonNullByDefault
2929
public class HandlerConfiguration {
3030
public static final String PROPERTY_BASETOPIC = "basetopic";
31+
public static final String PROPERTY_DEVICE_CONFIG = "deviceConfig";
3132
public static final String PROPERTY_TOPICS = "topics";
3233
public static final String DEFAULT_BASETOPIC = "homeassistant";
3334
/**
@@ -64,14 +65,20 @@ public class HandlerConfiguration {
6465
*
6566
*/
6667
public List<String> topics;
68+
public String deviceConfig = "";
6769

6870
public HandlerConfiguration() {
69-
this(DEFAULT_BASETOPIC, Collections.emptyList());
71+
this(DEFAULT_BASETOPIC, Collections.emptyList(), "");
7072
}
7173

7274
public HandlerConfiguration(String basetopic, List<String> topics) {
75+
this(basetopic, topics, "");
76+
}
77+
78+
public HandlerConfiguration(String basetopic, List<String> topics, String deviceConfig) {
7379
this.basetopic = basetopic;
7480
this.topics = topics;
81+
this.deviceConfig = deviceConfig;
7582
}
7683

7784
/**
@@ -83,6 +90,7 @@ public HandlerConfiguration(String basetopic, List<String> topics) {
8390
public <T extends Map<String, Object>> T appendToProperties(T properties) {
8491
properties.put(PROPERTY_BASETOPIC, basetopic);
8592
properties.put(PROPERTY_TOPICS, topics);
93+
properties.put(PROPERTY_DEVICE_CONFIG, deviceConfig);
8694
return properties;
8795
}
8896
}

bundles/org.openhab.binding.homeassistant/src/main/java/org/openhab/binding/homeassistant/internal/HomeAssistantBindingConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public class HomeAssistantBindingConstants {
2727
public static final String LEGACY_BINDING_ID = "mqtt";
2828
public static final String BINDING_ID = "homeassistant";
2929

30+
public static final String DEVICE_COMPONENT = "device";
31+
3032
// List of all Thing Type UIDs
3133
public static final ThingTypeUID LEGACY_MQTT_HOMEASSISTANT_THING = new ThingTypeUID(LEGACY_BINDING_ID,
3234
"homeassistant");

bundles/org.openhab.binding.homeassistant/src/main/java/org/openhab/binding/homeassistant/internal/HomeAssistantPythonBridge.java

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.graalvm.polyglot.Value;
3030
import org.graalvm.python.embedding.GraalPyResources;
3131
import org.graalvm.python.embedding.VirtualFileSystem;
32+
import org.openhab.binding.homeassistant.internal.config.dto.MqttComponentConfig;
3233
import org.openhab.binding.homeassistant.internal.exception.ConfigurationException;
3334
import org.openhab.core.OpenHAB;
3435
import org.osgi.service.component.annotations.Activate;
@@ -148,23 +149,20 @@ public String renderValueTemplate(Value template, Object payload, String default
148149
return renderValueTemplateWithVariablesMeth.execute(template, payload, defaultValue, variables).asString();
149150
}
150151

151-
public Map<String, @Nullable Object> processDiscoveryConfig(String component, String payload) {
152+
public List<MqttComponentConfig> processDiscoveryConfig(String topic, String payload) {
152153
try {
153154
@SuppressWarnings("unchecked")
154-
Map<String, @Nullable Object> config = (Map<String, @Nullable Object>) toJava(
155-
processDiscoveryConfigMeth.execute(component, payload));
156-
if (config == null) {
155+
List<Value> configs = (List<Value>) toJava(processDiscoveryConfigMeth.execute(topic, payload));
156+
if (configs == null || configs.isEmpty()) {
157157
throw new ConfigurationException("Invalid configuration");
158158
}
159-
return config;
160-
159+
return configs.stream().map(c -> new MqttComponentConfig(this, c)).toList();
161160
} catch (PolyglotException e) {
162-
throw new ConfigurationException(
163-
"Failed to process discovery config for " + component + ": " + e.getMessage());
161+
throw new ConfigurationException("Failed to process discovery config for " + topic + ": " + e.getMessage());
164162
}
165163
}
166164

167-
private @Nullable Object toJava(Value value) {
165+
public @Nullable Object toJava(Value value) {
168166
if (value.isNull()) {
169167
return null;
170168
}

bundles/org.openhab.binding.homeassistant/src/main/java/org/openhab/binding/homeassistant/internal/component/AbstractComponent.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,7 @@ public BigDecimal getDefaultPrecision() {
128128
* @param singleChannelComponent if this component only ever has one channel, so should never be in a group
129129
*/
130130
public AbstractComponent(ComponentFactory.ComponentContext componentContext, Class<C> clazz) {
131-
this(componentContext, AbstractComponentConfiguration.create(componentContext.getPython(),
132-
componentContext.getHaID().component, componentContext.getConfigJSON(), clazz));
131+
this(componentContext, AbstractComponentConfiguration.create(componentContext.getDiscoveryPayload(), clazz));
133132
}
134133

135134
/**
@@ -208,11 +207,13 @@ protected void finalizeChannels() {
208207
if (channels.size() == 1) {
209208
groupId = null;
210209
channels.values().forEach(c -> c.resetUID(buildChannelUID(componentId), getName()));
211-
} else {
210+
}
211+
212+
if (componentContext.shouldPersistChannelConfiguration()) {
212213
// only the first channel needs to persist the configuration
213-
channels.values().stream().skip(1).forEach(c -> {
214-
c.clearConfiguration();
215-
});
214+
channels.values().stream().skip(1).forEach(ComponentChannel::clearConfiguration);
215+
} else {
216+
channels.values().forEach(ComponentChannel::clearConfiguration);
216217
}
217218
}
218219

0 commit comments

Comments
 (0)