Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[homekit] add support for complex accessories #12346

Merged
merged 6 commits into from
Mar 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions bundles/org.openhab.io.homekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<type>" 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 |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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<Entry<HomekitAccessoryType, HomekitCharacteristicType>> accessoryTypes) {
if (accessoryTypes.size() > 1) {
final @Nullable Map<String, Object> 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
Expand All @@ -304,19 +359,33 @@ private void createRootAccessories(Item item) {
final List<GroupItem> 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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Integer, String> CREATED_ACCESSORY_IDS = new ConcurrentHashMap<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ public static List<Entry<HomekitAccessoryType, HomekitCharacteristicType>> getAc
*/
public static List<GroupItem> 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);
Expand Down Expand Up @@ -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
Expand All @@ -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)));
Expand Down Expand Up @@ -359,7 +360,7 @@ private static Map<HomekitCharacteristicType, GenericItem> 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 {
Expand Down Expand Up @@ -395,4 +396,17 @@ private static boolean isMandatoryCharacteristic(HomekitAccessoryType accessory,
private static boolean isRootAccessory(Entry<HomekitAccessoryType, HomekitCharacteristicType> 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<HomekitAccessoryType, HomekitCharacteristicType> characteristic) {
return ((characteristic.getKey() == accessoryType) || (characteristic.getKey() == DUMMY));
}
}