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.time.Clock;
16 import java.time.Duration;
17 import java.util.Collection;
18 import java.util.HashSet;
19 import java.util.List;
21 import java.util.Map.Entry;
22 import java.util.Optional;
24 import java.util.concurrent.ScheduledExecutorService;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.core.common.ThreadPoolManager;
29 import org.openhab.core.common.registry.RegistryChangeListener;
30 import org.openhab.core.items.GroupItem;
31 import org.openhab.core.items.Item;
32 import org.openhab.core.items.ItemNotFoundException;
33 import org.openhab.core.items.ItemRegistry;
34 import org.openhab.core.items.ItemRegistryChangeListener;
35 import org.openhab.core.items.Metadata;
36 import org.openhab.core.items.MetadataKey;
37 import org.openhab.core.items.MetadataRegistry;
38 import org.openhab.core.storage.Storage;
39 import org.openhab.io.homekit.internal.accessories.HomekitAccessoryFactory;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
43 import io.github.hapjava.accessories.HomekitAccessory;
44 import io.github.hapjava.server.impl.HomekitRoot;
47 * Listens for changes to the item and metadata registry. When changes are detected, check
48 * for HomeKit tags and, if present, add the items to the HomekitAccessoryRegistry.
50 * @author Andy Lintner - Initial contribution
53 public class HomekitChangeListener implements ItemRegistryChangeListener {
54 private final Logger logger = LoggerFactory.getLogger(HomekitChangeListener.class);
55 private final static String REVISION_CONFIG = "revision";
56 private final static String ACCESSORY_COUNT = "accessory_count";
57 private final ItemRegistry itemRegistry;
58 private final HomekitAccessoryRegistry accessoryRegistry = new HomekitAccessoryRegistry();
59 private final MetadataRegistry metadataRegistry;
60 private final Storage<String> storage;
61 private final RegistryChangeListener<Metadata> metadataChangeListener;
62 private HomekitAccessoryUpdater updater = new HomekitAccessoryUpdater();
63 private HomekitSettings settings;
64 private int lastAccessoryCount;
67 private final Set<String> pendingUpdates = new HashSet<>();
69 private final ScheduledExecutorService scheduler = ThreadPoolManager
70 .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
73 * Rather than reacting to item added/removed/modified changes directly, we mark them as dirty (and the groups to
76 * 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,
77 * rather than for each update at a time, preventing us from showing an error message with each addition until the
80 private final Debouncer applyUpdatesDebouncer;
82 HomekitChangeListener(ItemRegistry itemRegistry, HomekitSettings settings, MetadataRegistry metadataRegistry,
83 Storage<String> storage, int instance) {
84 this.itemRegistry = itemRegistry;
85 this.settings = settings;
86 this.metadataRegistry = metadataRegistry;
87 this.storage = storage;
88 this.instance = instance;
89 this.applyUpdatesDebouncer = new Debouncer("update-homekit-devices", scheduler, Duration.ofMillis(1000),
90 Clock.systemUTC(), this::applyUpdates);
91 metadataChangeListener = new RegistryChangeListener<Metadata>() {
93 public void added(final Metadata metadata) {
94 final MetadataKey uid = metadata.getUID();
95 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(uid.getNamespace())) {
97 markDirty(itemRegistry.getItem(uid.getItemName()));
98 } catch (ItemNotFoundException e) {
99 logger.debug("Could not find item for metadata {}", metadata);
105 public void removed(final Metadata metadata) {
106 final MetadataKey uid = metadata.getUID();
107 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(uid.getNamespace())) {
109 markDirty(itemRegistry.getItem(uid.getItemName()));
110 } catch (ItemNotFoundException e) {
111 logger.debug("Could not find item for metadata {}", metadata);
117 public void updated(final Metadata oldMetadata, final Metadata newMetadata) {
118 final MetadataKey oldUid = oldMetadata.getUID();
119 final MetadataKey newUid = newMetadata.getUID();
120 if (HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(oldUid.getNamespace())
121 || HomekitAccessoryFactory.METADATA_KEY.equalsIgnoreCase(newUid.getNamespace())) {
123 // the item name is same in old and new metadata, so we can take any.
124 markDirty(itemRegistry.getItem(oldUid.getItemName()));
125 } catch (ItemNotFoundException e) {
126 logger.debug("Could not find item for metadata {}", oldMetadata);
131 itemRegistry.addRegistryChangeListener(this);
132 metadataRegistry.addRegistryChangeListener(metadataChangeListener);
133 itemRegistry.getItems().forEach(this::createRootAccessories);
134 initialiseRevision();
135 makeNewConfigurationRevision();
136 logger.info("Created {} HomeKit items in instance {}.", accessoryRegistry.getAllAccessories().size(), instance);
139 private void initialiseRevision() {
142 String revisionString = storage.get(REVISION_CONFIG);
143 if (revisionString == null) {
144 throw new NumberFormatException();
146 revision = Integer.parseInt(revisionString);
147 } catch (NumberFormatException e) {
149 storage.put(REVISION_CONFIG, "" + revision);
152 String accessoryCountString = storage.get(ACCESSORY_COUNT);
153 if (accessoryCountString == null) {
154 throw new NumberFormatException();
156 lastAccessoryCount = Integer.parseInt(accessoryCountString);
157 } catch (NumberFormatException e) {
158 lastAccessoryCount = 0;
159 storage.put(ACCESSORY_COUNT, "" + accessoryRegistry.getAllAccessories().size());
161 accessoryRegistry.setConfigurationRevision(revision);
164 private boolean hasHomeKitMetadata(Item item) {
165 return metadataRegistry.get(new MetadataKey(HomekitAccessoryFactory.METADATA_KEY, item.getUID())) != null;
169 public synchronized void added(Item item) {
170 if (hasHomeKitMetadata(item)) {
176 public void allItemsChanged(Collection<String> oldItemNames) {
181 * Mark an item as dirty, plus any accessory groups to which it pertains, so that after a debounce period the
182 * accessory update can be applied.
184 * @param item The item that has been changed or removed.
186 private synchronized void markDirty(Item item) {
187 logger.trace("Mark dirty item {}", item.getName());
188 pendingUpdates.add(item.getName());
190 * If findMyAccessoryGroups fails because the accessory group has already been deleted, then we can count on a
191 * later update telling us that the accessory group was removed.
193 for (Item accessoryGroup : HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry)) {
194 pendingUpdates.add(accessoryGroup.getName());
198 * if metadata of a group item was changed, mark all group member as dirty.
200 if (item instanceof GroupItem) {
201 ((GroupItem) item).getMembers().forEach(groupMember -> pendingUpdates.add(groupMember.getName()));
203 applyUpdatesDebouncer.call();
207 public synchronized void removed(Item item) {
208 if (hasHomeKitMetadata(item)) {
213 private Optional<Item> getItemOptional(String name) {
215 return Optional.of(itemRegistry.getItem(name));
216 } catch (ItemNotFoundException e) {
217 return Optional.empty();
221 public void makeNewConfigurationRevision() {
222 final int newRevision = accessoryRegistry.makeNewConfigurationRevision();
223 lastAccessoryCount = accessoryRegistry.getAllAccessories().size();
224 logger.trace("Make new configuration revision. new revision number {}, number of accessories {}", newRevision,
226 storage.put(REVISION_CONFIG, "" + newRevision);
227 storage.put(ACCESSORY_COUNT, "" + lastAccessoryCount);
230 private synchronized void applyUpdates() {
231 logger.trace("Apply updates");
232 for (final String name : pendingUpdates) {
233 accessoryRegistry.remove(name);
234 logger.trace(" Add items {}", name);
235 getItemOptional(name).ifPresent(this::createRootAccessories);
237 if (!pendingUpdates.isEmpty()) {
238 makeNewConfigurationRevision();
239 pendingUpdates.clear();
244 public void updated(Item oldElement, Item element) {
245 markDirty(oldElement);
249 public int getLastAccessoryCount() {
250 return lastAccessoryCount;
253 public synchronized void clearAccessories() {
254 accessoryRegistry.clear();
257 public synchronized void setBridge(HomekitRoot bridge) {
258 accessoryRegistry.setBridge(bridge);
261 public void setUpdater(HomekitAccessoryUpdater updater) {
262 this.updater = updater;
265 public void updateSettings(HomekitSettings settings) {
266 this.settings = settings;
269 public synchronized void stop() {
270 this.itemRegistry.removeRegistryChangeListener(this);
271 this.metadataRegistry.removeRegistryChangeListener(metadataChangeListener);
272 applyUpdatesDebouncer.stop();
273 accessoryRegistry.unsetBridge();
276 public Map<String, HomekitAccessory> getAccessories() {
277 return this.accessoryRegistry.getAllAccessories();
280 public int getConfigurationRevision() {
281 return this.accessoryRegistry.getConfigurationRevision();
285 * select primary accessory type from list of types.
287 * - if accessory has only one type, it is the primary type
288 * - if accessory has no primary type defined per configuration, then the first type on the list is the primary type
289 * - if accessory has primary type defined per configuration and this type is on the list of types, then it is the
291 * - if accessory has primary type defined per configuration and this type is NOT on the list of types, then the
292 * first type on the list is the primary type
294 * @param item openhab item
295 * @param accessoryTypes list of accessory type attached to the item
296 * @return primary accessory type
298 private HomekitAccessoryType getPrimaryAccessoryType(Item item,
299 List<Entry<HomekitAccessoryType, HomekitCharacteristicType>> accessoryTypes,
300 @Nullable Map<String, Object> configuration) {
301 if (accessoryTypes.size() > 1 && configuration != null) {
302 final @Nullable Object value = configuration.get(HomekitTaggedItem.PRIMARY_SERVICE);
303 if (value instanceof String) {
304 return accessoryTypes.stream()
305 .filter(aType -> ((String) value).equalsIgnoreCase(aType.getKey().getTag())).findAny()
306 .orElse(accessoryTypes.get(0)).getKey();
309 // no primary accessory found or there is only one type, so return the first type from the list
310 return accessoryTypes.get(0).getKey();
314 * creates one or more HomeKit items for given openhab item.
315 * one OpenHAB item can be linked to several HomeKit accessories.
316 * OpenHAB item is a good candidate for a HomeKit accessory
318 * - it has HomeKit accessory types defined using HomeKit accessory metadata
319 * - AND is not part of a group with HomeKit metadata
321 * Switch light "Light" {homekit="Lighting"}
322 * Group gLight "Light Group" {homekit="Lighting"}
325 * - it has HomeKit accessory types defined using HomeKit accessory metadata
326 * - AND is part of groups with HomeKit metadata, but all groups have baseItem
328 * Group:Switch:OR(ON,OFF) gLight "Light Group " {homekit="Lighting"}
329 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
332 * In contrast, items which are part of groups without BaseItem are additional HomeKit characteristics of the
333 * accessory defined by that group and don't need to be created as accessory here.
335 * Group gLight "Light Group " {homekit="Lighting"}
336 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
337 * is not the root accessory but only a characteristic "OnState"
340 * // Single line HomeKit Accessory
341 * Switch light "Light" {homekit="Lighting"}
343 * // One HomeKit accessory defined using group
344 * Group gLight "Light Group" {homekit="Lighting"}
345 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
347 * // 2 HomeKit accessories: one is switch attached to group, another one a single switch
348 * Group:Switch:OR(ON,OFF) gLight "Light Group " {homekit="Lighting"}
349 * Switch light "Light" (gLight) {homekit="Lighting.OnState"}
351 * @param item openHAB item
353 private void createRootAccessories(Item item) {
354 final List<Entry<HomekitAccessoryType, HomekitCharacteristicType>> accessoryTypes = HomekitAccessoryFactory
355 .getAccessoryTypes(item, metadataRegistry);
356 final List<GroupItem> groups = HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry);
357 final @Nullable Map<String, Object> itemConfiguration = HomekitAccessoryFactory.getItemConfiguration(item,
359 if (accessoryTypes.isEmpty() || !(groups.isEmpty() || groups.stream().noneMatch(g -> g.getBaseItem() == null))
360 || !itemIsForThisBridge(item, itemConfiguration)) {
364 final HomekitAccessoryType primaryAccessoryType = getPrimaryAccessoryType(item, accessoryTypes,
366 logger.trace("Item {} is a HomeKit accessory of types {}. Primary type is {}", item.getName(), accessoryTypes,
367 primaryAccessoryType);
368 final HomekitOHItemProxy itemProxy = new HomekitOHItemProxy(item);
369 final HomekitTaggedItem taggedItem = new HomekitTaggedItem(new HomekitOHItemProxy(item), primaryAccessoryType,
372 final HomekitAccessory accessory = HomekitAccessoryFactory.create(taggedItem, metadataRegistry, updater,
375 accessoryTypes.stream().filter(aType -> !primaryAccessoryType.equals(aType.getKey()))
376 .forEach(additionalAccessoryType -> {
377 final HomekitTaggedItem additionalTaggedItem = new HomekitTaggedItem(itemProxy,
378 additionalAccessoryType.getKey(), itemConfiguration);
380 final HomekitAccessory additionalAccessory = HomekitAccessoryFactory
381 .create(additionalTaggedItem, metadataRegistry, updater, settings);
382 accessory.getServices().add(additionalAccessory.getPrimaryService());
383 } catch (HomekitException e) {
384 logger.warn("Cannot create additional accessory {}", additionalTaggedItem);
387 accessoryRegistry.addRootAccessory(taggedItem.getName(), accessory);
388 } catch (HomekitException e) {
389 logger.warn("Cannot create accessory {}", taggedItem);
393 private boolean itemIsForThisBridge(Item item, @Nullable Map<String, Object> configuration) {
394 // non-tagged accessories belong to the first instance
395 if (configuration == null) {
396 return (instance == 1);
399 final @Nullable Object value = configuration.get(HomekitTaggedItem.INSTANCE);
401 return (instance == 1);
403 if (value instanceof Number) {
404 return (instance == ((Number) value).intValue());
406 logger.warn("Unrecognized instance tag {} ({}) for item {}; assigning to default instance.", value,
407 value.getClass(), item.getName());
408 return (instance == 1);