]> git.basschouten.com Git - openhab-addons.git/blob
4626a9860468fe804b46d06efe40c273560777b7
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.mqtt.homeassistant.internal.discovery;
14
15 import java.nio.charset.StandardCharsets;
16 import java.util.ArrayList;
17 import java.util.Collection;
18 import java.util.Collections;
19 import java.util.Comparator;
20 import java.util.HashMap;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Objects;
24 import java.util.Set;
25 import java.util.TreeMap;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.function.Function;
30 import java.util.stream.Collector;
31 import java.util.stream.Collectors;
32 import java.util.stream.Stream;
33
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.mqtt.discovery.AbstractMQTTDiscovery;
37 import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
38 import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
39 import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
40 import org.openhab.binding.mqtt.homeassistant.internal.HaID;
41 import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
42 import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantConfiguration;
43 import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
44 import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
45 import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
46 import org.openhab.core.config.core.ConfigurableService;
47 import org.openhab.core.config.core.Configuration;
48 import org.openhab.core.config.discovery.DiscoveryResult;
49 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
50 import org.openhab.core.config.discovery.DiscoveryService;
51 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
52 import org.openhab.core.thing.ThingTypeUID;
53 import org.openhab.core.thing.ThingUID;
54 import org.openhab.core.thing.type.ThingType;
55 import org.osgi.framework.Constants;
56 import org.osgi.service.component.annotations.Activate;
57 import org.osgi.service.component.annotations.Component;
58 import org.osgi.service.component.annotations.Modified;
59 import org.osgi.service.component.annotations.Reference;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
62
63 import com.google.gson.Gson;
64 import com.google.gson.GsonBuilder;
65
66 /**
67  * The {@link HomeAssistantDiscovery} is responsible for discovering device nodes that follow the
68  * Home Assistant MQTT discovery convention (https://www.home-assistant.io/docs/mqtt/discovery/).
69  *
70  * @author David Graeff - Initial contribution
71  */
72 @Component(service = DiscoveryService.class, configurationPid = "discovery.mqttha", property = Constants.SERVICE_PID
73         + "=discovery.mqttha")
74 @ConfigurableService(category = "system", label = "Home Assistant Discovery", description_uri = "binding:mqtt.homeassistant")
75 @NonNullByDefault
76 public class HomeAssistantDiscovery extends AbstractMQTTDiscovery {
77     private final Logger logger = LoggerFactory.getLogger(HomeAssistantDiscovery.class);
78     private HomeAssistantConfiguration configuration;
79     protected final Map<String, Set<HaID>> componentsPerThingID = new TreeMap<>();
80     protected final Map<String, ThingUID> thingIDPerTopic = new TreeMap<>();
81     protected final Map<String, DiscoveryResult> results = new ConcurrentHashMap<>();
82
83     private @Nullable ScheduledFuture<?> future;
84     private final Gson gson;
85
86     public static final Map<String, String> HA_COMP_TO_NAME = new TreeMap<>();
87     {
88         HA_COMP_TO_NAME.put("alarm_control_panel", "Alarm Control Panel");
89         HA_COMP_TO_NAME.put("binary_sensor", "Sensor");
90         HA_COMP_TO_NAME.put("camera", "Camera");
91         HA_COMP_TO_NAME.put("cover", "Blind");
92         HA_COMP_TO_NAME.put("fan", "Fan");
93         HA_COMP_TO_NAME.put("climate", "Climate Control");
94         HA_COMP_TO_NAME.put("light", "Light");
95         HA_COMP_TO_NAME.put("lock", "Lock");
96         HA_COMP_TO_NAME.put("sensor", "Sensor");
97         HA_COMP_TO_NAME.put("switch", "Switch");
98     }
99
100     static final String BASE_TOPIC = "homeassistant";
101     static final String BIRTH_TOPIC = "homeassistant/status";
102     static final String ONLINE_STATUS = "online";
103
104     @NonNullByDefault({})
105     protected MqttChannelTypeProvider typeProvider;
106
107     @NonNullByDefault({})
108     protected MQTTTopicDiscoveryService mqttTopicDiscovery;
109
110     @Activate
111     public HomeAssistantDiscovery(@Nullable Map<String, Object> properties) {
112         super(null, 3, true, BASE_TOPIC + "/#");
113         this.gson = new GsonBuilder().registerTypeAdapterFactory(new ChannelConfigurationTypeAdapterFactory()).create();
114         configuration = (new Configuration(properties)).as(HomeAssistantConfiguration.class);
115     }
116
117     @Reference
118     public void setMQTTTopicDiscoveryService(MQTTTopicDiscoveryService service) {
119         mqttTopicDiscovery = service;
120     }
121
122     public void unsetMQTTTopicDiscoveryService(@Nullable MQTTTopicDiscoveryService service) {
123         mqttTopicDiscovery.unsubscribe(this);
124         this.mqttTopicDiscovery = null;
125     }
126
127     @Modified
128     protected void modified(@Nullable Map<String, Object> properties) {
129         configuration = (new Configuration(properties)).as(HomeAssistantConfiguration.class);
130     }
131
132     @Override
133     protected MQTTTopicDiscoveryService getDiscoveryService() {
134         return mqttTopicDiscovery;
135     }
136
137     @Reference
138     protected void setTypeProvider(MqttChannelTypeProvider provider) {
139         this.typeProvider = provider;
140     }
141
142     protected void unsetTypeProvider(MqttChannelTypeProvider provider) {
143         this.typeProvider = null;
144     }
145
146     @Override
147     public Set<ThingTypeUID> getSupportedThingTypes() {
148         return typeProvider.getThingTypeUIDs();
149     }
150
151     /**
152      * Summarize components such as {Switch, Switch, Sensor} into string "Sensor, 2x Switch"
153      *
154      * @param componentNames stream of component names
155      * @return summary string of component names and their counts
156      */
157     static String getComponentNamesSummary(Stream<String> componentNames) {
158         StringBuilder summary = new StringBuilder();
159         Collector<String, ?, Long> countingCollector = Collectors.counting();
160         Map<String, Long> componentCounts = componentNames
161                 .collect(Collectors.groupingBy(Function.identity(), countingCollector));
162         componentCounts.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
163             String componentName = entry.getKey();
164             long count = entry.getValue();
165             if (summary.length() > 0) {
166                 // not the first entry, so let's add the separating comma
167                 summary.append(", ");
168             }
169             if (count > 1) {
170                 summary.append(count);
171                 summary.append("x ");
172             }
173             summary.append(componentName);
174         });
175         return summary.toString();
176     }
177
178     @Override
179     public void receivedMessage(ThingUID connectionBridge, MqttBrokerConnection connection, String topic,
180             byte[] payload) {
181         resetTimeout();
182
183         // For HomeAssistant we need to subscribe to a wildcard topic, because topics can either be:
184         // homeassistant/<component>/<node_id>/<object_id>/config OR
185         // homeassistant/<component>/<object_id>/config.
186         // We check for the last part to filter all non-config topics out.
187         if (!topic.endsWith("/config")) {
188             return;
189         }
190
191         // Reset the found-component timer.
192         // We will collect components for the thing label description for another 2 seconds.
193         final ScheduledFuture<?> future = this.future;
194         if (future != null) {
195             future.cancel(false);
196         }
197         this.future = scheduler.schedule(this::publishResults, 2, TimeUnit.SECONDS);
198
199         // We will of course find multiple of the same unique Thing IDs, for each different component another one.
200         // Therefore the components are assembled into a list and given to the DiscoveryResult label for the user to
201         // easily recognize object capabilities.
202         HaID haID = new HaID(topic);
203
204         try {
205             AbstractChannelConfiguration config = AbstractChannelConfiguration
206                     .fromString(new String(payload, StandardCharsets.UTF_8), gson);
207
208             final String thingID = config.getThingId(haID.objectID);
209
210             final ThingTypeUID typeID = new ThingTypeUID(MqttBindingConstants.BINDING_ID,
211                     MqttBindingConstants.HOMEASSISTANT_MQTT_THING.getId() + "_" + thingID);
212
213             final ThingUID thingUID = new ThingUID(typeID, connectionBridge, thingID);
214
215             thingIDPerTopic.put(topic, thingUID);
216
217             // We need to keep track of already found component topics for a specific thing
218             final List<HaID> components;
219             {
220                 Set<HaID> componentsUnordered = componentsPerThingID.computeIfAbsent(thingID,
221                         key -> ConcurrentHashMap.newKeySet());
222
223                 // Invariant. For compiler, computeIfAbsent above returns always
224                 // non-null
225                 Objects.requireNonNull(componentsUnordered);
226                 componentsUnordered.add(haID);
227
228                 components = componentsUnordered.stream().collect(Collectors.toList());
229                 // We sort the components for consistent jsondb serialization order of 'topics' thing property
230                 // Sorting key is HaID::toString, i.e. using the full topic string
231                 components.sort(Comparator.comparing(HaID::toString));
232             }
233
234             final String componentNames = getComponentNamesSummary(
235                     components.stream().map(id -> id.component).map(c -> HA_COMP_TO_NAME.getOrDefault(c, c)));
236
237             final List<String> topics = components.stream().map(HaID::toShortTopic).collect(Collectors.toList());
238
239             Map<String, Object> properties = new HashMap<>();
240             HandlerConfiguration handlerConfig = new HandlerConfiguration(haID.baseTopic, topics);
241             properties = handlerConfig.appendToProperties(properties);
242             properties = config.appendToProperties(properties);
243             properties.put("deviceId", thingID);
244
245             // Because we need the new properties map with the updated "components" list
246             results.put(thingUID.getAsString(),
247                     DiscoveryResultBuilder.create(thingUID).withProperties(properties)
248                             .withRepresentationProperty("deviceId").withBridge(connectionBridge)
249                             .withLabel(config.getThingName() + " (" + componentNames + ")").build());
250         } catch (ConfigurationException e) {
251             logger.warn("HomeAssistant discover error: invalid configuration of thing {} component {}: {}",
252                     haID.objectID, haID.component, e.getMessage());
253         } catch (Exception e) {
254             logger.warn("HomeAssistant discover error: {}", e.getMessage());
255         }
256     }
257
258     @Override
259     protected void startScan() {
260         super.startScan();
261         triggerDeviceDiscovery();
262     }
263
264     @Override
265     protected void startBackgroundDiscovery() {
266         super.startBackgroundDiscovery();
267         triggerDeviceDiscovery();
268     }
269
270     private void triggerDeviceDiscovery() {
271         if (!configuration.status) {
272             return;
273         }
274         // https://www.home-assistant.io/integrations/mqtt/#use-the-birth-and-will-messages-to-trigger-discovery
275         getDiscoveryService().publish(BIRTH_TOPIC, ONLINE_STATUS.getBytes(), 1, false);
276     }
277
278     protected void publishResults() {
279         Collection<DiscoveryResult> localResults;
280
281         localResults = new ArrayList<>(results.values());
282         results.clear();
283         componentsPerThingID.clear();
284         for (DiscoveryResult result : localResults) {
285             final ThingTypeUID typeID = result.getThingTypeUID();
286             ThingType type = typeProvider.derive(typeID, MqttBindingConstants.HOMEASSISTANT_MQTT_THING).build();
287             typeProvider.setThingTypeIfAbsent(typeID, type);
288
289             thingDiscovered(result);
290         }
291     }
292
293     @Override
294     public void topicVanished(ThingUID connectionBridge, MqttBrokerConnection connection, String topic) {
295         if (!topic.endsWith("/config")) {
296             return;
297         }
298         if (thingIDPerTopic.containsKey(topic)) {
299             ThingUID thingUID = thingIDPerTopic.remove(topic);
300             if (thingUID != null) {
301                 final String thingID = thingUID.getId();
302
303                 HaID haID = new HaID(topic);
304
305                 Set<HaID> components = componentsPerThingID.getOrDefault(thingID, Collections.emptySet());
306                 components.remove(haID);
307                 if (components.isEmpty()) {
308                     thingRemoved(thingUID);
309                 }
310             }
311         }
312     }
313 }