diff --git a/bundles/org.openhab.io.homekit/README.md b/bundles/org.openhab.io.homekit/README.md index 55e21ef3d17fd..2957f583b19f8 100644 --- a/bundles/org.openhab.io.homekit/README.md +++ b/bundles/org.openhab.io.homekit/README.md @@ -457,7 +457,73 @@ or using UI ![sensor_ui_config.png](doc/sensor_ui_config.png) +### Complex accessory +Multiple HomeKit accessories can be combined to one accessory in order to group several functions provided by one or multiple physical devices. + +For example, ceiling fans often include lighting functionality. Such fans can be modeled as: + +- two separate HomeKit accessories - fan **and** light. + + iOS home app would show them as **two tiles** that can be controlled directly from home screen. + ![ios_fan_and_light_home_screen.png](doc/ios_fan_and_light_home_screen.png) + +- one complex accessory - fan **with** light. + + iOS home app would show them as **one tile** that opens view with two controls + + ![ios_fan_with_light_home_screen.png](doc/ios_fan_with_light_home_screen.png) + + ![ios_fan_with_light_details.png](doc/ios_fan_with_light_details.png) + +The provided functionality is in both cases identical. + +In order to combine multiple accessories to one HomeKit accessory you need: + +- add corresponding openHAB items to one openHAB group +- configure HomeKit metadata of both HomeKit accessories at that group. + +e.g. configuration for a fan with light would look as follows + +```xtend +Group FanWithLight "Fan with Light" {homekit = "Fan,Light"} +Switch FanActiveStatus "Fan Active Status" (FanWithLight) {homekit = "Fan.ActiveStatus"} +Number FanRotationSpeed "Fan Rotation Speed" (FanWithLight) {homekit = "Fan.RotationSpeed"} +Switch Light "Light" (FanWithLight) {homekit = "Lighting.OnState"} +``` + +or in mainUI +![ui_fan_with_light_group_view.png](doc/ui_fan_with_light_group_view.png) +![ui_fan_with_light_group_code.png](doc/ui_fan_with_light_group_code.png) +![ui_fan_with_light_group_config.png](doc/ui_fan_with_light_group_config.png) + + +iOS home app uses by default the type of the first accessory on the list for the tile on home screen. +e.g. an accessory defined as homekit = "Fan,Light" will be shown as a fan and an accessory defined as homekit = "Light,Fan" as a light in iOS home app. + +if you want to change the tile you can either change the order of types in homekit metadata or add "primary=" to HomeKit metadata configuration. +e.g. following configuration will force "fan" to be used as tile + +```xtend +Group FanWithLight "Fan with Light" {homekit = "Light,Fan" [primary = "Fan"]} +``` + +![ui_fan_with_light_primary.png](doc/ui_fan_with_light_primary.png) + +However, home app does not support changing of tiles for already added accessory. +If you want to change the tile after the accessory was added, you need either to rename the group, if you use textual item configuration, or to delete and to create a new group with a different name, if you use UI for configuration. + +You can combine more than two accessories as well as accessories linked to different physical devices. +You can also do unusually combinations, e.g. you can combine temperature sensor with blinds and light. +It will be represented by home app as follows +![ios_complex_accessory_detail_screen.png](doc/ios_complex_accessory_detail_screen.png) + + +#### Limitations + +Currently, it is not possible to combine multiple accessories of the same type, e.g. 2 lights. +Support for this is planned for the future release of openHAB HomeKit binding. + ## Supported accessory type | Accessory Tag | Mandatory Characteristics | Optional Characteristics | Supported OH items | Description | diff --git a/bundles/org.openhab.io.homekit/doc/ios_complex_accessory_detail_screen.png b/bundles/org.openhab.io.homekit/doc/ios_complex_accessory_detail_screen.png new file mode 100755 index 0000000000000..5022271360fd8 Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ios_complex_accessory_detail_screen.png differ diff --git a/bundles/org.openhab.io.homekit/doc/ios_fan_and_light_home_screen.png b/bundles/org.openhab.io.homekit/doc/ios_fan_and_light_home_screen.png new file mode 100755 index 0000000000000..a8dd927ddbef1 Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ios_fan_and_light_home_screen.png differ diff --git a/bundles/org.openhab.io.homekit/doc/ios_fan_with_light_details.png b/bundles/org.openhab.io.homekit/doc/ios_fan_with_light_details.png new file mode 100755 index 0000000000000..55dea0735f902 Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ios_fan_with_light_details.png differ diff --git a/bundles/org.openhab.io.homekit/doc/ios_fan_with_light_home_screen.png b/bundles/org.openhab.io.homekit/doc/ios_fan_with_light_home_screen.png new file mode 100755 index 0000000000000..ac7caf4e8e3be Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ios_fan_with_light_home_screen.png differ diff --git a/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_code.png b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_code.png new file mode 100644 index 0000000000000..bc4eead57fe44 Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_code.png differ diff --git a/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_config.png b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_config.png new file mode 100644 index 0000000000000..a05054b51444b Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_config.png differ diff --git a/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_view.png b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_view.png new file mode 100644 index 0000000000000..4ba4a532a2a1e Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_group_view.png differ diff --git a/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_primary.png b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_primary.png new file mode 100644 index 0000000000000..fbdf0df0817b1 Binary files /dev/null and b/bundles/org.openhab.io.homekit/doc/ui_fan_with_light_primary.png differ diff --git a/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitChangeListener.java b/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitChangeListener.java index 2a44a93b80a0c..ed8390cfd8f3a 100644 --- a/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitChangeListener.java +++ b/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitChangeListener.java @@ -24,6 +24,7 @@ import java.util.concurrent.ScheduledExecutorService; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.common.ThreadPoolManager; import org.openhab.core.common.registry.RegistryChangeListener; import org.openhab.core.items.GroupItem; @@ -189,6 +190,13 @@ private synchronized void markDirty(Item item) { for (Item accessoryGroup : HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry)) { pendingUpdates.add(accessoryGroup.getName()); } + + /* + * if metadata of a group item was changed, mark all group member as dirty. + */ + if (item instanceof GroupItem) { + ((GroupItem) item).getMembers().forEach(groupMember -> pendingUpdates.add(groupMember.getName())); + } applyUpdatesDebouncer.call(); } @@ -273,19 +281,66 @@ public int getConfigurationRevision() { return this.accessoryRegistry.getConfigurationRevision(); } + /** + * select primary accessory type from list of types. + * selection logic: + * - if accessory has only one type, it is the primary type + * - if accessory has no primary type defined per configuration, then the first type on the list is the primary type + * - if accessory has primary type defined per configuration and this type is on the list of types, then it is the + * primary + * - if accessory has primary type defined per configuration and this type is NOT on the list of types, then the + * first type on the list is the primary type + * + * @param item openhab item + * @param accessoryTypes list of accessory type attached to the item + * @return primary accessory type + */ + private HomekitAccessoryType getPrimaryAccessoryType(Item item, + List> accessoryTypes) { + if (accessoryTypes.size() > 1) { + final @Nullable Map configuration = HomekitAccessoryFactory.getItemConfiguration(item, + metadataRegistry); + if (configuration != null) { + final @Nullable Object value = configuration.get(HomekitTaggedItem.PRIMARY_SERVICE); + if (value instanceof String) { + return accessoryTypes.stream() + .filter(aType -> ((String) value).equalsIgnoreCase(aType.getKey().getTag())).findAny() + .orElse(accessoryTypes.get(0)).getKey(); + } + } + } + // no primary accessory found or there is only one type, so return the first type from the list + return accessoryTypes.get(0).getKey(); + } + /** * creates one or more HomeKit items for given openhab item. - * one OpenHAB item can linked to several HomeKit accessories or characteristics. - * OpenHAB Item is a good candidate for homeKit accessory IF - * - it has HomeKit accessory types, i.e. HomeKit accessory tag AND - * - has no group with HomeKit tag, i.e. single line accessory ODER - * - has groups with HomeKit tag, but all groups are with baseItem, e.g. Group:Switch, - * so that the groups already complete accessory and group members can be a standalone HomeKit accessory. + * one OpenHAB item can be linked to several HomeKit accessories. + * OpenHAB item is a good candidate for a HomeKit accessory + * IF + * - it has HomeKit accessory types defined using HomeKit accessory metadata + * - AND is not part of a group with HomeKit metadata + * e.g. + * Switch light "Light" {homekit="Lighting"} + * Group gLight "Light Group" {homekit="Lighting"} + * + * OR + * - it has HomeKit accessory types defined using HomeKit accessory metadata + * - AND is part of groups with HomeKit metadata, but all groups have baseItem + * e.g. + * Group:Switch:OR(ON,OFF) gLight "Light Group " {homekit="Lighting"} + * Switch light "Light" (gLight) {homekit="Lighting.OnState"} + * + * * In contrast, items which are part of groups without BaseItem are additional HomeKit characteristics of the - * accessory defined by that group and dont need to be created as RootAccessory here. + * accessory defined by that group and don't need to be created as accessory here. + * e.g. + * Group gLight "Light Group " {homekit="Lighting"} + * Switch light "Light" (gLight) {homekit="Lighting.OnState"} + * is not the root accessory but only a characteristic "OnState" * * Examples: - * // Single Line HomeKit Accessory + * // Single line HomeKit Accessory * Switch light "Light" {homekit="Lighting"} * * // One HomeKit accessory defined using group @@ -304,19 +359,33 @@ private void createRootAccessories(Item item) { final List groups = HomekitAccessoryFactory.getAccessoryGroups(item, itemRegistry, metadataRegistry); if (!accessoryTypes.isEmpty() && (groups.isEmpty() || groups.stream().noneMatch(g -> g.getBaseItem() == null))) { - logger.trace("Item {} is a HomeKit accessory of types {}", item.getName(), accessoryTypes); + final HomekitAccessoryType primaryAccessoryType = getPrimaryAccessoryType(item, accessoryTypes); + logger.trace("Item {} is a HomeKit accessory of types {}. Primary type is {}", item.getName(), + accessoryTypes, primaryAccessoryType); final HomekitOHItemProxy itemProxy = new HomekitOHItemProxy(item); - accessoryTypes.forEach(rootAccessory -> createRootAccessory(new HomekitTaggedItem(itemProxy, - rootAccessory.getKey(), HomekitAccessoryFactory.getItemConfiguration(item, metadataRegistry)))); - } - } + final HomekitTaggedItem taggedItem = new HomekitTaggedItem(new HomekitOHItemProxy(item), + primaryAccessoryType, HomekitAccessoryFactory.getItemConfiguration(item, metadataRegistry)); + try { + final HomekitAccessory accessory = HomekitAccessoryFactory.create(taggedItem, metadataRegistry, updater, + settings); - private void createRootAccessory(HomekitTaggedItem taggedItem) { - try { - accessoryRegistry.addRootAccessory(taggedItem.getName(), - HomekitAccessoryFactory.create(taggedItem, metadataRegistry, updater, settings)); - } catch (HomekitException e) { - logger.warn("Could not add device {}: {}", taggedItem.getItem().getUID(), e.getMessage()); + accessoryTypes.stream().filter(aType -> !primaryAccessoryType.equals(aType.getKey())) + .forEach(additionalAccessoryType -> { + final HomekitTaggedItem additionalTaggedItem = new HomekitTaggedItem(itemProxy, + additionalAccessoryType.getKey(), + HomekitAccessoryFactory.getItemConfiguration(item, metadataRegistry)); + try { + final HomekitAccessory additionalAccessory = HomekitAccessoryFactory + .create(additionalTaggedItem, metadataRegistry, updater, settings); + accessory.getServices().add(additionalAccessory.getPrimaryService()); + } catch (HomekitException e) { + logger.warn("Cannot create additional accessory {}", additionalTaggedItem); + } + }); + accessoryRegistry.addRootAccessory(taggedItem.getName(), accessory); + } catch (HomekitException e) { + logger.warn("Cannot create accessory {}", taggedItem); + } } } } diff --git a/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitTaggedItem.java b/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitTaggedItem.java index a366f755ac0bf..224e7169d3c3d 100644 --- a/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitTaggedItem.java +++ b/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/HomekitTaggedItem.java @@ -42,6 +42,7 @@ public class HomekitTaggedItem { public final static String DIMMER_MODE = "dimmerMode"; public final static String DELAY = "commandDelay"; public final static String INVERTED = "inverted"; + public final static String PRIMARY_SERVICE = "primary"; private static final Map CREATED_ACCESSORY_IDS = new ConcurrentHashMap<>(); diff --git a/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/accessories/HomekitAccessoryFactory.java b/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/accessories/HomekitAccessoryFactory.java index 0b9e65f3d8c7f..50cfebac68899 100644 --- a/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/accessories/HomekitAccessoryFactory.java +++ b/bundles/org.openhab.io.homekit/src/main/java/org/openhab/io/homekit/internal/accessories/HomekitAccessoryFactory.java @@ -225,7 +225,7 @@ public static List> getAc */ public static List getAccessoryGroups(Item item, ItemRegistry itemRegistry, MetadataRegistry metadataRegistry) { - return item.getGroupNames().stream().flatMap(name -> { + return (item instanceof GroupItem) ? Collections.emptyList() : item.getGroupNames().stream().flatMap(name -> { final @Nullable Item groupItem = itemRegistry.get(name); if ((groupItem instanceof GroupItem) && ((GroupItem) groupItem).getBaseItem() == null) { return Stream.of((GroupItem) groupItem); @@ -279,8 +279,8 @@ private static void addMandatoryCharacteristics(HomekitTaggedItem mainItem, List // no mandatory characteristics linked to accessory type of mainItem. we are done return; } - // check whether we adding characteristic to the main item, and if yes, use existing item proxy. - // if we adding no to the main item (typical for groups), create new proxy item. + // check whether we are adding characteristic to the main item, and if yes, use existing item proxy. + // if we are adding not to the main item (typical for groups), create new proxy item. final HomekitOHItemProxy itemProxy = mainItem.getItem().equals(item) ? mainItem.getProxyItem() : new HomekitOHItemProxy(item); // an item can have several tags, e.g. "ActiveStatus, InUse". we iterate here over all his tags @@ -300,7 +300,8 @@ private static void addMandatoryCharacteristics(HomekitTaggedItem mainItem, List final HomekitCharacteristicType characteristic = accessory.getValue(); // check whether it is a mandatory characteristic. optional will be added later by another method. - if (isMandatoryCharacteristic(mainItem.getAccessoryType(), characteristic)) { + if (belongsToType(mainItem.getAccessoryType(), accessory) + && isMandatoryCharacteristic(mainItem.getAccessoryType(), characteristic)) { characteristics.add(new HomekitTaggedItem(itemProxy, accessory.getKey(), characteristic, mainItem.isGroup() ? (GroupItem) mainItem.getItem() : null, HomekitAccessoryFactory.getItemConfiguration(item, metadataRegistry))); @@ -359,7 +360,7 @@ private static Map getOptionalCharacteri if (taggedItem.isGroup()) { GroupItem groupItem = (GroupItem) taggedItem.getItem(); groupItem.getMembers().forEach(item -> getAccessoryTypes(item, metadataRegistry).stream() - .filter(c -> !isRootAccessory(c)) + .filter(c -> !isRootAccessory(c)).filter(c -> belongsToType(taggedItem.getAccessoryType(), c)) .filter(c -> !isMandatoryCharacteristic(taggedItem.getAccessoryType(), c.getValue())) .forEach(characteristic -> characteristicItems.put(characteristic.getValue(), (GenericItem) item))); } else { @@ -395,4 +396,17 @@ private static boolean isMandatoryCharacteristic(HomekitAccessoryType accessory, private static boolean isRootAccessory(Entry accessory) { return ((accessory.getValue() == null) || (accessory.getValue() == EMPTY)); } + + /** + * check whether characteristic belongs to the specific accessory type. + * characteristic with no accessory type mentioned in metadata are considered as candidates for all types. + * + * @param accessoryType accessory type + * @param characteristic characteristic + * @return true if characteristic belongs to the accessory type. + */ + private static boolean belongsToType(HomekitAccessoryType accessoryType, + Entry characteristic) { + return ((characteristic.getKey() == accessoryType) || (characteristic.getKey() == DUMMY)); + } }