2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.io.homekit.internal;
15 import java.lang.reflect.InvocationTargetException;
16 import java.time.Clock;
17 import java.time.Duration;
18 import java.util.ArrayList;
19 import java.util.Collection;
20 import java.util.HashMap;
21 import java.util.HashSet;
22 import java.util.List;
24 import java.util.Map.Entry;
25 import java.util.Optional;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.ScheduledExecutorService;
29 import java.util.stream.Collectors;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.core.common.ThreadPoolManager;
34 import org.openhab.core.common.registry.RegistryChangeListener;
35 import org.openhab.core.items.GroupItem;
36 import org.openhab.core.items.Item;
37 import org.openhab.core.items.ItemNotFoundException;
38 import org.openhab.core.items.ItemRegistry;
39 import org.openhab.core.items.ItemRegistryChangeListener;
40 import org.openhab.core.items.Metadata;
41 import org.openhab.core.items.MetadataKey;
42 import org.openhab.core.items.MetadataRegistry;
43 import org.openhab.core.storage.Storage;
44 import org.openhab.io.homekit.internal.accessories.AbstractHomekitAccessoryImpl;
45 import org.openhab.io.homekit.internal.accessories.DummyHomekitAccessory;
46 import org.openhab.io.homekit.internal.accessories.HomekitAccessoryFactory;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
50 import io.github.hapjava.accessories.HomekitAccessory;
51 import io.github.hapjava.characteristics.impl.common.NameCharacteristic;
52 import io.github.hapjava.server.impl.HomekitRoot;
55 * Listens for changes to the item and metadata registry. When changes are detected, check
56 * for HomeKit tags and, if present, add the items to the HomekitAccessoryRegistry.
58 * @author Andy Lintner - Initial contribution
61 public class HomekitChangeListener implements ItemRegistryChangeListener {
62 private final Logger logger = LoggerFactory.getLogger(HomekitChangeListener.class);
63 private final static String REVISION_CONFIG = "revision";
64 private final static String ACCESSORY_COUNT = "accessory_count";
65 private final static String KNOWN_ACCESSORIES = "known_accessories";
66 private final ItemRegistry itemRegistry;
67 private final HomekitAccessoryRegistry accessoryRegistry = new HomekitAccessoryRegistry();
68 private final MetadataRegistry metadataRegistry;
69 private final Storage<Object> storage;
70 private final RegistryChangeListener<Metadata> metadataChangeListener;
71 private HomekitAccessoryUpdater updater = new HomekitAccessoryUpdater();
72 private HomekitSettings settings;
73 private int lastAccessoryCount;
74 private Map<String, String> knownAccessories = new HashMap<>();
76 private List<String> priorDummies = new ArrayList<>();
78 private final Set<String> pendingUpdates = new HashSet<>();
80 private final ScheduledExecutorService scheduler = ThreadPoolManager
81 .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
84 * Rather than reacting to item added/removed/modified changes directly, we mark them as dirty (and the groups to
87 * 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,
88 * rather than for each update at a time, preventing us from showing an error message with each addition until the
91 private final Debouncer applyUpdatesDebouncer;
93 HomekitChangeListener(ItemRegistry itemRegistry, HomekitSettings settings, MetadataRegistry metadataRegistry,
94 Storage<Object> storage, int instance) {
95 this.itemRegistry = itemRegistry;
96 this.settings = settings;
97 this.metadataRegistry = metadataRegistry;
98 this.storage = storage;
99 this.instance = instance;
100 this.applyUpdatesDebouncer = new Debouncer("update-homekit-devices-" + instance, scheduler,
101 Duration.ofMillis(1000), Clock.systemUTC(), this::applyUpdates);
102 metadataChangeListener = new RegistryChangeListener<Metadata>() {
104 public void added(final Metadata metadata) {
105 final MetadataKey uid = metadata.getUID();
106 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(uid.getNamespace())) {
108 markDirty(itemRegistry.getItem(uid.getItemName()));
109 } catch (ItemNotFoundException e) {
110 logger.trace("Could not find item for metadata {}", metadata);
116 public void removed(final Metadata metadata) {
117 final MetadataKey uid = metadata.getUID();
118 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(uid.getNamespace())) {
120 markDirty(itemRegistry.getItem(uid.getItemName()));
121 } catch (ItemNotFoundException e) {
122 logger.trace("Could not find item for metadata {}", metadata);
128 public void updated(final Metadata oldMetadata, final Metadata newMetadata) {
129 final MetadataKey oldUid = oldMetadata.getUID();
130 final MetadataKey newUid = newMetadata.getUID();
131 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(oldUid.getNamespace())
132 || HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(newUid.getNamespace())) {
134 // the item name is same in old and new metadata, so we can take any.
135 markDirty(itemRegistry.getItem(oldUid.getItemName()));
136 } catch (ItemNotFoundException e) {
137 logger.debug("Could not find item for metadata {}", oldMetadata);
142 itemRegistry.addRegistryChangeListener(this);
143 metadataRegistry.addRegistryChangeListener(metadataChangeListener);
144 initialiseRevision();
145 boolean changed = false;
146 for (var i : itemRegistry.getItems()) {
147 String oldValue = knownAccessories.get(i.getName());
148 createRootAccessories(i);
149 if (accessoryChanged(i.getName(), oldValue)) {
150 logger.debug("Accessory {} changed:\n{}\n{}", i.getName(), oldValue, knownAccessories.get(i.getName()));
154 // order of this conditional is important - checkMissingAccessories has side effects that need to always happen
155 if (checkMissingAccessories() || changed) {
156 makeNewConfigurationRevision();
158 logger.info("Created {} HomeKit items in instance {} (no change from prior configuration).",
159 accessoryRegistry.getAllAccessories().size(), instance);
160 if (settings.useDummyAccessories) {
161 checkForDummyAccessories();
166 private void initialiseRevision() {
169 String revisionString = (String) storage.get(REVISION_CONFIG);
170 if (revisionString == null) {
171 throw new NumberFormatException();
173 revision = Integer.parseInt(revisionString);
174 } catch (NumberFormatException e) {
176 accessoryRegistry.setConfigurationRevision(revision);
178 lastAccessoryCount = 0;
179 var localKnownAccessories = (Map<String, String>) storage.get(KNOWN_ACCESSORIES);
180 if (localKnownAccessories == null) {
181 knownAccessories = new HashMap<>();
184 String accessoryCountString = (String) storage.get(ACCESSORY_COUNT);
185 if (accessoryCountString == null) {
186 throw new NumberFormatException();
188 lastAccessoryCount = Integer.parseInt(accessoryCountString);
189 } catch (NumberFormatException e) {
192 knownAccessories = localKnownAccessories;
193 lastAccessoryCount = knownAccessories.size();
197 private boolean hasHomeKitMetadata(Item item) {
198 return metadataRegistry.get(new MetadataKey(HomekitAccessoryFactory.METADATA_KEY, item.getUID())) != null;
202 public synchronized void added(Item item) {
203 if (hasHomeKitMetadata(item)) {
209 public void allItemsChanged(Collection<String> oldItemNames) {
214 * Mark an item as dirty, plus any accessory groups to which it pertains, so that after a debounce period the
215 * accessory update can be applied.
217 * @param item The item that has been changed or removed.
219 private synchronized void markDirty(Item item) {
220 logger.trace("Mark dirty item {}", item.getName());
221 pendingUpdates.add(item.getName());
223 * If findMyAccessoryGroups fails because the accessory group has already been deleted, then we can count on a
224 * later update telling us that the accessory group was removed.
226 for (Item accessoryGroup : HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry)) {
227 pendingUpdates.add(accessoryGroup.getName());
231 * if metadata of a group item was changed, mark all group member as dirty.
233 if (item instanceof GroupItem) {
234 ((GroupItem) item).getMembers().forEach(groupMember -> pendingUpdates.add(groupMember.getName()));
236 applyUpdatesDebouncer.call();
240 public synchronized void removed(Item item) {
241 if (hasHomeKitMetadata(item)) {
246 private Optional<Item> getItemOptional(String name) {
248 return Optional.of(itemRegistry.getItem(name));
249 } catch (ItemNotFoundException e) {
250 return Optional.empty();
254 public void makeNewConfigurationRevision() {
255 final int newRevision = accessoryRegistry.makeNewConfigurationRevision();
256 lastAccessoryCount = accessoryRegistry.getAllAccessories().size();
257 logger.info("Created {} HomeKit items in instance {}.", accessoryRegistry.getAllAccessories().size(), instance);
258 logger.trace("Making new configuration revision {}", newRevision);
259 storage.put(REVISION_CONFIG, "" + newRevision);
260 storage.put(KNOWN_ACCESSORIES, knownAccessories);
263 public synchronized void pruneDummyAccessories() {
264 boolean removed = false;
265 for (HomekitAccessory accessory : accessoryRegistry.getAllAccessories().values()
266 .toArray(new HomekitAccessory[0])) {
267 if (accessory instanceof DummyHomekitAccessory) {
269 String name = accessory.getName().get();
270 logger.info("Pruning dummy accessory {}.", name);
271 knownAccessories.remove(name);
272 accessoryRegistry.remove(name);
274 } catch (ExecutionException | InterruptedException e) {
275 // will never happen; it's a always completed future
280 makeNewConfigurationRevision();
284 private synchronized void applyUpdates() {
285 logger.trace("Apply updates");
287 HomekitRoot bridge = accessoryRegistry.getBridge();
288 if (bridge != null) {
289 bridge.batchUpdate();
293 boolean changed = false;
294 boolean removed = false;
295 for (final String name : pendingUpdates) {
296 String oldValue = knownAccessories.get(name);
297 accessoryRegistry.remove(name);
298 logger.trace(" Add items {}", name);
299 getItemOptional(name).ifPresent(this::createRootAccessories);
300 if (accessoryChanged(name, oldValue)) {
304 pendingUpdates.clear();
305 if (checkMissingAccessories() || changed) {
306 makeNewConfigurationRevision();
308 checkForDummyAccessories();
310 if (bridge != null) {
311 bridge.completeUpdateBatch();
316 private boolean accessoryChanged(String name, @Nullable String oldValue) {
317 String newValue = knownAccessories.get(name);
318 if (oldValue == null && newValue == null) {
321 return oldValue == null && newValue != null || oldValue != null && newValue == null
322 || !oldValue.equals(newValue);
326 public void updated(Item oldElement, Item element) {
327 markDirty(oldElement);
331 public int getLastAccessoryCount() {
332 return lastAccessoryCount;
335 public synchronized void clearAccessories() {
336 accessoryRegistry.clear();
339 public synchronized void setBridge(HomekitRoot bridge) {
340 accessoryRegistry.setBridge(bridge);
343 public void setUpdater(HomekitAccessoryUpdater updater) {
344 this.updater = updater;
347 public void updateSettings(HomekitSettings settings) {
348 boolean wasUsingDummyAccessories = this.settings.useDummyAccessories;
349 this.settings = settings;
350 // If they turned off dummy accessories, immediately prune them
351 if (wasUsingDummyAccessories && !settings.useDummyAccessories) {
352 pruneDummyAccessories();
356 public synchronized void stop() {
357 this.itemRegistry.removeRegistryChangeListener(this);
358 this.metadataRegistry.removeRegistryChangeListener(metadataChangeListener);
359 applyUpdatesDebouncer.stop();
360 accessoryRegistry.unsetBridge();
363 public Map<String, HomekitAccessory> getAccessories() {
364 return this.accessoryRegistry.getAllAccessories();
367 public int getConfigurationRevision() {
368 return this.accessoryRegistry.getConfigurationRevision();
372 * select primary accessory type from list of types.
374 * - if accessory has only one type, it is the primary type
375 * - if accessory has no primary type defined per configuration, then the first type on the list is the primary type
376 * - if accessory has primary type defined per configuration and this type is on the list of types, then it is the
378 * - if accessory has primary type defined per configuration and this type is NOT on the list of types, then the
379 * first type on the list is the primary type
381 * @param item openhab item
382 * @param accessoryTypes list of accessory type attached to the item
383 * @return primary accessory type
385 private HomekitAccessoryType getPrimaryAccessoryType(Item item,
386 List<Entry<HomekitAccessoryType, HomekitCharacteristicType>> accessoryTypes,
387 @Nullable Map<String, Object> configuration) {
388 if (accessoryTypes.size() > 1 && configuration != null) {
389 final @Nullable Object value = configuration.get(HomekitTaggedItem.PRIMARY_SERVICE);
390 if (value instanceof String) {
391 return accessoryTypes.stream()
392 .filter(aType -> ((String) value).equalsIgnoreCase(aType.getKey().getTag())).findAny()
393 .orElse(accessoryTypes.get(0)).getKey();
396 // no primary accessory found or there is only one type, so return the first type from the list
397 return accessoryTypes.get(0).getKey();
401 * creates one or more HomeKit items for given openhab item.
402 * one OpenHAB item can be linked to several HomeKit accessories.
403 * OpenHAB item is a good candidate for a HomeKit accessory
405 * - it has HomeKit accessory types defined using HomeKit accessory metadata
406 * - AND is not part of a group with HomeKit metadata
408 * Switch light "Light" {homekit="Lighting"}
409 * Group gLight "Light Group" {homekit="Lighting"}
412 * - it has HomeKit accessory types defined using HomeKit accessory metadata
413 * - AND is part of groups with HomeKit metadata, but all groups have baseItem
415 * Group:Switch:OR(ON,OFF) gLight "Light Group " {homekit="Lighting"}
416 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
419 * In contrast, items which are part of groups without BaseItem are additional HomeKit characteristics of the
420 * accessory defined by that group and don't need to be created as accessory here.
422 * Group gLight "Light Group " {homekit="Lighting"}
423 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
424 * is not the root accessory but only a characteristic "OnState"
427 * // Single line HomeKit Accessory
428 * Switch light "Light" {homekit="Lighting"}
430 * // One HomeKit accessory defined using group
431 * Group gLight "Light Group" {homekit="Lighting"}
432 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
434 * // 2 HomeKit accessories: one is switch attached to group, another one a single switch
435 * Group:Switch:OR(ON,OFF) gLight "Light Group " {homekit="Lighting"}
436 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
438 * @param item openHAB item
440 private void createRootAccessories(Item item) {
441 final List<Entry<HomekitAccessoryType, HomekitCharacteristicType>> accessoryTypes = HomekitAccessoryFactory
442 .getAccessoryTypes(item, metadataRegistry);
443 if (accessoryTypes.isEmpty()) {
447 final List<GroupItem> groups = HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry);
448 // Don't create accessories that are sub-accessories of other accessories
449 if (groups.stream().anyMatch(g -> !HomekitAccessoryFactory.getAccessoryTypes(g, metadataRegistry).isEmpty())) {
453 final @Nullable Map<String, Object> itemConfiguration = HomekitAccessoryFactory.getItemConfiguration(item,
455 if (!itemIsForThisBridge(item, itemConfiguration)) {
459 final HomekitAccessoryType primaryAccessoryType = getPrimaryAccessoryType(item, accessoryTypes,
461 logger.trace("Item {} is a HomeKit accessory of types {}. Primary type is {}", item.getName(), accessoryTypes,
462 primaryAccessoryType);
463 final HomekitOHItemProxy itemProxy = new HomekitOHItemProxy(item);
464 final HomekitTaggedItem taggedItem = new HomekitTaggedItem(itemProxy, primaryAccessoryType, itemConfiguration);
466 final AbstractHomekitAccessoryImpl accessory = HomekitAccessoryFactory.create(taggedItem, metadataRegistry,
468 if (accessory.isLinkedServiceOnly()) {
469 logger.warn("Item '{}' is a '{}' which must be nested another another accessory.", taggedItem.getName(),
470 primaryAccessoryType);
474 accessoryTypes.stream().filter(aType -> !primaryAccessoryType.equals(aType.getKey()))
475 .forEach(additionalAccessoryType -> {
476 final HomekitTaggedItem additionalTaggedItem = new HomekitTaggedItem(itemProxy,
477 additionalAccessoryType.getKey(), itemConfiguration);
479 final AbstractHomekitAccessoryImpl additionalAccessory = HomekitAccessoryFactory
480 .create(additionalTaggedItem, metadataRegistry, updater, settings);
481 // Secondary accessories that don't explicitly specify a name will implicitly
482 // get a name characteristic based on the item's name
483 if (!additionalAccessory.getCharacteristic(HomekitCharacteristicType.NAME).isPresent()) {
485 additionalAccessory.addCharacteristic(
486 new NameCharacteristic(() -> additionalAccessory.getName()));
487 } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
488 // This should never happen; all services should support NameCharacteristic as an
489 // optional Characteristic.
490 // If HAP-Java defined a service that doesn't support
491 // addOptionalCharacteristic(NameCharacteristic), then it's a bug there, and we're
492 // just going to ignore the exception here.
495 accessory.getServices().add(additionalAccessory.getPrimaryService());
496 } catch (HomekitException e) {
497 logger.warn("Cannot create additional accessory {}", additionalTaggedItem);
500 knownAccessories.put(taggedItem.getName(), accessory.toJson());
501 accessoryRegistry.addRootAccessory(taggedItem.getName(), accessory);
502 } catch (HomekitException e) {
503 logger.warn("Cannot create accessory {}", taggedItem);
507 private boolean itemIsForThisBridge(Item item, @Nullable Map<String, Object> configuration) {
508 // non-tagged accessories belong to the first instance
509 if (configuration == null) {
510 return (instance == 1);
513 final @Nullable Object value = configuration.get(HomekitTaggedItem.INSTANCE);
515 return (instance == 1);
517 if (value instanceof Number) {
518 return (instance == ((Number) value).intValue());
520 logger.warn("Unrecognized instance tag {} ({}) for item {}; assigning to default instance.", value,
521 value.getClass(), item.getName());
522 return (instance == 1);
526 * Check for any missing accessories.
528 * If there are, return true so we know to increment the config version. UNLESS
529 * we're configured to use dummy accessories, in which case backfill it with a dummy.
531 * @return if we need to increment the configuration version
533 private boolean checkMissingAccessories() {
534 List<String> toRemove = new ArrayList<>();
535 for (Map.Entry<String, String> accessory : knownAccessories.entrySet()) {
536 if (!accessoryRegistry.getAllAccessories().containsKey(accessory.getKey())) {
537 if (settings.useDummyAccessories) {
538 logger.debug("Creating dummy accessory for missing item {}.", accessory.getKey());
539 accessoryRegistry.addRootAccessory(accessory.getKey(),
540 new DummyHomekitAccessory(accessory.getKey(), accessory.getValue()));
542 toRemove.add(accessory.getKey());
547 toRemove.forEach(k -> knownAccessories.remove(k));
548 return !toRemove.isEmpty();
551 private void checkForDummyAccessories() {
552 List<String> currentDummies = accessoryRegistry.getAllAccessories().values().stream()
553 .filter(a -> a instanceof DummyHomekitAccessory).map(a -> {
555 return a.getSerialNumber().get();
556 } catch (InterruptedException | ExecutionException e) {
559 }).collect(Collectors.toList());
561 List<String> resolvedDummies = new ArrayList(priorDummies);
562 resolvedDummies.removeAll(currentDummies);
563 List<String> newDummies = new ArrayList(currentDummies);
564 newDummies.removeAll(priorDummies);
566 if (resolvedDummies.size() <= 5) {
567 for (String item : resolvedDummies) {
568 logger.info("{} has been resolved to an actual accessory, and is no longer a dummy.", item);
570 } else if (currentDummies.isEmpty() && !resolvedDummies.isEmpty()) {
571 logger.info("All dummy accessories have been resolved to actual accessories.");
572 } else if (!resolvedDummies.isEmpty()) {
573 logger.info("{} dummy accessories have been resolved to actual accessories.", resolvedDummies.size());
576 if (newDummies.size() <= 5) {
577 for (String item : newDummies) {
579 "{} has been replaced with a dummy. See https://www.openhab.org/addons/integrations/homekit/#dummy-accessories for more information.",
582 } else if (!newDummies.isEmpty()) {
584 "{} accessories have been replaced with dummies. See https://www.openhab.org/addons/integrations/homekit/#dummy-accessories for more information.",
586 } else if (!currentDummies.isEmpty()) {
588 "{} accessories are still dummies. See https://www.openhab.org/addons/integrations/homekit/#dummy-accessories for more information.",
589 currentDummies.size());
591 priorDummies.clear();
592 priorDummies.addAll(currentDummies);