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() {}
+ }
+}