[homekit] add support for complex accessories (#12346)

* Add complex accessories

Signed-off-by: Eugen Freiter <freiter@gmx.de>
This commit is contained in:
eugen 2022-03-28 00:11:44 +02:00 committed by GitHub
parent 53bb6f48ad
commit f1176a062b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 174 additions and 24 deletions

View File

@ -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 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -24,6 +24,7 @@ import java.util.Set;
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 @@ public class HomekitChangeListener implements ItemRegistryChangeListener {
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 class HomekitChangeListener implements ItemRegistryChangeListener {
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
@ -304,19 +359,33 @@ public class HomekitChangeListener implements ItemRegistryChangeListener {
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);
}
}
}
}

View File

@ -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<>();

View File

@ -225,7 +225,7 @@ public class HomekitAccessoryFactory {
*/
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);
@ -279,8 +279,8 @@ public class HomekitAccessoryFactory {
// 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 @@ public class HomekitAccessoryFactory {
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 @@ public class HomekitAccessoryFactory {
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 @@ public class HomekitAccessoryFactory {
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));
}
}