diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 4b1e82997..3206d8a70 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -8,6 +8,7 @@ on:
branches: [ master ]
permissions:
+ contents: write
packages: write
jobs:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 380426925..29bfe0e59 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,6 +33,6 @@ jobs:
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Maven release
- run: mvn release:prepare release:perform -B -DreleaseVersion=${{ github.event.inputs.releaseVersion }}
+ run: mvn release:prepare release:perform -B -DreleaseVersion=${{ github.event.inputs.releaseVersion }} -Dgoals=deploy
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/CHANGES.md b/CHANGES.md
index 8da353402..ab4bb1202 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,14 @@
+# HAP-Java 2.0.10
+* Add constructors with custom min/max to the window covering tilt angle characteristics [#162](https://github.com/hap-java/HAP-Java/issues/162)
+* Add optional Mute characteristic to Doorbell service
+* Fix leak of mDNS instances and shutdown hooks when stopping `HomekitRoot`
+* Updated netty to 4.1.139.Final
+
+# 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
+* Fix splitting of encrypted frames when several frames of different length arrive in one read, or when a read ends after the first length byte
+
# HAP-Java 2.0.8
* Updated bouncy castle to 1.83
* Updated netty to 4.1.126.Final
diff --git a/pom.xml b/pom.xml
index c292424af..7bbbb9021 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,13 +5,13 @@
hap
hap-java
Homekit Accessory Protocol for Java
- 2.0.9-SNAPSHOT
+ 2.0.10-SNAPSHOT
jar
https://github.com/hap-java/HAP-Java
UTF-8
- 4.1.126.Final
+ 4.1.139.Final
diff --git a/src/main/java/io/github/hapjava/characteristics/impl/base/IntegerCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/base/IntegerCharacteristic.java
index 96448eea8..6b6cd2982 100644
--- a/src/main/java/io/github/hapjava/characteristics/impl/base/IntegerCharacteristic.java
+++ b/src/main/java/io/github/hapjava/characteristics/impl/base/IntegerCharacteristic.java
@@ -47,8 +47,55 @@ public IntegerCharacteristic(
Optional> setter,
Optional> subscriber,
Optional unsubscriber) {
+ this(
+ type,
+ "int",
+ description,
+ minValue,
+ maxValue,
+ unit,
+ getter,
+ setter,
+ subscriber,
+ unsubscriber);
+ }
+
+ /**
+ * Constructor for characteristics whose HAP format differs from the default "int", e.g. "uint8",
+ * "uint16" or "uint32". Values are still handled as Java {@link Integer}, so the supported range
+ * must fit into a signed 32-bit integer.
+ *
+ * @param type a string containing a UUID that indicates the type of characteristic. Apple defines
+ * a set of these, however implementors can create their own as well.
+ * @param format the HAP format to advertise, e.g. "int" or "uint32".
+ * @param description a description of the characteristic to be passed to the consuming device.
+ * @param minValue the minimum supported value.
+ * @param maxValue the maximum supported value
+ * @param unit a description of the unit this characteristic supports.
+ * @param getter getter for value
+ * @param setter setter for value
+ * @param subscriber subscribers to changes
+ * @param unsubscriber unsubscribers to changes
+ */
+ protected IntegerCharacteristic(
+ String type,
+ String format,
+ String description,
+ int minValue,
+ int maxValue,
+ String unit,
+ Optional>> getter,
+ Optional> setter,
+ Optional> subscriber,
+ Optional unsubscriber) {
super(
- type, "int", description, getter.isPresent(), setter.isPresent(), subscriber, unsubscriber);
+ type,
+ format,
+ description,
+ getter.isPresent(),
+ setter.isPresent(),
+ subscriber,
+ unsubscriber);
this.minValue = minValue;
this.maxValue = maxValue;
this.unit = unit;
diff --git a/src/main/java/io/github/hapjava/characteristics/impl/valve/RemainingDurationCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/valve/RemainingDurationCharacteristic.java
index 828fafab1..29409027a 100644
--- a/src/main/java/io/github/hapjava/characteristics/impl/valve/RemainingDurationCharacteristic.java
+++ b/src/main/java/io/github/hapjava/characteristics/impl/valve/RemainingDurationCharacteristic.java
@@ -26,6 +26,7 @@ public RemainingDurationCharacteristic(
Runnable unsubscriber) {
super(
"000000D4-0000-1000-8000-0026BB765291",
+ "uint32",
"remaining duration",
minValue,
maxValue,
diff --git a/src/main/java/io/github/hapjava/characteristics/impl/valve/SetDurationCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/valve/SetDurationCharacteristic.java
index 59cef5646..109a7bb6e 100644
--- a/src/main/java/io/github/hapjava/characteristics/impl/valve/SetDurationCharacteristic.java
+++ b/src/main/java/io/github/hapjava/characteristics/impl/valve/SetDurationCharacteristic.java
@@ -24,6 +24,7 @@ public SetDurationCharacteristic(
Runnable unsubscriber) {
super(
"000000D3-0000-1000-8000-0026BB765291",
+ "uint32",
"set duration",
minValue,
maxValue,
diff --git a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentHorizontalTiltAngleCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentHorizontalTiltAngleCharacteristic.java
index 1255d7eff..272a822f5 100644
--- a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentHorizontalTiltAngleCharacteristic.java
+++ b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentHorizontalTiltAngleCharacteristic.java
@@ -11,20 +11,31 @@
/** This characteristic describes the current angle of horizontal slats. */
public class CurrentHorizontalTiltAngleCharacteristic extends IntegerCharacteristic
implements EventableCharacteristic {
+ public static final int DEFAULT_MIN_VALUE = -90;
+ public static final int DEFAULT_MAX_VALUE = 90;
public CurrentHorizontalTiltAngleCharacteristic(
+ int minValue,
+ int maxValue,
Supplier> getter,
Consumer subscriber,
Runnable unsubscriber) {
super(
"0000006C-0000-1000-8000-0026BB765291",
"current horizontal tilt angle",
- -90,
- 90,
+ minValue,
+ maxValue,
"arcdegrees",
Optional.of(getter),
Optional.empty(),
Optional.of(subscriber),
Optional.of(unsubscriber));
}
+
+ public CurrentHorizontalTiltAngleCharacteristic(
+ Supplier> getter,
+ Consumer subscriber,
+ Runnable unsubscriber) {
+ this(DEFAULT_MIN_VALUE, DEFAULT_MAX_VALUE, getter, subscriber, unsubscriber);
+ }
}
diff --git a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentVerticalTiltAngleCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentVerticalTiltAngleCharacteristic.java
index c6258a2db..7d7168973 100644
--- a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentVerticalTiltAngleCharacteristic.java
+++ b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/CurrentVerticalTiltAngleCharacteristic.java
@@ -11,20 +11,31 @@
/** This characteristic describes the current angle of vertical slats for accessory. */
public class CurrentVerticalTiltAngleCharacteristic extends IntegerCharacteristic
implements EventableCharacteristic {
+ public static final int DEFAULT_MIN_VALUE = -90;
+ public static final int DEFAULT_MAX_VALUE = 90;
public CurrentVerticalTiltAngleCharacteristic(
+ int minValue,
+ int maxValue,
Supplier> getter,
Consumer subscriber,
Runnable unsubscriber) {
super(
"0000006E-0000-1000-8000-0026BB765291",
"current vertical tilt angle",
- -90,
- 90,
+ minValue,
+ maxValue,
"arcdegrees",
Optional.of(getter),
Optional.empty(),
Optional.of(subscriber),
Optional.of(unsubscriber));
}
+
+ public CurrentVerticalTiltAngleCharacteristic(
+ Supplier> getter,
+ Consumer subscriber,
+ Runnable unsubscriber) {
+ this(DEFAULT_MIN_VALUE, DEFAULT_MAX_VALUE, getter, subscriber, unsubscriber);
+ }
}
diff --git a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetHorizontalTiltAngleCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetHorizontalTiltAngleCharacteristic.java
index 38c075760..17639ba6e 100644
--- a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetHorizontalTiltAngleCharacteristic.java
+++ b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetHorizontalTiltAngleCharacteristic.java
@@ -12,8 +12,12 @@
/** This characteristic describes the target angle of horizontal slats for accessories. */
public class TargetHorizontalTiltAngleCharacteristic extends IntegerCharacteristic
implements EventableCharacteristic {
+ public static final int DEFAULT_MIN_VALUE = -90;
+ public static final int DEFAULT_MAX_VALUE = 90;
public TargetHorizontalTiltAngleCharacteristic(
+ int minValue,
+ int maxValue,
Supplier> getter,
ExceptionalConsumer setter,
Consumer subscriber,
@@ -21,12 +25,20 @@ public TargetHorizontalTiltAngleCharacteristic(
super(
"0000007B-0000-1000-8000-0026BB765291",
"target horizontal tilt angle",
- -90,
- 90,
+ minValue,
+ maxValue,
"arcdegrees",
Optional.of(getter),
Optional.of(setter),
Optional.of(subscriber),
Optional.of(unsubscriber));
}
+
+ public TargetHorizontalTiltAngleCharacteristic(
+ Supplier> getter,
+ ExceptionalConsumer setter,
+ Consumer subscriber,
+ Runnable unsubscriber) {
+ this(DEFAULT_MIN_VALUE, DEFAULT_MAX_VALUE, getter, setter, subscriber, unsubscriber);
+ }
}
diff --git a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetVerticalTiltAngleCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetVerticalTiltAngleCharacteristic.java
index 4d7c3ad71..03fd7a3f5 100644
--- a/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetVerticalTiltAngleCharacteristic.java
+++ b/src/main/java/io/github/hapjava/characteristics/impl/windowcovering/TargetVerticalTiltAngleCharacteristic.java
@@ -12,8 +12,12 @@
/** This characteristic describes the target angle of vertical slats. */
public class TargetVerticalTiltAngleCharacteristic extends IntegerCharacteristic
implements EventableCharacteristic {
+ public static final int DEFAULT_MIN_VALUE = -90;
+ public static final int DEFAULT_MAX_VALUE = 90;
public TargetVerticalTiltAngleCharacteristic(
+ int minValue,
+ int maxValue,
Supplier> getter,
ExceptionalConsumer setter,
Consumer subscriber,
@@ -21,12 +25,20 @@ public TargetVerticalTiltAngleCharacteristic(
super(
"0000007D-0000-1000-8000-0026BB765291",
"target vertical tilt angle",
- -90,
- 90,
+ minValue,
+ maxValue,
"arcdegrees",
Optional.of(getter),
Optional.of(setter),
Optional.of(subscriber),
Optional.of(unsubscriber));
}
+
+ public TargetVerticalTiltAngleCharacteristic(
+ Supplier> getter,
+ ExceptionalConsumer setter,
+ Consumer subscriber,
+ Runnable unsubscriber) {
+ this(DEFAULT_MIN_VALUE, DEFAULT_MAX_VALUE, getter, setter, subscriber, unsubscriber);
+ }
}
diff --git a/src/main/java/io/github/hapjava/server/impl/connections/LengthPrefixedByteArrayProcessor.java b/src/main/java/io/github/hapjava/server/impl/connections/LengthPrefixedByteArrayProcessor.java
index 124acbb51..34ec3a41c 100644
--- a/src/main/java/io/github/hapjava/server/impl/connections/LengthPrefixedByteArrayProcessor.java
+++ b/src/main/java/io/github/hapjava/server/impl/connections/LengthPrefixedByteArrayProcessor.java
@@ -21,9 +21,9 @@ public synchronized Collection handle(byte[] data) {
int pos = 0;
LOGGER.trace(
"Received message of length {}. Existing buffer is {}", data.length, buffer.size());
- if (buffer.size() == 0) {
+ if (buffer.size() == 0 && firstLengthByteBuffer == null) {
while (data.length - pos > 18) {
- int targetLength = (data[0] & 0xFF) + (data[1] & 0xFF) * 256 + 16 + 2;
+ int targetLength = (data[pos] & 0xFF) + (data[pos + 1] & 0xFF) * 256 + 16 + 2;
LOGGER.trace("Attempting to read message of length {}", targetLength);
if (data.length >= pos + targetLength) {
byte[] b = new byte[targetLength - 2];
@@ -56,6 +56,7 @@ private void step(byte[] data, int pos, Collection results) {
if (targetLength == 0) {
if (firstLengthByteBuffer != null) {
targetLength = (firstLengthByteBuffer & 0xFF) + (data[pos] & 0xFF) * 256 + 16;
+ firstLengthByteBuffer = null;
pos += 1;
LOGGER.trace(
"Received the second byte after storing the first byte. New length is {}",
diff --git a/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java b/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java
index 9086acb3e..1073f9b86 100644
--- a/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java
+++ b/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java
@@ -17,6 +17,8 @@ public class JmdnsHomekitAdvertiser {
private static final String SERVICE_TYPE = "_hap._tcp.local.";
private final JmDNS jmdns;
+ private final boolean ownsJmdns;
+ private Thread shutdownHook;
private boolean discoverable = true;
private static final Logger logger = LoggerFactory.getLogger(JmdnsHomekitAdvertiser.class);
private boolean isAdvertising = false;
@@ -30,11 +32,16 @@ public class JmdnsHomekitAdvertiser {
private int category;
public JmdnsHomekitAdvertiser(JmDNS jmdns) {
- this.jmdns = jmdns;
+ this(jmdns, false);
}
public JmdnsHomekitAdvertiser(InetAddress localAddress) throws UnknownHostException, IOException {
- jmdns = JmDNS.create(localAddress);
+ this(JmDNS.create(localAddress), true);
+ }
+
+ JmdnsHomekitAdvertiser(JmDNS jmdns, boolean ownsJmdns) {
+ this.jmdns = jmdns;
+ this.ownsJmdns = ownsJmdns;
}
public synchronized void advertise(
@@ -54,18 +61,33 @@ public synchronized void advertise(
registerService();
- Runtime.getRuntime()
- .addShutdownHook(
- new Thread(
- () -> {
- logger.trace("Stopping advertising in response to shutdown.");
- jmdns.unregisterAllServices();
- }));
+ shutdownHook =
+ new Thread(
+ () -> {
+ logger.trace("Stopping advertising in response to shutdown.");
+ jmdns.unregisterAllServices();
+ });
+ Runtime.getRuntime().addShutdownHook(shutdownHook);
isAdvertising = true;
}
public synchronized void stop() {
unregisterService();
+ isAdvertising = false;
+ if (shutdownHook != null) {
+ try {
+ Runtime.getRuntime().removeShutdownHook(shutdownHook);
+ } catch (IllegalStateException e) {
+ // already shutting down
+ }
+ }
+ if (ownsJmdns) {
+ try {
+ jmdns.close();
+ } catch (IOException e) {
+ logger.warn("Failed to close mDNS", e);
+ }
+ }
}
public synchronized void setDiscoverable(boolean discoverable) throws IOException {
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java b/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java
index 4b7c3eb11..6bae3ab84 100755
--- a/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java
@@ -31,8 +31,6 @@ public ExchangeHandler(byte[] k, HomekitAuthInfo authInfo) {
}
public HttpResponse handle(PairSetupRequest req) throws Exception {
- LOGGER.debug("ExchangeHandler: Starting M5 exchange with shared secret K: {}", bytesToHex(k));
-
HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest());
hkdf.init(
new HKDFParameters(
@@ -42,15 +40,10 @@ public HttpResponse handle(PairSetupRequest req) throws Exception {
byte[] okm = hkdf_enc_key = new byte[32];
hkdf.generateBytes(okm, 0, 32);
- LOGGER.debug("ExchangeHandler: HKDF encryption key: {}", bytesToHex(okm));
-
return decrypt((ExchangeRequest) req, okm);
}
private HttpResponse decrypt(ExchangeRequest req, byte[] key) throws Exception {
- LOGGER.debug("ExchangeHandler: Received AuthTag: {}", bytesToHex(req.getAuthTagData()));
- LOGGER.debug("ExchangeHandler: Received MessageData: {}", bytesToHex(req.getMessageData()));
-
try {
ChachaDecoder chacha = new ChachaDecoder(key, "PS-Msg05".getBytes(StandardCharsets.UTF_8));
byte[] plaintext = chacha.decodeCiphertext(req.getAuthTagData(), req.getMessageData());
diff --git a/src/main/java/io/github/hapjava/services/impl/DoorbellService.java b/src/main/java/io/github/hapjava/services/impl/DoorbellService.java
index fd8fc7004..06ee325d4 100644
--- a/src/main/java/io/github/hapjava/services/impl/DoorbellService.java
+++ b/src/main/java/io/github/hapjava/services/impl/DoorbellService.java
@@ -2,8 +2,10 @@
import io.github.hapjava.accessories.DoorbellAccessory;
import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithBrightness;
+import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithMute;
import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithName;
import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithVolume;
+import io.github.hapjava.characteristics.impl.audio.MuteCharacteristic;
import io.github.hapjava.characteristics.impl.audio.VolumeCharacteristic;
import io.github.hapjava.characteristics.impl.common.NameCharacteristic;
import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEventCharacteristic;
@@ -42,6 +44,14 @@ public DoorbellService(DoorbellAccessory accessory) {
((AccessoryWithVolume) accessory)::subscribeVolume,
((AccessoryWithVolume) accessory)::unsubscribeVolume));
}
+ if (accessory instanceof AccessoryWithMute) {
+ addOptionalCharacteristic(
+ new MuteCharacteristic(
+ ((AccessoryWithMute) accessory)::isMuted,
+ ((AccessoryWithMute) accessory)::setMute,
+ ((AccessoryWithMute) accessory)::subscribeMuteState,
+ ((AccessoryWithMute) accessory)::unsubscribeMuteState));
+ }
}
public void addOptionalCharacteristic(NameCharacteristic name) {
@@ -55,4 +65,8 @@ public void addOptionalCharacteristic(BrightnessCharacteristic brightness) {
public void addOptionalCharacteristic(VolumeCharacteristic volume) {
addCharacteristic(volume);
}
+
+ public void addOptionalCharacteristic(MuteCharacteristic mute) {
+ addCharacteristic(mute);
+ }
}
diff --git a/src/test/java/io/github/hapjava/characteristics/impl/CustomTiltAngleRangeTest.java b/src/test/java/io/github/hapjava/characteristics/impl/CustomTiltAngleRangeTest.java
new file mode 100644
index 000000000..d3cd51756
--- /dev/null
+++ b/src/test/java/io/github/hapjava/characteristics/impl/CustomTiltAngleRangeTest.java
@@ -0,0 +1,78 @@
+package io.github.hapjava.characteristics.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.github.hapjava.characteristics.Characteristic;
+import io.github.hapjava.characteristics.impl.windowcovering.CurrentHorizontalTiltAngleCharacteristic;
+import io.github.hapjava.characteristics.impl.windowcovering.CurrentVerticalTiltAngleCharacteristic;
+import io.github.hapjava.characteristics.impl.windowcovering.TargetHorizontalTiltAngleCharacteristic;
+import io.github.hapjava.characteristics.impl.windowcovering.TargetVerticalTiltAngleCharacteristic;
+import java.util.Arrays;
+import java.util.concurrent.CompletableFuture;
+import javax.json.JsonObject;
+import org.junit.Test;
+
+/** Custom tilt angle ranges, see https://github.com/hap-java/HAP-Java/issues/162 */
+public class CustomTiltAngleRangeTest {
+
+ @Test
+ public void defaultConstructorsKeepTheirSerializedOutput() {
+ // Serialized output captured before custom ranges were added.
+ for (Case c :
+ Arrays.asList(
+ Case.of(
+ new CurrentHorizontalTiltAngleCharacteristic(
+ () -> CompletableFuture.completedFuture(50), cb -> {}, () -> {}),
+ "{\"iid\":7,\"type\":\"6C\",\"perms\":[\"pr\",\"ev\"],\"format\":\"int\",\"value\":50,\"minValue\":-90,\"maxValue\":90,\"minStep\":1,\"unit\":\"arcdegrees\"}"),
+ Case.of(
+ new TargetHorizontalTiltAngleCharacteristic(
+ () -> CompletableFuture.completedFuture(50), v -> {}, cb -> {}, () -> {}),
+ "{\"iid\":7,\"type\":\"7B\",\"perms\":[\"pr\",\"pw\",\"ev\"],\"format\":\"int\",\"value\":50,\"minValue\":-90,\"maxValue\":90,\"minStep\":1,\"unit\":\"arcdegrees\"}"),
+ Case.of(
+ new CurrentVerticalTiltAngleCharacteristic(
+ () -> CompletableFuture.completedFuture(50), cb -> {}, () -> {}),
+ "{\"iid\":7,\"type\":\"6E\",\"perms\":[\"pr\",\"ev\"],\"format\":\"int\",\"value\":50,\"minValue\":-90,\"maxValue\":90,\"minStep\":1,\"unit\":\"arcdegrees\"}"),
+ Case.of(
+ new TargetVerticalTiltAngleCharacteristic(
+ () -> CompletableFuture.completedFuture(50), v -> {}, cb -> {}, () -> {}),
+ "{\"iid\":7,\"type\":\"7D\",\"perms\":[\"pr\",\"pw\",\"ev\"],\"format\":\"int\",\"value\":50,\"minValue\":-90,\"maxValue\":90,\"minStep\":1,\"unit\":\"arcdegrees\"}"))) {
+ assertThat(c.characteristic.toJson(7).join().toString())
+ .as(c.characteristic.getClass().getSimpleName())
+ .isEqualTo(c.expected);
+ }
+ }
+
+ @Test
+ public void customRangeIsAdvertised() {
+ for (Characteristic c :
+ Arrays.asList(
+ new CurrentHorizontalTiltAngleCharacteristic(
+ 0, 90, () -> CompletableFuture.completedFuture(50), cb -> {}, () -> {}),
+ new TargetHorizontalTiltAngleCharacteristic(
+ 0, 90, () -> CompletableFuture.completedFuture(50), v -> {}, cb -> {}, () -> {}),
+ new CurrentVerticalTiltAngleCharacteristic(
+ 0, 90, () -> CompletableFuture.completedFuture(50), cb -> {}, () -> {}),
+ new TargetVerticalTiltAngleCharacteristic(
+ 0, 90, () -> CompletableFuture.completedFuture(50), v -> {}, cb -> {}, () -> {}))) {
+ JsonObject json = c.toJson(7).join();
+ String name = c.getClass().getSimpleName();
+ assertThat(json.getInt("minValue")).as(name).isEqualTo(0);
+ assertThat(json.getInt("maxValue")).as(name).isEqualTo(90);
+ assertThat(json.getInt("minStep")).as(name).isEqualTo(1);
+ }
+ }
+
+ private static class Case {
+ final Characteristic characteristic;
+ final String expected;
+
+ private Case(Characteristic characteristic, String expected) {
+ this.characteristic = characteristic;
+ this.expected = expected;
+ }
+
+ static Case of(Characteristic characteristic, String expected) {
+ return new Case(characteristic, expected);
+ }
+ }
+}
diff --git a/src/test/java/io/github/hapjava/characteristics/impl/valve/DurationCharacteristicFormatTest.java b/src/test/java/io/github/hapjava/characteristics/impl/valve/DurationCharacteristicFormatTest.java
new file mode 100644
index 000000000..2e12b2daa
--- /dev/null
+++ b/src/test/java/io/github/hapjava/characteristics/impl/valve/DurationCharacteristicFormatTest.java
@@ -0,0 +1,56 @@
+package io.github.hapjava.characteristics.impl.valve;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.github.hapjava.characteristics.impl.windowcovering.CurrentPositionCharacteristic;
+import java.util.concurrent.CompletableFuture;
+import javax.json.JsonObject;
+import org.junit.Test;
+
+/** Regression tests for https://github.com/hap-java/HAP-Java/issues/202 */
+public class DurationCharacteristicFormatTest {
+
+ @Test
+ public void setDurationAdvertisesUint32() {
+ SetDurationCharacteristic characteristic =
+ new SetDurationCharacteristic(
+ () -> CompletableFuture.completedFuture(300), value -> {}, callback -> {}, () -> {});
+
+ JsonObject json = characteristic.toJson(12).join();
+
+ assertThat(json.getString("type")).isEqualTo("D3");
+ assertThat(json.getString("format")).isEqualTo("uint32");
+ assertThat(json.getInt("value")).isEqualTo(300);
+ assertThat(json.getInt("minValue")).isEqualTo(0);
+ assertThat(json.getInt("maxValue")).isEqualTo(3600);
+ assertThat(json.getInt("minStep")).isEqualTo(1);
+ assertThat(json.getString("unit")).isEqualTo("s");
+ }
+
+ @Test
+ public void remainingDurationAdvertisesUint32() {
+ RemainingDurationCharacteristic characteristic =
+ new RemainingDurationCharacteristic(
+ () -> CompletableFuture.completedFuture(180), callback -> {}, () -> {});
+
+ JsonObject json = characteristic.toJson(13).join();
+
+ assertThat(json.getString("type")).isEqualTo("D4");
+ assertThat(json.getString("format")).isEqualTo("uint32");
+ assertThat(json.getInt("value")).isEqualTo(180);
+ assertThat(json.getInt("minValue")).isEqualTo(0);
+ assertThat(json.getInt("maxValue")).isEqualTo(3600);
+ assertThat(json.getString("unit")).isEqualTo("s");
+ }
+
+ @Test
+ public void otherIntegerCharacteristicsKeepIntFormat() {
+ CurrentPositionCharacteristic characteristic =
+ new CurrentPositionCharacteristic(
+ () -> CompletableFuture.completedFuture(50), callback -> {}, () -> {});
+
+ JsonObject json = characteristic.toJson(14).join();
+
+ assertThat(json.getString("format")).isEqualTo("int");
+ }
+}
diff --git a/src/test/java/io/github/hapjava/server/impl/connections/LengthPrefixedByteArrayProcessorTest.java b/src/test/java/io/github/hapjava/server/impl/connections/LengthPrefixedByteArrayProcessorTest.java
new file mode 100644
index 000000000..9e839848d
--- /dev/null
+++ b/src/test/java/io/github/hapjava/server/impl/connections/LengthPrefixedByteArrayProcessorTest.java
@@ -0,0 +1,125 @@
+package io.github.hapjava.server.impl.connections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import org.junit.Test;
+
+public class LengthPrefixedByteArrayProcessorTest {
+
+ private static final int TAG_LENGTH = 16;
+
+ /**
+ * Builds a frame as it arrives on the wire: 2-byte little-endian payload length, payload, 16-byte
+ * tag. The payload and tag bytes start at 0x80 so that they never look like a short length.
+ */
+ private static byte[] frame(int payloadLength, int seed) {
+ byte[] frame = new byte[2 + payloadLength + TAG_LENGTH];
+ frame[0] = (byte) (payloadLength & 0xFF);
+ frame[1] = (byte) ((payloadLength >> 8) & 0xFF);
+ for (int i = 2; i < frame.length; i++) {
+ frame[i] = (byte) (0x80 + seed + i);
+ }
+ return frame;
+ }
+
+ /** What the processor is expected to emit for a frame: payload + tag, without the length. */
+ private static byte[] body(byte[] frame) {
+ return Arrays.copyOfRange(frame, 2, frame.length);
+ }
+
+ private static byte[] concat(byte[]... parts) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ for (byte[] part : parts) {
+ out.write(part, 0, part.length);
+ }
+ return out.toByteArray();
+ }
+
+ private static List feed(LengthPrefixedByteArrayProcessor processor, byte[]... reads) {
+ List results = new ArrayList<>();
+ for (byte[] read : reads) {
+ results.addAll(processor.handle(read));
+ }
+ return results;
+ }
+
+ private static List sizes(Collection results) {
+ List sizes = new ArrayList<>();
+ for (byte[] result : results) {
+ sizes.add(result.length);
+ }
+ return sizes;
+ }
+
+ private static void assertBodies(List results, byte[]... frames) {
+ List expectedSizes = new ArrayList<>();
+ for (byte[] frame : frames) {
+ expectedSizes.add(frame.length - 2);
+ }
+ assertThat(sizes(results)).isEqualTo(expectedSizes);
+ for (int i = 0; i < frames.length; i++) {
+ assertThat(results.get(i)).as("frame %d", i).isEqualTo(body(frames[i]));
+ }
+ }
+
+ @Test
+ public void singleFrameInOneRead() {
+ byte[] a = frame(100, 1);
+ assertBodies(feed(new LengthPrefixedByteArrayProcessor(), a), a);
+ }
+
+ @Test
+ public void threeFramesInOneRead() {
+ byte[] a = frame(1024, 1);
+ byte[] b = frame(1024, 2);
+ byte[] c = frame(200, 3);
+ assertBodies(feed(new LengthPrefixedByteArrayProcessor(), concat(a, b, c)), a, b, c);
+ }
+
+ @Test
+ public void frameSplitAcrossTwoReads() {
+ byte[] a = frame(1024, 1);
+ int[] offsets = {1, 2, 3, 10, 18, 19, 20, 500, a.length - 1};
+ for (int offset : offsets) {
+ byte[] first = Arrays.copyOfRange(a, 0, offset);
+ byte[] second = Arrays.copyOfRange(a, offset, a.length);
+ List results = feed(new LengthPrefixedByteArrayProcessor(), first, second);
+ assertThat(sizes(results)).as("split at %d", offset).containsExactly(a.length - 2);
+ assertThat(results.get(0)).as("split at %d", offset).isEqualTo(body(a));
+ }
+ }
+
+ @Test
+ public void frameSplitWithNextFrameInSecondRead() {
+ byte[] a = frame(1024, 1);
+ byte[] b = frame(200, 2);
+ byte[] first = Arrays.copyOfRange(a, 0, 500);
+ byte[] second = concat(Arrays.copyOfRange(a, 500, a.length), b);
+ assertBodies(feed(new LengthPrefixedByteArrayProcessor(), first, second), a, b);
+ }
+
+ @Test
+ public void shortFrameFollowedByLongerFrameInOneRead() {
+ byte[] a = frame(5, 1);
+ byte[] b = frame(100, 2);
+ List results = feed(new LengthPrefixedByteArrayProcessor(), concat(a, b));
+ assertThat(sizes(results)).containsExactly(21, 116);
+ assertBodies(results, a, b);
+ }
+
+ @Test
+ public void singleLengthByteThenRestThenAnotherFrame() {
+ byte[] a = frame(5, 1);
+ byte[] b = frame(40, 2);
+ byte[] first = Arrays.copyOfRange(a, 0, 1);
+ byte[] second = concat(Arrays.copyOfRange(a, 1, a.length), b);
+ List results = feed(new LengthPrefixedByteArrayProcessor(), first, second);
+ assertThat(sizes(results)).containsExactly(21, 56);
+ assertBodies(results, a, b);
+ }
+}
diff --git a/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java b/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java
index 870bd739b..731be498f 100644
--- a/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java
+++ b/src/test/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiserTest.java
@@ -2,7 +2,9 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.io.IOException;
@@ -54,6 +56,29 @@ public void testSetConfigurationIndex() throws Exception {
assertThat(getArgumentFromUnregister().getPropertyString("c#")).isEqualTo("1");
}
+ @Test
+ public void testStopClosesOwnedJmdns() throws Exception {
+ subject = new JmdnsHomekitAdvertiser(jmdns, true);
+ advertise();
+ subject.stop();
+ verify(jmdns).close();
+ }
+
+ @Test
+ public void testStopDoesNotCloseSuppliedJmdns() throws Exception {
+ advertise();
+ subject.stop();
+ verify(jmdns, never()).close();
+ }
+
+ @Test
+ public void testSetDiscoverableAfterStopDoesNotRegister() throws Exception {
+ advertise();
+ subject.stop();
+ subject.setDiscoverable(false);
+ verify(jmdns).registerService(any(ServiceInfo.class));
+ }
+
private ServiceInfo getArgumentFromUnregister() {
ArgumentCaptor serviceInfoCaptor = ArgumentCaptor.forClass(ServiceInfo.class);
verify(jmdns).unregisterService(serviceInfoCaptor.capture());
diff --git a/src/test/java/io/github/hapjava/services/impl/DoorbellServiceTest.java b/src/test/java/io/github/hapjava/services/impl/DoorbellServiceTest.java
new file mode 100644
index 000000000..feb67b94c
--- /dev/null
+++ b/src/test/java/io/github/hapjava/services/impl/DoorbellServiceTest.java
@@ -0,0 +1,51 @@
+package io.github.hapjava.services.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+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.DoorbellAccessory;
+import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithMute;
+import io.github.hapjava.characteristics.Characteristic;
+import io.github.hapjava.characteristics.impl.audio.MuteCharacteristic;
+import io.github.hapjava.characteristics.impl.common.ProgrammableSwitchEventCharacteristic;
+import java.util.concurrent.CompletableFuture;
+import javax.json.JsonObject;
+import javax.json.JsonValue;
+import org.junit.Test;
+
+public class DoorbellServiceTest {
+
+ @Test
+ public void doorbellWithoutOptionalsOnlyHasSwitchEvent() {
+ DoorbellService service = new DoorbellService(mock(DoorbellAccessory.class));
+
+ assertThat(service.getType()).isEqualTo("00000121-0000-1000-8000-0026BB765291");
+ assertThat(service.getCharacteristics())
+ .hasExactlyElementsOfTypes(ProgrammableSwitchEventCharacteristic.class);
+ }
+
+ @Test
+ public void doorbellWithMuteHasMuteCharacteristic() throws Exception {
+ DoorbellAccessory accessory =
+ mock(DoorbellAccessory.class, withSettings().extraInterfaces(AccessoryWithMute.class));
+ when(((AccessoryWithMute) accessory).isMuted())
+ .thenReturn(CompletableFuture.completedFuture(true));
+
+ DoorbellService service = new DoorbellService(accessory);
+
+ assertThat(service.getCharacteristics())
+ .hasExactlyElementsOfTypes(
+ ProgrammableSwitchEventCharacteristic.class, MuteCharacteristic.class);
+
+ Characteristic mute = service.getCharacteristics().get(1);
+ JsonObject json = mute.toJson(2).join();
+ assertThat(json.getString("type")).isEqualTo("11A");
+ assertThat(json.getBoolean("value")).isTrue();
+
+ mute.setValue(JsonValue.FALSE);
+ verify((AccessoryWithMute) accessory).setMute(false);
+ }
+}