diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 61753eab7..3206d8a70 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,10 @@ on: push: branches: [ master ] +permissions: + contents: write + packages: write + jobs: build: @@ -14,26 +18,19 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up JDK 11 for Maven Central + - name: Set up JDK 11 uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: '11' architecture: x64 - server-id: ossrh - gpg-private-key: ${{ secrets.GPG_SECRET_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE - server-username: MAVEN_USERNAME - server-password: MAVEN_CENTRAL_TOKEN - name: Setup git profile run: | git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Publish Snapshot to Maven Central - run: mvn deploy -B -Possrh + - name: Publish Snapshot to GitHub Packages + run: mvn deploy -B env: - MAVEN_USERNAME: hap-java-dev - MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish site run: mvn -B site-deploy -Dusername=github-actions -Dpassword=${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48b5ee7cf..29bfe0e59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write jobs: build: @@ -27,14 +28,11 @@ jobs: distribution: 'zulu' java-version: '11' architecture: x64 - server-id: ossrh - gpg-private-key: ${{ secrets.GPG_SECRET_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE - server-username: MAVEN_USERNAME - server-password: MAVEN_CENTRAL_TOKEN + - name: Setup git profile + run: | + 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 }} -Possrh + run: mvn release:prepare release:perform -B -DreleaseVersion=${{ github.event.inputs.releaseVersion }} -Dgoals=deploy env: - MAVEN_USERNAME: hap-java-dev - MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f14d2e2b9..000000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: java -jdk: openjdk8 -after_success: -- openssl aes-256-cbc -pass pass:$ENCRYPTION_PASSWORD -in deploy/pubring.gpg.enc -out - deploy/pubring.gpg -d -- openssl aes-256-cbc -pass pass:$ENCRYPTION_PASSWORD -in deploy/secring.gpg.enc -out - deploy/secring.gpg -d -- deploy/publish.sh -env: - global: - - secure: f/Ka8GVA6NnyUktn+TtBhdjVuM1wQI24ANxKsinfm4ldjfC6jueONgt3bog40fQ+ud5gVtJBAhven/qRicx9hT8bnrNUBSbeSD74KfuaZ6ICUmFyG64mNgbfkbc/pRzSbUf5WVAHPtgCo/p5P2Kt5/4l6/+DKmxLEPQ6K8bMz3nQCv0VtsbuGWh+QRxewA3AYLXF/O3nmP5YbP/X081C+TLiQQoibaL9UUGGWmcdEdsupgNdivvPfoYSji+gw3Dj6dn/gFZC1Y3tdcciyA4WOcspCuhZ4OVHZrencHr7PvvCjwjTQQ9bOQZi3l2zcWMGtnZlwlREdzTjUDP6sc2sjDJYy4FL1I87LRAhkiCO+Bo/SznQxAlljtng5R7eqYplt7ByLDpA6GJZQbchn6O+3YqqC9bScgQlyGQiHpbNGAzY5RQRFmvOosYIFTJm6KxMNMO2jogwID72e/+iU/nn5VaUA1btTziTdshb4en2g6f2jLLKURFHKEcXKmZgEOUV1ajqTUh4e4I++drhM8Nq+7vSYpuXbmnVleNbqbq4fWmH6Y0JlMSTn9Hk/K2tq2zw+Jz1oFWeaE39JCkK5gf3IsyGoOSy+dXqI0f9LtG7bPV0dDQGaE3Dh8P1gsXIqTo1E8fD8C1CvZjFgTV4rHukRTk5nfiNxItyWqRIQiLfBqU= - - secure: wHq7ZQPVYVuHrxnQj5bE4KZscBWr9pYL6U1YO1n/P/obkUz4ni6wG9KgLSOk+v9zj69of/HNRtBuo794tup76QREwEYBdKZsBg7cf9Fe14M44WJjY0O9FXJnUbuRfRqTQ+WhbugwiYPVaNZkrBM3FNj/kt0VP/0CtFqB180vrzv9Gokh0ZUYEuqrA9DGV2OszCt27FUQKAGUxDvVdV7d+9D+2FugkeMRg4CLqpFFx5E0THvnQnoKGCLiMZKnv8DN5BGUedzXHFHZtldJNCve2XNYCVNp8xKlCslsR1bwNYsHNM1nuG4kPjWYQfGAIBzbratTpMUcUTIC4l3liG9PkRvg5MBxi2HEoB+FlH4r3X1Pzj5QBDvNP6/+hZjgJQhhKHEdgau1qcC/fVT6Rf/Hmc3EwPSYOKRobzxXJcSG2TDxijAQD4QO41ekMqQJ+FXHIMR7xL+hK//xZSPJJxvjaKgC081/L1GP5mNvXuuo5FjG+1dr4yCpRtpqAShvmcybGK9VHaRdh+MRG+Ec81PdXm2lqGAjHWkfZnDo79WeyEVkqcjTnv1+lgBrRC5SL1WLy3fErLGMJG1HjM41G2O8B6wVIm0nMM8ejoTocHNw3k1l1rSicM7s8wamk8hlU4QhF6wpfgY1LKs0hSkfqlaIdGLHldCsyKakTQSPSgq/TA0= - - secure: hknjOefKy9U3A9PLrrf+s3uUaD9Is2XnkKr4X4o5mT79iWBjHbNbGllK+o/lrh8oV+SNKs5l+mp+FVtB0JBJK1bPGb7r7nR8zJcCUNrfJlOYePAmRhlc6fyGcclPQ81LLrBvbgn1PXFOfcttjWjZH8uIVMc4uEgobYQeS1l6jPPL2w471PtBgajeF1MSpy1/pwnZEdAFVSAB8RMNnqR/s7KjwG+qhkFgLEr5nz3AhIj3JSXlBQRiUjDH+qlxIt8SOunQZVtt6nYAVDBGp89v6SYk6w2CrEri1aUu8/9I7OT/X9+jSD2ksVFvoWW1hXNHhf7FCWqhbQzO7fGOg90ZM/lmgOVbnA+dihtT60GfBfSET52bInqG1QjDnig+/kggVcupUjThVI7VFIl8SGJXfhQtlnwlCWG2OYq+JBXoy0xUpEdvUnbnJv/8i4uvFpGZ/cgqPazw3arrURL+Shd82l+aRFBhHAa+qTvWdmcxBF1TvkWIodfaEPa95PS4mmao709/2bo7rf+FwaTRFtikiW2Asw7xSNlWmRBl90cS+B62xFiie8H6OeeOFcSKTN07MfuYaCdWKfViHaBU/W7Dfp2Sc7isPEEuBiJYDHucH2dsw0qBk6DyWlnWMaxo297/9QGyopiQNocgG+efKd+x7/cENLKUEVUXKEiyD1aebXY= - - secure: r5CcVp0fSkOunI63uI0Y0LETKeWMGStDOJayKzHsSmUg5t0GlH/8Zd8YrItbppCW/WNOjZ2c3XHAnFf55N4hcOvaHIMLJQaPh4LrMEe1uhFXQFfF8RFte4Ek1KBD0l//MeZQZMqykyz+O/5q4dsAvOi6B617OW2bmpUaF8hs+x+/+6C7hWxYaOU/zHpRrb+LC4YpnHqwfVMgpUdKxo83uysMTPcJDjjBvn+MTiXQvxBBAz1k+3fLQxJSUur+pdc22D/i0VZFpkQ9UpQRosfu8hAjZo4NzTLt/PeKbLvIx4kPRBlJqsU89iRcBE8QKUi3Y/Kelif2e30SdDUhoDSHN66mRe2fx/QmsP29yM7myC3dtxRUedXYNw9nTyx+Gmj23nL/U4IzifNVizWZgoHNheAt0E7+Z0WVhGKTtrYyy/2ffn1DCa80A9jO5DS/nDHkqukaW0cwHFq2EbdKv1UQBacM0QFzgHTiH1qkOzAQ1dh5ekViAyUIQ8qR05M06yZzIEeCxY/p5x6fs/yzSoOI37t5t5V1qWW7QaJYDAGGQ/u+tNNieq9y2/w2umDPRHvcPS7jA6ElNlK/XIlwkJA8n9xOANRYVLI4AgaXm7dYBopK/bs88cMJ7l6rZt3TMJ78gafL1XA3E3qNoceuoSHYIdu5m3oBvKvqfgcrPt8QrKI= - - secure: cMqrDc1B+hsAQOguaJUWNDSb9Jdo51CiRBPXDP5dBhgf+oErOPED9obSmKiCPaDfaWjdZWENpBs5CPjm+drNF4xEPIoYKaLI4che16wxko+To6I7k+/aCDT+thzgi1qIoWTn7KTq0ZB73tRLDktyugND0fwetgKYMqBOn5laCazWQeLpdIq3r4zWG/dX30lOB4kdnsY2X4Yb+9OPZi9+ZEr8AAiI+FomEGCv22tKAUyA/SYqPA/XXg6XJ2hyDdkTfLrmuz+dJDasglIDj2Js4p/my0vsFtlvGIubdzfnbqCPmtFjECSfVmQz92947/Rb36zK5wrQH8UDsNWRqFmd/WDYbGX7Eg2hSrWpHGO8ury6yyZRvPL4R2XiOQ4/T1yTOosc57M/wG8+wLavKlULXiH+yDWwXJPTGbKbaXxfVxnu9t1VAoLx6f9dSUEd+NP5TdDVOyp0dyvjs/dAsTedCooMiuJw/Y2+bktmhr14I3XWs8+w1jFDLO6/GhH1QOoNf9wi3ywEKnYGfY5YXiFp8meBm7koZ5DvU21uxUG2OBrOYgYrt1KatmN9A0og7JR6QeHJ1yawT0wM3L14JbqOjktG2e3MNBCqNyP+hWDqD7RDRm1pITbTr5oz7gjXeAI1E00okQ3y2/SdbHEPOUu/tSc2YYsrPCfriRz1AHpedr0= diff --git a/CHANGES.md b/CHANGES.md index ab4bc7e4f..ab4bb1202 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,21 @@ +# 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 + +# HAP-Java 2.0.7 +* Add overloads to characteristics so that the username can be passed through. + # HAP-Java 2.0.6 * Several methods allowing library users to manipulate characteristics themselves * Allow library users to provider their own implementation of AccessoryInformationService diff --git a/pom.xml b/pom.xml index ffd5798a3..7bbbb9021 100644 --- a/pom.xml +++ b/pom.xml @@ -5,13 +5,13 @@ hap hap-java Homekit Accessory Protocol for Java - 2.0.8-SNAPSHOT + 2.0.10-SNAPSHOT jar https://github.com/hap-java/HAP-Java UTF-8 - 4.1.72.Final + 4.1.139.Final @@ -105,10 +105,16 @@ org.bouncycastle - bcprov-jdk15on - 1.51 + bcpkix-jdk18on + 1.83 + + org.bouncycastle + bctls-jdk18on + 1.82 + + net.vrallev.ecc ecc-25519-java @@ -156,7 +162,7 @@ org.assertj assertj-core - 3.19.0 + 3.27.7 test @@ -272,12 +278,12 @@ https://hap-java.github.io/ - ossrh - https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ + github + https://maven.pkg.github.com/hap-java/HAP-Java - ossrh - https://s01.oss.sonatype.org/content/repositories/snapshots + github + https://maven.pkg.github.com/hap-java/HAP-Java diff --git a/src/main/java/io/github/hapjava/characteristics/Characteristic.java b/src/main/java/io/github/hapjava/characteristics/Characteristic.java index d2e87030a..b2454e5f9 100644 --- a/src/main/java/io/github/hapjava/characteristics/Characteristic.java +++ b/src/main/java/io/github/hapjava/characteristics/Characteristic.java @@ -44,4 +44,14 @@ public interface Characteristic { * @param jsonValue the JSON serialized value to set. */ void setValue(JsonValue jsonValue); + + /** + * Invoked by the remote client, this updates the current value of the characteristic. + * + * @param jsonValue the JSON serialized value to set. + * @param username the authenticated username making the request + */ + default void setValue(JsonValue jsonValue, String username) { + setValue(jsonValue); + } } diff --git a/src/main/java/io/github/hapjava/characteristics/ExceptionalConsumer.java b/src/main/java/io/github/hapjava/characteristics/ExceptionalConsumer.java index 653d73c5f..1c625470d 100644 --- a/src/main/java/io/github/hapjava/characteristics/ExceptionalConsumer.java +++ b/src/main/java/io/github/hapjava/characteristics/ExceptionalConsumer.java @@ -2,4 +2,8 @@ public interface ExceptionalConsumer { void accept(T t) throws Exception; + + default void accept(T t, String username) throws Exception { + accept(t); + } } diff --git a/src/main/java/io/github/hapjava/characteristics/impl/base/BaseCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/base/BaseCharacteristic.java index 38d1d8e6d..0808883bb 100644 --- a/src/main/java/io/github/hapjava/characteristics/impl/base/BaseCharacteristic.java +++ b/src/main/java/io/github/hapjava/characteristics/impl/base/BaseCharacteristic.java @@ -128,13 +128,20 @@ protected CompletableFuture makeBuilder(int instanceId) { /** {@inheritDoc} */ @Override public final void setValue(JsonValue jsonValue) { + setValue(jsonValue, null); + } + + /** {@inheritDoc} */ + @Override + public final void setValue(JsonValue jsonValue, String username) { try { - setValue(convert(jsonValue)); + setValue(convert(jsonValue), username); } catch (Exception e) { logger.warn( - "Error while setting JSON value {} for characteristic {}", + "Error while setting JSON value {} for characteristic {} from user {}", jsonValue, getClass().getName(), + username, e); } } @@ -185,6 +192,17 @@ public void unsubscribe() { */ public abstract void setValue(T value) throws Exception; + /** + * Update the characteristic value using a new value supplied by the connected client. + * + * @param value the new value to set. + * @param username the authenticated username making the request + * @throws Exception if the value cannot be set. + */ + public void setValue(T value, String username) throws Exception { + setValue(value); + } + /** * Retrieves the current value of the characteristic. * diff --git a/src/main/java/io/github/hapjava/characteristics/impl/base/BooleanCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/base/BooleanCharacteristic.java index 4b484eb85..b8c93a4e9 100644 --- a/src/main/java/io/github/hapjava/characteristics/impl/base/BooleanCharacteristic.java +++ b/src/main/java/io/github/hapjava/characteristics/impl/base/BooleanCharacteristic.java @@ -66,7 +66,12 @@ public CompletableFuture getValue() { @Override public void setValue(Boolean value) throws Exception { - if (setter.isPresent()) setter.get().accept(value); + setValue(value, null); + } + + @Override + public void setValue(Boolean value, String username) throws Exception { + if (setter.isPresent()) setter.get().accept(value, username); } /** {@inheritDoc} */ diff --git a/src/main/java/io/github/hapjava/characteristics/impl/base/EnumCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/base/EnumCharacteristic.java index 4d8dfdb5f..a12cb0673 100644 --- a/src/main/java/io/github/hapjava/characteristics/impl/base/EnumCharacteristic.java +++ b/src/main/java/io/github/hapjava/characteristics/impl/base/EnumCharacteristic.java @@ -113,15 +113,24 @@ public CompletableFuture getValue() { } public void setValue(T value) throws Exception { + setValue(value, null); + } + + public void setValue(T value, String username) throws Exception { if (!setter.isPresent()) { return; } - setter.get().accept(value); + setter.get().accept(value, username); } @Override public void setValue(Integer value) throws Exception { + setValue(value, null); + } + + @Override + public void setValue(Integer value, String username) throws Exception { if (!setter.isPresent()) { return; } @@ -130,7 +139,7 @@ public void setValue(Integer value) throws Exception { if (validValues != null && value != null) { for (T valid : validValues) { if (valid.getCode() == value) { - setValue(valid); + setValue(valid, username); return; } } diff --git a/src/main/java/io/github/hapjava/characteristics/impl/base/FloatCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/base/FloatCharacteristic.java index 727cea645..f4b5fa562 100644 --- a/src/main/java/io/github/hapjava/characteristics/impl/base/FloatCharacteristic.java +++ b/src/main/java/io/github/hapjava/characteristics/impl/base/FloatCharacteristic.java @@ -130,7 +130,12 @@ public final CompletableFuture getValue() { @Override public void setValue(Double value) throws Exception { - if (setter.isPresent()) setter.get().accept(value); + setValue(value, null); + } + + @Override + public void setValue(Double value, String username) throws Exception { + if (setter.isPresent()) setter.get().accept(value, username); } /** {@inheritDoc} */ 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 6d1b7df6d..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; @@ -77,7 +124,12 @@ public CompletableFuture getValue() { @Override public void setValue(Integer value) throws Exception { - if (setter.isPresent()) setter.get().accept(value); + setValue(value, null); + } + + @Override + public void setValue(Integer value, String username) throws Exception { + if (setter.isPresent()) setter.get().accept(value, username); } /** {@inheritDoc} */ diff --git a/src/main/java/io/github/hapjava/characteristics/impl/base/StringCharacteristic.java b/src/main/java/io/github/hapjava/characteristics/impl/base/StringCharacteristic.java index 53196efb9..72ff0afd6 100644 --- a/src/main/java/io/github/hapjava/characteristics/impl/base/StringCharacteristic.java +++ b/src/main/java/io/github/hapjava/characteristics/impl/base/StringCharacteristic.java @@ -63,7 +63,13 @@ public String convert(JsonValue jsonValue) { /** {@inheritDoc} */ @Override public void setValue(String value) throws Exception { - if (setter.isPresent()) setter.get().accept(value); + setValue(value, null); + } + + /** {@inheritDoc} */ + @Override + public void setValue(String value, String username) throws Exception { + if (setter.isPresent()) setter.get().accept(value, username); } /** {@inheritDoc} */ 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/HomekitServer.java b/src/main/java/io/github/hapjava/server/impl/HomekitServer.java index fc8ecaf76..7728896a2 100644 --- a/src/main/java/io/github/hapjava/server/impl/HomekitServer.java +++ b/src/main/java/io/github/hapjava/server/impl/HomekitServer.java @@ -117,13 +117,15 @@ public HomekitStandaloneAccessoryServer createStandaloneAccessory( return new HomekitStandaloneAccessoryServer(accessory, http, localAddress, authInfo); } } + public HomekitStandaloneAccessoryServer createStandaloneAccessory( HomekitAuthInfo authInfo, HomekitAccessory accessory, int category) throws IOException, ExecutionException, InterruptedException { if (jmdns != null) { return new HomekitStandaloneAccessoryServer(accessory, http, jmdns, authInfo, category); } else { - return new HomekitStandaloneAccessoryServer(accessory, http, localAddress, authInfo, category); + return new HomekitStandaloneAccessoryServer( + accessory, http, localAddress, authInfo, category); } } diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java b/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java index 99ff7b746..4a5dbabee 100644 --- a/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java +++ b/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java @@ -40,7 +40,7 @@ public class HomekitStandaloneAccessoryServer { root = new HomekitRoot(accessory.getName().get(), webHandler, jmdns, authInfo); root.addAccessory(accessory); } - + HomekitStandaloneAccessoryServer( HomekitAccessory accessory, HomekitWebHandler webHandler, diff --git a/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java b/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java index 4869712e1..759506722 100644 --- a/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java +++ b/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java @@ -27,6 +27,7 @@ class ConnectionImpl implements HomekitClientConnection { private int outboundBinaryMessageCount = 0; private byte[] readKey; private byte[] writeKey; + private String username; private boolean isUpgraded = false; private final Consumer outOfBandMessageCallback; private final SubscriptionManager subscriptions; @@ -58,6 +59,7 @@ private HttpResponse doHandleRequest(HttpRequest request) throws IOException { isUpgraded = true; readKey = ((UpgradeResponse) response).getReadKey().array(); writeKey = ((UpgradeResponse) response).getWriteKey().array(); + username = ((UpgradeResponse) response).getUsername(); } LOGGER.trace("{} {} {}", response.getStatusCode(), request.getMethod(), request.getUri()); return response; @@ -145,4 +147,9 @@ public void close() { public void outOfBand(HttpResponse message) { outOfBandMessageCallback.accept(message); } + + @Override + public String getUsername() { + return username; + } } 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/crypto/ChachaDecoder.java b/src/main/java/io/github/hapjava/server/impl/crypto/ChachaDecoder.java old mode 100644 new mode 100755 index 4c74a469d..014f4b500 --- a/src/main/java/io/github/hapjava/server/impl/crypto/ChachaDecoder.java +++ b/src/main/java/io/github/hapjava/server/impl/crypto/ChachaDecoder.java @@ -1,54 +1,98 @@ package io.github.hapjava.server.impl.crypto; import java.io.IOException; -import org.bouncycastle.crypto.engines.ChaChaEngine; -import org.bouncycastle.crypto.generators.Poly1305KeyGenerator; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.bouncycastle.crypto.modes.ChaCha20Poly1305; +import org.bouncycastle.crypto.params.AEADParameters; import org.bouncycastle.crypto.params.KeyParameter; -import org.bouncycastle.crypto.params.ParametersWithIV; -import org.bouncycastle.crypto.tls.AlertDescription; -import org.bouncycastle.crypto.tls.TlsFatalAlert; -import org.bouncycastle.util.Arrays; +import org.bouncycastle.tls.AlertDescription; +import org.bouncycastle.tls.TlsFatalAlert; public class ChachaDecoder { - private final ChaChaEngine decryptCipher; + private final ChaCha20Poly1305 cipher; + private final byte[] key; + private final byte[] nonce; public ChachaDecoder(byte[] key, byte[] nonce) throws IOException { + this.key = key; + // ChaCha20-Poly1305 requires exactly 12 bytes (96 bits) for nonce + this.nonce = ensureNonceSize(nonce); + this.cipher = new ChaCha20Poly1305(); + } - this.decryptCipher = new ChaChaEngine(20); + private byte[] ensureNonceSize(byte[] nonce) { + if (nonce == null) { + return new byte[12]; // Default to zero nonce if null + } - this.decryptCipher.init(false, new ParametersWithIV(new KeyParameter(key), nonce)); + // For HomeKit pairing messages, handle Apple's string-based nonces + if (nonce.length == 8) { + // Apple's HomeKit implementation uses a specific nonce format + // Based on RFC 7539 and Apple's implementation, the nonce should be: + // - 4 bytes constant (0x00000000) + // - 8 bytes nonce string + // This matches ChaCha20's 96-bit nonce requirement + byte[] adjustedNonce = new byte[12]; + // Put the 8-byte nonce at the END (bytes 4-11), not at the beginning + System.arraycopy(nonce, 0, adjustedNonce, 4, 8); + // First 4 bytes remain zero (counter initialization) + return adjustedNonce; + } + + if (nonce.length == 12) { + return nonce; // Already correct size + } + + // For other nonce lengths, pad or truncate to 12 bytes + byte[] adjustedNonce = new byte[12]; + if (nonce.length < 12) { + // Pad with zeros if too short - put nonce at beginning + System.arraycopy(nonce, 0, adjustedNonce, 0, nonce.length); + // Remaining bytes are already zero + } else { + // Truncate if too long - take first 12 bytes + System.arraycopy(nonce, 0, adjustedNonce, 0, 12); + } + + return adjustedNonce; } public byte[] decodeCiphertext(byte[] receivedMAC, byte[] additionalData, byte[] ciphertext) throws IOException { - KeyParameter macKey = initRecordMAC(decryptCipher); + try { + byte[] ciphertextWithTag = new byte[ciphertext.length + receivedMAC.length]; + System.arraycopy(ciphertext, 0, ciphertextWithTag, 0, ciphertext.length); + System.arraycopy(receivedMAC, 0, ciphertextWithTag, ciphertext.length, receivedMAC.length); + + ChaCha20Poly1305 cipher1 = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, additionalData); + cipher1.init(false, params); - byte[] calculatedMAC = PolyKeyCreator.create(macKey, additionalData, ciphertext); + byte[] output = new byte[cipher1.getOutputSize(ciphertextWithTag.length)]; + int len = cipher1.processBytes(ciphertextWithTag, 0, ciphertextWithTag.length, output, 0); + len += cipher1.doFinal(output, len); - if (!Arrays.constantTimeAreEqual(calculatedMAC, receivedMAC)) { + byte[] result = new byte[len]; + System.arraycopy(output, 0, result, 0, len); + + return result; + + } catch (InvalidCipherTextException e) { throw new TlsFatalAlert(AlertDescription.bad_record_mac); } + } - byte[] output = new byte[ciphertext.length]; - decryptCipher.processBytes(ciphertext, 0, ciphertext.length, output, 0); - - return output; + private static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); } public byte[] decodeCiphertext(byte[] receivedMAC, byte[] ciphertext) throws IOException { return decodeCiphertext(receivedMAC, null, ciphertext); } - - private KeyParameter initRecordMAC(ChaChaEngine cipher) { - byte[] firstBlock = new byte[64]; - cipher.processBytes(firstBlock, 0, firstBlock.length, firstBlock, 0); - - // NOTE: The BC implementation puts 'r' after 'k' - System.arraycopy(firstBlock, 0, firstBlock, 32, 16); - KeyParameter macKey = new KeyParameter(firstBlock, 16, 32); - Poly1305KeyGenerator.clamp(macKey.getKey()); - return macKey; - } } diff --git a/src/main/java/io/github/hapjava/server/impl/crypto/ChachaEncoder.java b/src/main/java/io/github/hapjava/server/impl/crypto/ChachaEncoder.java old mode 100644 new mode 100755 index 3304e7d9a..889c9ca86 --- a/src/main/java/io/github/hapjava/server/impl/crypto/ChachaEncoder.java +++ b/src/main/java/io/github/hapjava/server/impl/crypto/ChachaEncoder.java @@ -1,20 +1,64 @@ package io.github.hapjava.server.impl.crypto; import java.io.IOException; -import org.bouncycastle.crypto.engines.ChaChaEngine; -import org.bouncycastle.crypto.generators.Poly1305KeyGenerator; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.bouncycastle.crypto.modes.ChaCha20Poly1305; +import org.bouncycastle.crypto.params.AEADParameters; import org.bouncycastle.crypto.params.KeyParameter; -import org.bouncycastle.crypto.params.ParametersWithIV; public class ChachaEncoder { - private final ChaChaEngine encryptCipher; + private final ChaCha20Poly1305 cipher; + private final byte[] key; + private final byte[] nonce; public ChachaEncoder(byte[] key, byte[] nonce) throws IOException { + this.key = key; + // ChaCha20-Poly1305 requires exactly 12 bytes (96 bits) for nonce + this.nonce = ensureNonceSize(nonce); + this.cipher = new ChaCha20Poly1305(); + } + + private byte[] ensureNonceSize(byte[] nonce) { + if (nonce == null) { + return new byte[12]; // Default to zero nonce if null + } + + // For HomeKit pairing messages, handle Apple's string-based nonces + if (nonce.length == 8) { + // Apple's HomeKit implementation uses a specific nonce format + // Based on RFC 7539 and Apple's implementation, the nonce should be: + // - 4 bytes constant (0x00000000) + // - 8 bytes nonce string + // This matches ChaCha20's 96-bit nonce requirement and ChachaDecoder format + byte[] adjustedNonce = new byte[12]; + // Put the 8-byte nonce at the END (bytes 4-11), not at the beginning + System.arraycopy(nonce, 0, adjustedNonce, 4, 8); + // First 4 bytes remain zero (counter initialization) + return adjustedNonce; + } + + if (nonce.length == 12) { + return nonce; // Already correct size + } - this.encryptCipher = new ChaChaEngine(20); + byte[] adjustedNonce = new byte[12]; + if (nonce.length < 12) { + // Pad with zeros if too short + System.arraycopy(nonce, 0, adjustedNonce, 0, nonce.length); + } else { + // Truncate if too long + System.arraycopy(nonce, 0, adjustedNonce, 0, 12); + } + return adjustedNonce; + } - this.encryptCipher.init(true, new ParametersWithIV(new KeyParameter(key), nonce)); + private static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); } public byte[] encodeCiphertext(byte[] plaintext) throws IOException { @@ -22,27 +66,28 @@ public byte[] encodeCiphertext(byte[] plaintext) throws IOException { } public byte[] encodeCiphertext(byte[] plaintext, byte[] additionalData) throws IOException { - KeyParameter macKey = initRecordMAC(encryptCipher); - - byte[] ciphertext = new byte[plaintext.length]; - encryptCipher.processBytes(plaintext, 0, plaintext.length, ciphertext, 0); + try { + // Use the nonce provided during construction + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, additionalData); + cipher.init(true, params); - byte[] calculatedMAC = PolyKeyCreator.create(macKey, additionalData, ciphertext); - - byte[] ret = new byte[ciphertext.length + 16]; - System.arraycopy(ciphertext, 0, ret, 0, ciphertext.length); - System.arraycopy(calculatedMAC, 0, ret, ciphertext.length, 16); - return ret; - } + byte[] output = new byte[cipher.getOutputSize(plaintext.length)]; + int len = cipher.processBytes(plaintext, 0, plaintext.length, output, 0); + len += cipher.doFinal(output, len); - private KeyParameter initRecordMAC(ChaChaEngine cipher) { - byte[] firstBlock = new byte[64]; - cipher.processBytes(firstBlock, 0, firstBlock.length, firstBlock, 0); + // Split the result into ciphertext and MAC + byte[] ciphertext = new byte[plaintext.length]; + byte[] mac = new byte[16]; + System.arraycopy(output, 0, ciphertext, 0, plaintext.length); + System.arraycopy(output, plaintext.length, mac, 0, 16); - // NOTE: The BC implementation puts 'r' after 'k' - System.arraycopy(firstBlock, 0, firstBlock, 32, 16); - KeyParameter macKey = new KeyParameter(firstBlock, 16, 32); - Poly1305KeyGenerator.clamp(macKey.getKey()); - return macKey; + // Return combined ciphertext + MAC as expected by the original interface + byte[] ret = new byte[ciphertext.length + 16]; + System.arraycopy(ciphertext, 0, ret, 0, ciphertext.length); + System.arraycopy(mac, 0, ret, ciphertext.length, 16); + return ret; + } catch (InvalidCipherTextException e) { + throw new IOException("Encryption failed", e); + } } } diff --git a/src/main/java/io/github/hapjava/server/impl/http/HomekitClientConnection.java b/src/main/java/io/github/hapjava/server/impl/http/HomekitClientConnection.java index 58ef23c6e..2648808bb 100644 --- a/src/main/java/io/github/hapjava/server/impl/http/HomekitClientConnection.java +++ b/src/main/java/io/github/hapjava/server/impl/http/HomekitClientConnection.java @@ -13,4 +13,8 @@ public interface HomekitClientConnection { void close(); void outOfBand(HttpResponse message); + + default String getUsername() { + return null; + } } 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/json/CharacteristicsController.java b/src/main/java/io/github/hapjava/server/impl/json/CharacteristicsController.java index d061a11fc..f5f8bb87c 100644 --- a/src/main/java/io/github/hapjava/server/impl/json/CharacteristicsController.java +++ b/src/main/java/io/github/hapjava/server/impl/json/CharacteristicsController.java @@ -89,7 +89,7 @@ public HttpResponse put(HttpRequest request, HomekitClientConnection connection) } if (jsonCharacteristic.containsKey("value")) { - characteristic.setValue(jsonCharacteristic.get("value")); + characteristic.setValue(jsonCharacteristic.get("value"), connection.getUsername()); } if (jsonCharacteristic.containsKey("ev") && characteristic instanceof EventableCharacteristic) { 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 old mode 100644 new mode 100755 index 2310975fa..6bae3ab84 --- a/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java +++ b/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java @@ -44,8 +44,17 @@ public HttpResponse handle(PairSetupRequest req) throws Exception { } private HttpResponse decrypt(ExchangeRequest req, byte[] key) throws Exception { - ChachaDecoder chacha = new ChachaDecoder(key, "PS-Msg05".getBytes(StandardCharsets.UTF_8)); - byte[] plaintext = chacha.decodeCiphertext(req.getAuthTagData(), req.getMessageData()); + try { + ChachaDecoder chacha = new ChachaDecoder(key, "PS-Msg05".getBytes(StandardCharsets.UTF_8)); + byte[] plaintext = chacha.decodeCiphertext(req.getAuthTagData(), req.getMessageData()); + return processDecryptedData(plaintext); + } catch (Exception e) { + LOGGER.error("ExchangeHandler: M5 decryption failed: {}", e.getMessage()); + throw new RuntimeException("HomeKit M5 message decryption failed", e); + } + } + + private HttpResponse processDecryptedData(byte[] plaintext) throws Exception { DecodeResult d = TypeLengthValueUtils.decode(plaintext); byte[] username = d.getBytes(MessageType.USERNAME); @@ -54,6 +63,14 @@ private HttpResponse decrypt(ExchangeRequest req, byte[] key) throws Exception { return createUser(username, ltpk, proof); } + private static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } + private HttpResponse createUser(byte[] username, byte[] ltpk, byte[] proof) throws Exception { HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); hkdf.init( diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairSetupRequest.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairSetupRequest.java old mode 100644 new mode 100755 index 4e7260fed..6fa099be4 --- a/src/main/java/io/github/hapjava/server/impl/pairing/PairSetupRequest.java +++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairSetupRequest.java @@ -85,10 +85,38 @@ static class ExchangeRequest extends PairSetupRequest { private final byte[] authTagData; public ExchangeRequest(DecodeResult d) { - messageData = new byte[d.getLength(MessageType.ENCRYPTED_DATA) - 16]; + // Get the complete encrypted data field + byte[] encryptedData = d.getBytes(MessageType.ENCRYPTED_DATA); + logger.debug("ExchangeRequest: Total encrypted data length: {}", encryptedData.length); + logger.debug("ExchangeRequest: Raw encrypted data: {}", bytesToHex(encryptedData)); + + // For HomeKit M5, the encrypted data contains ciphertext + 16-byte auth tag + // The auth tag is the LAST 16 bytes + if (encryptedData.length < 16) { + throw new RuntimeException( + "Encrypted data too short, expected at least 16 bytes for auth tag"); + } + + int ciphertextLength = encryptedData.length - 16; + messageData = new byte[ciphertextLength]; authTagData = new byte[16]; - d.getBytes(MessageType.ENCRYPTED_DATA, messageData, 0); - d.getBytes(MessageType.ENCRYPTED_DATA, authTagData, messageData.length); + + // Copy ciphertext (everything except last 16 bytes) + System.arraycopy(encryptedData, 0, messageData, 0, ciphertextLength); + // Copy auth tag (last 16 bytes) + System.arraycopy(encryptedData, ciphertextLength, authTagData, 0, 16); + + logger.debug("ExchangeRequest: Parsed ciphertext length: {}", messageData.length); + logger.debug("ExchangeRequest: Parsed ciphertext: {}", bytesToHex(messageData)); + logger.debug("ExchangeRequest: Parsed auth tag: {}", bytesToHex(authTagData)); + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); } public byte[] getMessageData() { diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyManager.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyManager.java index 44f25c6d1..4960c6f6a 100644 --- a/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyManager.java +++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyManager.java @@ -105,11 +105,10 @@ private HttpResponse handleVerifyFinishRequest(VerifyFinishRequest request) thro byte[] material = ByteUtils.joinBytes(clientPublicKey, clientUsername, publicKey); - byte[] clientLtpk = - authInfo.getUserPublicKey( - authInfo.getMac() + new String(clientUsername, StandardCharsets.UTF_8)); + String username = new String(clientUsername, StandardCharsets.UTF_8); + byte[] clientLtpk = authInfo.getUserPublicKey(authInfo.getMac() + username); if (clientLtpk == null) { - logger.warn("Unknown user: {}", new String(clientUsername, StandardCharsets.UTF_8)); + logger.warn("Unknown user: {}", username); return new PairingResponse(4, ErrorCode.AUTHENTICATION); } @@ -120,7 +119,8 @@ private HttpResponse handleVerifyFinishRequest(VerifyFinishRequest request) thro return new UpgradeResponse( encoder.toByteArray(), createKey("Control-Write-Encryption-Key"), - createKey("Control-Read-Encryption-Key")); + createKey("Control-Read-Encryption-Key"), + username); } else { logger.warn("Invalid signature. Could not pair " + registry.getLabel()); return new PairingResponse(4, ErrorCode.AUTHENTICATION); diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/UpgradeResponse.java b/src/main/java/io/github/hapjava/server/impl/pairing/UpgradeResponse.java index 87168aed1..121fc7fd8 100644 --- a/src/main/java/io/github/hapjava/server/impl/pairing/UpgradeResponse.java +++ b/src/main/java/io/github/hapjava/server/impl/pairing/UpgradeResponse.java @@ -6,11 +6,13 @@ public class UpgradeResponse extends PairingResponse { private final byte[] readKey; private final byte[] writeKey; + private final String username; - UpgradeResponse(byte[] body, byte[] readKey, byte[] writeKey) { + UpgradeResponse(byte[] body, byte[] readKey, byte[] writeKey, String username) { super(body); this.readKey = readKey; this.writeKey = writeKey; + this.username = username; } @Override @@ -25,4 +27,8 @@ public ByteBuffer getReadKey() { public ByteBuffer getWriteKey() { return ByteBuffer.wrap(writeKey); } + + public String getUsername() { + return username; + } } 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); + } +}