diff --git a/CHANGES.md b/CHANGES.md index a50fc7fae..c20e9af93 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# HAP-Java 2.0.10 +* Add Stateful Programmable Switch accessory, service and Programmable Switch Output State characteristic [#161](https://github.com/hap-java/HAP-Java/issues/161) + # HAP-Java 2.0.9 * Fix Set Duration and Remaining Duration characteristics to advertise format `uint32` instead of `int` [#202](https://github.com/hap-java/HAP-Java/issues/202) * Stop logging the SRP shared secret, derived key and M5 message data during pairing at DEBUG level diff --git a/src/main/java/io/github/hapjava/accessories/StatefulProgrammableSwitchAccessory.java b/src/main/java/io/github/hapjava/accessories/StatefulProgrammableSwitchAccessory.java new file mode 100644 index 000000000..1fe8d9f08 --- /dev/null +++ b/src/main/java/io/github/hapjava/accessories/StatefulProgrammableSwitchAccessory.java @@ -0,0 +1,68 @@ +package io.github.hapjava.accessories; + +import io.github.hapjava.characteristics.HomekitCharacteristicChangeCallback; +import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEnum; +import io.github.hapjava.services.Service; +import io.github.hapjava.services.impl.StatefulProgrammableSwitchService; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; + +/** + * The Stateful Programmable Switch accessory: a programmable switch that also keeps an output state + * (0 or 1). + * + *

