]> git.basschouten.com Git - openhab-addons.git/blob
fb9e68e862fa691dd460f949de3aaf11e23c44c7
[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.handler;
14
15 import java.net.URI;
16 import java.util.Comparator;
17 import java.util.HashMap;
18 import java.util.HashSet;
19 import java.util.List;
20 import java.util.Map;
21 import java.util.Objects;
22 import java.util.Optional;
23 import java.util.Set;
24 import java.util.concurrent.CompletableFuture;
25 import java.util.function.Consumer;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.binding.mqtt.generic.AbstractMQTTThingHandler;
30 import org.openhab.binding.mqtt.generic.ChannelState;
31 import org.openhab.binding.mqtt.generic.MqttChannelStateDescriptionProvider;
32 import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
33 import org.openhab.binding.mqtt.generic.tools.DelayedBatchProcessing;
34 import org.openhab.binding.mqtt.generic.utils.FutureCollector;
35 import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
36 import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
37 import org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents;
38 import org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents.ComponentDiscovered;
39 import org.openhab.binding.mqtt.homeassistant.internal.HaID;
40 import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
41 import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent;
42 import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory;
43 import org.openhab.binding.mqtt.homeassistant.internal.component.Update;
44 import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
45 import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
46 import org.openhab.core.config.core.validation.ConfigValidationException;
47 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
48 import org.openhab.core.thing.Channel;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.ThingTypeUID;
54 import org.openhab.core.thing.ThingUID;
55 import org.openhab.core.thing.binding.builder.ThingBuilder;
56 import org.openhab.core.thing.type.ChannelTypeRegistry;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 import com.google.gson.Gson;
61 import com.google.gson.GsonBuilder;
62
63 /**
64  * Handles HomeAssistant MQTT object things. Such an HA Object can have multiple HA Components with different instances
65  * of those Components. This handler auto-discovers all available Components and Component Instances and
66  * adds any new appearing components over time.<br>
67  * <br>
68  *
69  * The specification does not cover the case of disappearing Components. This handler doesn't as well therefore.<br>
70  * <br>
71  *
72  * A Component Instance equals a Channel Group and the Component parts equal Channels.<br>
73  * <br>
74  *
75  * If a Components configuration changes, the known ChannelGroupType and ChannelTypes are replaced with the new ones.
76  *
77  * @author David Graeff - Initial contribution
78  */
79 @NonNullByDefault
80 public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
81         implements ComponentDiscovered, Consumer<List<AbstractComponent<?>>> {
82     public static final String AVAILABILITY_CHANNEL = "availability";
83     private static final Comparator<AbstractComponent<?>> COMPONENT_COMPARATOR = Comparator
84             .comparing((AbstractComponent<?> component) -> component.hasGroup())
85             .thenComparing(AbstractComponent::getName);
86     private static final URI UPDATABLE_CONFIG_DESCRIPTION_URI = URI.create("thing-type:mqtt:homeassistant-updatable");
87
88     private final Logger logger = LoggerFactory.getLogger(HomeAssistantThingHandler.class);
89
90     protected final MqttChannelTypeProvider channelTypeProvider;
91     protected final MqttChannelStateDescriptionProvider stateDescriptionProvider;
92     protected final ChannelTypeRegistry channelTypeRegistry;
93     public final int attributeReceiveTimeout;
94     protected final DelayedBatchProcessing<AbstractComponent<?>> delayedProcessing;
95     protected final DiscoverComponents discoverComponents;
96
97     private final Gson gson;
98     protected final Map<@Nullable String, AbstractComponent<?>> haComponents = new HashMap<>();
99
100     protected HandlerConfiguration config = new HandlerConfiguration();
101     private Set<HaID> discoveryHomeAssistantIDs = new HashSet<>();
102
103     private boolean started;
104     private boolean newStyleChannels;
105     private @Nullable Update updateComponent;
106
107     /**
108      * Create a new thing handler for HomeAssistant MQTT components.
109      * A channel type provider and a topic value receive timeout must be provided.
110      *
111      * @param thing The thing of this handler
112      * @param channelTypeProvider A channel type provider
113      * @param subscribeTimeout Timeout for the entire tree parsing and subscription. In milliseconds.
114      * @param attributeReceiveTimeout The timeout per attribute field subscription. In milliseconds.
115      */
116     public HomeAssistantThingHandler(Thing thing, MqttChannelTypeProvider channelTypeProvider,
117             MqttChannelStateDescriptionProvider stateDescriptionProvider, ChannelTypeRegistry channelTypeRegistry,
118             int subscribeTimeout, int attributeReceiveTimeout) {
119         super(thing, subscribeTimeout);
120         this.gson = new GsonBuilder().registerTypeAdapterFactory(new ChannelConfigurationTypeAdapterFactory()).create();
121         this.channelTypeProvider = channelTypeProvider;
122         this.stateDescriptionProvider = stateDescriptionProvider;
123         this.channelTypeRegistry = channelTypeRegistry;
124         this.attributeReceiveTimeout = attributeReceiveTimeout;
125         this.delayedProcessing = new DelayedBatchProcessing<>(attributeReceiveTimeout, this, scheduler);
126
127         newStyleChannels = "true".equals(thing.getProperties().get("newStyleChannels"));
128
129         this.discoverComponents = new DiscoverComponents(thing.getUID(), scheduler, this, this, gson, newStyleChannels);
130     }
131
132     @Override
133     public void initialize() {
134         started = false;
135
136         config = getConfigAs(HandlerConfiguration.class);
137         if (config.topics.isEmpty()) {
138             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Device topics unknown");
139             return;
140         }
141         discoveryHomeAssistantIDs.addAll(HaID.fromConfig(config));
142
143         ThingTypeUID typeID = getThing().getThingTypeUID();
144         for (Channel channel : thing.getChannels()) {
145             final String groupID = channel.getUID().getGroupId();
146             // Already restored component?
147             @Nullable
148             AbstractComponent<?> component = haComponents.get(groupID);
149
150             HaID haID = HaID.fromConfig(config.basetopic, channel.getConfiguration());
151             discoveryHomeAssistantIDs.add(haID);
152             ThingUID thingUID = channel.getUID().getThingUID();
153             String channelConfigurationJSON = (String) channel.getConfiguration().get("config");
154             if (channelConfigurationJSON == null) {
155                 logger.warn("Provided channel does not have a 'config' configuration key!");
156             } else {
157                 try {
158                     component = ComponentFactory.createComponent(thingUID, haID, channelConfigurationJSON, this, this,
159                             scheduler, gson, newStyleChannels);
160                     if (typeID.equals(MqttBindingConstants.HOMEASSISTANT_MQTT_THING)) {
161                         typeID = calculateThingTypeUID(component);
162                     }
163
164                     haComponents.put(component.getGroupId(), component);
165                 } catch (ConfigurationException e) {
166                     logger.error("Cannot restore component {}: {}", thing, e.getMessage());
167                 }
168             }
169         }
170         if (updateThingType(typeID)) {
171             super.initialize();
172         }
173     }
174
175     @Override
176     public void dispose() {
177         removeStateDescriptions();
178         // super.dispose() calls stop()
179         super.dispose();
180     }
181
182     @Override
183     public CompletableFuture<Void> unsubscribeAll() {
184         // already unsubscribed everything by calling stop()
185         return CompletableFuture.allOf();
186     }
187
188     /**
189      * Start a background discovery for the configured HA MQTT object-id.
190      */
191     @Override
192     protected CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection) {
193         started = true;
194
195         connection.setQos(1);
196         updateStatus(ThingStatus.UNKNOWN);
197
198         // Start all known components and channels within the components and put the Thing offline
199         // if any subscribing failed ( == broker connection lost)
200         CompletableFuture<@Nullable Void> future = CompletableFuture.allOf(super.start(connection),
201                 haComponents.values().stream().map(e -> e.start(connection, scheduler, attributeReceiveTimeout))
202                         .reduce(CompletableFuture.completedFuture(null), (a, v) -> a.thenCompose(b -> v)) // reduce to
203                                                                                                           // one
204                         .exceptionally(e -> {
205                             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
206                             return null;
207                         }));
208
209         return future
210                 .thenCompose(b -> discoverComponents.startDiscovery(connection, 0, discoveryHomeAssistantIDs, this));
211     }
212
213     @Override
214     protected void stop() {
215         if (started) {
216             discoverComponents.stopDiscovery();
217             delayedProcessing.join();
218             // haComponents does not need to be synchronised -> the discovery thread is disabled
219             haComponents.values().stream().map(AbstractComponent::stop) //
220                     // we need to join all the stops, otherwise they might not be done when start is called
221                     .collect(FutureCollector.allOf()).join();
222
223             started = false;
224         }
225         super.stop();
226     }
227
228     @Override
229     public @Nullable ChannelState getChannelState(ChannelUID channelUID) {
230         String componentId;
231         if (channelUID.isInGroup()) {
232             componentId = channelUID.getGroupId();
233         } else {
234             componentId = channelUID.getId();
235         }
236         AbstractComponent<?> component;
237         synchronized (haComponents) { // sync whenever discoverComponents is started
238             component = haComponents.get(componentId);
239         }
240         if (component == null) {
241             component = haComponents.get("");
242             if (component == null) {
243                 return null;
244             }
245         }
246         ComponentChannel componentChannel = component.getChannel(channelUID.getIdWithoutGroup());
247         if (componentChannel == null) {
248             return null;
249         }
250         return componentChannel.getState();
251     }
252
253     /**
254      * Callback of {@link DiscoverComponents}. Add to a delayed batch processor.
255      */
256     @Override
257     public void componentDiscovered(HaID homeAssistantTopicID, AbstractComponent<?> component) {
258         delayedProcessing.accept(component);
259     }
260
261     /**
262      * Callback of {@link DelayedBatchProcessing}.
263      * Add all newly discovered components to the Thing and start the components.
264      */
265     @Override
266     public void accept(List<AbstractComponent<?>> discoveredComponentsList) {
267         MqttBrokerConnection connection = this.connection;
268         if (connection == null) {
269             return;
270         }
271
272         synchronized (haComponents) { // sync whenever discoverComponents is started
273             ThingTypeUID typeID = getThing().getThingTypeUID();
274             for (AbstractComponent<?> discovered : discoveredComponentsList) {
275                 if (typeID.equals(MqttBindingConstants.HOMEASSISTANT_MQTT_THING)) {
276                     typeID = calculateThingTypeUID(discovered);
277                 }
278                 String id = discovered.getGroupId();
279                 AbstractComponent<?> known = haComponents.get(id);
280                 // Is component already known?
281                 if (known != null) {
282                     if (discovered.getConfigHash() != known.getConfigHash()) {
283                         // Don't wait for the future to complete. We are also not interested in failures.
284                         // The component will be replaced in a moment.
285                         known.stop();
286                     } else {
287                         known.setConfigSeen();
288                         continue;
289                     }
290                 }
291
292                 // Add component to the component map
293                 haComponents.put(id, discovered);
294                 // Start component / Subscribe to channel topics
295                 discovered.start(connection, scheduler, 0).exceptionally(e -> {
296                     logger.warn("Failed to start component {}", discovered.getHaID(), e);
297                     return null;
298                 });
299
300                 if (discovered instanceof Update) {
301                     updateComponent = (Update) discovered;
302                     updateComponent.setReleaseStateUpdateListener(this::releaseStateUpdated);
303                 }
304             }
305             updateThingType(typeID);
306         }
307     }
308
309     @Override
310     protected void updateThingStatus(boolean messageReceived, Optional<Boolean> availabilityTopicsSeen) {
311         if (availabilityTopicsSeen.orElse(messageReceived)) {
312             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
313         } else {
314             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
315         }
316     }
317
318     @Override
319     public void handleConfigurationUpdate(Map<String, Object> configurationParameters)
320             throws ConfigValidationException {
321         if (configurationParameters.containsKey("doUpdate")) {
322             configurationParameters = new HashMap<>(configurationParameters);
323             Object value = configurationParameters.remove("doUpdate");
324             if (value instanceof Boolean doUpdate && doUpdate) {
325                 Update updateComponent = this.updateComponent;
326                 if (updateComponent == null) {
327                     logger.warn(
328                             "Received update command for Home Assistant device {}, but it does not have an update component.",
329                             getThing().getUID());
330                 } else {
331                     updateComponent.doUpdate();
332                 }
333             }
334         }
335         super.handleConfigurationUpdate(configurationParameters);
336     }
337
338     private boolean updateThingType(ThingTypeUID typeID) {
339         // if this is a dynamic type, then we update the type
340         if (!MqttBindingConstants.HOMEASSISTANT_MQTT_THING.equals(typeID)) {
341             var thingTypeBuilder = channelTypeProvider.derive(typeID, MqttBindingConstants.HOMEASSISTANT_MQTT_THING);
342
343             if (getThing().getThingTypeUID().equals(MqttBindingConstants.HOMEASSISTANT_MQTT_THING)) {
344                 logger.debug("Migrating Home Assistant thing {} from generic type to dynamic type {}",
345                         getThing().getUID(), typeID);
346
347                 // just create an empty thing type for now; channel configurations won't follow over
348                 // to the re-created Thing, so we need to re-discover them all anyway
349                 channelTypeProvider.putThingType(thingTypeBuilder.build());
350                 changeThingType(typeID, getConfig());
351                 return false;
352             }
353
354             synchronized (haComponents) { // sync whenever discoverComponents is started
355                 var sortedComponents = haComponents.values().stream().sorted(COMPONENT_COMPARATOR).toList();
356
357                 var channelGroupTypes = sortedComponents.stream().map(c -> c.getChannelGroupType(typeID.getId()))
358                         .filter(Objects::nonNull).map(Objects::requireNonNull).toList();
359                 channelTypeProvider.updateChannelGroupTypesForPrefix(typeID.getId(), channelGroupTypes);
360
361                 var groupDefs = sortedComponents.stream().map(c -> c.getGroupDefinition(typeID.getId()))
362                         .filter(Objects::nonNull).map(Objects::requireNonNull).toList();
363                 var channelDefs = sortedComponents.stream().map(AbstractComponent::getChannelDefinitions)
364                         .flatMap(List::stream).toList();
365                 thingTypeBuilder.withChannelDefinitions(channelDefs).withChannelGroupDefinitions(groupDefs);
366                 Update updateComponent = this.updateComponent;
367                 if (updateComponent != null && updateComponent.isUpdatable()) {
368                     thingTypeBuilder.withConfigDescriptionURI(UPDATABLE_CONFIG_DESCRIPTION_URI);
369                 }
370
371                 channelTypeProvider.putThingType(thingTypeBuilder.build());
372
373                 removeStateDescriptions();
374                 sortedComponents.stream().forEach(c -> c.addStateDescriptions(stateDescriptionProvider));
375
376                 ThingBuilder thingBuilder = editThing().withChannels();
377
378                 sortedComponents.stream().map(AbstractComponent::getChannels).flatMap(List::stream)
379                         .forEach(c -> thingBuilder.withChannel(c));
380
381                 updateThing(thingBuilder.build());
382             }
383         }
384         return true;
385     }
386
387     private ThingTypeUID calculateThingTypeUID(AbstractComponent component) {
388         return new ThingTypeUID(MqttBindingConstants.BINDING_ID, MqttBindingConstants.HOMEASSISTANT_MQTT_THING.getId()
389                 + "_" + component.getChannelConfiguration().getThingId(component.getHaID().objectID));
390     }
391
392     @Override
393     public void handleRemoval() {
394         synchronized (haComponents) {
395             channelTypeProvider.removeThingType(thing.getThingTypeUID());
396             channelTypeProvider.removeChannelGroupTypesForPrefix(thing.getThingTypeUID().getId());
397             removeStateDescriptions();
398         }
399         super.handleRemoval();
400     }
401
402     private void removeStateDescriptions() {
403         thing.getChannels().stream().forEach(c -> stateDescriptionProvider.remove(c.getUID()));
404     }
405
406     private void releaseStateUpdated(Update.ReleaseState state) {
407         Map<String, String> properties = editProperties();
408         properties = state.appendToProperties(properties);
409         updateProperties(properties);
410     }
411 }