[homekit] implement StatelessProgrammableSwitch (#17129)

also supports adding multiple of them in a group, by supporting ServiceIndex
as an optional characteristic

refs #9969

Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
Cody Cutrer 2024-07-23 00:22:27 -06:00 committed by GitHub
parent 64fc6bcd1f
commit cbbc36697c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 428 additions and 239 deletions

View File

@ -694,6 +694,19 @@ or using UI
![sensor_ui_config.png](doc/sensor_ui_config.png)
### Stateless Programmable Switch Groups
To expose multiple Stateless Programmable Switches as a single accessory with multiple buttons (aka a scene controller), you need to configure ServiceLabel on the accessory group and ServiceIndex on each switch.
Note that in the Home app, the individual switch names will be ignored, and will simply be displayed as "Button 1", "Button 2", etc. (for ARABIC_NUMERALS style).
```java
Group gSceneController "Scene Controller" { homekit="AccessoryGroup"[ServiceLabel="ARABIC_NUMERALS"] }
Switch Button1 "Switch A" (gSceneController) { homekit="StatelessProgrammableSwitch"[ServiceIndex=1] }
Switch Button2 "Switch B" (gSceneController) { homekit="StatelessProgrammableSwitch"[ServiceIndex=2] }
Switch Button3 "Switch C" (gSceneController) { homekit="StatelessProgrammableSwitch"[ServiceIndex=3] }
Switch Button4 "Switch D" (gSceneController) { homekit="StatelessProgrammableSwitch"[ServiceIndex=4] }
```
## Supported accessory types
For configuration options, the default values are in parentheses.
@ -717,7 +730,7 @@ All accessories also support the following optional characteristic that can be l
* Identify (receives `ON` command when the user wishes to identify the accessory)
| Accessory Tag | Mandatory Characteristics | Optional Characteristics | Supported openHAB item types | Description | Configuration Options | Valid Enum Values |
|----------------------|-----------------------------|-----------------------------|-----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
|-----------------------------|-----------------------------|-----------------------------|-----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| AirQualitySensor | | | | Air Quality Sensor which can measure different parameters | | |
| | AirQuality | | Number, String, Switch | Air quality state | | UNKNOWN (0, OFF), EXCELLENT (1, ON), GOOD (2), FAIR (3), INFERIOR (4), POOR (5) |
| | | ActiveStatus | Contact, Switch | Working status | | |
@ -880,6 +893,9 @@ All accessories also support the following optional characteristic that can be l
| | Mute | | Contact, Dimmer, Switch | Mute indication. ON/OPEN = speaker is muted | inverted (false) | |
| | | Active | Contact, Dimmer, Number, String, Switch | Accessory current working status | inverted (false) | INACTIVE (0, OFF), ACTIVE (1, ON) |
| | | Volume | Number | Speaker volume from 0% to 100% | | |
| StatelessProgrammableSwitch | | | | A stateless programmable switch is a button or scene controller that simply sends an event to Home when it is pressed, allowing automations to occur. See [Stateless Programmable Switch Groups](#Stateless-Programmable-Switch-Groups) for configuring multiple in one accessory. | | |
| | ProgrammableSwitchEvent | | Contact, Number, String, Switch | The button press event. Note that the event will be forwarded to Home for every _update_ of the item, not just on change. | inverted (false) | SINGLE_PRESS (0, ON, OPEN), DOUBLE_PRESS (1), LONG_PRESS (2) [*](#customizable-enum) |
| | | Volume | Number | Speaker volume from 0% to 100% | | |
| Switchable | | | | An accessory that can be turned off and on. While similar to a lightbulb, this will be presented differently in the Siri grammar and iOS apps | | |
| | OnState | | Dimmer, Switch | State of the switch - ON/OFF | | |
| Television | | | | Television accessory with inputs | | |

View File

@ -54,6 +54,7 @@ public enum HomekitAccessoryType {
SMART_SPEAKER("SmartSpeaker"),
SMOKE_SENSOR("SmokeSensor"),
SPEAKER("Speaker"),
STATELESS_PROGRAMMABLE_SWITCH("StatelessProgrammableSwitch"),
SWITCH("Switchable"),
TELEVISION("Television"),
TELEVISION_SPEAKER("TelevisionSpeaker"),

View File

@ -14,6 +14,7 @@ package org.openhab.io.homekit.internal;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.items.GenericItem;
@ -35,7 +36,7 @@ import io.github.hapjava.characteristics.HomekitCharacteristicChangeCallback;
*/
public class HomekitAccessoryUpdater {
private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryUpdater.class);
private final ConcurrentMap<ItemKey, Subscription> subscriptionsByName = new ConcurrentHashMap<>();
private final ConcurrentMap<ItemKey, StateChangeListener> subscriptionsByName = new ConcurrentHashMap<>();
public void subscribe(GenericItem item, HomekitCharacteristicChangeCallback callback) {
subscribe(item, null, callback);
@ -63,6 +64,28 @@ public class HomekitAccessoryUpdater {
});
}
public void subscribeToUpdates(GenericItem item, String key, Consumer<State> callback) {
logger.trace("Received subscription request for {} / {}", item, key);
if (item == null) {
return;
}
if (callback == null) {
logger.trace("The received subscription contains a null callback, skipping");
return;
}
ItemKey itemKey = new ItemKey(item, key);
subscriptionsByName.compute(itemKey, (k, v) -> {
if (v != null) {
logger.debug("Received duplicate subscription for {} / {}", item, key);
unsubscribe(item, key);
}
logger.trace("Adding subscription for {} / {}", item, key);
UpdateSubscription subscription = (changedItem, newState) -> callback.accept(newState);
item.addStateChangeListener(subscription);
return subscription;
});
}
public void unsubscribe(GenericItem item) {
unsubscribe(item, null);
}
@ -91,6 +114,19 @@ public class HomekitAccessoryUpdater {
}
}
@FunctionalInterface
@NonNullByDefault
private interface UpdateSubscription extends StateChangeListener {
@Override
default void stateChanged(Item item, State oldState, State newState) {
// Do nothing on change update
}
@Override
void stateUpdated(Item item, State state);
}
private static class ItemKey {
public final GenericItem item;
public final String key;

View File

@ -97,6 +97,7 @@ public enum HomekitCharacteristicType {
POSITION_STATE("PositionState"),
POWER_MODE("PowerMode"),
PROGRAM_MODE("ProgramMode"),
PROGRAMMABLE_SWITCH_EVENT("ProgrammableSwitchEvent"),
RELATIVE_HUMIDITY("RelativeHumidity"),
REMAINING_DURATION("RemainingDuration"),
REMOTE_KEY("RemoteKey"),

View File

@ -420,7 +420,8 @@ public abstract class AbstractHomekitAccessoryImpl implements HomekitAccessory {
T defaultValue) {
final Optional<HomekitTaggedItem> c = getCharacteristic(characteristicType);
if (c.isPresent()) {
return HomekitCharacteristicFactory.getKeyFromMapping(c.get(), mapping, defaultValue);
return HomekitCharacteristicFactory.getKeyFromMapping(c.get(), c.get().getItem().getState(), mapping,
defaultValue);
}
return defaultValue;
}

View File

@ -100,6 +100,7 @@ public class HomekitAccessoryFactory {
put(SMOKE_SENSOR, new HomekitCharacteristicType[] { SMOKE_DETECTED_STATE });
put(SLAT, new HomekitCharacteristicType[] { CURRENT_SLAT_STATE });
put(SPEAKER, new HomekitCharacteristicType[] { MUTE });
put(STATELESS_PROGRAMMABLE_SWITCH, new HomekitCharacteristicType[] { PROGRAMMABLE_SWITCH_EVENT });
put(SWITCH, new HomekitCharacteristicType[] { ON_STATE });
put(TELEVISION, new HomekitCharacteristicType[] { ACTIVE });
put(TELEVISION_SPEAKER, new HomekitCharacteristicType[] { MUTE });
@ -145,6 +146,7 @@ public class HomekitAccessoryFactory {
put(SMART_SPEAKER, HomekitSmartSpeakerImpl.class);
put(SMOKE_SENSOR, HomekitSmokeSensorImpl.class);
put(SPEAKER, HomekitSpeakerImpl.class);
put(STATELESS_PROGRAMMABLE_SWITCH, HomekitStatelessProgrammableSwitchImpl.class);
put(SWITCH, HomekitSwitchImpl.class);
put(TELEVISION, HomekitTelevisionImpl.class);
put(TELEVISION_SPEAKER, HomekitTelevisionSpeakerImpl.class);

View File

@ -16,10 +16,13 @@ import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.io.homekit.internal.HomekitAccessoryUpdater;
import org.openhab.io.homekit.internal.HomekitException;
import org.openhab.io.homekit.internal.HomekitSettings;
import org.openhab.io.homekit.internal.HomekitTaggedItem;
import io.github.hapjava.characteristics.Characteristic;
import io.github.hapjava.characteristics.impl.common.ServiceLabelNamespaceCharacteristic;
import io.github.hapjava.services.impl.ServiceLabelService;
/**
* Bare accessory (for being the root of a multi-service accessory).
@ -33,4 +36,12 @@ public class HomekitAccessoryGroupImpl extends AbstractHomekitAccessoryImpl {
throws IncompleteAccessoryException {
super(taggedItem, mandatoryCharacteristics, mandatoryRawCharacteristics, updater, settings);
}
@Override
public void init() throws HomekitException {
super.init();
getCharacteristic(ServiceLabelNamespaceCharacteristic.class)
.ifPresent(c -> getServices().add(new ServiceLabelService(c)));
}
}

View File

@ -99,6 +99,8 @@ import io.github.hapjava.characteristics.impl.common.IsConfiguredCharacteristic;
import io.github.hapjava.characteristics.impl.common.IsConfiguredEnum;
import io.github.hapjava.characteristics.impl.common.NameCharacteristic;
import io.github.hapjava.characteristics.impl.common.ObstructionDetectedCharacteristic;
import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEnum;
import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEventCharacteristic;
import io.github.hapjava.characteristics.impl.common.StatusActiveCharacteristic;
import io.github.hapjava.characteristics.impl.common.StatusFaultCharacteristic;
import io.github.hapjava.characteristics.impl.common.StatusFaultEnum;
@ -231,6 +233,7 @@ public class HomekitCharacteristicFactory {
put(PM10_DENSITY, HomekitCharacteristicFactory::createPM10DensityCharacteristic);
put(PM25_DENSITY, HomekitCharacteristicFactory::createPM25DensityCharacteristic);
put(POWER_MODE, HomekitCharacteristicFactory::createPowerModeCharacteristic);
put(PROGRAMMABLE_SWITCH_EVENT, HomekitCharacteristicFactory::createProgrammableSwitchEventCharacteristic);
put(REMAINING_DURATION, HomekitCharacteristicFactory::createRemainingDurationCharacteristic);
put(REMOTE_KEY, HomekitCharacteristicFactory::createRemoteKeyCharacteristic);
put(RELATIVE_HUMIDITY, HomekitCharacteristicFactory::createRelativeHumidityCharacteristic);
@ -391,8 +394,7 @@ public class HomekitCharacteristicFactory {
* @param <T> type of the result derived from
* @return key for the value
*/
public static <T> T getKeyFromMapping(HomekitTaggedItem item, Map<T, String> mapping, T defaultValue) {
final State state = item.getItem().getState();
public static <T> T getKeyFromMapping(HomekitTaggedItem item, State state, Map<T, String> mapping, T defaultValue) {
LOGGER.trace("getKeyFromMapping: characteristic {}, state {}, mapping {}", item.getAccessoryType().getTag(),
state, mapping);
@ -448,7 +450,8 @@ public class HomekitCharacteristicFactory {
private static <T extends CharacteristicEnum> CompletableFuture<T> getEnumFromItem(HomekitTaggedItem item,
Map<T, String> mapping, T defaultValue) {
return CompletableFuture.completedFuture(getKeyFromMapping(item, mapping, defaultValue));
return CompletableFuture
.completedFuture(getKeyFromMapping(item, item.getItem().getState(), mapping, defaultValue));
}
public static <T extends Enum<T>> void setValueFromEnum(HomekitTaggedItem taggedItem, T value, Map<T, String> map) {
@ -1160,6 +1163,80 @@ public class HomekitCharacteristicFactory {
return new PowerModeCharacteristic((value) -> setValueFromEnum(taggedItem, value, map));
}
// this characteristic is unique in a few ways, so we can't use the "normal" helpers:
// * you don't return a "current" value, just the value of the most recent event
// * NULL/invalid values are very much expected, and should silently _not_ trigger an event
// * every update to the item should trigger an event, not just changes
private static ProgrammableSwitchEventCharacteristic createProgrammableSwitchEventCharacteristic(
HomekitTaggedItem taggedItem, HomekitAccessoryUpdater updater) {
// have to build the map custom, since SINGLE_PRESS starts at 0
Map<ProgrammableSwitchEnum, String> map = new EnumMap(ProgrammableSwitchEnum.class);
List<ProgrammableSwitchEnum> validValues = new ArrayList<>();
if (taggedItem.getBaseItem().getAcceptedDataTypes().contains(OnOffType.class)) {
map.put(ProgrammableSwitchEnum.SINGLE_PRESS, OnOffType.ON.toString());
validValues.add(ProgrammableSwitchEnum.SINGLE_PRESS);
} else if (taggedItem.getBaseItem().getAcceptedDataTypes().contains(OpenClosedType.class)) {
map.put(ProgrammableSwitchEnum.SINGLE_PRESS, OpenClosedType.OPEN.toString());
validValues.add(ProgrammableSwitchEnum.SINGLE_PRESS);
} else {
map = createMapping(taggedItem, ProgrammableSwitchEnum.class, validValues, false);
}
var helper = new ProgrammableSwitchEventCharacteristicHelper(taggedItem, updater, map);
return new ProgrammableSwitchEventCharacteristic(validValues.toArray(new ProgrammableSwitchEnum[0]),
helper::getValue, helper::subscribe, getUnsubscriber(taggedItem, PROGRAMMABLE_SWITCH_EVENT, updater));
}
private static class ProgrammableSwitchEventCharacteristicHelper {
private @Nullable ProgrammableSwitchEnum lastValue = null;
private final HomekitTaggedItem taggedItem;
private final Map<ProgrammableSwitchEnum, String> map;
private final HomekitAccessoryUpdater updater;
ProgrammableSwitchEventCharacteristicHelper(HomekitTaggedItem taggedItem, HomekitAccessoryUpdater updater,
Map<ProgrammableSwitchEnum, String> map) {
this.taggedItem = taggedItem;
this.map = map;
this.updater = updater;
}
public CompletableFuture<ProgrammableSwitchEnum> getValue() {
return CompletableFuture.completedFuture(lastValue);
}
public void subscribe(HomekitCharacteristicChangeCallback cb) {
updater.subscribeToUpdates((GenericItem) taggedItem.getItem(), PROGRAMMABLE_SWITCH_EVENT.getTag(),
state -> {
// perform inversion here, so logic below only needs to deal with the
// canonical style
if (state instanceof OnOffType && taggedItem.isInverted()) {
if (state.equals(OnOffType.ON)) {
state = OnOffType.OFF;
} else {
state = OnOffType.ON;
}
} else if (state instanceof OpenClosedType && taggedItem.isInverted()) {
if (state.equals(OpenClosedType.OPEN)) {
state = OpenClosedType.CLOSED;
} else {
state = OpenClosedType.OPEN;
}
}
// if "not pressed", don't send an event
if (state instanceof UnDefType || (state instanceof OnOffType && state.equals(OnOffType.OFF))
|| (state instanceof OpenClosedType && state.equals(OpenClosedType.CLOSED))) {
lastValue = null;
return;
}
lastValue = getKeyFromMapping(taggedItem, state, map, ProgrammableSwitchEnum.SINGLE_PRESS);
cb.changed();
});
}
}
private static RemainingDurationCharacteristic createRemainingDurationCharacteristic(HomekitTaggedItem taggedItem,
HomekitAccessoryUpdater updater) {
return new RemainingDurationCharacteristic(getIntSupplier(taggedItem, 0),

View File

@ -48,7 +48,6 @@ import io.github.hapjava.services.impl.ServiceLabelService;
public class HomekitIrrigationSystemImpl extends AbstractHomekitAccessoryImpl implements IrrigationSystemAccessory {
private Map<InUseEnum, String> inUseMapping;
private Map<ProgramModeEnum, String> programModeMap;
private static final String SERVICE_LABEL = "ServiceLabel";
public HomekitIrrigationSystemImpl(HomekitTaggedItem taggedItem, List<HomekitTaggedItem> mandatoryCharacteristics,
List<Characteristic> mandatoryRawCharacteristics, HomekitAccessoryUpdater updater, HomekitSettings settings)
@ -64,17 +63,9 @@ public class HomekitIrrigationSystemImpl extends AbstractHomekitAccessoryImpl im
public void init() throws HomekitException {
super.init();
String serviceLabelNamespaceConfig = getAccessoryConfiguration(SERVICE_LABEL, "ARABIC_NUMERALS");
ServiceLabelNamespaceEnum serviceLabelEnum;
try {
serviceLabelEnum = ServiceLabelNamespaceEnum.valueOf(serviceLabelNamespaceConfig.toUpperCase());
} catch (IllegalArgumentException e) {
serviceLabelEnum = ServiceLabelNamespaceEnum.ARABIC_NUMERALS;
}
final var finalEnum = serviceLabelEnum;
var serviceLabelNamespace = getCharacteristic(ServiceLabelNamespaceCharacteristic.class).orElseGet(
() -> new ServiceLabelNamespaceCharacteristic(() -> CompletableFuture.completedFuture(finalEnum)));
var serviceLabelNamespace = getCharacteristic(ServiceLabelNamespaceCharacteristic.class)
.orElseGet(() -> new ServiceLabelNamespaceCharacteristic(
() -> CompletableFuture.completedFuture(ServiceLabelNamespaceEnum.ARABIC_NUMERALS)));
addService(new ServiceLabelService(serviceLabelNamespace));
}

View File

@ -43,6 +43,8 @@ import io.github.hapjava.characteristics.impl.common.IsConfiguredCharacteristic;
import io.github.hapjava.characteristics.impl.common.IsConfiguredEnum;
import io.github.hapjava.characteristics.impl.common.NameCharacteristic;
import io.github.hapjava.characteristics.impl.common.ServiceLabelIndexCharacteristic;
import io.github.hapjava.characteristics.impl.common.ServiceLabelNamespaceCharacteristic;
import io.github.hapjava.characteristics.impl.common.ServiceLabelNamespaceEnum;
import io.github.hapjava.characteristics.impl.heatercooler.CurrentHeaterCoolerStateCharacteristic;
import io.github.hapjava.characteristics.impl.heatercooler.CurrentHeaterCoolerStateEnum;
import io.github.hapjava.characteristics.impl.heatercooler.TargetHeaterCoolerStateCharacteristic;
@ -101,6 +103,7 @@ public class HomekitMetadataCharacteristicFactory {
put(PICTURE_MODE, HomekitMetadataCharacteristicFactory::createPictureModeCharacteristic);
put(SERIAL_NUMBER, HomekitMetadataCharacteristicFactory::createSerialNumberCharacteristic);
put(SERVICE_INDEX, HomekitMetadataCharacteristicFactory::createServiceIndexCharacteristic);
put(SERVICE_LABEL, HomekitMetadataCharacteristicFactory::createServiceLabelNamespaceCharacteristic);
put(SLEEP_DISCOVERY_MODE, HomekitMetadataCharacteristicFactory::createSleepDiscoveryModeCharacteristic);
put(TARGET_HEATER_COOLER_STATE,
HomekitMetadataCharacteristicFactory::createTargetHeaterCoolerStateCharacteristic);
@ -292,6 +295,10 @@ public class HomekitMetadataCharacteristicFactory {
return new ServiceLabelIndexCharacteristic(getInteger(value));
}
private static Characteristic createServiceLabelNamespaceCharacteristic(Object value) {
return new ServiceLabelNamespaceCharacteristic(getEnum(value, ServiceLabelNamespaceEnum.class));
}
private static Characteristic createSleepDiscoveryModeCharacteristic(Object value) {
return new SleepDiscoveryModeCharacteristic(getEnum(value, SleepDiscoveryModeEnum.class,
SleepDiscoveryModeEnum.ALWAYS_DISCOVERABLE, SleepDiscoveryModeEnum.NOT_DISCOVERABLE), v -> {

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.homekit.internal.accessories;
import java.util.List;
import org.openhab.io.homekit.internal.HomekitAccessoryUpdater;
import org.openhab.io.homekit.internal.HomekitException;
import org.openhab.io.homekit.internal.HomekitSettings;
import org.openhab.io.homekit.internal.HomekitTaggedItem;
import io.github.hapjava.characteristics.Characteristic;
import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEventCharacteristic;
import io.github.hapjava.services.impl.StatelessProgrammableSwitchService;
/**
* Implements a HomeKit Stateless Programmable Switch
*
* @author Cody Cutrer - Initial contribution
*/
class HomekitStatelessProgrammableSwitchImpl extends AbstractHomekitAccessoryImpl {
public HomekitStatelessProgrammableSwitchImpl(HomekitTaggedItem taggedItem,
List<HomekitTaggedItem> mandatoryCharacteristics, List<Characteristic> mandatoryRawCharacteristics,
HomekitAccessoryUpdater updater, HomekitSettings settings) {
super(taggedItem, mandatoryCharacteristics, mandatoryRawCharacteristics, updater, settings);
}
@Override
public void init() throws HomekitException {
super.init();
addService(new StatelessProgrammableSwitchService(
getCharacteristic(ProgrammableSwitchEventCharacteristic.class).get()));
}
}