This service is not part of the public HAP specification and the Apple Home app may not + * display it; third-party HomeKit apps can use it. + */ +public interface StatefulProgrammableSwitchAccessory extends HomekitAccessory { + + /** + * Retrieves the last states of the switch. Bluetooth device should return the last event, the IP + * device should always return null + * + * @return state of the switch event + */ + CompletableFuture getSwitchEvent(); + + /** + * Subscribes to changes in switch event, i.e. pressing on the button. + * + * @param callback the function to call when the state changes. + */ + void subscribeSwitchEvent(HomekitCharacteristicChangeCallback callback); + + /** Unsubscribes from changes in switch event. */ + void unsubscribeSwitchEvent(); + + /** + * Retrieves the output state of the switch. + * + * @return a future with the output state, 0 or 1 + */ + CompletableFuture getSwitchOutputState(); + + /** + * Sets the output state of the switch. + * + * @param state the output state to set, 0 or 1 + * @return a future that completes when the change is made + * @throws Exception when the output state cannot be set + */ + CompletableFuture setSwitchOutputState(int state) throws Exception; + + /** + * Subscribes to changes in the output state of the switch. + * + * @param callback the function to call when the state changes. + */ + void subscribeSwitchOutputState(HomekitCharacteristicChangeCallback callback); + + /** Unsubscribes from changes in the output state of the switch. */ + void unsubscribeSwitchOutputState(); + + @Override + default Collection getServices() { + return Collections.singleton(new StatefulProgrammableSwitchService(this)); + } +} diff --git a/src/main/java/io/github/hapjava/characteristics/impl/common/ProgrammableSwitchOutputStateCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/common/ProgrammableSwitchOutputStateCharacteristic.java new file mode 100644 index 000000000..0247a0d78 --- /dev/null +++ b/src/main/java/io/github/hapjava/characteristics/impl/common/ProgrammableSwitchOutputStateCharacteristic.java @@ -0,0 +1,38 @@ +package io.github.hapjava.characteristics.impl.common; + +import io.github.hapjava.characteristics.EventableCharacteristic; +import io.github.hapjava.characteristics.ExceptionalConsumer; +import io.github.hapjava.characteristics.HomekitCharacteristicChangeCallback; +import io.github.hapjava.characteristics.impl.base.IntegerCharacteristic; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * This characteristic describes the output state of a stateful programmable switch, e.g. {@link + * io.github.hapjava.accessories.StatefulProgrammableSwitchAccessory}. Valid values are 0 and 1. + */ +public class ProgrammableSwitchOutputStateCharacteristic extends IntegerCharacteristic + implements EventableCharacteristic { + public static final int MIN_VALUE = 0; + public static final int MAX_VALUE = 1; + + public ProgrammableSwitchOutputStateCharacteristic( + Supplier> getter, + ExceptionalConsumer setter, + Consumer subscriber, + Runnable unsubscriber) { + super( + "00000074-0000-1000-8000-0026BB765291", + "uint8", + "programmable switch output state", + MIN_VALUE, + MAX_VALUE, + null, + Optional.of(getter), + Optional.of(setter), + Optional.of(subscriber), + Optional.of(unsubscriber)); + } +} diff --git a/src/main/java/io/github/hapjava/services/impl/StatefulProgrammableSwitchService.java b/src/main/java/io/github/hapjava/services/impl/StatefulProgrammableSwitchService.java new file mode 100644 index 000000000..0100189a0 --- /dev/null +++ b/src/main/java/io/github/hapjava/services/impl/StatefulProgrammableSwitchService.java @@ -0,0 +1,42 @@ +package io.github.hapjava.services.impl; + +import io.github.hapjava.accessories.StatefulProgrammableSwitchAccessory; +import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithName; +import io.github.hapjava.characteristics.impl.common.NameCharacteristic; +import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEventCharacteristic; +import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchOutputStateCharacteristic; + +/** + * This service describes a stateful programmable switch. It is not part of the public HAP + * specification; the definition follows HAP-NodeJS. + */ +public class StatefulProgrammableSwitchService extends AbstractServiceImpl { + + public StatefulProgrammableSwitchService( + ProgrammableSwitchEventCharacteristic switchEvent, + ProgrammableSwitchOutputStateCharacteristic outputState) { + super("00000088-0000-1000-8000-0026BB765291"); + addCharacteristic(switchEvent); + addCharacteristic(outputState); + } + + public StatefulProgrammableSwitchService(StatefulProgrammableSwitchAccessory accessory) { + this( + new ProgrammableSwitchEventCharacteristic( + accessory::getSwitchEvent, + accessory::subscribeSwitchEvent, + accessory::unsubscribeSwitchEvent), + new ProgrammableSwitchOutputStateCharacteristic( + accessory::getSwitchOutputState, + accessory::setSwitchOutputState, + accessory::subscribeSwitchOutputState, + accessory::unsubscribeSwitchOutputState)); + if (accessory instanceof AccessoryWithName) { + addOptionalCharacteristic(new NameCharacteristic(((AccessoryWithName) accessory)::getName)); + } + } + + public void addOptionalCharacteristic(NameCharacteristic name) { + addCharacteristic(name); + } +} diff --git a/src/test/java/io/github/hapjava/services/impl/StatefulProgrammableSwitchServiceTest.java b/src/test/java/io/github/hapjava/services/impl/StatefulProgrammableSwitchServiceTest.java new file mode 100644 index 000000000..cad741b09 --- /dev/null +++ b/src/test/java/io/github/hapjava/services/impl/StatefulProgrammableSwitchServiceTest.java @@ -0,0 +1,200 @@ +package io.github.hapjava.services.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import io.github.hapjava.accessories.StatefulProgrammableSwitchAccessory; +import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithName; +import io.github.hapjava.characteristics.Characteristic; +import io.github.hapjava.characteristics.EventableCharacteristic; +import io.github.hapjava.characteristics.HomekitCharacteristicChangeCallback; +import io.github.hapjava.characteristics.impl.common.NameCharacteristic; +import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEnum; +import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEventCharacteristic; +import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchOutputStateCharacteristic; +import io.github.hapjava.services.Service; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonString; +import org.junit.Before; +import org.junit.Test; + +public class StatefulProgrammableSwitchServiceTest { + + private StatefulProgrammableSwitchAccessory accessory; + + @Before + public void setup() throws Exception { + accessory = mock(StatefulProgrammableSwitchAccessory.class); + when(accessory.getSwitchEvent()).thenReturn(CompletableFuture.completedFuture(null)); + when(accessory.getSwitchOutputState()).thenReturn(CompletableFuture.completedFuture(1)); + } + + @Test + public void serviceHasStatefulSwitchTypeAndRequiredCharacteristics() { + StatefulProgrammableSwitchService service = new StatefulProgrammableSwitchService(accessory); + + assertThat(service.getType()).isEqualTo("00000088-0000-1000-8000-0026BB765291"); + assertThat(service.getCharacteristics()) + .hasSize(2) + .hasExactlyElementsOfTypes( + ProgrammableSwitchEventCharacteristic.class, + ProgrammableSwitchOutputStateCharacteristic.class); + } + + @Test + public void nameIsAddedWhenAccessoryHasName() { + StatefulProgrammableSwitchAccessory namedAccessory = + mock( + StatefulProgrammableSwitchAccessory.class, + withSettings().extraInterfaces(AccessoryWithName.class)); + + StatefulProgrammableSwitchService service = + new StatefulProgrammableSwitchService(namedAccessory); + + assertThat(service.getCharacteristics()) + .hasSize(3) + .last() + .isInstanceOf(NameCharacteristic.class); + } + + @Test + public void defaultGetServicesReturnsStatefulSwitchService() { + Collection services = new TestSwitch().getServices(); + + assertThat(services).hasSize(1); + assertThat(services.iterator().next()).isInstanceOf(StatefulProgrammableSwitchService.class); + } + + @Test + public void outputStateAdvertisesUint8WithRangeZeroToOne() { + JsonObject json = + outputState(new StatefulProgrammableSwitchService(accessory)).toJson(9).join(); + + assertThat(json.getString("type")).isEqualTo("74"); + assertThat(json.getString("format")).isEqualTo("uint8"); + assertThat( + json.getJsonArray("perms").getValuesAs(JsonString.class).stream() + .map(JsonString::getString) + .collect(Collectors.toList())) + .containsExactly("pr", "pw", "ev"); + assertThat(json.getInt("value")).isEqualTo(1); + assertThat(json.getInt("minValue")).isEqualTo(0); + assertThat(json.getInt("maxValue")).isEqualTo(1); + assertThat(json.getInt("minStep")).isEqualTo(1); + assertThat(json.containsKey("unit")).isFalse(); + } + + @Test + public void writingOutputStateCallsAccessorySetter() throws Exception { + outputState(new StatefulProgrammableSwitchService(accessory)) + .setValue(Json.createArrayBuilder().add(1).build().get(0)); + + verify(accessory).setSwitchOutputState(1); + } + + @Test + public void outputStateSubscriptionsAreDelegatedToAccessory() { + EventableCharacteristic outputState = + (EventableCharacteristic) outputState(new StatefulProgrammableSwitchService(accessory)); + HomekitCharacteristicChangeCallback callback = () -> {}; + + outputState.subscribe(callback); + outputState.unsubscribe(); + + verify(accessory).subscribeSwitchOutputState(callback); + verify(accessory).unsubscribeSwitchOutputState(); + } + + @Test + public void switchEventStaysReadOnlyAndEventable() { + JsonObject json = + new StatefulProgrammableSwitchService(accessory) + .getCharacteristics() + .get(0) + .toJson(8) + .join(); + + assertThat(json.getString("type")).isEqualTo("73"); + assertThat(json.getJsonArray("perms").toString()).isEqualTo("[\"pr\",\"ev\"]"); + + ((EventableCharacteristic) + new StatefulProgrammableSwitchService(accessory).getCharacteristics().get(0)) + .subscribe(() -> {}); + verify(accessory).subscribeSwitchEvent(any()); + } + + private static Characteristic outputState(StatefulProgrammableSwitchService service) { + return service.getCharacteristics().get(1); + } + + /** Minimal accessory, so the interface's default getServices() runs for real. */ + private static class TestSwitch implements StatefulProgrammableSwitchAccessory { + @Override + public int getId() { + return 2; + } + + @Override + public CompletableFuture getName() { + return CompletableFuture.completedFuture("switch"); + } + + @Override + public void identify() {} + + @Override + public CompletableFuture getSerialNumber() { + return CompletableFuture.completedFuture("1"); + } + + @Override + public CompletableFuture getModel() { + return CompletableFuture.completedFuture("model"); + } + + @Override + public CompletableFuture getManufacturer() { + return CompletableFuture.completedFuture("maker"); + } + + @Override + public CompletableFuture getFirmwareRevision() { + return CompletableFuture.completedFuture("1.0"); + } + + @Override + public CompletableFuture getSwitchEvent() { + return CompletableFuture.completedFuture(null); + } + + @Override + public void subscribeSwitchEvent(HomekitCharacteristicChangeCallback callback) {} + + @Override + public void unsubscribeSwitchEvent() {} + + @Override + public CompletableFuture getSwitchOutputState() { + return CompletableFuture.completedFuture(0); + } + + @Override + public CompletableFuture setSwitchOutputState(int state) { + return CompletableFuture.completedFuture(null); + } + + @Override + public void subscribeSwitchOutputState(HomekitCharacteristicChangeCallback callback) {} + + @Override + public void unsubscribeSwitchOutputState() {} + } +}