subscriber,
+ Runnable unsubscriber) {
+ this(DEFAULT_MIN_VALUE, DEFAULT_MAX_VALUE, getter, setter, subscriber, unsubscriber);
+ }
}
diff --git a/src/main/java/io/github/hapjava/server/HomekitAuthInfo.java b/src/main/java/io/github/hapjava/server/HomekitAuthInfo.java
index 069bf7270..6441962d7 100644
--- a/src/main/java/io/github/hapjava/server/HomekitAuthInfo.java
+++ b/src/main/java/io/github/hapjava/server/HomekitAuthInfo.java
@@ -3,6 +3,8 @@
import io.github.hapjava.server.impl.HomekitServer;
import io.github.hapjava.server.impl.crypto.HAPSetupCodeUtils;
import java.math.BigInteger;
+import java.util.Collection;
+import java.util.List;
/**
* Authentication info that must be provided when constructing a new {@link HomekitServer}. You will
@@ -65,8 +67,23 @@ default String getSetupId() {
* @param username the iOS device's username. The value will not be meaningful to anything but
* iOS.
* @param publicKey the iOS device's public key.
+ * @param isAdmin if the user is an admin, authorized to and/remove other users
*/
- void createUser(String username, byte[] publicKey);
+ default void createUser(String username, byte[] publicKey, boolean isAdmin) {
+ createUser(username, publicKey);
+ }
+
+ /**
+ * Deprecated method to add a user, assuming all users are admins.
+ *
+ * At least one of the createUser methods must be implemented.
+ *
+ * @param username the iOS device's username.
+ * @param publicKey the iOS device's public key.
+ */
+ default void createUser(String username, byte[] publicKey) {
+ createUser(username, publicKey, true);
+ }
/**
* Called when an iOS device needs to remove an existing pairing. Subsequent calls to {@link
@@ -76,6 +93,15 @@ default String getSetupId() {
*/
void removeUser(String username);
+ /**
+ * List all users which have been authenticated.
+ *
+ * @return the previously stored list of users.
+ */
+ default Collection listUsers() {
+ return List.of();
+ }
+
/**
* Called when an already paired iOS device is re-connecting. The public key returned by this
* method will be compared with the signature of the pair verification request to validate the
@@ -86,6 +112,16 @@ default String getSetupId() {
*/
byte[] getUserPublicKey(String username);
+ /**
+ * Determine if the specified user is an admin.
+ *
+ * @param username the username of the iOS device to retrieve permissions for.
+ * @return the previously stored permissions.
+ */
+ default boolean userIsAdmin(String username) {
+ return true;
+ }
+
/**
* Called to check if a user has been created. The homekit accessory advertises whether the
* accessory has already been paired. At this time, it's unclear whether multiple users can be
diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitRegistry.java b/src/main/java/io/github/hapjava/server/impl/HomekitRegistry.java
index 39ec31ff9..3c70cfb9e 100644
--- a/src/main/java/io/github/hapjava/server/impl/HomekitRegistry.java
+++ b/src/main/java/io/github/hapjava/server/impl/HomekitRegistry.java
@@ -2,6 +2,7 @@
import io.github.hapjava.accessories.HomekitAccessory;
import io.github.hapjava.characteristics.Characteristic;
+import io.github.hapjava.server.impl.connections.SubscriptionManager;
import io.github.hapjava.services.Service;
import io.github.hapjava.services.impl.AccessoryInformationService;
import java.util.ArrayList;
@@ -19,14 +20,16 @@ public class HomekitRegistry {
private static final Logger logger = LoggerFactory.getLogger(HomekitRegistry.class);
private final String label;
+ private final SubscriptionManager subscriptions;
private final Map accessories;
private final Map> services = new HashMap<>();
private final Map> characteristics =
new HashMap<>();
private boolean isAllowUnauthenticatedRequests = false;
- public HomekitRegistry(String label) {
+ public HomekitRegistry(String label, SubscriptionManager subscriptions) {
this.label = label;
+ this.subscriptions = subscriptions;
this.accessories = new ConcurrentHashMap<>();
reset();
}
@@ -39,8 +42,12 @@ public synchronized void reset() {
List newServices;
try {
newServices = new ArrayList<>(2);
- newServices.add(new AccessoryInformationService(accessory));
- for (Service service : accessory.getServices()) {
+ Collection services = accessory.getServices();
+ if (!services.stream()
+ .anyMatch(s -> s.getType().equals(AccessoryInformationService.TYPE))) {
+ newServices.add(new AccessoryInformationService(accessory));
+ }
+ for (Service service : services) {
newServices.add(service);
newServices.addAll(service.getLinkedServices());
}
@@ -61,6 +68,7 @@ public synchronized void reset() {
services.put(accessory, newServicesByInterfaceId);
characteristics.put(accessory, newCharacteristicsByInterfaceId);
}
+ subscriptions.resync(this);
}
public String getLabel() {
@@ -87,8 +95,8 @@ public void add(HomekitAccessory accessory) {
accessories.put(accessory.getId(), accessory);
}
- public void remove(HomekitAccessory accessory) {
- accessories.remove(accessory.getId());
+ public boolean remove(HomekitAccessory accessory) {
+ return accessories.remove(accessory.getId()) != null;
}
public boolean isAllowUnauthenticatedRequests() {
diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java b/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java
index ca182f812..36bfeefa2 100644
--- a/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java
+++ b/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java
@@ -36,6 +36,8 @@ public class HomekitRoot {
private final SubscriptionManager subscriptions = new SubscriptionManager();
private boolean started = false;
private int configurationIndex = 1;
+ private int nestedBatches = 0;
+ private boolean madeChanges = false;
HomekitRoot(
String label, HomekitWebHandler webHandler, InetAddress host, HomekitAuthInfo authInfo)
@@ -65,7 +67,7 @@ public class HomekitRoot {
this.authInfo = authInfo;
this.label = label;
this.category = category;
- this.registry = new HomekitRegistry(label);
+ this.registry = new HomekitRegistry(label, subscriptions);
}
HomekitRoot(
@@ -83,11 +85,27 @@ public class HomekitRoot {
this(
label, DEFAULT_ACCESSORY_CATEGORY, webHandler, authInfo, new JmdnsHomekitAdvertiser(jmdns));
}
+
/**
- * Add an accessory to be handled and advertised by this root. Any existing HomeKit connections
- * will be terminated to allow the clients to reconnect and see the updated accessory list. When
- * using this for a bridge, the ID of the accessory must be greater than 1, as that ID is reserved
- * for the Bridge itself.
+ * Begin a batch update of accessories.
+ *
+ * After calling this, you can call addAccessory() and removeAccessory() multiple times without
+ * causing HAP-Java to re-publishing the metadata to HomeKit. You'll need to call
+ * completeUpdateBatch in order to publish all accumulated changes.
+ */
+ public synchronized void batchUpdate() {
+ if (this.nestedBatches == 0) madeChanges = false;
+ ++this.nestedBatches;
+ }
+
+ /** Publish accumulated accessory changes since batchUpdate() was called. */
+ public synchronized void completeUpdateBatch() {
+ if (--this.nestedBatches == 0 && madeChanges) registry.reset();
+ }
+
+ /**
+ * Add an accessory to be handled and advertised by this root. When using this for a bridge, the
+ * ID of the accessory must be greater than 1, as that ID is reserved for the Bridge itself.
*
* @param accessory to advertise and handle.
*/
@@ -110,27 +128,28 @@ void addAccessorySkipRangeCheck(HomekitAccessory accessory) {
if (logger.isTraceEnabled()) {
accessory.getName().thenAccept(name -> logger.trace("Added accessory {}", name));
}
- if (started) {
+ madeChanges = true;
+ if (started && nestedBatches == 0) {
registry.reset();
- webHandler.resetConnections();
}
}
/**
- * Removes an accessory from being handled or advertised by this root. Any existing HomeKit
- * connections will be terminated to allow the clients to reconnect and see the updated accessory
- * list.
+ * Removes an accessory from being handled or advertised by this root.
*
* @param accessory accessory to cease advertising and handling
*/
public void removeAccessory(HomekitAccessory accessory) {
- this.registry.remove(accessory);
- if (logger.isTraceEnabled()) {
- accessory.getName().thenAccept(name -> logger.trace("Removed accessory {}", name));
- }
- if (started) {
- registry.reset();
- webHandler.resetConnections();
+ if (this.registry.remove(accessory)) {
+ if (logger.isTraceEnabled()) {
+ accessory.getName().thenAccept(name -> logger.trace("Removed accessory {}", name));
+ }
+ madeChanges = true;
+ if (started && nestedBatches == 0) {
+ registry.reset();
+ }
+ } else {
+ accessory.getName().thenAccept(name -> logger.warn("Could not remove accessory {}", name));
}
}
@@ -142,6 +161,7 @@ public void removeAccessory(HomekitAccessory accessory) {
*/
public void start() {
started = true;
+ madeChanges = false;
registry.reset();
webHandler
.start(
@@ -177,6 +197,7 @@ public void stop() {
* @throws IOException if there is an error in the underlying protocol, such as a TCP error
*/
public void refreshAuthInfo() throws IOException {
+ advertiser.setMac(authInfo.getMac());
advertiser.setDiscoverable(!authInfo.hasUser());
}
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 af129a7a8..7728896a2 100644
--- a/src/main/java/io/github/hapjava/server/impl/HomekitServer.java
+++ b/src/main/java/io/github/hapjava/server/impl/HomekitServer.java
@@ -118,6 +118,17 @@ public HomekitStandaloneAccessoryServer createStandaloneAccessory(
}
}
+ 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);
+ }
+ }
+
/**
* Creates a bridge accessory, capable of holding multiple child accessories. This has the
* advantage over multiple standalone accessories of only requiring a single pairing from iOS for
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 5eecfe45c..4a5dbabee 100644
--- a/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java
+++ b/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java
@@ -41,6 +41,28 @@ public class HomekitStandaloneAccessoryServer {
root.addAccessory(accessory);
}
+ HomekitStandaloneAccessoryServer(
+ HomekitAccessory accessory,
+ HomekitWebHandler webHandler,
+ InetAddress localhost,
+ HomekitAuthInfo authInfo,
+ int category)
+ throws UnknownHostException, IOException, ExecutionException, InterruptedException {
+ root = new HomekitRoot(accessory.getName().get(), category, webHandler, localhost, authInfo);
+ root.addAccessory(accessory);
+ }
+
+ HomekitStandaloneAccessoryServer(
+ HomekitAccessory accessory,
+ HomekitWebHandler webHandler,
+ JmDNS jmdns,
+ HomekitAuthInfo authInfo,
+ int category)
+ throws UnknownHostException, IOException, ExecutionException, InterruptedException {
+ root = new HomekitRoot(accessory.getName().get(), category, webHandler, jmdns, authInfo);
+ root.addAccessory(accessory);
+ }
+
/** Begins advertising and handling requests for this accessory. */
public void start() {
root.start();
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 ab2ff4444..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,8 +59,9 @@ 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.getUri());
+ 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/HttpSession.java b/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java
index 36ac04085..f1c97c62c 100644
--- a/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java
+++ b/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java
@@ -9,9 +9,9 @@
import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
import io.github.hapjava.server.impl.json.AccessoryController;
import io.github.hapjava.server.impl.json.CharacteristicsController;
-import io.github.hapjava.server.impl.pairing.PairVerificationManager;
-import io.github.hapjava.server.impl.pairing.PairingManager;
-import io.github.hapjava.server.impl.pairing.PairingUpdateController;
+import io.github.hapjava.server.impl.pairing.PairSetupManager;
+import io.github.hapjava.server.impl.pairing.PairVerifyManager;
+import io.github.hapjava.server.impl.pairing.PairingsManager;
import io.github.hapjava.server.impl.responses.InternalServerErrorResponse;
import io.github.hapjava.server.impl.responses.NotFoundResponse;
import java.io.IOException;
@@ -21,8 +21,8 @@
class HttpSession {
- private volatile PairingManager pairingManager;
- private volatile PairVerificationManager pairVerificationManager;
+ private volatile PairSetupManager pairSetupManager;
+ private volatile PairVerifyManager pairVerifyManager;
private volatile AccessoryController accessoryController;
private volatile CharacteristicsController characteristicsController;
@@ -67,7 +67,7 @@ public HttpResponse handleRequest(HttpRequest request) throws IOException {
public HttpResponse handleAuthenticatedRequest(HttpRequest request) throws IOException {
advertiser.setDiscoverable(
- false); // brigde is already bound and should not be discoverable anymore
+ false); // bridge is already bound and should not be discoverable anymore
try {
switch (request.getUri()) {
case "/accessories":
@@ -84,7 +84,7 @@ public HttpResponse handleAuthenticatedRequest(HttpRequest request) throws IOExc
}
case "/pairings":
- return new PairingUpdateController(authInfo, advertiser).handle(request);
+ return new PairingsManager(authInfo, advertiser).handle(request);
default:
if (request.getUri().startsWith("/characteristics?")) {
@@ -100,15 +100,15 @@ public HttpResponse handleAuthenticatedRequest(HttpRequest request) throws IOExc
}
private HttpResponse handlePairSetup(HttpRequest request) {
- if (pairingManager == null) {
+ if (pairSetupManager == null) {
synchronized (HttpSession.class) {
- if (pairingManager == null) {
- pairingManager = new PairingManager(authInfo, registry);
+ if (pairSetupManager == null) {
+ pairSetupManager = new PairSetupManager(authInfo, registry);
}
}
}
try {
- return pairingManager.handle(request);
+ return pairSetupManager.handle(request);
} catch (Exception e) {
logger.warn("Exception encountered during pairing", e);
return new InternalServerErrorResponse(e);
@@ -116,15 +116,15 @@ private HttpResponse handlePairSetup(HttpRequest request) {
}
private HttpResponse handlePairVerify(HttpRequest request) {
- if (pairVerificationManager == null) {
+ if (pairVerifyManager == null) {
synchronized (HttpSession.class) {
- if (pairVerificationManager == null) {
- pairVerificationManager = new PairVerificationManager(authInfo, registry);
+ if (pairVerifyManager == null) {
+ pairVerifyManager = new PairVerifyManager(authInfo, registry);
}
}
}
try {
- return pairVerificationManager.handle(request);
+ return pairVerifyManager.handle(request);
} catch (Exception e) {
logger.warn("Exception encountered while verifying pairing", e);
return new InternalServerErrorResponse(e);
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/connections/SubscriptionManager.java b/src/main/java/io/github/hapjava/server/impl/connections/SubscriptionManager.java
index 25f68526c..9399295fb 100644
--- a/src/main/java/io/github/hapjava/server/impl/connections/SubscriptionManager.java
+++ b/src/main/java/io/github/hapjava/server/impl/connections/SubscriptionManager.java
@@ -1,15 +1,18 @@
package io.github.hapjava.server.impl.connections;
+import io.github.hapjava.characteristics.Characteristic;
import io.github.hapjava.characteristics.EventableCharacteristic;
+import io.github.hapjava.server.impl.HomekitRegistry;
import io.github.hapjava.server.impl.http.HomekitClientConnection;
import io.github.hapjava.server.impl.http.HttpResponse;
import io.github.hapjava.server.impl.json.EventController;
import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -17,12 +20,22 @@ public class SubscriptionManager {
private static final Logger LOGGER = LoggerFactory.getLogger(SubscriptionManager.class);
- private final ConcurrentMap> subscriptions =
- new ConcurrentHashMap<>();
- private final ConcurrentMap> reverse =
- new ConcurrentHashMap<>();
- private final ConcurrentMap>
- pendingNotifications = new ConcurrentHashMap<>();
+ private static class ConnectionsWithIds {
+ Set connections;
+ int aid, iid;
+
+ ConnectionsWithIds(int aid, int iid) {
+ this.aid = aid;
+ this.iid = iid;
+ this.connections = new HashSet<>();
+ }
+ }
+
+ private final Map subscriptions = new HashMap<>();
+ private final Map> reverse =
+ new HashMap<>();
+ private final Map> pendingNotifications =
+ new HashMap<>();
private int nestedBatches = 0;
public synchronized void addSubscription(
@@ -31,96 +44,111 @@ public synchronized void addSubscription(
EventableCharacteristic characteristic,
HomekitClientConnection connection) {
synchronized (this) {
- if (!subscriptions.containsKey(characteristic)) {
- subscriptions.putIfAbsent(characteristic, newSet());
- }
- subscriptions.get(characteristic).add(connection);
- if (subscriptions.get(characteristic).size() == 1) {
- characteristic.subscribe(
- () -> {
- publish(aid, iid, characteristic);
- });
+ ConnectionsWithIds subscribers;
+ if (subscriptions.containsKey(characteristic)) {
+ subscribers = subscriptions.get(characteristic);
+ } else {
+ subscribers = new ConnectionsWithIds(aid, iid);
+ subscriptions.put(characteristic, subscribers);
+ subscribe(aid, iid, characteristic);
}
+ subscribers.connections.add(connection);
if (!reverse.containsKey(connection)) {
- reverse.putIfAbsent(connection, newSet());
+ reverse.put(connection, new HashSet<>());
}
reverse.get(connection).add(characteristic);
LOGGER.trace(
- "Added subscription to " + characteristic.getClass() + " for " + connection.hashCode());
+ "Added subscription to {}:{} ({}) for {}",
+ aid,
+ iid,
+ characteristic.getClass().getSimpleName(),
+ connection.hashCode());
}
}
public synchronized void removeSubscription(
EventableCharacteristic characteristic, HomekitClientConnection connection) {
- Set subscriptions = this.subscriptions.get(characteristic);
- if (subscriptions != null) {
- subscriptions.remove(connection);
- if (subscriptions.size() == 0) {
+ ConnectionsWithIds subscribers = subscriptions.get(characteristic);
+ if (subscribers != null) {
+ subscribers.connections.remove(connection);
+ if (subscribers.connections.isEmpty()) {
+ LOGGER.trace("Unsubscribing from characteristic as all subscriptions are closed");
characteristic.unsubscribe();
+ subscriptions.remove(characteristic);
+ }
+
+ // Remove pending notifications for this no-longer-subscribed characteristic
+ List connectionNotifications = pendingNotifications.get(connection);
+ if (connectionNotifications != null) {
+ connectionNotifications.removeIf(n -> n.aid == subscribers.aid && n.iid == subscribers.iid);
+ if (connectionNotifications.isEmpty()) pendingNotifications.remove(connection);
}
+
+ LOGGER.trace(
+ "Removed subscription from {}:{} ({}) for {}",
+ subscribers.aid,
+ subscribers.iid,
+ characteristic.getClass().getSimpleName(),
+ connection.hashCode());
}
Set reverse = this.reverse.get(connection);
if (reverse != null) {
reverse.remove(characteristic);
+ if (reverse.isEmpty()) this.reverse.remove(connection);
}
- LOGGER.trace(
- "Removed subscription to " + characteristic.getClass() + " for " + connection.hashCode());
}
public synchronized void removeConnection(HomekitClientConnection connection) {
- Set characteristics = reverse.remove(connection);
+ removeConnection(connection, reverse.remove(connection));
+ }
+
+ private void removeConnection(
+ HomekitClientConnection connection, Set characteristics) {
pendingNotifications.remove(connection);
if (characteristics != null) {
for (EventableCharacteristic characteristic : characteristics) {
- Set characteristicSubscriptions =
- subscriptions.get(characteristic);
- characteristicSubscriptions.remove(connection);
- if (characteristicSubscriptions.isEmpty()) {
- LOGGER.trace("Unsubscribing from characteristic as all subscriptions are closed");
- characteristic.unsubscribe();
- subscriptions.remove(characteristic);
- }
+ removeSubscription(characteristic, connection);
}
}
LOGGER.trace("Removed connection {}", connection.hashCode());
}
- private Set newSet() {
- return Collections.newSetFromMap(new ConcurrentHashMap());
- }
-
public synchronized void batchUpdate() {
++this.nestedBatches;
}
public synchronized void completeUpdateBatch() {
- if (--this.nestedBatches == 0 && !pendingNotifications.isEmpty()) {
- LOGGER.trace("Publishing batched changes");
- for (ConcurrentMap.Entry> entry :
- pendingNotifications.entrySet()) {
- try {
- HttpResponse message = new EventController().getMessage(entry.getValue());
- entry.getKey().outOfBand(message);
- } catch (Exception e) {
- LOGGER.warn("Failed to create new event message", e);
- }
+ if (--this.nestedBatches == 0) flushUpdateBatch();
+ }
+
+ private void flushUpdateBatch() {
+ if (pendingNotifications.isEmpty()) return;
+
+ LOGGER.trace("Publishing batched changes");
+ for (Map.Entry> entry :
+ pendingNotifications.entrySet()) {
+ try {
+ HttpResponse message = new EventController().getMessage(entry.getValue());
+ entry.getKey().outOfBand(message);
+ } catch (Exception e) {
+ LOGGER.warn("Failed to create new event message", e);
}
- pendingNotifications.clear();
}
+ pendingNotifications.clear();
}
public synchronized void publish(int accessoryId, int iid, EventableCharacteristic changed) {
- final Set subscribers = subscriptions.get(changed);
- if ((subscribers == null) || (subscribers.isEmpty())) {
- LOGGER.debug("No subscribers to characteristic {} at accessory {} ", changed, accessoryId);
+ final ConnectionsWithIds subscribers = subscriptions.get(changed);
+ if (subscribers == null || subscribers.connections.isEmpty()) {
+ LOGGER.trace("No subscribers to characteristic {} at accessory {} ", changed, accessoryId);
return; // no subscribers
}
if (nestedBatches != 0) {
LOGGER.trace("Batching change for accessory {} and characteristic {} " + accessoryId, iid);
PendingNotification notification = new PendingNotification(accessoryId, iid, changed);
- for (HomekitClientConnection connection : subscribers) {
+ for (HomekitClientConnection connection : subscribers.connections) {
if (!pendingNotifications.containsKey(connection)) {
pendingNotifications.put(connection, new ArrayList());
}
@@ -132,7 +160,7 @@ public synchronized void publish(int accessoryId, int iid, EventableCharacterist
try {
HttpResponse message = new EventController().getMessage(accessoryId, iid, changed);
LOGGER.trace("Publishing change for " + accessoryId);
- for (HomekitClientConnection connection : subscribers) {
+ for (HomekitClientConnection connection : subscribers.connections) {
connection.outOfBand(message);
}
} catch (Exception e) {
@@ -140,14 +168,89 @@ public synchronized void publish(int accessoryId, int iid, EventableCharacterist
}
}
+ /**
+ * The accessory registry has changed; go through all subscriptions and link to any new/changed
+ * characteristics
+ */
+ public synchronized void resync(HomekitRegistry registry) {
+ LOGGER.trace("Resyncing subscriptions");
+ flushUpdateBatch();
+
+ Map newSubscriptions = new HashMap<>();
+ Iterator> i =
+ subscriptions.entrySet().iterator();
+ while (i.hasNext()) {
+ Map.Entry entry = i.next();
+ EventableCharacteristic oldCharacteristic = entry.getKey();
+ ConnectionsWithIds subscribers = entry.getValue();
+ Characteristic newCharacteristic =
+ registry.getCharacteristics(subscribers.aid).get(subscribers.iid);
+ if (newCharacteristic == null || newCharacteristic.getType() != oldCharacteristic.getType()) {
+ // characteristic is gone or has completely changed; drop all subscriptions for it
+ LOGGER.trace(
+ "{}:{} ({}) has gone missing; dropping subscriptions.",
+ subscribers.aid,
+ subscribers.iid,
+ oldCharacteristic.getClass().getSimpleName());
+ i.remove();
+ for (HomekitClientConnection conn : subscribers.connections) {
+ removeSubscription(oldCharacteristic, conn);
+ }
+ } else if (newCharacteristic != oldCharacteristic) {
+ EventableCharacteristic newEventableCharacteristic =
+ (EventableCharacteristic) newCharacteristic;
+ LOGGER.trace(
+ "{}:{} has been replaced by a compatible characteristic; re-connecting subscriptions",
+ subscribers.aid,
+ subscribers.iid);
+ // characteristic has been replaced by another instance of the same thing;
+ // re-connect subscriptions
+ i.remove();
+ oldCharacteristic.unsubscribe();
+ subscribe(subscribers.aid, subscribers.iid, newEventableCharacteristic);
+ // we can't replace the key, and we can't add to the map while we're iterating,
+ // so save it off to a temporary map that we'll add them all at the end
+ newSubscriptions.put(newEventableCharacteristic, subscribers);
+
+ for (HomekitClientConnection conn : subscribers.connections) {
+ Set subscribedCharacteristics = reverse.get(conn);
+ subscribedCharacteristics.remove(oldCharacteristic);
+ subscribedCharacteristics.add(newEventableCharacteristic);
+
+ // and also update references for any pending notifications, so they'll get the proper
+ // value
+ List connectionPendingNotifications = pendingNotifications.get(conn);
+ if (connectionPendingNotifications != null) {
+ for (PendingNotification notification : connectionPendingNotifications) {
+ if (notification.characteristic == oldCharacteristic) {
+ notification.characteristic = newEventableCharacteristic;
+ }
+ }
+ }
+ }
+ }
+ }
+ subscriptions.putAll(newSubscriptions);
+ }
+
+ private void subscribe(int aid, int iid, EventableCharacteristic characteristic) {
+ characteristic.subscribe(
+ () -> {
+ publish(aid, iid, characteristic);
+ });
+ }
+
/** Remove all existing subscriptions */
- public void removeAll() {
+ public synchronized void removeAll() {
LOGGER.trace("Removing {} reverse connections from subscription manager", reverse.size());
- Iterator i = reverse.keySet().iterator();
+ Iterator>> i =
+ reverse.entrySet().iterator();
while (i.hasNext()) {
- HomekitClientConnection connection = i.next();
+ Map.Entry> entry = i.next();
+ HomekitClientConnection connection = entry.getKey();
LOGGER.trace("Removing connection {}", connection.hashCode());
- removeConnection(connection);
+ i.remove();
+ removeConnection(connection, entry.getValue());
}
LOGGER.trace("Subscription sizes are {} and {}", reverse.size(), subscriptions.size());
}
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 1535a490a..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 {
@@ -79,6 +101,17 @@ public synchronized void setDiscoverable(boolean discoverable) throws IOExceptio
}
}
+ public synchronized void setMac(String mac) throws IOException {
+ if (this.mac != mac) {
+ this.mac = mac;
+ if (isAdvertising) {
+ logger.trace("Re-creating service due to change in mac to " + mac);
+ unregisterService();
+ registerService();
+ }
+ }
+ }
+
public synchronized void setConfigurationIndex(int revision) throws IOException {
if (this.configurationIndex != revision) {
this.configurationIndex = revision;
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/json/EventController.java b/src/main/java/io/github/hapjava/server/impl/json/EventController.java
index 41473f716..0c7c2bc7c 100644
--- a/src/main/java/io/github/hapjava/server/impl/json/EventController.java
+++ b/src/main/java/io/github/hapjava/server/impl/json/EventController.java
@@ -4,7 +4,7 @@
import io.github.hapjava.server.impl.connections.PendingNotification;
import io.github.hapjava.server.impl.http.HttpResponse;
import java.io.ByteArrayOutputStream;
-import java.util.ArrayList;
+import java.util.List;
import javax.json.Json;
import javax.json.JsonArrayBuilder;
import javax.json.JsonObject;
@@ -34,7 +34,7 @@ public HttpResponse getMessage(int accessoryId, int iid, EventableCharacteristic
}
}
- public HttpResponse getMessage(ArrayList notifications) throws Exception {
+ public HttpResponse getMessage(List notifications) throws Exception {
JsonArrayBuilder characteristics = Json.createArrayBuilder();
for (PendingNotification notification : notifications) {
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/ErrorCode.java b/src/main/java/io/github/hapjava/server/impl/pairing/ErrorCode.java
new file mode 100644
index 000000000..90112fc46
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/ErrorCode.java
@@ -0,0 +1,26 @@
+package io.github.hapjava.server.impl.pairing;
+
+public enum ErrorCode {
+ OK(0),
+ UNKNOWN(1),
+ AUTHENTICATION(2),
+ BACKOFF(3),
+ MAX_PEERS(4),
+ MAX_TRIES(5),
+ UNAVAILABLE(6),
+ BUSY(7);
+
+ private final short key;
+
+ ErrorCode(short key) {
+ this.key = key;
+ }
+
+ ErrorCode(int key) {
+ this.key = (short) key;
+ }
+
+ public short getKey() {
+ return key;
+ }
+}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/FinalPairHandler.java b/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java
old mode 100644
new mode 100755
similarity index 71%
rename from src/main/java/io/github/hapjava/server/impl/pairing/FinalPairHandler.java
rename to src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java
index e5d9e371c..6bae3ab84
--- a/src/main/java/io/github/hapjava/server/impl/pairing/FinalPairHandler.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/ExchangeHandler.java
@@ -6,22 +6,26 @@
import io.github.hapjava.server.impl.crypto.EdsaSigner;
import io.github.hapjava.server.impl.crypto.EdsaVerifier;
import io.github.hapjava.server.impl.http.HttpResponse;
-import io.github.hapjava.server.impl.pairing.PairSetupRequest.Stage3Request;
+import io.github.hapjava.server.impl.pairing.PairSetupRequest.ExchangeRequest;
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult;
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.Encoder;
import java.nio.charset.StandardCharsets;
import org.bouncycastle.crypto.digests.SHA512Digest;
import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
import org.bouncycastle.crypto.params.HKDFParameters;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
-class FinalPairHandler {
+class ExchangeHandler {
private final byte[] k;
private final HomekitAuthInfo authInfo;
private byte[] hkdf_enc_key;
- public FinalPairHandler(byte[] k, HomekitAuthInfo authInfo) {
+ private static final Logger LOGGER = LoggerFactory.getLogger(ExchangeHandler.class);
+
+ public ExchangeHandler(byte[] k, HomekitAuthInfo authInfo) {
this.k = k;
this.authInfo = authInfo;
}
@@ -36,12 +40,21 @@ public HttpResponse handle(PairSetupRequest req) throws Exception {
byte[] okm = hkdf_enc_key = new byte[32];
hkdf.generateBytes(okm, 0, 32);
- return decrypt((Stage3Request) req, okm);
+ return decrypt((ExchangeRequest) req, okm);
+ }
+
+ private HttpResponse decrypt(ExchangeRequest req, byte[] key) throws Exception {
+ 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 decrypt(Stage3Request req, byte[] key) throws Exception {
- ChachaDecoder chacha = new ChachaDecoder(key, "PS-Msg05".getBytes(StandardCharsets.UTF_8));
- byte[] plaintext = chacha.decodeCiphertext(req.getAuthTagData(), req.getMessageData());
+ private HttpResponse processDecryptedData(byte[] plaintext) throws Exception {
DecodeResult d = TypeLengthValueUtils.decode(plaintext);
byte[] username = d.getBytes(MessageType.USERNAME);
@@ -50,6 +63,14 @@ private HttpResponse decrypt(Stage3Request 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(
@@ -63,9 +84,11 @@ private HttpResponse createUser(byte[] username, byte[] ltpk, byte[] proof) thro
byte[] completeData = ByteUtils.joinBytes(okm, username, ltpk);
if (!new EdsaVerifier(ltpk).verify(completeData, proof)) {
- throw new Exception("Invalid signature");
+ return new PairingResponse(6, ErrorCode.AUTHENTICATION);
}
- authInfo.createUser(authInfo.getMac() + new String(username, StandardCharsets.UTF_8), ltpk);
+ String stringUsername = new String(username, StandardCharsets.UTF_8);
+ LOGGER.trace("Creating initial user {}", stringUsername);
+ authInfo.createUser(authInfo.getMac() + stringUsername, ltpk, true);
return createResponse();
}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/MessageType.java b/src/main/java/io/github/hapjava/server/impl/pairing/MessageType.java
index ffdd0ba33..84991d130 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/MessageType.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/MessageType.java
@@ -9,7 +9,12 @@ public enum MessageType {
ENCRYPTED_DATA(5),
STATE(6),
ERROR(7),
- SIGNATURE(10);
+ SIGNATURE(0x0a),
+ PERMISSIONS(0x0b),
+ FRAGMENT_DATA(0x0c),
+ FRAGMENT_LAST(0x0d),
+ FLAGS(0x13),
+ SEPARATOR(0xff);
private final short key;
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairingManager.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairSetupManager.java
similarity index 64%
rename from src/main/java/io/github/hapjava/server/impl/pairing/PairingManager.java
rename to src/main/java/io/github/hapjava/server/impl/pairing/PairSetupManager.java
index af6a6e01f..05b37b8ab 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/PairingManager.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairSetupManager.java
@@ -9,48 +9,49 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class PairingManager {
+public class PairSetupManager {
- private static final Logger logger = LoggerFactory.getLogger(PairingManager.class);
+ private static final Logger logger = LoggerFactory.getLogger(PairSetupManager.class);
private final HomekitAuthInfo authInfo;
private final HomekitRegistry registry;
private SrpHandler srpHandler;
- public PairingManager(HomekitAuthInfo authInfo, HomekitRegistry registry) {
+ public PairSetupManager(HomekitAuthInfo authInfo, HomekitRegistry registry) {
this.authInfo = authInfo;
this.registry = registry;
}
public HttpResponse handle(HttpRequest httpRequest) throws Exception {
PairSetupRequest req = PairSetupRequest.of(httpRequest.getBody());
+ logger.trace("Handling pair-setup request {}", req);
- if (req.getStage() == Stage.ONE) {
- logger.trace("Starting pair for " + registry.getLabel());
+ if (req.getState() == 1) {
+ logger.trace("Received SRP Start Request " + registry.getLabel());
srpHandler = new SrpHandler(authInfo.getPin(), authInfo.getSalt());
return srpHandler.handle(req);
- } else if (req.getStage() == Stage.TWO) {
- logger.trace("Entering second stage of pair for " + registry.getLabel());
+ } else if (req.getState() == 3) {
+ logger.trace("Receive SRP Verify Request for " + registry.getLabel());
if (srpHandler == null) {
- logger.warn("Received unexpected stage 2 request for " + registry.getLabel());
+ logger.warn("Received unexpected SRP Verify Request for " + registry.getLabel());
return new UnauthorizedResponse();
} else {
try {
return srpHandler.handle(req);
} catch (Exception e) {
srpHandler = null; // You don't get to try again - need a new key
- logger.warn("Exception encountered while processing pairing request", e);
+ logger.warn("Exception encountered while processing SRP Verify Request", e);
return new UnauthorizedResponse();
}
}
- } else if (req.getStage() == Stage.THREE) {
- logger.trace("Entering third stage of pair for " + registry.getLabel());
+ } else if (req.getState() == 5) {
+ logger.trace("Received Exchange Request for " + registry.getLabel());
if (srpHandler == null) {
- logger.warn("Received unexpected stage 3 request for " + registry.getLabel());
+ logger.warn("Received unexpected Exchanged Request for " + registry.getLabel());
return new UnauthorizedResponse();
} else {
- FinalPairHandler handler = new FinalPairHandler(srpHandler.getK(), authInfo);
+ ExchangeHandler handler = new ExchangeHandler(srpHandler.getK(), authInfo);
try {
return handler.handle(req);
} catch (Exception e) {
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 bd0da4c81..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
@@ -2,46 +2,61 @@
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult;
import java.math.BigInteger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
abstract class PairSetupRequest {
-
- private static final short VALUE_STAGE_1 = 1;
- private static final short VALUE_STAGE_2 = 3;
- private static final short VALUE_STAGE_3 = 5;
+ private static final Logger logger = LoggerFactory.getLogger(PairSetupRequest.class);
public static PairSetupRequest of(byte[] content) throws Exception {
DecodeResult d = TypeLengthValueUtils.decode(content);
+ logger.trace("Decoded pair setup request: {}", d);
short stage = d.getByte(MessageType.STATE);
switch (stage) {
- case VALUE_STAGE_1:
- return new Stage1Request();
+ case 1:
+ return new SRPStartRequest(d);
- case VALUE_STAGE_2:
- return new Stage2Request(d);
+ case 3:
+ return new SRPVerifyRequest(d);
- case VALUE_STAGE_3:
- return new Stage3Request(d);
+ case 5:
+ return new ExchangeRequest(d);
default:
throw new Exception("Unknown pair process stage: " + stage);
}
}
- public abstract Stage getStage();
+ // Raw integer.
+ // State of the pairing process. 1=M1, 2=M2, etc.
+ public abstract int getState();
+
+ public static class SRPStartRequest extends PairSetupRequest {
+ int flags;
+
+ public SRPStartRequest(DecodeResult d) {
+ flags = 0;
+ if (d.hasMessage(MessageType.FLAGS)) {
+ flags = d.getInt(MessageType.FLAGS);
+ }
+ }
- public static class Stage1Request extends PairSetupRequest {
@Override
- public Stage getStage() {
- return Stage.ONE;
+ public int getState() {
+ return 1;
+ }
+
+ public String toString() {
+ return "";
}
}
- public static class Stage2Request extends PairSetupRequest {
+ public static class SRPVerifyRequest extends PairSetupRequest {
private final BigInteger a;
private final BigInteger m1;
- public Stage2Request(DecodeResult d) {
+ public SRPVerifyRequest(DecodeResult d) {
a = d.getBigInt(MessageType.PUBLIC_KEY);
m1 = d.getBigInt(MessageType.PROOF);
}
@@ -55,21 +70,53 @@ public BigInteger getM1() {
}
@Override
- public Stage getStage() {
- return Stage.TWO;
+ public int getState() {
+ return 3;
+ }
+
+ public String toString() {
+ return "";
}
}
- static class Stage3Request extends PairSetupRequest {
+ static class ExchangeRequest extends PairSetupRequest {
private final byte[] messageData;
private final byte[] authTagData;
- public Stage3Request(DecodeResult d) {
- messageData = new byte[d.getLength(MessageType.ENCRYPTED_DATA) - 16];
+ public ExchangeRequest(DecodeResult d) {
+ // 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() {
@@ -81,8 +128,12 @@ public byte[] getAuthTagData() {
}
@Override
- public Stage getStage() {
- return Stage.THREE;
+ public int getState() {
+ return 5;
+ }
+
+ public String toString() {
+ return "";
}
}
}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairVerificationManager.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyManager.java
similarity index 79%
rename from src/main/java/io/github/hapjava/server/impl/pairing/PairVerificationManager.java
rename to src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyManager.java
index d43b2d210..4960c6f6a 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/PairVerificationManager.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyManager.java
@@ -9,12 +9,11 @@
import io.github.hapjava.server.impl.crypto.EdsaVerifier;
import io.github.hapjava.server.impl.http.HttpRequest;
import io.github.hapjava.server.impl.http.HttpResponse;
-import io.github.hapjava.server.impl.pairing.PairVerificationRequest.Stage1Request;
-import io.github.hapjava.server.impl.pairing.PairVerificationRequest.Stage2Request;
+import io.github.hapjava.server.impl.pairing.PairVerifyRequest.VerifyFinishRequest;
+import io.github.hapjava.server.impl.pairing.PairVerifyRequest.VerifyStartRequest;
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult;
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.Encoder;
import io.github.hapjava.server.impl.responses.NotFoundResponse;
-import io.github.hapjava.server.impl.responses.OkResponse;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import org.bouncycastle.crypto.digests.SHA512Digest;
@@ -23,9 +22,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class PairVerificationManager {
+public class PairVerifyManager {
- private static final Logger logger = LoggerFactory.getLogger(PairVerificationManager.class);
+ private static final Logger logger = LoggerFactory.getLogger(PairVerifyManager.class);
private static volatile SecureRandom secureRandom;
private final HomekitAuthInfo authInfo;
@@ -36,26 +35,26 @@ public class PairVerificationManager {
private byte[] publicKey;
private byte[] sharedSecret;
- public PairVerificationManager(HomekitAuthInfo authInfo, HomekitRegistry registry) {
+ public PairVerifyManager(HomekitAuthInfo authInfo, HomekitRegistry registry) {
this.authInfo = authInfo;
this.registry = registry;
}
public HttpResponse handle(HttpRequest rawRequest) throws Exception {
- PairVerificationRequest request = PairVerificationRequest.of(rawRequest.getBody());
- switch (request.getStage()) {
- case ONE:
- return stage1((Stage1Request) request);
+ PairVerifyRequest request = PairVerifyRequest.of(rawRequest.getBody());
+ switch (request.getState()) {
+ case 1:
+ return handleVerifyStartRequest((VerifyStartRequest) request);
- case TWO:
- return stage2((Stage2Request) request);
+ case 3:
+ return handleVerifyFinishRequest((VerifyFinishRequest) request);
default:
return new NotFoundResponse();
}
}
- private HttpResponse stage1(Stage1Request request) throws Exception {
+ private HttpResponse handleVerifyStartRequest(VerifyStartRequest request) throws Exception {
logger.trace("Starting pair verification for " + registry.getLabel());
clientPublicKey = request.getClientPublicKey();
publicKey = new byte[32];
@@ -96,7 +95,7 @@ private HttpResponse stage1(Stage1Request request) throws Exception {
return new PairingResponse(encoder.toByteArray());
}
- private HttpResponse stage2(Stage2Request request) throws Exception {
+ private HttpResponse handleVerifyFinishRequest(VerifyFinishRequest request) throws Exception {
ChachaDecoder chacha = new ChachaDecoder(hkdfKey, "PV-Msg03".getBytes(StandardCharsets.UTF_8));
byte[] plaintext = chacha.decodeCiphertext(request.getAuthTagData(), request.getMessageData());
@@ -106,11 +105,11 @@ private HttpResponse stage2(Stage2Request request) throws Exception {
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) {
- throw new Exception("Unknown user: " + new String(clientUsername, StandardCharsets.UTF_8));
+ logger.warn("Unknown user: {}", username);
+ return new PairingResponse(4, ErrorCode.AUTHENTICATION);
}
Encoder encoder = TypeLengthValueUtils.getEncoder();
@@ -120,11 +119,11 @@ private HttpResponse stage2(Stage2Request request) throws Exception {
return new UpgradeResponse(
encoder.toByteArray(),
createKey("Control-Write-Encryption-Key"),
- createKey("Control-Read-Encryption-Key"));
+ createKey("Control-Read-Encryption-Key"),
+ username);
} else {
- encoder.add(MessageType.ERROR, (short) 4);
logger.warn("Invalid signature. Could not pair " + registry.getLabel());
- return new OkResponse(encoder.toByteArray());
+ return new PairingResponse(4, ErrorCode.AUTHENTICATION);
}
}
@@ -142,7 +141,7 @@ private byte[] createKey(String info) {
private static SecureRandom getSecureRandom() {
if (secureRandom == null) {
- synchronized (PairVerificationManager.class) {
+ synchronized (PairVerifyManager.class) {
if (secureRandom == null) {
secureRandom = new SecureRandom();
}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairVerificationRequest.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyRequest.java
similarity index 61%
rename from src/main/java/io/github/hapjava/server/impl/pairing/PairVerificationRequest.java
rename to src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyRequest.java
index 3bb6627d9..7cf31585b 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/PairVerificationRequest.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairVerifyRequest.java
@@ -2,33 +2,32 @@
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult;
-abstract class PairVerificationRequest {
+abstract class PairVerifyRequest {
- private static final short VALUE_STAGE_1 = 1;
- private static final short VALUE_STAGE_2 = 3;
-
- static PairVerificationRequest of(byte[] content) throws Exception {
+ static PairVerifyRequest of(byte[] content) throws Exception {
DecodeResult d = TypeLengthValueUtils.decode(content);
short stage = d.getByte(MessageType.STATE);
switch (stage) {
- case VALUE_STAGE_1:
- return new Stage1Request(d);
+ case 1:
+ return new VerifyStartRequest(d);
- case VALUE_STAGE_2:
- return new Stage2Request(d);
+ case 3:
+ return new VerifyFinishRequest(d);
default:
throw new Exception("Unknown pair process stage: " + stage);
}
}
- abstract Stage getStage();
+ // Raw integer.
+ // State of the pairing process. 1=M1, 2=M2, etc.
+ abstract int getState();
- static class Stage1Request extends PairVerificationRequest {
+ static class VerifyStartRequest extends PairVerifyRequest {
private final byte[] clientPublicKey;
- public Stage1Request(DecodeResult d) {
+ public VerifyStartRequest(DecodeResult d) {
clientPublicKey = d.getBytes(MessageType.PUBLIC_KEY);
}
@@ -37,17 +36,17 @@ public byte[] getClientPublicKey() {
}
@Override
- Stage getStage() {
- return Stage.ONE;
+ int getState() {
+ return 1;
}
}
- static class Stage2Request extends PairVerificationRequest {
+ static class VerifyFinishRequest extends PairVerifyRequest {
private final byte[] messageData;
private final byte[] authTagData;
- public Stage2Request(DecodeResult d) {
+ public VerifyFinishRequest(DecodeResult d) {
messageData = new byte[d.getLength(MessageType.ENCRYPTED_DATA) - 16];
authTagData = new byte[16];
d.getBytes(MessageType.ENCRYPTED_DATA, messageData, 0);
@@ -63,8 +62,8 @@ public byte[] getAuthTagData() {
}
@Override
- public Stage getStage() {
- return Stage.TWO;
+ public int getState() {
+ return 3;
}
}
}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairingMethod.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairingMethod.java
new file mode 100644
index 000000000..07decf02d
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairingMethod.java
@@ -0,0 +1,24 @@
+package io.github.hapjava.server.impl.pairing;
+
+public enum PairingMethod {
+ PAIR_SETUP(0),
+ PAIR_SETUP_WITH_AUTH(1),
+ PAIR_VERIFY(2),
+ ADD_PAIRING(3),
+ REMOVE_PAIRING(4),
+ LIST_PAIRINGS(5);
+
+ private final byte value;
+
+ PairingMethod(byte value) {
+ this.value = value;
+ }
+
+ PairingMethod(int value) {
+ this.value = (byte) value;
+ }
+
+ public byte getValue() {
+ return value;
+ }
+}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairingResponse.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairingResponse.java
index af678a86c..defc5798f 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/PairingResponse.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairingResponse.java
@@ -21,6 +21,27 @@ public PairingResponse(byte[] body) {
super(body);
}
+ public PairingResponse(int state) {
+ this(encodeSuccess(state));
+ }
+
+ public PairingResponse(int state, ErrorCode errorCode) {
+ this(encodeError(state, errorCode));
+ }
+
+ private static byte[] encodeSuccess(int state) {
+ TypeLengthValueUtils.Encoder encoder = TypeLengthValueUtils.getEncoder();
+ encoder.add(MessageType.STATE, (byte) state);
+ return encoder.toByteArray();
+ }
+
+ private static byte[] encodeError(int state, ErrorCode errorCode) {
+ TypeLengthValueUtils.Encoder encoder = TypeLengthValueUtils.getEncoder();
+ encoder.add(MessageType.STATE, (byte) state);
+ encoder.add(MessageType.ERROR, (byte) errorCode.getKey());
+ return encoder.toByteArray();
+ }
+
@Override
public Map getHeaders() {
return headers;
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairingUpdateController.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairingUpdateController.java
deleted file mode 100644
index 3d52fe907..000000000
--- a/src/main/java/io/github/hapjava/server/impl/pairing/PairingUpdateController.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package io.github.hapjava.server.impl.pairing;
-
-import io.github.hapjava.server.HomekitAuthInfo;
-import io.github.hapjava.server.impl.http.HttpRequest;
-import io.github.hapjava.server.impl.http.HttpResponse;
-import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
-import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-
-public class PairingUpdateController {
-
- private final HomekitAuthInfo authInfo;
- private final JmdnsHomekitAdvertiser advertiser;
-
- public PairingUpdateController(HomekitAuthInfo authInfo, JmdnsHomekitAdvertiser advertiser) {
- this.authInfo = authInfo;
- this.advertiser = advertiser;
- }
-
- public HttpResponse handle(HttpRequest request) throws IOException {
- DecodeResult d = TypeLengthValueUtils.decode(request.getBody());
-
- int method = d.getByte(MessageType.METHOD);
- if (method == 3) { // Add pairing
- byte[] username = d.getBytes(MessageType.USERNAME);
- byte[] ltpk = d.getBytes(MessageType.PUBLIC_KEY);
- authInfo.createUser(authInfo.getMac() + new String(username, StandardCharsets.UTF_8), ltpk);
- } else if (method == 4) { // Remove pairing
- byte[] username = d.getBytes(MessageType.USERNAME);
- authInfo.removeUser(authInfo.getMac() + new String(username, StandardCharsets.UTF_8));
- if (!authInfo.hasUser()) {
- advertiser.setDiscoverable(true);
- }
- } else {
- throw new RuntimeException("Unrecognized method: " + method);
- }
- return new PairingResponse(new byte[] {0x06, 0x01, 0x02});
- }
-}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java
new file mode 100644
index 000000000..11eb4a709
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java
@@ -0,0 +1,77 @@
+package io.github.hapjava.server.impl.pairing;
+
+import io.github.hapjava.server.HomekitAuthInfo;
+import io.github.hapjava.server.impl.http.HttpRequest;
+import io.github.hapjava.server.impl.http.HttpResponse;
+import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
+import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Iterator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PairingsManager {
+
+ private final HomekitAuthInfo authInfo;
+ private final JmdnsHomekitAdvertiser advertiser;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PairingsManager.class);
+
+ public PairingsManager(HomekitAuthInfo authInfo, JmdnsHomekitAdvertiser advertiser) {
+ this.authInfo = authInfo;
+ this.advertiser = advertiser;
+ }
+
+ public HttpResponse handle(HttpRequest request) throws IOException {
+ DecodeResult d = TypeLengthValueUtils.decode(request.getBody());
+
+ int method = d.getByte(MessageType.METHOD);
+
+ if (method == PairingMethod.ADD_PAIRING.getValue()) {
+ byte[] username = d.getBytes(MessageType.USERNAME);
+ byte[] ltpk = d.getBytes(MessageType.PUBLIC_KEY);
+ byte permissions = d.getByte(MessageType.PERMISSIONS);
+ authInfo.createUser(
+ authInfo.getMac() + new String(username, StandardCharsets.UTF_8), ltpk, permissions == 1);
+ } else if (method == PairingMethod.REMOVE_PAIRING.getValue()) {
+ byte[] username = d.getBytes(MessageType.USERNAME);
+ authInfo.removeUser(authInfo.getMac() + new String(username, StandardCharsets.UTF_8));
+ if (!authInfo.hasUser()) {
+ advertiser.setDiscoverable(true);
+ }
+ } else if (method == PairingMethod.LIST_PAIRINGS.getValue()) {
+ TypeLengthValueUtils.Encoder e = TypeLengthValueUtils.getEncoder();
+
+ Collection usernames = authInfo.listUsers();
+ boolean first = true;
+ Iterator iterator = usernames.iterator();
+ String mac = authInfo.getMac();
+
+ while (iterator.hasNext()) {
+ String username = iterator.next();
+ if (first) {
+ e.add(MessageType.STATE, (byte) 2);
+ first = false;
+ } else {
+ e.add(MessageType.SEPARATOR);
+ }
+ byte[] publicKey = authInfo.getUserPublicKey(username);
+ boolean isAdmin = authInfo.userIsAdmin(username);
+ if (username.startsWith(mac)) {
+ username = username.substring(mac.length());
+ }
+ e.add(MessageType.USERNAME, username);
+ e.add(MessageType.PUBLIC_KEY, publicKey);
+ e.add(MessageType.PERMISSIONS, (short) (isAdmin ? 1 : 0));
+ }
+
+ return new PairingResponse(e.toByteArray());
+ } else {
+ throw new RuntimeException("Unrecognized method: " + method);
+ }
+
+ return new PairingResponse(2);
+ }
+}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/SrpHandler.java b/src/main/java/io/github/hapjava/server/impl/pairing/SrpHandler.java
index e02ccdb90..5c1c23353 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/SrpHandler.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/SrpHandler.java
@@ -3,7 +3,7 @@
import com.nimbusds.srp6.*;
import io.github.hapjava.server.impl.http.HttpResponse;
import io.github.hapjava.server.impl.pairing.HomekitSRP6ServerSession.State;
-import io.github.hapjava.server.impl.pairing.PairSetupRequest.Stage2Request;
+import io.github.hapjava.server.impl.pairing.PairSetupRequest.SRPVerifyRequest;
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.Encoder;
import io.github.hapjava.server.impl.responses.ConflictResponse;
import io.github.hapjava.server.impl.responses.NotFoundResponse;
@@ -39,19 +39,19 @@ public SrpHandler(String pin, BigInteger salt) {
}
public HttpResponse handle(PairSetupRequest request) throws Exception {
- switch (request.getStage()) {
- case ONE:
- return step1();
+ switch (request.getState()) {
+ case 1:
+ return handleSrpStartRequest();
- case TWO:
- return step2((Stage2Request) request);
+ case 3:
+ return handleSrpVerifyRequest((SRPVerifyRequest) request);
default:
return new NotFoundResponse();
}
}
- private HttpResponse step1() throws Exception {
+ private HttpResponse handleSrpStartRequest() throws Exception {
if (session.getState() != State.INIT) {
logger.warn("Session is not in state INIT when receiving step1");
return new ConflictResponse();
@@ -68,7 +68,7 @@ private HttpResponse step1() throws Exception {
return new PairingResponse(encoder.toByteArray());
}
- private HttpResponse step2(Stage2Request request) throws Exception {
+ private HttpResponse handleSrpVerifyRequest(SRPVerifyRequest request) throws Exception {
if (session.getState() != State.STEP_1) {
logger.warn("Session is not in state Stage 1 when receiving step2");
return new ConflictResponse();
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/Stage.java b/src/main/java/io/github/hapjava/server/impl/pairing/Stage.java
deleted file mode 100644
index 19bac9a14..000000000
--- a/src/main/java/io/github/hapjava/server/impl/pairing/Stage.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package io.github.hapjava.server.impl.pairing;
-
-public enum Stage {
- ONE,
- TWO,
- THREE
-}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/TypeLengthValueUtils.java b/src/main/java/io/github/hapjava/server/impl/pairing/TypeLengthValueUtils.java
index 396829d34..cbd76f118 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/TypeLengthValueUtils.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/TypeLengthValueUtils.java
@@ -5,6 +5,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@@ -37,6 +39,11 @@ private Encoder() {
baos = new ByteArrayOutputStream();
}
+ public void add(MessageType type) {
+ baos.write(type.getKey());
+ baos.write(0);
+ }
+
public void add(MessageType type, BigInteger i) throws IOException {
add(type, ByteUtils.toByteArray(i));
}
@@ -58,6 +65,10 @@ public void add(MessageType type, byte[] bytes) throws IOException {
}
}
+ public void add(MessageType type, String string) throws IOException {
+ add(type, string.getBytes(StandardCharsets.UTF_8));
+ }
+
public byte[] toByteArray() {
return baos.toByteArray();
}
@@ -68,10 +79,23 @@ public static final class DecodeResult {
private DecodeResult() {}
+ public String toString() {
+ return result.toString();
+ }
+
+ public boolean hasMessage(MessageType type) {
+ return result.containsKey(type.getKey());
+ }
+
public byte getByte(MessageType type) {
return result.get(type.getKey())[0];
}
+ public int getInt(MessageType type) {
+ ByteBuffer wrapped = ByteBuffer.wrap(result.get(type.getKey()));
+ return wrapped.getInt();
+ }
+
public BigInteger getBigInt(MessageType type) {
return new BigInteger(1, result.get(type.getKey()));
}
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/AccessoryInformationService.java b/src/main/java/io/github/hapjava/services/impl/AccessoryInformationService.java
index 8aa7f3635..1fdb30d2a 100644
--- a/src/main/java/io/github/hapjava/services/impl/AccessoryInformationService.java
+++ b/src/main/java/io/github/hapjava/services/impl/AccessoryInformationService.java
@@ -14,6 +14,7 @@
/** Accessory Information service. */
public class AccessoryInformationService extends AbstractServiceImpl {
+ public static final String TYPE = "0000003E-0000-1000-8000-0026BB765291";
public AccessoryInformationService(
IdentifyCharacteristic identify,
@@ -22,7 +23,7 @@ public AccessoryInformationService(
NameCharacteristic name,
SerialNumberCharacteristic serialNumber,
FirmwareRevisionCharacteristic firmwareRevision) {
- super("0000003E-0000-1000-8000-0026BB765291");
+ super(TYPE);
addCharacteristic(identify);
addCharacteristic(manufacturer);
addCharacteristic(model);
diff --git a/src/main/java/io/github/hapjava/services/impl/BasicFanService.java b/src/main/java/io/github/hapjava/services/impl/BasicFanService.java
new file mode 100644
index 000000000..9a033dd0c
--- /dev/null
+++ b/src/main/java/io/github/hapjava/services/impl/BasicFanService.java
@@ -0,0 +1,65 @@
+package io.github.hapjava.services.impl;
+
+import io.github.hapjava.accessories.BasicFanAccessory;
+import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithName;
+import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithRotationDirection;
+import io.github.hapjava.accessories.optionalcharacteristic.AccessoryWithRotationSpeed;
+import io.github.hapjava.characteristics.impl.common.NameCharacteristic;
+import io.github.hapjava.characteristics.impl.common.OnCharacteristic;
+import io.github.hapjava.characteristics.impl.fan.RotationDirectionCharacteristic;
+import io.github.hapjava.characteristics.impl.fan.RotationSpeedCharacteristic;
+
+/**
+ * This service describes a fan.
+ *
+ * In the R1 release of the HAP specification, this is simply described as Fan. It is no longer
+ * present in the R2 release.
+ */
+public class BasicFanService extends AbstractServiceImpl {
+
+ public BasicFanService(OnCharacteristic on) {
+ super("00000040-0000-1000-8000-0026BB765291");
+ addCharacteristic(on);
+ }
+
+ public BasicFanService(BasicFanAccessory accessory) {
+ this(
+ new OnCharacteristic(
+ () -> accessory.isOn(),
+ (v) -> accessory.setOn(v),
+ accessory::subscribeOn,
+ accessory::unsubscribeOn));
+ if (accessory instanceof AccessoryWithName) {
+ addOptionalCharacteristic(new NameCharacteristic(((AccessoryWithName) accessory)::getName));
+ }
+
+ if (accessory instanceof AccessoryWithRotationDirection) {
+ addOptionalCharacteristic(
+ new RotationDirectionCharacteristic(
+ ((AccessoryWithRotationDirection) accessory)::getRotationDirection,
+ ((AccessoryWithRotationDirection) accessory)::setRotationDirection,
+ ((AccessoryWithRotationDirection) accessory)::subscribeRotationDirection,
+ ((AccessoryWithRotationDirection) accessory)::unsubscribeRotationDirection));
+ }
+ if (accessory instanceof AccessoryWithRotationSpeed) {
+ addOptionalCharacteristic(
+ new RotationSpeedCharacteristic(
+ ((AccessoryWithRotationSpeed) accessory)::getRotationSpeed,
+ ((AccessoryWithRotationSpeed) accessory)::setRotationSpeed,
+ ((AccessoryWithRotationSpeed) accessory)::subscribeRotationSpeed,
+ ((AccessoryWithRotationSpeed) accessory)::unsubscribeRotationSpeed));
+ }
+ }
+
+ public void addOptionalCharacteristic(NameCharacteristic name) {
+ addCharacteristic(name);
+ }
+
+ public void addOptionalCharacteristic(RotationDirectionCharacteristic direction) {
+ addCharacteristic(direction);
+ }
+
+ public void addOptionalCharacteristic(RotationSpeedCharacteristic speed) {
+ addCharacteristic(speed);
+ }
+}
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/main/java/io/github/hapjava/services/impl/FanService.java b/src/main/java/io/github/hapjava/services/impl/FanService.java
index 24d2bc052..a8f3a7103 100644
--- a/src/main/java/io/github/hapjava/services/impl/FanService.java
+++ b/src/main/java/io/github/hapjava/services/impl/FanService.java
@@ -17,7 +17,12 @@
import io.github.hapjava.characteristics.impl.fan.SwingModeCharacteristic;
import io.github.hapjava.characteristics.impl.fan.TargetFanStateCharacteristic;
-/** This service describes a fan. */
+/**
+ * This service describes a fan.
+ *
+ *
In the R1 release of the HAP specification, this is described as Fan v2. In the R1 release of
+ * the HAP specification, this is simply described as Fan.
+ */
public class FanService extends AbstractServiceImpl {
public FanService(ActiveCharacteristic active) {
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/HomekitRootTest.java b/src/test/java/io/github/hapjava/server/impl/HomekitRootTest.java
index 3ac40288a..81d2666e5 100644
--- a/src/test/java/io/github/hapjava/server/impl/HomekitRootTest.java
+++ b/src/test/java/io/github/hapjava/server/impl/HomekitRootTest.java
@@ -91,20 +91,18 @@ public void testAdvertiserStops() throws Exception {
}
@Test
- public void testAddAccessoryResetsWeb() {
+ public void testAddAccessoryDoesntResetWeb() {
root.start();
- verify(webHandler, never()).resetConnections();
root.addAccessory(accessory);
- verify(webHandler).resetConnections();
+ verify(webHandler, never()).resetConnections();
}
@Test
- public void testRemoveAccessoryResetsWeb() {
+ public void testRemoveAccessoryDoesntResetWeb() {
root.addAccessory(accessory);
root.start();
- verify(webHandler, never()).resetConnections();
root.removeAccessory(accessory);
- verify(webHandler).resetConnections();
+ verify(webHandler, never()).resetConnections();
}
@Test(expected = IndexOutOfBoundsException.class)
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);
+ }
+}