]> git.basschouten.com Git - openhab-addons.git/blob
8da0410312f220423388757c10577d955e07a957
[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.io.homekit.internal;
14
15 import java.time.Clock;
16 import java.time.Duration;
17 import java.util.ArrayList;
18 import java.util.Collection;
19 import java.util.HashMap;
20 import java.util.HashSet;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Map.Entry;
24 import java.util.Optional;
25 import java.util.Set;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.ScheduledExecutorService;
28 import java.util.stream.Collectors;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.core.common.ThreadPoolManager;
33 import org.openhab.core.common.registry.RegistryChangeListener;
34 import org.openhab.core.items.GroupItem;
35 import org.openhab.core.items.Item;
36 import org.openhab.core.items.ItemNotFoundException;
37 import org.openhab.core.items.ItemRegistry;
38 import org.openhab.core.items.ItemRegistryChangeListener;
39 import org.openhab.core.items.Metadata;
40 import org.openhab.core.items.MetadataKey;
41 import org.openhab.core.items.MetadataRegistry;
42 import org.openhab.core.storage.Storage;
43 import org.openhab.io.homekit.internal.accessories.AbstractHomekitAccessoryImpl;
44 import org.openhab.io.homekit.internal.accessories.DummyHomekitAccessory;
45 import org.openhab.io.homekit.internal.accessories.HomekitAccessoryFactory;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import io.github.hapjava.accessories.HomekitAccessory;
50 import io.github.hapjava.server.impl.HomekitRoot;
51
52 /**
53  * Listens for changes to the item and metadata registry. When changes are detected, check
54  * for HomeKit tags and, if present, add the items to the HomekitAccessoryRegistry.
55  *
56  * @author Andy Lintner - Initial contribution
57  */
58 @NonNullByDefault
59 public class HomekitChangeListener implements ItemRegistryChangeListener {
60     private final Logger logger = LoggerFactory.getLogger(HomekitChangeListener.class);
61     private static final String REVISION_CONFIG = "revision";
62     private static final String ACCESSORY_COUNT = "accessory_count";
63     private static final String KNOWN_ACCESSORIES = "known_accessories";
64     private final ItemRegistry itemRegistry;
65     private final HomekitAccessoryRegistry accessoryRegistry = new HomekitAccessoryRegistry();
66     private final MetadataRegistry metadataRegistry;
67     private final Storage<Object> storage;
68     private final RegistryChangeListener<Metadata> metadataChangeListener;
69     private HomekitAccessoryUpdater updater = new HomekitAccessoryUpdater();
70     private HomekitSettings settings;
71     private int lastAccessoryCount;
72     private Map<String, String> knownAccessories = new HashMap<>();
73     private int instance;
74     private List<String> priorDummies = new ArrayList<>();
75
76     private final Set<String> pendingUpdates = new HashSet<>();
77
78     private final ScheduledExecutorService scheduler = ThreadPoolManager
79             .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
80
81     /**
82      * Rather than reacting to item added/removed/modified changes directly, we mark them as dirty (and the groups to
83      * which they belong)
84      *
85      * We wait for a second to pass until no more items are changed. This allows us to add a group of items all at once,
86      * rather than for each update at a time, preventing us from showing an error message with each addition until the
87      * group is complete.
88      */
89     private final Debouncer applyUpdatesDebouncer;
90
91     HomekitChangeListener(ItemRegistry itemRegistry, HomekitSettings settings, MetadataRegistry metadataRegistry,
92             Storage<Object> storage, int instance) {
93         this.itemRegistry = itemRegistry;
94         this.settings = settings;
95         this.metadataRegistry = metadataRegistry;
96         this.storage = storage;
97         this.instance = instance;
98         this.applyUpdatesDebouncer = new Debouncer("update-homekit-devices-" + instance, scheduler,
99                 Duration.ofMillis(1000), Clock.systemUTC(), this::applyUpdates);
100         metadataChangeListener = new RegistryChangeListener<>() {
101             @Override
102             public void added(final Metadata metadata) {
103                 final MetadataKey uid = metadata.getUID();
104                 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(uid.getNamespace())) {
105                     try {
106                         markDirty(itemRegistry.getItem(uid.getItemName()));
107                     } catch (ItemNotFoundException e) {
108                         logger.trace("Could not find item for metadata {}", metadata);
109                     }
110                 }
111             }
112
113             @Override
114             public void removed(final Metadata metadata) {
115                 final MetadataKey uid = metadata.getUID();
116                 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(uid.getNamespace())) {
117                     try {
118                         markDirty(itemRegistry.getItem(uid.getItemName()));
119                     } catch (ItemNotFoundException e) {
120                         logger.trace("Could not find item for metadata {}", metadata);
121                     }
122                 }
123             }
124
125             @Override
126             public void updated(final Metadata oldMetadata, final Metadata newMetadata) {
127                 final MetadataKey oldUid = oldMetadata.getUID();
128                 final MetadataKey newUid = newMetadata.getUID();
129                 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(oldUid.getNamespace())
130                         || HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(newUid.getNamespace())) {
131                     try {
132                         // the item name is same in old and new metadata, so we can take any.
133                         markDirty(itemRegistry.getItem(oldUid.getItemName()));
134                     } catch (ItemNotFoundException e) {
135                         logger.debug("Could not find item for metadata {}", oldMetadata);
136                     }
137                 }
138             }
139         };
140         itemRegistry.addRegistryChangeListener(this);
141         metadataRegistry.addRegistryChangeListener(metadataChangeListener);
142         initialiseRevision();
143         boolean changed = false;
144         for (var i : itemRegistry.getItems()) {
145             String oldValue = knownAccessories.get(i.getName());
146             createRootAccessories(i);
147             if (accessoryChanged(i.getName(), oldValue)) {
148                 logger.debug("Accessory {} changed:\n{}\n{}", i.getName(), oldValue, knownAccessories.get(i.getName()));
149                 changed = true;
150             }
151         }
152         // order of this conditional is important - checkMissingAccessories has side effects that need to always happen
153         if (checkMissingAccessories() || changed) {
154             makeNewConfigurationRevision();
155         } else {
156             logger.info("Created {} HomeKit items in instance {} (no change from prior configuration).",
157                     accessoryRegistry.getAllAccessories().size(), instance);
158             if (settings.useDummyAccessories) {
159                 checkForDummyAccessories();
160             }
161         }
162     }
163
164     private void initialiseRevision() {
165         int revision = 1;
166         try {
167             String revisionString = (String) storage.get(REVISION_CONFIG);
168             if (revisionString == null) {
169                 throw new NumberFormatException();
170             }
171             revision = Integer.parseInt(revisionString);
172         } catch (NumberFormatException e) {
173         }
174         accessoryRegistry.setConfigurationRevision(revision);
175
176         lastAccessoryCount = 0;
177         var localKnownAccessories = (Map<String, String>) storage.get(KNOWN_ACCESSORIES);
178         if (localKnownAccessories == null) {
179             knownAccessories = new HashMap<>();
180             // Back-compat
181             try {
182                 String accessoryCountString = (String) storage.get(ACCESSORY_COUNT);
183                 if (accessoryCountString == null) {
184                     throw new NumberFormatException();
185                 }
186                 lastAccessoryCount = Integer.parseInt(accessoryCountString);
187             } catch (NumberFormatException e) {
188             }
189         } else {
190             knownAccessories = localKnownAccessories;
191             lastAccessoryCount = knownAccessories.size();
192         }
193     }
194
195     private boolean hasHomeKitMetadata(Item item) {
196         return metadataRegistry.get(new MetadataKey(HomekitAccessoryFactory.METADATA_KEY, item.getUID())) != null;
197     }
198
199     @Override
200     public synchronized void added(Item item) {
201         if (hasHomeKitMetadata(item)) {
202             markDirty(item);
203         }
204     }
205
206     @Override
207     public void allItemsChanged(Collection<String> oldItemNames) {
208         clearAccessories();
209     }
210
211     /**
212      * Mark an item as dirty, plus any accessory groups to which it pertains, so that after a debounce period the
213      * accessory update can be applied.
214      *
215      * @param item The item that has been changed or removed.
216      */
217     private synchronized void markDirty(Item item) {
218         logger.trace("Mark dirty item {}", item.getName());
219         pendingUpdates.add(item.getName());
220         /*
221          * If findMyAccessoryGroups fails because the accessory group has already been deleted, then we can count on a
222          * later update telling us that the accessory group was removed.
223          */
224         for (Item accessoryGroup : HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry)) {
225             pendingUpdates.add(accessoryGroup.getName());
226         }
227
228         /*
229          * if metadata of a group item was changed, mark all group member as dirty.
230          */
231         if (item instanceof GroupItem itemAsGroupItem) {
232             itemAsGroupItem.getMembers().forEach(groupMember -> pendingUpdates.add(groupMember.getName()));
233         }
234         applyUpdatesDebouncer.call();
235     }
236
237     @Override
238     public synchronized void removed(Item item) {
239         if (hasHomeKitMetadata(item)) {
240             markDirty(item);
241         }
242     }
243
244     private Optional<Item> getItemOptional(String name) {
245         try {
246             return Optional.of(itemRegistry.getItem(name));
247         } catch (ItemNotFoundException e) {
248             return Optional.empty();
249         }
250     }
251
252     public void makeNewConfigurationRevision() {
253         final int newRevision = accessoryRegistry.makeNewConfigurationRevision();
254         lastAccessoryCount = accessoryRegistry.getAllAccessories().size();
255         logger.info("Created {} HomeKit items in instance {}.", accessoryRegistry.getAllAccessories().size(), instance);
256         logger.trace("Making new configuration revision {}", newRevision);
257         storage.put(REVISION_CONFIG, "" + newRevision);
258         storage.put(KNOWN_ACCESSORIES, knownAccessories);
259     }
260
261     public synchronized void pruneDummyAccessories() {
262         boolean removed = false;
263         for (HomekitAccessory accessory : accessoryRegistry.getAllAccessories().values()
264                 .toArray(new HomekitAccessory[0])) {
265             if (accessory instanceof DummyHomekitAccessory) {
266                 try {
267                     String name = accessory.getName().get();
268                     logger.info("Pruning dummy accessory {}.", name);
269                     knownAccessories.remove(name);
270                     accessoryRegistry.remove(name);
271                     removed = true;
272                 } catch (ExecutionException | InterruptedException e) {
273                     // will never happen; it's a always completed future
274                 }
275             }
276         }
277         if (removed) {
278             makeNewConfigurationRevision();
279         }
280     }
281
282     private synchronized void applyUpdates() {
283         logger.trace("Apply updates");
284
285         HomekitRoot bridge = accessoryRegistry.getBridge();
286         if (bridge != null) {
287             bridge.batchUpdate();
288         }
289
290         try {
291             boolean changed = false;
292             for (final String name : pendingUpdates) {
293                 String oldValue = knownAccessories.get(name);
294                 accessoryRegistry.remove(name);
295                 logger.trace(" Add items {}", name);
296                 getItemOptional(name).ifPresent(this::createRootAccessories);
297                 if (accessoryChanged(name, oldValue)) {
298                     changed = true;
299                 }
300             }
301             pendingUpdates.clear();
302             if (checkMissingAccessories() || changed) {
303                 makeNewConfigurationRevision();
304             }
305             checkForDummyAccessories();
306         } finally {
307             if (bridge != null) {
308                 bridge.completeUpdateBatch();
309             }
310         }
311     }
312
313     private boolean accessoryChanged(String name, @Nullable String oldValue) {
314         String newValue = knownAccessories.get(name);
315         if (oldValue == null && newValue == null) {
316             return false;
317         }
318         return oldValue == null && newValue != null || oldValue != null && newValue == null
319                 || !oldValue.equals(newValue);
320     }
321
322     @Override
323     public void updated(Item oldElement, Item element) {
324         markDirty(oldElement);
325         markDirty(element);
326     }
327
328     public int getLastAccessoryCount() {
329         return lastAccessoryCount;
330     }
331
332     public synchronized void clearAccessories() {
333         accessoryRegistry.clear();
334     }
335
336     public synchronized void setBridge(HomekitRoot bridge) {
337         accessoryRegistry.setBridge(bridge);
338     }
339
340     public void setUpdater(HomekitAccessoryUpdater updater) {
341         this.updater = updater;
342     }
343
344     public void updateSettings(HomekitSettings settings) {
345         boolean wasUsingDummyAccessories = this.settings.useDummyAccessories;
346         this.settings = settings;
347         // If they turned off dummy accessories, immediately prune them
348         if (wasUsingDummyAccessories && !settings.useDummyAccessories) {
349             pruneDummyAccessories();
350         }
351     }
352
353     public synchronized void stop() {
354         this.itemRegistry.removeRegistryChangeListener(this);
355         this.metadataRegistry.removeRegistryChangeListener(metadataChangeListener);
356         applyUpdatesDebouncer.stop();
357         accessoryRegistry.unsetBridge();
358     }
359
360     public Map<String, HomekitAccessory> getAccessories() {
361         return this.accessoryRegistry.getAllAccessories();
362     }
363
364     public int getConfigurationRevision() {
365         return this.accessoryRegistry.getConfigurationRevision();
366     }
367
368     /**
369      * select primary accessory type from list of types.
370      * selection logic:
371      * - if accessory has only one type, it is the primary type
372      * - if accessory has no primary type defined per configuration, then the first type on the list is the primary type
373      * - if accessory has primary type defined per configuration and this type is on the list of types, then it is the
374      * primary
375      * - if accessory has primary type defined per configuration and this type is NOT on the list of types, then the
376      * first type on the list is the primary type
377      *
378      * @param item openhab item
379      * @param accessoryTypes list of accessory type attached to the item
380      * @return primary accessory type
381      */
382     private HomekitAccessoryType getPrimaryAccessoryType(Item item,
383             List<Entry<HomekitAccessoryType, HomekitCharacteristicType>> accessoryTypes,
384             @Nullable Map<String, Object> configuration) {
385         if (accessoryTypes.size() > 1 && configuration != null) {
386             final @Nullable Object value = configuration.get(HomekitTaggedItem.PRIMARY_SERVICE);
387             if (value instanceof String valueAsString) {
388                 return accessoryTypes.stream().filter(aType -> valueAsString.equalsIgnoreCase(aType.getKey().getTag()))
389                         .findAny().orElse(accessoryTypes.get(0)).getKey();
390             }
391         }
392         // no primary accessory found or there is only one type, so return the first type from the list
393         return accessoryTypes.get(0).getKey();
394     }
395
396     /**
397      * creates one or more HomeKit items for given openhab item.
398      * one OpenHAB item can be linked to several HomeKit accessories.
399      * OpenHAB item is a good candidate for a HomeKit accessory
400      * IF
401      * - it has HomeKit accessory types defined using HomeKit accessory metadata
402      * - AND is not part of a group with HomeKit metadata
403      * e.g.
404      * Switch light "Light" {homekit="Lighting"}
405      * Group gLight "Light Group" {homekit="Lighting"}
406      *
407      * OR
408      * - it has HomeKit accessory types defined using HomeKit accessory metadata
409      * - AND is part of groups with HomeKit metadata, but all groups have baseItem
410      * e.g.
411      * Group:Switch:OR(ON,OFF) gLight "Light Group " {homekit="Lighting"}
412      * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
413      *
414      *
415      * In contrast, items which are part of groups without BaseItem are additional HomeKit characteristics of the
416      * accessory defined by that group and don't need to be created as accessory here.
417      * e.g.
418      * Group gLight "Light Group " {homekit="Lighting"}
419      * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
420      * is not the root accessory but only a characteristic "OnState"
421      *
422      * Examples:
423      * // Single line HomeKit Accessory
424      * Switch light "Light" {homekit="Lighting"}
425      *
426      * // One HomeKit accessory defined using group
427      * Group gLight "Light Group" {homekit="Lighting"}
428      * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
429      *
430      * // 2 HomeKit accessories: one is switch attached to group, another one a single switch
431      * Group:Switch:OR(ON,OFF) gLight "Light Group " {homekit="Lighting"}
432      * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
433      *
434      * @param item openHAB item
435      */
436     private void createRootAccessories(Item item) {
437         final List<Entry<HomekitAccessoryType, HomekitCharacteristicType>> accessoryTypes = HomekitAccessoryFactory
438                 .getAccessoryTypes(item, metadataRegistry);
439         if (accessoryTypes.isEmpty()) {
440             return;
441         }
442
443         final List<GroupItem> groups = HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry);
444         // Don't create accessories that are sub-accessories of other accessories
445         if (groups.stream().anyMatch(g -> !HomekitAccessoryFactory.getAccessoryTypes(g, metadataRegistry).isEmpty())) {
446             return;
447         }
448
449         final @Nullable Map<String, Object> itemConfiguration = HomekitAccessoryFactory.getItemConfiguration(item,
450                 metadataRegistry);
451         if (!itemIsForThisBridge(item, itemConfiguration)) {
452             return;
453         }
454
455         final HomekitAccessoryType primaryAccessoryType = getPrimaryAccessoryType(item, accessoryTypes,
456                 itemConfiguration);
457         logger.trace("Item {} is a HomeKit accessory of types {}. Primary type is {}", item.getName(), accessoryTypes,
458                 primaryAccessoryType);
459         final HomekitOHItemProxy itemProxy = new HomekitOHItemProxy(item);
460         final HomekitTaggedItem taggedItem = new HomekitTaggedItem(itemProxy, primaryAccessoryType, itemConfiguration);
461         try {
462             final AbstractHomekitAccessoryImpl accessory = HomekitAccessoryFactory.create(taggedItem, metadataRegistry,
463                     updater, settings);
464             if (accessory.isLinkedServiceOnly()) {
465                 logger.warn("Item '{}' is a '{}' which must be nested another another accessory.", taggedItem.getName(),
466                         primaryAccessoryType);
467                 return;
468             }
469
470             accessoryTypes.stream().filter(aType -> !primaryAccessoryType.equals(aType.getKey()))
471                     .forEach(additionalAccessoryType -> {
472                         final HomekitTaggedItem additionalTaggedItem = new HomekitTaggedItem(itemProxy,
473                                 additionalAccessoryType.getKey(), itemConfiguration);
474                         try {
475                             final AbstractHomekitAccessoryImpl additionalAccessory = HomekitAccessoryFactory
476                                     .create(additionalTaggedItem, metadataRegistry, updater, settings);
477                             additionalAccessory.promoteNameCharacteristic();
478                             accessory.getServices().add(additionalAccessory.getPrimaryService());
479                         } catch (HomekitException e) {
480                             logger.warn("Cannot create additional accessory {}", additionalTaggedItem);
481                         }
482                     });
483             knownAccessories.put(taggedItem.getName(), accessory.toJson());
484             accessoryRegistry.addRootAccessory(taggedItem.getName(), accessory);
485         } catch (HomekitException e) {
486             logger.warn("Cannot create accessory {}", taggedItem);
487         }
488     }
489
490     private boolean itemIsForThisBridge(Item item, @Nullable Map<String, Object> configuration) {
491         // non-tagged accessories belong to the first instance
492         if (configuration == null) {
493             return (instance == 1);
494         }
495
496         final @Nullable Object value = configuration.get(HomekitTaggedItem.INSTANCE);
497         if (value == null) {
498             return (instance == 1);
499         }
500         if (value instanceof Number valueAsNumber) {
501             return (instance == valueAsNumber.intValue());
502         }
503         logger.warn("Unrecognized instance tag {} ({}) for item {}; assigning to default instance.", value,
504                 value.getClass(), item.getName());
505         return (instance == 1);
506     }
507
508     /**
509      * Check for any missing accessories.
510      *
511      * If there are, return true so we know to increment the config version. UNLESS
512      * we're configured to use dummy accessories, in which case backfill it with a dummy.
513      *
514      * @return if we need to increment the configuration version
515      */
516     private boolean checkMissingAccessories() {
517         List<String> toRemove = new ArrayList<>();
518         for (Map.Entry<String, String> accessory : knownAccessories.entrySet()) {
519             if (!accessoryRegistry.getAllAccessories().containsKey(accessory.getKey())) {
520                 if (settings.useDummyAccessories) {
521                     logger.debug("Creating dummy accessory for missing item {}.", accessory.getKey());
522                     accessoryRegistry.addRootAccessory(accessory.getKey(),
523                             new DummyHomekitAccessory(accessory.getKey(), accessory.getValue()));
524                 } else {
525                     toRemove.add(accessory.getKey());
526                 }
527             }
528         }
529
530         toRemove.forEach(k -> knownAccessories.remove(k));
531         return !toRemove.isEmpty();
532     }
533
534     private void checkForDummyAccessories() {
535         List<String> currentDummies = accessoryRegistry.getAllAccessories().values().stream()
536                 .filter(a -> a instanceof DummyHomekitAccessory).map(a -> {
537                     try {
538                         return a.getSerialNumber().get();
539                     } catch (InterruptedException | ExecutionException e) {
540                         return "<unknown>";
541                     }
542                 }).collect(Collectors.toList());
543
544         List<String> resolvedDummies = new ArrayList(priorDummies);
545         resolvedDummies.removeAll(currentDummies);
546         List<String> newDummies = new ArrayList(currentDummies);
547         newDummies.removeAll(priorDummies);
548
549         if (resolvedDummies.size() <= 5) {
550             for (String item : resolvedDummies) {
551                 logger.info("{} has been resolved to an actual accessory, and is no longer a dummy.", item);
552             }
553         } else if (currentDummies.isEmpty() && !resolvedDummies.isEmpty()) {
554             logger.info("All dummy accessories have been resolved to actual accessories.");
555         } else if (!resolvedDummies.isEmpty()) {
556             logger.info("{} dummy accessories have been resolved to actual accessories.", resolvedDummies.size());
557         }
558
559         if (newDummies.size() <= 5) {
560             for (String item : newDummies) {
561                 logger.warn(
562                         "{} has been replaced with a dummy. See https://www.openhab.org/addons/integrations/homekit/#dummy-accessories for more information.",
563                         item);
564             }
565         } else if (!newDummies.isEmpty()) {
566             logger.warn(
567                     "{} accessories have been replaced with dummies. See https://www.openhab.org/addons/integrations/homekit/#dummy-accessories for more information.",
568                     newDummies.size());
569         } else if (!currentDummies.isEmpty()) {
570             logger.warn(
571                     "{} accessories are still dummies. See https://www.openhab.org/addons/integrations/homekit/#dummy-accessories for more information.",
572                     currentDummies.size());
573         }
574         priorDummies.clear();
575         priorDummies.addAll(currentDummies);
576     }
577 }